🐼

Neovim + nvim-lspconfigで `gq` を使う際は気をつけよう

2024/08/19に公開

結論

Neovimでgqコマンドを使う際、特にLSPのマルチクライアント環境では注意が必要です。
意図しないクライアントによってファイルがフォーマットされる可能性があります

調査

背景

現在のNeovimは、LSPによる各種機能の提供にかなり積極的です。
Neovim標準のクライアント(vim.lsp)にはNeovim本来のformat呼び出し機構である'formatexpr'に設定するためのvim.lsp.formatexpr()が用意されています。

formatexpr({opts})                                      *vim.lsp.formatexpr()*
    Provides an interface between the built-in client and a `formatexpr`
    function.

    Currently only supports a single client. This can be set via
    ...

そして、このvim.lsp.formatexpr()は、NeovimのLSPクライアントを設定するデファクトスタンダードプラグインの内の1つである、
nvim-lspconfigによって、自動的に'formatexpr'に設定されています。

Configuration

Nvim sets some default options whenever a buffer attaches to an LSP client. See [:h lsp-config][lsp-config] for more details. In particular, the following options are set:

...(中略)...

  • ['formatexpr'][formatexpr]
    • Enables LSP formatting with [gq][gq].

ですが、先のvim.lsp.formtexpr()のヘルプを見ると分かるとおり、この関数は

Currently only supports a single client
(1つのクライアントだけサポートする)

よう実装されています。

しかし、実際のLSP設定においては、1つのバッファ(あるいは1つのファイルタイプと解しても良い)で複数のサーバーをアタッチすることがあります。
例えばこれは私の環境においてTypeScriptReact(.tsx)ファイルにアタッチされているLSPクライアントの一覧です。

  • GitHub Copilot
  • cssmodules_ls
  • vtsls
  • efm

これは疑問が生まれるところです。

果たしてvim.lsp.formatexpr()はどのクライアントを使ってフォーマットするのか?

実装を読み解く

怪しげな機能の実際を知るべく、vim.lsp.formatexpr()が何をしているのか追いかけてみました。
この関数はluaで実装されているようです。

https://github.com/neovim/neovim/blob/33464189bc02b2555e26dc4e9f7b3fbbcdd02490/runtime/lua/vim/lsp.lua#L1018-L1076

function lsp.formatexpr(opts)
  ----------- ...中略... -----------
  local bufnr = api.nvim_get_current_buf()
  for _, client in pairs(lsp.get_clients({ bufnr = bufnr })) do
    if client.supports_method(ms.textDocument_rangeFormatting) then
      local params = util.make_formatting_params()
      local end_line = vim.fn.getline(end_lnum) --[[@as string]]
      local end_col = util._str_utfindex_enc(end_line, nil, client.offset_encoding)
      --- @cast params +lsp.DocumentRangeFormattingParams
      params.range = {
        start = {
          line = start_lnum - 1,
          character = 0,
        },
        ['end'] = {
          line = end_lnum - 1,
          character = end_col,
        },
      }
      local response =
        client.request_sync(ms.textDocument_rangeFormatting, params, timeout_ms, bufnr)
      if response and response.result then
        lsp.util.apply_text_edits(response.result, bufnr, client.offset_encoding)
        return 0
      end
    end
  end

  -- do not run builtin formatter.
  return 0
end

簡単に流れを追うと、この関数は

  • 現在のバッファにアタッチされたクライアントリストを取得
  • クライアントリストを順繰りにtextDocument.formattingに対応しているか確認、対応していれば呼び出し、呼び出しに成功すれば反映する
  • 1つでも反映したら、関数を終了する

という作りになっています。
そして、このときクライアントをpairs()で辿っていることから、順序は定まっていません

next({table} [, {index}])                               *next()*
        Allows a program to traverse all fields of a table. Its first argument
        is a table and its second argument is an index in this table. `next`
        returns the next index of the table and its associated value. When
        called with `nil` as its second argument, `next` returns an initial
        index and its associated value. When called with the last index, or
        with `nil` in an empty table, `next` returns `nil`. If the second
        argument is absent, then it is interpreted as `nil`. In particular,
        you can use `next(t)` to check whether a table is empty.

        The order in which the indices are enumerated is not specified, even
        for numeric indices. (To traverse a table in numeric order, use a
        numerical `for` or the |ipairs()| function.)

        The behavior of `next` is `undefined` if, during the traversal, you
        assign any value to a non-existent field in the table. You may however
        modify existing fields. In particular, you may clear existing fields.

pairs({t})                                              *pairs()*
        Returns three values: the |next()| function, the table {t}, and `nil`,
        so that the construction

               `for k,v in pairs(t) do`  `body`  `end`

        will iterate over all key-value pairs of table {t}.

The order in which the indices are enumerated is not specified

実際の動き

not specifiedとある通り仕様として明言されていないだけで、実際の動きは背景の実装によるものです。
lua/Neovimのpairs(), next()は、確認する限りにおいては 追加した順にエントリを返す ように見えています。

そこでこの列挙されるクライアントとその順序を追いかけてみると、次のような動きをしています。

  • 各クライアントを設定からstartしたときに追加される
  • 各クライアントが停止したときに削除される
  • 再度startすると、末尾に追加される

問題点

このような実装から、この機能には以下の様な問題があります。

  • LSP クライアントがstartされる順序という非明示的な情報に依存して選ばれるクライアントが決まる
  • LSP クライアントの終了・起動によって選ばれるクライアントが変わる
  • not specifiedな順序に依存しており、内部実装しだいで選ばれるクライアントが決まる

LSPクライアントのラインナップによっては、かなり不安定な動作を体験することになります。
困った場合には、vim.lsp.buf.format()など、より安定的な機能を使用するようにしましょう。

Discussion