Zed has a neat feature where you can use an external formatter to format your code:
1
{
2
"formatter": {
3
"external": {
4
"command": "prettier",
5
"arguments": ["--stdin-filepath", "{buffer_path}"]
6
}
7
}
8
}
You can even specify multiple formatters in an array:
1
{
2
"formatter": [
3
{ "language_server": { "name": "rust-analyzer" } },
4
{
5
"external": {
6
"command": "sed",
7
"arguments": ["-e", "s/ *$//"]
8
}
9
}
10
]
11
}
However, this doesn’t work like I would expect it to.
From the Zed documentation:
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?
1
{
2
"formatter": [
3
{
4
"external": {
5
"command": "prettier",
6
"arguments": ["--stdin-filepath", "{buffer_path}"]
7
}
8
},
9
{ "language_server": { "name": "biome" } }
10
]
11
}
You’re right, but this has some limitations:
- 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
}
This assumes format-wrapper.bash is in your PATH. You can specify an absolute path if needed.
Let’s write a simple formatter:
1
#!/usr/bin/env bash
2
3
input="$(< /dev/stdin)"
4
5
echo "// this is from our formatter!"
6
echo "${input}"
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:
1
console.log("hello world")
We would get this:
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.
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!
1
{
2
"external": {
3
"command": "format-wrapper.bash",
4
"arguments": ["{buffer_path}"]
5
}
6
}
1
#!/usr/bin/env bash
2
3
input="$(< /dev/stdin)"
4
5
buffer_path="${1}"
6
7
echo -n "${input}" | prettier --stdin-filepath "${buffer_path}"
8
# 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>
- I have a ~/.prettierrc.mjs file that I use for formatting one-off files.
This is what I came up with:
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}" -gt 0 || -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--------"
23
return "${status}"
24
fi
25
26
echo "${output}"
27
exit 0
28
}
29
30
# Biome
31
if [[ -f biome.json || -f biome.jsonc ]]; then
32
biome_project_cmd="$(pwd)"/node_modules/.bin/biome
33
if [[ -f "${biome_project_cmd}" ]]; then
34
output="$(echo -n "${input}" | "${biome_project_cmd}" check --stdin-file-path="${buffer_path}" --write 2>&1)"
35
handle_output $? "${output}" "biome (${biome_project_cmd})"
36
fi
37
fi
38
39
# Project Prettier
40
# if we don't give --find-config-path an argument, it won't check the cwd
41
prettier_project_config="$(prettier --find-config-path ' ' 2> /dev/null)"
42
# 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
45
prettier_project_cmd="$(pwd)"/node_modules/.bin/prettier
46
if [[ -f "${prettier_project_cmd}" ]]; then
47
output="$(echo -n "${input}" | "${prettier_project_cmd}" --stdin-filepath "${buffer_path}" 2>&1)"
48
handle_output $? "${output}" "prettier (${prettier_project_cmd})"
49
fi
50
fi
51
52
# Global Prettier
53
output="$(echo -n "${input}" | prettier --stdin-filepath "${buffer_path}" --config "${prettier_config}" 2>&1)"
54
handle_output $? "${output}" "prettier ($(type -p prettier))"
55
56
# if we got here, none of the formatters succeeded
57
echo -n -e "\n--------${errors}" >&2
58
exit 1
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:
1
{
2
"formatter": {
3
"external": {
4
"command": "format-wrapper.bash",
5
"arguments": ["{buffer_path}", "/Users/adam/.prettierrc.mjs"]
6
}
7
},
8
"languages": {
9
"Rust": { "formatter": "language_server" },
10
"Dockerfile": { "formatter": "language_server" },
11
"Prisma": { "formatter": "language_server" },
12
"SQL": { "formatter": "language_server" }
13
}
14
}
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.