However, this doesn’t work like I would expect it to.
From the Zed documentation:
quote
Here rust-analyzer will be used first to format the code, followed by a call of sed. If any of the formatters fails, the subsequent ones will still be executed.
This also means that if the first formatter succeeds, the second formatter will still be executed. So the last formatter specified always wins. This makes sense for use cases where you want to modify the output of a previous formatter.
But I’d like to specify multiple formatters where the additional formatters are used as fallbacks.
I use Biome to format/lint a lot of my TypeScript projects. Biome’s language support is pretty limited compared to Prettier’s, especially when you take into account all of the plugins available for Prettier.
So I want to use Prettier to format files that Biome doesn’t support.
Wait, can’t you do this by specifying Biome last in the list of formatters? Biome will run last and overwrite anything Prettier changed, so it “wins” for any files it supports, right?
Running multiple formatters can lead to unexpected results (there are situations where changes the first formatter make could affect the second formatter)
Running multiple formatters is slower
What if I want to change the arguments passed to the formatter based on some condition?
We could probably come up with more reasons to want to do something custom.
A “formatter” is just some executable
This is where we can start having some fun. At a basic level, a formatter is some executable that takes the contents of the current file on stdin and writes the formatted contents to stdout.
Which means specifying a script as our formatter is perfectly valid:
1
{
2
"external": {
3
"command": "format-wrapper.bash"
4
}
5
}
tip
This assumes format-wrapper.bash is in your PATH. You can specify an absolute path if needed.
Let’s write a simple formatter:
format-wrapper.bash
1
#!/usr/bin/env bash
2
3
input="$(< /dev/stdin)"
4
5
echo"// this is from our formatter!"
6
echo"${input}"
tip
If the process exits with a non-zero status, Zed will show a warning that the formatter failed and will not apply any changes to the buffer.
Then imagine we run Zed’s formatting command on a file like this:
example.js
1
console.log("hello world")
We would get this:
example.js
1
// this is from our formatter!
2
console.log("hello world")
Well, obviously this didn’t do any actual formatting, and if we ran it again, it would append another comment, but you get the idea.
Let’s make our formatter a little better. You may have noticed from above that Zed provides a special {buffer_path} argument. This is generally used by the formatter to determine the kind of file it’s working with based on the extension.
info
Remember, formatters in this context work with whatever they get on stdin. They don’t read the file’s contents directly or write to it directly. The passed in file path doesn’t even need to exist!
# prettier writes the formatted output to stdout which will replace the buffer contents
We basically just recreated what Zed was already doing in the example at the start of this post, but now we’re in a script so we can do whatever we want!
That’s pretty much it, but read on if you’re curious about how I solved my particular problem.
My custom formatter
Specifically, this is what I want to do:
Use Biome
if the above fails, use Prettier with the project’s configuration
if the above fails, use Prettier with my “global” configuration
I have a ~/.prettierrc.mjs file that I use for formatting one-off files.
e.g. prettier --config ~/.prettierrc.mjs --write <some-file>
This is what I came up with:
format-wrapper.bash
1
#!/usr/bin/env bash
2
3
buffer_path="${1}"
4
prettier_config="${2}"# the path to the prettier config which will be used by global prettier
5
6
input="$(< /dev/stdin)"
7
8
errors=""# a string to collect errors from each formatter
9
10
# This function is called after each formatter runs
11
# If the formatter failed, the error will be collected and the script will continue
12
# If the formatter succeeded, we print the output to stdout and exit
13
handle_output() {
14
local status="${1}"
15
local output="${2}"
16
local identifier="${3}"
17
18
# if the output is empty, it's likely a formatter failed, printed nothing, but didn't exit with a non-zero status
19
# regardless of the reason it's empty, we don't want to continue or else we'd replace the current buffer with nothing
20
if [[ "${status}"-gt0||-z"${output}" ]]; then
21
[[ -z"${output}" ]] && output="format-wrapper: something went wrong, output is empty"
22
errors="${errors}\n${identifier}: [exit status ${status}] ${output}\n--------"
# if prettier doesn't find a config, the string will be empty
43
# prettier will look outside the cwd for a config, so the project config must exist within the cwd if we're going to use it (i.e. don't use the config if it starts with '../')
44
if [[ -n"${prettier_project_config}"&&!"${prettier_project_config}"=~ ^\.\.\/ ]]; then
# if we got here, none of the formatters succeeded
57
echo-n-e"\n--------${errors}">&2
58
exit1
A lot of the complexity here is due to the fact that we want to capture any errors that occur and display them in the Zed log should all of the formatters fail.
The relevant part of my Zed config looks like this:
Note that I have my formatter set as the top-level formatter, which means by default every file will be formatted using my script. Obviously this won’t work for all files, so I set the formatter back to language_server for specific languages.
You could certainly flip this and set the top-level formatter to language_server or auto and use your custom formatter only for specific languages. Whatever seems easier to you.
A TypeScript version
note
This section was added at a later date (June 29, 2025).
Unsurprisingly, I quickly ran into limitations with my bash script. Like any shell script that’s more than a handful of lines, you’re better off writing it in, well, not shell.
Specifically, I wanted to be able to define a “group” of formatters, where the output of each formatter is passed as input to the next formatter in the group. This is different from the above approach, where the output of the first successful formatter is used.
To be clear, I still want the original “fallback” behavior. That is, if any formatter in the group fails, the whole group fails and it will move onto the next formatter.
So I decided to implement this, and took the opportunity to rewrite the script in TypeScript with Bun as the runtime.
// if the output is empty, it's likely a formatter failed, printed nothing, but didn't exit with a non-zero status
22
// regardless of the reason it's empty, we don't want to continue or else we'd replace the current buffer with nothing
23
if (result.exitCode ===0&& result.stdout.trim().length>0) return
24
return`\n${result.identifier}: [exit status ${result.exitCode}] ${result.stderr||"format-wrapper: something went wrong, output is empty"}\n--------`
25
}
26
27
/**
28
* Runs each {@linkFormatterGroup} in order. If a `FormatterGroup` succeeds, no further `FormatterGroup`s are run and the result is written to stdout (and ultimately to the Zed buffer).
29
*
30
* In other words, subsequent `FormatterGroup`s are used as fallbacks.
31
*
32
* If the `FormatterGroup` is an array, _all_ of the `Formatter`s in the array must succeed for the `FormatterGroup` to succeed.
33
* - The output of each `Formatter` is used as the input for the next `Formatter` in the array.
34
* - This allows you to chain multiple formatters together and use the combined results.
35
*
36
* @paramformatterGroups One or more {@linkFormatterGroup}, which is either a single {@linkFormatter} or an array of {@linkFormatter}
37
* @returns void
38
*/
39
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: it's fine
// if prettier doesn't find a config, the string will be empty
144
if (!prettierProjectConfig) {
145
result.stderr ="skipped, no prettier config found"
146
return result
147
}
148
149
// prettier will look outside the cwd for a config, so the project config must exist within the cwd if we're going to use it (i.e. don't use the config if it starts with '../')
150
if (prettierProjectConfig.startsWith("../")) {
151
result.stderr =`skipped, the resolved config is outside the cwd (${prettierProjectConfig})`