Making format-on-save fast with prettierdFebruary 12, 2021
- Who cares?!
- "My Visual Studio Code doesn't take a second to format-on-save, your Vim is trash"
- Installing and starting prettierd
- Integrating Neovim with prettierd
- Bonus: using it on the command line with prettierme
Back in June of 2020, when I was migrating my Neovim configuration to Lua and to the native LSP client available in neovim 0.5.0, my main language at work was TypeScript and we used prettier to keep our code formatted, and I had it configured to format-on-save with coc-prettier. One of the first issues I ran into was performance: saving files became deadly slow, to the point where I gave up and disabled format-on-save.
The thing is: prettier is known to be a fast code formatter, and I didn't have the issue before, so what's the problem here? Is Neovim making prettier slower? Is coc-prettier doing some magic shit?
Before we start looking into this, let's see how prettier behaves when formatting a somewhat large TypeScript file:
％ wc -l sample.ts 586 sample.ts ％ time npx prettier -w sample.ts sample.ts 332ms 0.85 real 0.91 user 0.11 sys
It's interesting that prettier reports that it took 332ms to format the file,
time reports that the whole process took 850ms. Who's lying?
Let's take a look at multiple files:
％ wc -l sample*.ts 330 sample1.ts 718 sample2.ts 655 sample3.ts 2511 sample4.ts 601 sample5.ts 4815 total ％ time npx prettier -w sample1.ts sample1.ts 290ms 0.93 real 0.88 user 0.11 sys ％ time npx prettier -w sample2.ts sample2.ts 358ms 1.02 real 1.04 user 0.12 sys ％ time npx prettier -w sample3.ts sample3.ts 330ms 0.97 real 0.95 user 0.13 sys ％ time npx prettier -w sample4.ts sample4.ts 648ms 1.27 real 1.48 user 0.13 sys ％ time npx prettier -w sample5.ts sample5.ts 375ms 1.00 real 1.00 user 0.12 sys
Notice how formatting
sample4.ts takes more than 1 second in
total! Also interesting is the fact that even though
sample1.ts is less the
half the size of
sample2.ts does not take twice as
OK, let's take a step back and reflect: who cares if prettier is slow to format my files? I could run it on a git hook or something like that and not even notice.
As I mentioned before, I was running format-on-save in Neovim, with a simple setup, not very fancy:
autocmd BufWritePre *.ts execute "silent %!npx prettier --stdin-filepath '" . expand('%:p') . "'"
So, imagine you're using Neovim and every time you save the file you have to wait 1 second. You'd be mad, right?! There must be a better way...
"My Visual Studio Code doesn't take a second to format-on-save, your Vim is trash"
To be fair, coc-prettier was pretty fast too. How is that even possible?
Let's go back to our sample files, but this time let's see what prettier does if we pass all 5 files to it instead of invoking it 5 times:
％ time npx prettier -w *.ts sample1.ts 248ms sample2.ts 205ms sample3.ts 110ms sample4.ts 327ms sample5.ts 79ms 1.61 real 2.09 user 0.15 sys
sample2.ts is faster than
sample1.ts, even though it's twice as
large! What's going on? Turns out prettier is slow to
start, both because of
overhead introduced by node.js and prettier itself (it has tons of plugins and
And how is it fast to format-on-save using VSCode/coc-prettier? Simple: both coc-prettier and Visual Studio Code are long-running node.js processes, which host prettier as a library, therefore paying the initialization cost once.
The solution is simple: we need a long-running node.js process! If you read through the issue about slow startups in prettier, someone suggests using prettier_d, but after looking at how large that project was, I was a bit scared.
Doing some more research, I found eslint_d.js, which solves a similar issue for eslint, by introducing a daemon which supports binding on a TCP socket! And the author of eslint_d.js extracted its core functionality in a library called core_d.js. So I figured I could combine that library with prettier and make prettierd, a TCP-enabled daemon for formatting code using prettier!
Installing and starting prettierd
The code for prettierd is pretty boring, as it is basically a tiny wrapper around core_d to invoke the proper prettier functions whenever the server receives a "request". The two important things to know about are:
- You can install it with npm and start it with
％ npm install -g @fsouza/prettierd ％ prettierd start
Alternatively you can do both things with
％ npx -p @fsouza/prettierd prettierd start
- When it starts, prettierd writes a file with its port number and token
％ cat ~/.prettierd 53561 cb2ad753df0aca85
This means that prettierd is running on port 53561 and we can use the token
cb2ad753df0aca85 in our requests to format our source code.
core_d's protocol is pretty simple:
<token> <working-dir> <file-name>\n <file-content>
For example, we can use netcat:
％ echo "cb2ad753df0aca85 $PWD sample2.ts" | cat - sample2.ts | /usr/bin/time nc localhost 53561 >sample2-formatted.ts 0.14 real 0.00 user 0.00 sys
Remember how formatting
sample2.ts took over 1 second? Not anymore. :)
Integrating Neovim with prettierd
Using netcat is great and we could probably write a shell script that we could use in our (fun fact: someone else did this, check the bonus section!), but Neovim is powerful enough to connect directly to the TCP server.
How? Neovim has an event loop, which is implemented using
libuv. libuv is probably the best event loop there in the
wild, but don't quote me :) Besides shipping the event loop and all the libuv
code, Neovim also bundles luv and expose the
loop as a Lua API, so we can use
Thompson has written an amazing post about the using libuv in
Neovim, go check it out if you're curious :)
Among the utilities provided by libuv, there's a tcp module which includes both
a TCP client and a TCP server! In our case we want to use a client, so we
local callback = ... local port = 53561 local token = 'cb2ad753df0aca85' local client = vim.loop.new_tcp() vim.loop.tcp_connect(client, '127.0.0.1', port, callback)
Since this is async world, we need to pass a callback that gets executed whenever the connection happens (or in case something goes wrong). So the first thing we do in our callback is check for errors, which looks familiar for Go developers:
local callback = function(err) if err then error(err) end ... end
If there are no errors, it means we can send the contents of our file to the remote server. We have the port and the token, but we also need the contents of the buffer. So let's grab the contents of the current buffer and send that to the server, then read back the response and write it back to the buffer! This time I'll include the entire implementation of the callback, with some inline comments:
local callback = function(err) if err then error(err) end -- grab the contents of the buffer and add first row to match core_d's protocol local bufnr = vim.api.nvim_get_current_buf() local first_line = string.format('%s %s %s', token, vim.loop.cwd(), 'sample2.js') local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) table.insert(lines, 1, first_line) -- start reading the response local response = '' vim.loop.read_start(client, function(read_err, chunk) -- check if there was any error reading data back, if so, close the -- connection and report the error. if read_err then vim.loop.close(client) error('failed to read data from prettierd: ' .. read_err) end -- libuv will call this callback with no data and no error when it's done, -- so if there's data, concatenate it into the final response. Otherwise it -- means we're done, so invoke the `write_to_buf` to write the data back. if chunk then response = response .. chunk else vim.loop.close(client) write_to_buf(response, bufnr) end end) -- write the request vim.loop.write(client, table.concat(lines, '\n')) -- signal to the server that we're done writing the request vim.loop.shutdown(client) end
And here's a simple implementation of
write_to_buf. The trickiest bit is
error handling: the way errors are reported isn't great, but it's acceptable:
if prettier fails, the last line contains a message in the format
# exit <code> ....
local function write_to_buf(data, bufnr) local new_lines = vim.split(data, '\n') -- check for errors if string.find(new_lines[#new_lines], '^# exit %d+') then error(string.format('failed to format with prettier: %s', data)) end -- write contents vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) end
Now you can throw all of that in a
format() function and invoke it on write!
Note: the code here is a simplified version of what I actually use. For the actual config, including automatic process management, retries, error handling and cursor positioning, checkout prettierd.lua in my dotfiles repo.
Bonus: using it on the command line with prettierme
If you want to use Vim instead of Neovim, or don't want to maintain a TCP
client in your editor configuration, you can leverage Ruy Adorno's
prettierme to use a command line
interface that is more similar to the standard prettier interface. prettierme
is basically a wrapper around our