## 事象 [[🦉Another Quick Switcher]]のリポジトリで発生。 ```console $ bun pm ls /home/tadashi-aikawa/git/github.com/tadashi-aikawa/obsidian-another-quick-switcher node_modules (976) ├── @biomejs/[email protected] ├── @types/[email protected] ├── @types/[email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] └── [email protected] ``` ### CLIコマンド / exコマンド 以下のコマンドを実行すると ```console bun x biome format src/ui/AnotherQuickSwitcherModal.ts ``` 436行目の `.map`のインデントが変わる。 ```ts const matchedSuggestions = isQueryEmpty ? this.ignoredItems : this.ignoredItems .map((x) => stampMatchResults(x, qs, { ``` のコードが以下のようになる。 ```ts const matchedSuggestions = isQueryEmpty ? this.ignoredItems : this.ignoredItems .map((x) => stampMatchResults(x, qs, { ``` 他にも複数個所、同等の変更点が見られる。 また、[[Neovim]]で `:lua vim.lsp.buf.format()` を実行した場合にも同じ結果になった。 ### none-ls 保存時に自動実行される[[none-ls.nvim]]のフォーマットでは上記と結果が異なる。変更前の状態に戻る。 `src/ui/AnotherQuickSwitcherModal.ts` における [[nvim-lspconfig]]の`:LspInfo`コマンドの実行結果。 ```console 3 client(s) attached to this buffer: Client: biome (id: 1, bufnr: [1]) filetypes: javascript, javascriptreact, json, jsonc, typescript, typescript.tsx, typescriptreact, astro, svelte, vue autostart: true root directory: /home/tadashi-aikawa/git/github.com/tadashi-aikawa/obsidian-another-quick-switcher cmd: /home/tadashi-aikawa/.local/share/mise/installs/node/20/bin/npx biome lsp-proxy Client: volar (id: 2, bufnr: [1]) filetypes: typescript, javascript, javascriptreact, typescriptreact, vue autostart: true root directory: /home/tadashi-aikawa/git/github.com/tadashi-aikawa/obsidian-another-quick-switcher cmd: /home/tadashi-aikawa/.local/share/mise/installs/node/20/bin/vue-language-server --stdio Client: null-ls (id: 3, bufnr: [1]) filetypes: luau, lua, go, sh, jsonc, json, typescript, javascript, javascriptreact, typescriptreact autostart: false root directory: /home/tadashi-aikawa/git/github.com/tadashi-aikawa/obsidian-another-quick-switcher cmd: <function> ``` 同ファイルにおける `:NullLsInfo` の結果。 ```console null-ls https://github.com/nvimtools/none-ls.nvim Logging * current level: warn * path: /home/tadashi-aikawa/.cache/nvim/null-ls.log Active source(s) * name: biome * filetypes: jsonc | json | typescript | javascript | javascriptreact | typescriptreact * methods: formatting Supported source(s) * formatting: biome | prettier | prettierd | rustywind * diagnostics: semgrep * code_actions: refactoring ``` ### 他に気になったこと - `biome.json`に`"formatter.indentWidth": 1`を追加すると[[Neovim]]で保存時の結果が変わる - ただし、**CLIと同じ結果にはならない & 意図しない変更が入る** - 具体的には改行になる1行の文字数が変わった (どうして???) ### 設定 [[none-ls.nvim]]の設定。 ```lua config = function() local null_ls = require("null-ls") local augroup = vim.api.nvim_create_augroup("LspFormatting", {}) null_ls.setup({ sources = { null_ls.builtins.formatting.biome.with({ only_local = "node_modules/.bin", condition = function(utils) return utils.root_has_file({ "biome.json" }) end, }), null_ls.builtins.formatting.prettierd.with({ prefer_local = "node_modules/.bin", extra_filetypes = { "svelte" }, disabled_filetypes = { "markdown" }, condition = function(utils) return not utils.root_has_file({ "biome.json", "deno.json", "deno.jsonc" }) end, }), null_ls.builtins.formatting.stylua, null_ls.builtins.formatting.gofumpt, null_ls.builtins.formatting.goimports, null_ls.builtins.formatting.shfmt, }, on_attach = function(client, bufnr) if client.supports_method("textDocument/formatting") then vim.api.nvim_clear_autocmds({ group = augroup, buffer = bufnr }) vim.api.nvim_create_autocmd("BufWritePre", { group = augroup, buffer = bufnr, callback = function() vim.lsp.buf.format({ async = false, filter = function(c) return c.name == "null-ls" end, }) end, }) end end, }) end ``` [[nvim-lspconfig]]の設定。 ```lua config = function() ---------------------------------- -- nvim-cmp ---------------------------------- local cmp = require("cmp") local luasnip = require("luasnip") local lspkind = require("lspkind") require("luasnip.loaders.from_snipmate").lazy_load() cmp.setup({ completion = { completeopt = "menu,menuone,noinsert", }, snippet = { expand = function(args) luasnip.lsp_expand(args.body) end, }, window = { completion = cmp.config.window.bordered(), documentation = cmp.config.window.bordered(), }, mapping = cmp.mapping.preset.insert({ ["<F5>"] = cmp.mapping.complete(), ["<CR>"] = cmp.mapping.confirm({ select = true, }), ["<C-p>"] = cmp.mapping.abort(), ["<Tab>"] = cmp.mapping(function(fallback) if cmp.visible() then cmp.select_next_item({ behavior = cmp.SelectBehavior.Select }) elseif luasnip.expand_or_jumpable() then luasnip.expand_or_jump() else fallback() end end, { "i", "s" }), ["<S-Tab>"] = cmp.mapping(function(fallback) if cmp.visible() then cmp.select_prev_item({ behavior = cmp.SelectBehavior.Select }) elseif luasnip.jumpable(-1) then luasnip.jump(-1) else fallback() end end, { "i", "s" }), }), sources = cmp.config.sources({ { name = "nvim_lsp" }, { name = "luasnip" }, { name = "buffer" }, { name = "path" }, }), formatting = { format = lspkind.cmp_format({ mode = "symbol", maxwidth = 50, ellipsis_char = "...", show_labelDetails = true, }), }, }) cmp.setup.cmdline(":", { completion = { completeopt = "menu,menuone,noinsert,noselect", }, mapping = cmp.mapping.preset.cmdline(), sources = cmp.config.sources({ { name = "path" }, }, { { name = "cmdline", option = { ignore_cmds = { "Man", "!" }, }, }, }), }) ---------------------------------- -- lspconfig ---------------------------------- local util = require("lspconfig.util") local lspconfig = require("lspconfig") require("lspconfig.ui.windows").default_options.border = "single" local capabilities = require("cmp_nvim_lsp").default_capabilities() capabilities.textDocument.completion.completionItem.snippetSupport = true -- ufo capabilities.textDocument.foldingRange = { dynamicRegistration = false, lineFoldingOnly = true, } lspconfig.ruff_lsp.setup({ capabilities = capabilities }) lspconfig.pyright.setup({ capabilities = capabilities }) lspconfig.gopls.setup({ capabilities = capabilities }) lspconfig.cssls.setup({ capabilities = capabilities }) lspconfig.html.setup({ capabilities = capabilities }) lspconfig.emmet_language_server.setup({ capabilities = capabilities }) lspconfig.marksman.setup({ capabilities = capabilities }) lspconfig.bashls.setup({ capabilities = capabilities }) lspconfig.svelte.setup({ capabilities = capabilities }) lspconfig.jdtls.setup({ capabilities = capabilities }) lspconfig.denols.setup({ capabilities = capabilities, root_dir = util.root_pattern("deno.json", "deno.jsonc"), }) lspconfig.rust_analyzer.setup({ capabilities = capabilities, settings = { ["rust-analyzer"] = { check = { command = "clippy", }, }, }, }) lspconfig.biome.setup({ capabilities = capabilities, cmd = { "npx", "biome", "lsp-proxy" }, }) lspconfig.jsonls.setup({ capabilities = capabilities, settings = { json = { schemas = require("schemastore").json.schemas(), validate = { enable = true }, }, }, }) lspconfig.yamlls.setup({ capabilities = capabilities, settings = { yaml = { schemaStore = { enable = false, url = "", }, schemas = require("schemastore").yaml.schemas({ extra = { { description = "Bitbucke Pipelines", fileMatch = "bitbucket-pipelines.yml", name = "bitbucket-pipelines.yml", url = "https://bitbucket.org/atlassianlabs/intellij-bitbucket-references-plugin/raw/f9f41a5d1e7b3d25236b15296eb26eba426c6895/src/main/resources/schemas/bitbucket-pipelines.schema.json", }, }, }), }, }, }) -- VueだけでなくTypeScriptやReactもVolarを使う lspconfig.volar.setup({ capabilities = capabilities, filetypes = { "typescript", "javascript", "javascriptreact", "typescriptreact", "vue" }, init_options = { vue = { hybridMode = false, }, }, }) lspconfig.tailwindcss.setup({ capabilities = capabilities, root_dir = util.root_pattern( "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.config.ts" ), }) lspconfig.lua_ls.setup({ capabilities = capabilities, on_init = function(client) local path = client.workspace_folders[1].name if not vim.loop.fs_stat(path .. "/.luarc.json") and not vim.loop.fs_stat(path .. "/.luarc.jsonc") then client.config.settings = vim.tbl_deep_extend("force", client.config.settings, { Lua = { runtime = { version = "LuaJIT", }, workspace = { checkThirdParty = false, library = { vim.env.VIMRUNTIME, }, }, }, }) client.notify("workspace/didChangeConfiguration", { settings = client.config.settings }) end return true end, }) -- LSP key bindings vim.api.nvim_create_autocmd("LspAttach", { group = vim.api.nvim_create_augroup("UserLspConfig", {}), callback = function(ev) -- Enable completion triggered by <c-x><c-o> vim.bo[ev.buf].omnifunc = "v:lua.vim.lsp.omnifunc" local opts = { buffer = ev.buf } -- 定義に移動 vim.keymap.set("n", "<C-]>", vim.lsp.buf.definition, opts) vim.keymap.set("n", "<M-]>", function() vim.cmd([[ vsplit ]]) vim.lsp.buf.definition() end, opts) -- 定義をホバー vim.keymap.set("n", "<M-s>", "<cmd>Lspsaga hover_doc<CR>", opts) -- 実装へ移動 vim.keymap.set("n", "<C-j>i", vim.lsp.buf.implementation, opts) -- 実装をホバー vim.keymap.set("n", "<M-d>", "<cmd>Lspsaga peek_definition<CR>", opts) -- 型の実装をホバー vim.keymap.set("n", "<M-i>", "<cmd>Lspsaga peek_type_definition<CR>", opts) -- 呼び出し元の表示 vim.keymap.set("n", "<C-j>u", "<cmd>Lspsaga finder ref<CR>", opts) -- リネーム vim.keymap.set({ "n", "i" }, "<S-M-r>", "<cmd>Lspsaga rename<CR>", opts) -- Code action vim.keymap.set({ "n", "i" }, "<M-CR>", "<cmd>Lspsaga code_action<CR>", opts) -- 次の診断へ移動 vim.keymap.set("n", "<M-j>", "<cmd>Lspsaga diagnostic_jump_next<CR>", opts) -- 前の診断へ移動 vim.keymap.set("n", "<M-k>", "<cmd>Lspsaga diagnostic_jump_prev<CR>", opts) -- LSP再起動 vim.keymap.set("n", "<C-j>r", "<cmd>LspRestart<CR>", opts) -- 保存時に自動フォーマット vim.api.nvim_create_autocmd("BufWritePre", { pattern = { "*.rs", "*.py", "*.ts", "*.js", "*.html", "*.css" }, callback = function() vim.lsp.buf.format({ buffer = ev.buf, filter = function(f_client) return f_client.name ~= "null-ls" end, async = false, }) end, }) -- 深刻度が高い方を優先して表示 vim.diagnostic.config({ severity_sort = true }) end, }) local signs = { Error = " ", Warn = " ", Hint = "󱩎 ", Info = " " } for type, icon in pairs(signs) do local hl = "DiagnosticSign" .. type vim.fn.sign_define(hl, { text = icon, texthl = hl }) end end ``` ## 詳細調査 `vim.lsp.buf.format`の手前にログを仕込んでみる。[[nvim-lspconfig]]のsetupにて、ファイル保存時に実行される箇所は`[LSP]`、[[none-ls.nvim]]の方は`[none-ls]`のprefixをつけた。 ![[Pasted image 20240504001315.png]] *下から順に実行されている* | ログ | 実行されるか? | | -------------------- | ------- | | `[LSP] biome` | O | | `[LSP] volar` | O | | `[LSP] null-ls` | x | | `[none-ls] biome` | x | | `[none-ls] volar` | x | | `formatting null-ls` | | | `[none-ls] null-ls` | O | | `[LSP] volar` | O | | `[LSP] biome` | O | | `[LSP] null-ls` | x | | `[LSP] biome` | O | | `[LSP] volar` | O | | `written` | | [[nvim-lspconfig]]の設定にて、以下の`pattern`から`*.ts`を除外すると結果が一致する。つまり、以下callbackで`f_client.name`が`volar`か`biome`のときに問題が発生してそう。 ```diff vim.api.nvim_create_autocmd("BufWritePre", { - pattern = { "*.rs", "*.py", "*.ts", "*.js", "*.html", "*.css" }, - pattern = { "*.rs", "*.py", "*.js", "*.html", "*.css" }, callback = function() vim.lsp.buf.format({ buffer = ev.buf, filter = function(f_client) return f_client.name ~= "null-ls" and f_client.name ~= "biome" end, async = false, }) end, }) ``` `biome`は問題なかったため、[[Volar]]の[[LSP]]によるフォーマットが問題になっていそう。 ## 原因 [[nvim-lspconfig]]と[[none-ls.nvim]]の設定が不適切だった。 - [[nvim-lspconfig]]では[[LSPクライアント]]の種類にかからわず、`BufWritePre`の[[autocmd]]を設定してしまっている - `null-ls`以外の`textDocument/formatting`要求をすべて受け入れてしまっている - [[Python]]や[[Rust]]のように[[LSP]]でしかformatしない場合はよいが、[[TypeScript]]のように利用環境によっては[[none-ls.nvim]]でformatするケースがカバーできていない - 今回だと[[Volar]]と[[Biome]]のフォーマットが競合しており、[[Volar]]が後勝ちしている ## 解決方法 [[nvim-lspconfig]]の設定で[[none-ls.nvim]]同等の細かなハンドリングができるようにする。具体的には、以下のように設定する。 ```lua vim.api.nvim_create_autocmd("LspAttach", { group = vim.api.nvim_create_augroup("UserLspConfig", {}), callback = function(ev) -- 中略。。。 -- 保存時に自動フォーマット local augroup = vim.api.nvim_create_augroup("LspFormatting", {}) local client = vim.lsp.get_client_by_id(ev.data.client_id) if client.supports_method("textDocument/formatting") then local set_auto_format = function(lsp_name, pattern) if client.name == lsp_name then print(string.format("[%s] Enable auto-format on save", lsp_name)) vim.api.nvim_clear_autocmds({ group = augroup, buffer = ev.buf }) vim.api.nvim_create_autocmd("BufWritePre", { group = augroup, buffer = ev.buf, pattern = pattern, callback = function() print("[LSP] " .. client.name .. " format") vim.lsp.buf.format({ buffer = ev.buf, async = false }) end, }) end end set_auto_format("rust_analyzer", { "*.rs" }) set_auto_format("ruff_lsp", { "*.py" }) set_auto_format("denols", { "*.ts", "*.js" }) end ``` [[nvim-lspconfig]]は`textDocument/formatting`に対応している場合のみ[[autocmd]]を設定する。また、対応する[[LSPクライアント]]は拡張子つきで明示的に指定する。[[augroup]]も作成し、`BufWritePre`の指定に[[augroup]]とバッファを加えてスコープを狭める。 あわせて[[none-ls.nvim]]の設定も少し変更する。[[augroup]]が`LspFormatting`のままでは[[nvim-lspconfig]]に追加した[[augroup]]と競合するので、`LspNullFormatting`に変更する。 ```diff - local augroup = vim.api.nvim_create_augroup("LspFormatting", {}) + local augroup = vim.api.nvim_create_augroup("LspNullFormatting", {}) ``` 同じ名前にすると、[[nvim-lspconfig]]と[[none-ls.nvim]]で先に設定された[[autocmd]]が無効になってしまうので、それを回避するのが一番の目的。 > [[📝Neovimで設定したautocmdがnvim_clear_autocmdsを呼び出していないのに無効化されてしまう]] `clear = false`オプション追加で回避もできるが、以下の理由から[[augroup]]の名称変更に至った。 - `clear = false`なしで設定できた方がシンプル - [[nvim-lspconfig]]と[[none-ls.nvim]]のフォーマット競合リスクはあるが、暗黙的にどちらか一方が打ち消されるよりは問題に気づきやすい気がする ### 関連コミット <div class="link-card"> <div class="link-card-header"> <img src="https://github.githubassets.com/favicons/favicon.svg" class="link-card-site-icon"/> <span class="link-card-site-name">GitHub</span> </div> <div class="link-card-body"> <div class="link-card-content"> <p class="link-card-title">[LSP] formatting周りの設定を最適化 · tadashi-aikawa/owl-playbook@89950fe</p> <p class="link-card-description">Playbook both Linux and Windows for me. Contribute to tadashi-aikawa/owl-playbook development by creatin ... </p> </div> <img src="https://opengraph.githubassets.com/5ddd78a296686c1dae6cf3548a5c0e39307493fdf1af5f5f4e6f22b5a00bed68/tadashi-aikawa/owl-playbook/commit/89950fe9ad02c0b5a158092bc6490ef80cf78682" class="link-card-image" /> </div> <a href="https://github.com/tadashi-aikawa/owl-playbook/commit/89950fe9ad02c0b5a158092bc6490ef80cf78682"></a> </div>