## 事象
[[🦉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>