Neovim 异步格式化探索

nvim-async-formatting

很长时间以来,我都在以同步的方式格式化代码,而这么做的唯一原因就是实现简单,我只需要在文件保存前的 BufWritePre 事件中以阻塞的方式调用 vim.lsp.buf.format() 即可轻松完成,就像这样:

vim.api.nvim_create_autocmd("BufWritePre", {
	group = group,
	buffer = bufnr,
	callback = function() vim.lsp.buf.format { bufnr = bufnr } end
})

但它有个致命问题 —— 在格式化时会阻塞 UI。这意味着每次保存文件,都需要等上一阵才能继续其它操作。

随着文件体积越来越大,格式化所需时间越来越长,我也越来越讨厌这种方式。

异步

有趣的是,vim.lsp.buf.format() 有个 async 选项,打开它,可以避免阻塞,以异步的方式格式化代码 —— 这才是正确的方式,格式化是你 formatter 的事,阻塞我 nvim 干啥。

vim.lsp.buf.format({ async = true }) 真的就只是“以异步的方式格式化代码”,它并不提供像 buffer 锁定这种保护机制。

这表明,如果你改了 buffer 内容,在格式化完成时,它会直接粗暴地将做出的更改丢弃,进而覆盖为格式化结果。

lsp-format.nvim

lsp-format.nvimvim.lsp.buf.format({ async = true }) 的基础上,增加了 buffer 锁定 —— 格式化完成时,若 buffer 已经变更,则丢弃格式化结果,避免将其覆盖。这点很符合我的 workflow,这次没赶上格式化就等下次,反正总有一次能赶上,我只需要保证我的文件样式“最终一致”。

到这里,我已经觉得 lsp-format.nvim 完全能满足我的需求了,还将其用更简练的代码重新实现了一版,主要是去除了多 LSP 支持,因为我不认为会用到它 —— 直到我遇见了 ESLint、Prettier 这对冤家。

ESLint 与 Prettier 在格式化这点上,有大量功能重叠,照常来说,只使用 Prettier 作为 formatter 就够了,ESLint 只负责报告 diagnostics,和提供 code actions。

但 Prettier 过于固执,可供开发者配置的选项少得可怜。于是我尝试使用 ESLint 完全替换 Prettier,这样我仍能仅使用一个 LSP 完成 format,但在逐个配完 200+ 条 formatting rules 后,我放弃了

目前,就我而言,会同时使用 ESLint、Prettier 两个作为 formatter,先过一遍 Prettier 作为 baseline,再过一遍 ESLint 调整某些更加细微的样式。当然,这也取决于项目配置,若未配置 ESLint 则没有后面这步。

多 LSP 窘境

lsp-format.nvim 支持多 LSP,但实现却很糙。具体来说,它只是依次请求每个 LSP,LSP 之间则简单地使用 buffer 传递上个格式化结果,这就会导致非预期的中间结果被看到,像这样:

Prettier 先是为 setPosts 参数加了换行,再由 ESLint 将返回的各个属性对其。这是不可接受的,我期望能一步到位,像这样:

可能有人会说,给属性加空格对其是邪恶的!因为属性名变更后,可能会影响空格数量的变化,从而影响到其它行,这会给 code review 带来麻烦。

的确,但还容我辩解下:

  • 这只是我的默认配置,而项目特定的 ESLint 配置始终处于第一顺位
  • 现代 diff 工具都可以 ignore whitespaces,而且我真的很喜欢对其它们

诚然,要解决这个问题可以通过 eslint-config-prettier 使 ESLint 部分规则与 Prettier 表现一致,这也是大多数人在做的。但这只是一种妥协,毕竟它们真的不一样。况且将多种工具很好地整合在一起,这本身应该是 editor 的责任。

自己动手

读了下 Neovim LSP 实现,发现这根本不可能!也终于明白了为什么 lsp-format.nvim 使用 buffer 传递格式化结果,因为 nvim 的 LSP 和 buffer 强绑定,每次 format 请求都需要、且仅需要传递 bufnr

client.request(
	"textDocument/formatting",
	vim.lsp.util.make_formatting_params(),
	function(err, result) end,
	bufnr
)

可以看到,根本没有传递“待格式化代码”的参数,只有一个 bufnr。这还不是关键,关键是,格式化完返回的 result 也只是代码中需要替换的部分,而不是完整的格式化结果,像这样:

{
	{
		newText = "..."
		range = {
			["end"] = { character = 7, line = 15 },
			start = { character = 7, line = 15 }
		}
	},
	{
		newText = "...",
		range = {
			["end"] = { character = 10, line = 16 },
			start = { character = 10, line = 16 }
		}
	},
	-- ...
}

该结果只能通过 vim.lsp.util.apply_text_edits() 处理,让人崩溃的是,它的参数也只是 bufnr

vim.lsp.util.apply_text_edits(result, bufnr, client.offset_encoding)

调用后 buffer 会发生实质的变化。什么,你想在不改变 buffer 的前提下,得到格式化结果?门儿都没有!

于是,我选择重写它们:

  • client.request() 不能用,用更底层的 client.rpc.request(),它是与 language server 建立的最原始的连接
  • 重写 send_changes,使用 client.rpc.request() 必须手动发送 textDocument/didChange 通知
  • 重写 vim.lsp.util.apply_text_edits(),在内存中计算格式化后的完整结果,不变更 buffer
  • 支持 buffer 锁定、合并多 LSP 格式化结果
  • ……

完整实现见:https://github.com/sxyazi/dotfiles/blob/main/nvim/lua/formatter.lua

结尾

如果这 300 来行对你有用,请告诉我!我会考虑将其从我的配置中抽离,制成一个真正的插件,并创建一个新的 repo 维护它。