adamhl.dev

Leveraging (abusing?) external formatters in Zed

11 min read

I really like formatting my code I guess


Zed has a neat feature where you can use an external formatter to format your code:

{
"formatter": {
"external": {
"command": "prettier",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
}

You can even specify multiple formatters in an array:

{
"formatter": [
{ "language_server": { "name": "rust-analyzer" } },
{
"external": {
"command": "sed",
"arguments": ["-e", "s/ *$//"]
}
}
]
}

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?

{
"formatter": [
{
"external": {
"command": "prettier",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
},
{ "language_server": { "name": "biome" } }
]
}

You’re right, but this has some limitations:

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:

{
"external": {
"command": "format-wrapper.bash"
}
}

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
#!/usr/bin/env bash
input="$(< /dev/stdin)"
echo "// this is from our formatter!"
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
console.log("hello world")

We would get this:

example.js
// this is from our formatter!
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!


{
"external": {
"command": "format-wrapper.bash",
"arguments": ["{buffer_path}"]
}
}

format-wrapper.bash
#!/usr/bin/env bash
input="$(< /dev/stdin)"
buffer_path="${1}"
echo -n "${input}" | prettier --stdin-filepath "${buffer_path}"
# 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:

  1. Use Biome
  2. if the above fails, use Prettier with the project’s configuration
  3. 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
#!/usr/bin/env bash
buffer_path="${1}"
prettier_config="${2}" # the path to the prettier config which will be used by global prettier
input="$(< /dev/stdin)"
errors="" # a string to collect errors from each formatter
# This function is called after each formatter runs
# If the formatter failed, the error will be collected and the script will continue
# If the formatter succeeded, we print the output to stdout and exit
handle_output() {
local status="${1}"
local output="${2}"
local identifier="${3}"
# if the output is empty, it's likely a formatter failed, printed nothing, but didn't exit with a non-zero status
# regardless of the reason it's empty, we don't want to continue or else we'd replace the current buffer with nothing
if [[ "${status}" -gt 0 || -z "${output}" ]]; then
[[ -z "${output}" ]] && output="format-wrapper: something went wrong, output is empty"
errors="${errors}\n${identifier}: [exit status ${status}] ${output}\n--------"
return "${status}"
fi
echo "${output}"
exit 0
}
# Biome
if [[ -f biome.json || -f biome.jsonc ]]; then
biome_project_cmd="$(pwd)"/node_modules/.bin/biome
if [[ -f "${biome_project_cmd}" ]]; then
output="$(echo -n "${input}" | "${biome_project_cmd}" check --stdin-file-path="${buffer_path}" --write 2>&1)"
handle_output $? "${output}" "biome (${biome_project_cmd})"
fi
fi
# Project Prettier
# if we don't give --find-config-path an argument, it won't check the cwd
prettier_project_config="$(prettier --find-config-path ' ' 2> /dev/null)"
# if prettier doesn't find a config, the string will be empty
# 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 '../')
if [[ -n "${prettier_project_config}" && ! "${prettier_project_config}" =~ ^\.\.\/ ]]; then
prettier_project_cmd="$(pwd)"/node_modules/.bin/prettier
if [[ -f "${prettier_project_cmd}" ]]; then
output="$(echo -n "${input}" | "${prettier_project_cmd}" --stdin-filepath "${buffer_path}" 2>&1)"
handle_output $? "${output}" "prettier (${prettier_project_cmd})"
fi
fi
# Global Prettier
output="$(echo -n "${input}" | prettier --stdin-filepath "${buffer_path}" --config "${prettier_config}" 2>&1)"
handle_output $? "${output}" "prettier ($(type -p prettier))"
# if we got here, none of the formatters succeeded
echo -n -e "\n--------${errors}" >&2
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:

~/.config/zed/settings.json
{
"formatter": {
"external": {
"command": "format-wrapper.bash",
"arguments": ["{buffer_path}", "/Users/adam/.prettierrc.mjs"]
}
},
"languages": {
"Rust": { "formatter": "language_server" },
"Dockerfile": { "formatter": "language_server" },
"Prisma": { "formatter": "language_server" },
"SQL": { "formatter": "language_server" }
}
}

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.

format-wrapper.ts
#!/usr/bin/env bun
import path from "node:path"
import process from "node:process"
import { $ } from "bun"
const [bufferPath = "", prettierConfigPath = ""] = process.argv.slice(2)
const stdin = await Bun.stdin.text()
const cwd = process.cwd()
type FormatterResult = {
identifier: string
stdout: string
stderr: string
exitCode: number
}
type Formatter = (input: string) => FormatterResult | Promise<FormatterResult>
type FormatterGroup = Formatter | Formatter[]
function getFormatterErrorString(result: FormatterResult): string | undefined {
// if the output is empty, it's likely a formatter failed, printed nothing, but didn't exit with a non-zero status
// regardless of the reason it's empty, we don't want to continue or else we'd replace the current buffer with nothing
if (result.exitCode === 0 && result.stdout.trim().length > 0) return
return `\n${result.identifier}: [exit status ${result.exitCode}] ${result.stderr || "format-wrapper: something went wrong, output is empty"}\n--------`
}
/**
* Runs each {@link FormatterGroup} 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).
*
* In other words, subsequent `FormatterGroup`s are used as fallbacks.
*
* If the `FormatterGroup` is an array, _all_ of the `Formatter`s in the array must succeed for the `FormatterGroup` to succeed.
* - The output of each `Formatter` is used as the input for the next `Formatter` in the array.
* - This allows you to chain multiple formatters together and use the combined results.
*
* @param formatterGroups One or more {@link FormatterGroup}, which is either a single {@link Formatter} or an array of {@link Formatter}
* @returns void
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: it's fine
async function useFormatters(...formatterGroups: FormatterGroup[]) {
let errors = ""
for (const formatterGroup of formatterGroups) {
if (Array.isArray(formatterGroup)) {
let currentInput = stdin
let allSucceeded = true
let finalResult: FormatterResult | undefined
for (const formatter of formatterGroup) {
// biome-ignore lint/nursery/noAwaitInLoop: we need to wait for each formatter
const result = await formatter(currentInput)
const errorString = getFormatterErrorString(result)
if (errorString) {
errors += errorString
allSucceeded = false
break
}
// use output as input for next formatter
currentInput = result.stdout
finalResult = result
}
if (allSucceeded && finalResult) {
process.stdout.write(`${finalResult.stdout}\n`)
return
}
} else {
const result = await formatterGroup(stdin)
const errorString = getFormatterErrorString(result)
if (errorString) {
errors += errorString
continue
}
process.stdout.write(`${result.stdout}\n`)
return
}
}
// if we got here, none of the formatters succeeded
if (errors) process.stderr.write(`\n--------${errors}`)
process.exitCode = 1
}
async function runFormatterCmd(cmd: string, input: string) {
const { stdout, stderr, exitCode } = await $`echo ${input} | ${{ raw: cmd }}`.quiet().nothrow()
return { stdout: stdout.toString().trim(), stderr: stderr.toString().trim(), exitCode }
}
const biome: Formatter = async (input) => {
const biomeProjectCmd = `${cwd}/node_modules/.bin/biome`
const identifier = `biome (${biomeProjectCmd})`
const result: FormatterResult = {
identifier,
stdout: "",
stderr: "",
exitCode: 1,
}
const biomeBinaryExists = await Bun.file(biomeProjectCmd).exists()
if (!biomeBinaryExists) {
result.stderr = "skipped, biome binary not found"
return result
}
const biomeConfigJsonExists = await Bun.file(`${cwd}/biome.json`).exists()
const biomeConfigJsoncExists = await Bun.file(`${cwd}/biome.jsonc`).exists()
if (!(biomeConfigJsonExists || biomeConfigJsoncExists)) {
result.stderr = "skipped, no biome config found"
return result
}
const { stdout, stderr, exitCode } = await runFormatterCmd(
`"${biomeProjectCmd}" format --stdin-file-path="${bufferPath}" --write`,
input,
)
result.stdout = stdout
result.stderr = stderr
result.exitCode = exitCode
return result
}
const projectPrettier: Formatter = async (input) => {
const prettierProjectCmd = `${cwd}/node_modules/.bin/prettier`
const identifier = `prettier (${prettierProjectCmd})`
const result: FormatterResult = {
identifier,
stdout: "",
stderr: "",
exitCode: 1,
}
const prettierBinaryExists = await Bun.file(prettierProjectCmd).exists()
if (!prettierBinaryExists) {
result.stderr = "skipped, prettier binary not found"
return result
}
// if we don't give --find-config-path an argument, it won't check the cwd
const prettierProjectConfig = (await $`prettier --find-config-path ' '`.quiet().nothrow().text()).trim()
// if prettier doesn't find a config, the string will be empty
if (!prettierProjectConfig) {
result.stderr = "skipped, no prettier config found"
return result
}
// 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 '../')
if (prettierProjectConfig.startsWith("../")) {
result.stderr = `skipped, the resolved config is outside the cwd (${prettierProjectConfig})`
return result
}
const { stdout, stderr, exitCode } = await runFormatterCmd(
`"${prettierProjectCmd}" --stdin-filepath "${bufferPath}"`,
input,
)
result.stdout = stdout
result.stderr = stderr
result.exitCode = exitCode
return result
}
const prettier: Formatter = async (input) => {
const prettierCmd = Bun.which("prettier")
const identifier = `prettier (${prettierCmd})`
const { stdout, stderr, exitCode } = await runFormatterCmd(
`"${prettierCmd}" --stdin-filepath "${bufferPath}" --config "${prettierConfigPath}"`,
input,
)
return {
identifier,
stdout,
stderr,
exitCode,
}
}
const fileExtension = path.extname(bufferPath)
if (fileExtension === ".astro") await useFormatters([projectPrettier, biome], prettier)
else await useFormatters(biome, projectPrettier, prettier)