Making format-on-save fast with prettierd
February 12, 2021- Intro
- 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
- Not just TypeScript and JavaScript
- Bonus: using it on the command line with prettierme
- Feedback
Intro
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,
but 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 sample2.ts
and 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
, formatting sample2.ts
does not take twice as
much time.
Who cares?!
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') . "'"
(it was a bit fancier than that, but not by much)
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
This time 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
dependencies).
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
prettierd start
:
% npm install -g @fsouza/prettierd
% prettierd start
Alternatively you can do both things with npx
:
% 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 vim.loop.<nice-async-things>
! Taylor
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
invoke the tcp_connect
function:
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.
Not just TypeScript and JavaScript
Users of prettier are aware of this, but prettier is not just about JavaScript and TypeScript, it can be used with many other file formats, including HTML, Markdown, CSS, YAML, JSON and others. Check the parser configuration in prettier docs for a full list, and keep in mind that additional file types can be added via plugins!
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 netcat
example.
Feedback
Do you have any feedback? Questions? Concerns? Wanna fix a typo? Checkout the source for this post in GitHub (feel free to send a PR), or the discussion in the GitHub repo.