🧹

neovim-lspconfig + Biome で保存時に import の整理をする

2024/07/22に公開

3行まとめ

  • neovim-lspconfig で Biome の LSP Proxy を起動できる
  • textDocument/codeActionsource.organizeImportssource.fixAll を実行することで、import の整理が可能
  • codeAction の同期実行は gopls のドキュメントを参考にする

前提1: Biome と import

Biome には import に関連する機能が2つ存在する。

  • Analyzer の Imports Sorting
    • import をいい感じに並び替えてくれる機能
    • デフォルトで有効になっている
  • Lint の noUnusedImports rule
    • 「未使用の import を許可しない」という Lint ルールで、 safe fix 対応
    • Recommended ルールセットに含まれておらず、自分で有効化が必要なケースが多そう
    •  {
         "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
         "linter": {
           "enabled": true,
           "rules": {
             "recommended": true,
      +      "correctness": {
      +        "noUnusedImports": "error"
      +      }
           }
         }
       }
      

前提2: neovim-lspconfig と Biome

Biome は lsp-proxy というコマンドを持っており、バックグラウンドで立ち上げた long-running な Biome プロセスに対して LSP(Language Server Protocol) でやり取りすることができる。

https://biomejs.dev/guides/editors/create-a-plugin/#use-the-lsp-proxy

Neovim で Biome を動かす際、neovim-lspconfig を使う場合はこの biome lsp-proxy を使うことになる。

保存時に import の整理をする

保存時に Biome によるフォーマットを動かすのであれば、そのとき import の並び替えおよび未使用 import の削除もまとめてやってほしい。しかし、vim.lsp.buf.format() だけではそこまではやってくれない。これは LSP 的にはフォーマットとこれらの処理は別ものになっているのが理由。

Biome LSP Proxy 経由で import の整理をするには、textDocument/codeActionsource.organizeImportssource.fixAll を実行する必要がある。

  • source.organizeImports は import の並び替え
  • source.fixAll は未使用 import の削除
    • (おそらく biome lint 相当の処理が動いてるので、他にもいろいろ修正される)

Neovim にはいまのところ textDocument/codeAction を同期的に実行する直接的な API は存在せず、ちょっと低レベルな関数で実現することになる。
Go の Language Server である gopls のドキュメントでその実装が紹介されており、それが参考になる。

https://github.com/golang/tools/blob/v0.23.0/gopls/doc/vim.md#neovim-imports

筆者は以下のようなコードを実行することで、保存時(BufPreWrite) に format に加えて textDocument/codeActionsource.organizeImportssource.fixAll を動かすようにした。

---@param client vim.lsp.Client
---@param bufnr integer
---@param cmd string
local function code_action_sync(client, bufnr, cmd)
  -- https://github.com/golang/tools/blob/gopls/v0.11.0/gopls/doc/vim.md#imports
  local params = vim.lsp.util.make_range_params()
  params.context = { only = { cmd }, diagnostics = {} }
  -- gopls のドキュメントでは `vim.lsp.buf_request_sync` を使っているが、
  -- ここでは対象 Language Server を1つに絞るために `vim.lsp.Client` の `request_sync` を使う
  local res = client.request_sync("textDocument/codeAction", params, 3000, bufnr)
  for _, r in pairs(res and res.result or {}) do
    if r.edit then
      local enc = (vim.lsp.get_client_by_id(cid) or {}).offset_encoding or "utf-16"
      vim.lsp.util.apply_workspace_edit(r.edit, enc)
    end
  end
end

---@param client vim.lsp.Client
---@param bufnr integer
local function organize_imports_sync(client, bufnr)
  code_action_sync(client, bufnr, "source.organizeImports")
end

---@param client vim.lsp.Client
---@param bufnr integer
local function fix_all_sync(client, bufnr)
  code_action_sync(client, bufnr, "source.fixAll")
end

---@type table<string, fun(client: vim.lsp.Client, bufnr: integer)[]>
local save_handlers_by_client_name = {
  gopls = { organize_imports_sync, format_sync },
  biome = { fix_all_sync, organize_imports_sync, format_sync },
}

-- none-ls を含むすべての Language Server の保存時の処理をまとめてしまう
--
-- Language Server の処理を連続で呼び出すと意図通りの動作をしないことがある
-- Server 側の内部状態の更新が間に合わないのか?
-- その回避のために sleep が必要
-- 
-- かつ、複数 Language Server にリクエストを送るときにも sleep を入れるために
-- 1つの BufWritePre にまとめている
vim.api.nvim_create_autocmd("BufWritePre", {
  ---@param args { buf: integer }
  callback = function(args)
    local bufnr = args.buf
    local shouldSleep = false
    for _, client in pairs(vim.lsp.get_clients({ bufnr = bufnr })) do
      local save_handlers = save_handlers_by_client_name[client.name]
      for _, f in pairs(save_handlers or {}) do
        if shouldSleep then
          vim.api.nvim_command("sleep 10ms")
        else
          shouldSleep = true
        end
        f(client, bufnr)
      end
    end
  end,
})

https://github.com/izumin5210/dotfiles/pull/476

Discussion