Neovimの保存時フォーマット conform.nvimとLSPの使い分け
Neovimで「保存したら整形される」状態を作る方法は2つあります。本体のvim.lsp.buf.format()をBufWritePreで呼ぶか、conform.nvimに任せるかです。どちらを選んでも最初の1回は動きますが、設定を足していくうちに「書いたのに整形されない」「LSPとフォーマッタのどちらが走ったのか分からない」という状態に入りやすいのは後者です。conform.nvimは、LSPと外部コマンドのどちらに整形させるかをlsp_formatという1つの値で決めていて、この値の意味を取り違えると原因が見えなくなります。
この記事では、同じ崩れたGoのファイルを相手に、:%!gofmtとconform.nvimで整形したときにカーソル・マーク・折りたたみがどう残るか、lsp_formatの5つの値で実際にどちらが走るかを、ログを取りながら確かめました。
検証環境:NVIM v0.12.4(macOS)。conform.nvimはvim.packで入れたコミット016802d(2026-08-11、タグstableから97コミット先)。整形役はgo1.22.3付属のgofmt、goimports、言語サーバはgopls v0.16.2、JavaScriptは一時ディレクトリにnpmで入れたprettier 3.9.10です。HOMEを一時ディレクトリに向けたNeovimをtmuxの中で起動して:wし、画面の表示とconformのログ(log_level = vim.log.levels.DEBUG)を見ました。styluaとruffは入れておらず、試していません。
目次(9項目)
結論 本体のLSP整形で足りる人、conform.nvimが要る人
分かれ目は「整形を誰がやるか」です。言語サーバが整形まで引き受けてくれて、その結果に不満が無いならプラグインは要りません。GoならgoplsがtextDocument/formattingに応えて、gofmtと同じ形に直してくれます。逆に、整形役が言語サーバではなくコマンドラインのツール(prettier、goimports、stylua、ruffなど)なら、本体だけではそのツールを保存時に呼ぶ仕組みが無いので、conform.nvimのようなプラグインが必要になります。
| 状況 | 選ぶもの |
|---|---|
| 使う言語すべてで言語サーバが整形できる | 本体のvim.lsp.buf.format() |
| 整形役がCLIのツール | conform.nvim |
| 1つの言語に複数のツールを順に流したい | conform.nvim |
| LSPとCLIの両方が整形できる | conform.nvimでlsp_formatを決める |
4行目がいちばん事故の多いところです。両方とも整形できる状態で何も決めずにおくと、片方しか走っていないのに両方が働いていると思い込んだり、その逆が起きたりします。詳しくは後半の節で、実際にどちらが走ったかをログで示します。
conform.nvimを入れて保存時に整形する(vim.pack と最小設定)
Neovim 0.12なら、プラグインマネージャを別に入れなくてもNeovim 0.12の標準プラグイン管理 vim.packで入ります。init.luaに次の数行を書きました。
vim.pack.add({ "https://github.com/stevearc/conform.nvim" })
require("conform").setup({
formatters_by_ft = {
go = { "gofmt" },
},
format_on_save = { timeout_ms = 500, lsp_format = "fallback" },
})
初めて起動すると、画面下にThese plugins will be installed:とProceed? [Y]es, (N)o, (A)lways:が出ます。yで取得が始まり、vim.pack: 100% Installing plugins (1/1)で終わります。conform.nvimの本家はstevearc/conform.nvimです。
formatters_by_ftのキーは拡張子ではなくfiletypeの名前です。.goならgo、.jsならjavascript、.tsならtypescriptになります。迷ったら対象のファイルを開いて:set filetype?で確かめます。
次の崩れたファイルを開いて:wしました。
package main
import "fmt"
func main(){
x:=1
fmt.Println( x )
}
package main
import "fmt"
func main() {
x := 1
fmt.Println(x)
}
画面下の表示は"main.go" 8L, 68B writtenで、書き込まれたのは整形後の8行です。format_on_saveはBufWritePreで動くので、整形がバッファに当たってから書き込みが起きます。保存したあとでディスク上のファイルとバッファがずれることはありません。
:%! で整形するのと何が違うか(カーソル・マーク・折りたたみ・undo)
プラグインを使わなくても、:%!gofmtと打てばバッファ全体をgofmtに通して置き換えられます(Vimからシェルコマンドを実行する方法(:! と :terminal)のフィルタの使い方そのものです)。整形結果の文字列は同じなので、違いが出るのはそれ以外の部分です。
確かめるために、先頭に崩れたmain関数、その下に整ったfunc a()を置いた12行のファイルを用意しました。gofmtは先頭付近に空行を2つ足すだけで、func a()の中身には触りません。そのうえで、func a()の5行(8〜12行目)をzf相当の手動折りたたみにし、11行目のfmt.Println("c")にマークaを付け、カーソルを10行目の6桁目に置いてから、それぞれ整形してuを1回押しました。
| 見たもの | :%!gofmt | conform.nvim |
|---|---|---|
| カーソル | 1行目の先頭へ飛ぶ | 12行目(同じ行の中身)の同じ桁 |
マーク'a | 11行目のまま。中身はfmt.Println("a")に変わる | 13行目へずれ、fmt.Println("c")を指したまま |
| 折りたたみ | 8〜12行目のまま。範囲が2行ずれる | 10〜14行目へ移り、func a()を包んだまま |
| u1回 | 全体が元に戻る。折りたたみは消える | 全体が元に戻る。折りたたみは8〜12行目に戻る |
:%!は12行をまとめて捨てて、gofmtが返した14行を流し込みます。マークや折りたたみは「何行目か」でしか覚えていないので、行が増えた後ろでは別の行を指すことになります。マークaは数字の上では11行目のままでしたが、そこにあるのは"c"ではなく"a"の行でした。いちばん困るのは折りたたみで、範囲が}と空行を含む8〜12行目にずれたまま残り、uで戻すと消えてしまいました。
conform.nvimは整形前と整形後の差分を取り、変わった箇所だけをバッファに当てます。ソースのlua/conform/runner.luaでvim.text.diff()を呼んでいる部分がそれで、今回は先頭の数行だけが書き換わり、func a()の5行は1文字も触られていません。触られていない行に付いたマークや折りたたみは、上で行が増えたぶんだけ下へずれて、正しい行を指し続けます。uについては、どちらも1回で整形前に戻り、changenr()の値も1から0に戻りました。整形がundoを何段も積むことはありませんでした。
保存のたびに整形する使い方では、この差がそのまま体感の差になります。ファイルの末尾で作業しているのに、保存した瞬間に:%!でカーソルが先頭へ飛ぶのは毎回やり直しになるからです。
LSPの整形とどちらが走るか(lsp_format の5つの値)
どちらが走ったかを整形結果で見分けるために、importを書き忘れたファイルを使いました。
package main
func main(){
os.Exit( 0 )
}
フォーマッタにgoimportsを指定しておくと、足りないimport "os"を足して整形します。一方goplsのtextDocument/formattingは字下げと空白を直すだけで、importは足しません。結果にimport "os"があればgoimportsが走ったと分かります。goplsはNeovimの標準LSP設定(lsp/とvim.lsp.enable)と同じvim.lsp.config()とvim.lsp.enable()で起動し、クライアントが初期化を終えるのを待ってからrequire("conform").format()を呼びました。ログにはRunning LSP formatter on …とRun goimports on …がそれぞれ出るので、順番も読めます。
lsp_format | 走ったもの(ログの順) | import "os" |
|---|---|---|
| 指定なし | goimportsだけ | 入る |
"never" | goimportsだけ | 入る |
"fallback" | goimportsだけ | 入る |
"prefer" | goplsだけ | 入らない |
"first" | gopls → goimports | 入る |
"last" | goimports → gopls | 入る |
指定しないときは"never"と同じで、ソースの既定値もlsp_format = "never"でした。つまりconform.nvimは、何も書かなければLSPには一切整形させません。「LSPが動いているのに保存時に整形されない」という相談の多くは、formatters_by_ftにその言語が無いままこの既定値で動いているケースです。
"fallback"の「フォールバック」は、使えるフォーマッタが1つも無いときだけLSPに回すという意味です。formatters_by_ftを{ "gofumpt" }(このMacには入っていない)にして試すと、"never"では何も起きず、"fallback"ではgoplsが整形しました。ところが{ "goimports", "gofumpt" }のように1つでも使えるものが混ざっていると、gofumptが無くてもLSPには回らず、goimportsだけが走りました。「見つからないツールの代わりにLSPが走る」わけではない点は誤解しやすいところです。
「LSPと二重に走る」の正体は2つあります。1つは"first"か"last"を選んでいて、表のとおり両方が順に走っているケースです。もう1つはconform.nvimの設定とは別に、以前からLspAttachなどでBufWritePreにvim.lsp.buf.format()を登録したままになっているケースで、この場合はautocmdが2つあるので、conform.nvimのlsp_formatをどう変えてもLSPの整形は止まりません。:autocmd BufWritePreで一覧を出し、conform.nvim以外に整形を呼んでいるものが無いかを見ると切り分けられます。
整形されないときに見る場所(:ConformInfo とフォーマッタの PATH)
指定したフォーマッタがPATHに無いとき、:wは止まりません。formatters_by_ft = { go = { "gofumpt" } }で:wすると、ファイルは整形されないまま書き込まれ、画面下には次の1行だけが出ました。
Formatters unavailable for go file
保存自体は成功しているので、この行を見落とすと「設定したのに何も起きない」としか感じられません。原因を見るには:ConformInfoを開きます。goimportsとgofumptを並べ、goplsも起動している状態で開いた画面がこれです(ログのパスは省略しています)。
Log file: …/.local/state/nvim/conform.log
Formatters for this buffer:
LSP: gopls
goimports ready (go) /Users/…/go/bin/goimports
gofumpt unavailable: Command 'gofumpt' not found
Other formatters:
見るのは3点です。LSP:の行があれば、整形できる言語サーバが付いています。フォーマッタごとにreadyかunavailableが出て、readyなら実際に使うコマンドのフルパスが添えられます。unavailableの行は理由まで書いてくれるので、Command '…' not foundならPATHの問題です。
PATHはNeovimを起動したときのものが使われます。今回の検証では~/go/binをPATHに足さずに起動するとgoimportsも見つからず、足して起動し直すとreadyになりました。シェルの設定ファイルでPATHを足している場合、GUIやランチャーから起動したNeovimにはそれが渡っていないことがあります。
一方、prettierのようにプロジェクトに入れて使うツールは事情が違います。npm i -D prettierしただけでグローバルのPATHにはprettierが無い状態でも、:ConformInfoはprettier ready (javascript) …/node_modules/.bin/prettierと表示し、保存時にconst a = { b: 1, c: "x" };へ整形されました。conform.nvimの組み込みのprettier定義は、ファイルの位置からnode_modules/.binを探しに行きます。
どれが走ったかまで知りたいときは、setup()にlog_level = vim.log.levels.DEBUGを足してから:ConformInfoの先頭にあるログファイルを開きます。この記事の表は、そこに出るRunning LSP formatter onとRun goimports onの行を数えて作りました。
遅いフォーマッタで保存が止まるとき(timeout_ms と format_after_save)
format_on_saveは:wの前に整形を終わらせる必要があるので、フォーマッタが遅いとその間Neovimが待ちます。2秒眠ってからgofmtを呼ぶだけのラッパーを作って再現しました。
#!/bin/sh
sleep 2
exec gofmt
require("conform").setup({
formatters = { slowfmt = { command = "/path/to/slowfmt" } },
formatters_by_ft = { go = { "slowfmt" } },
format_on_save = { timeout_ms = 500 },
})
timeout_ms = 500で:wすると、0.5秒ほど待ったあとにFormatter 'slowfmt' timeoutと出て、ファイルは整形されないまま書き込まれました。:set modified?はnomodifiedで、保存そのものは成功しています。タイムアウトしても保存を巻き込んで失敗させることはなく、整形だけを諦めるという動きです。timeout_msを書かないときの既定値は、ソースでは1000ミリ秒でした。
同じラッパーをformat_after_save = {}で動かすと、流れがまったく変わります。:wした直後に"m.go" 4L, 46B writtenと整形前の内容で書き込まれ、約2秒後に整形がバッファに当たって、もう一度"m.go" 5L, 42B writtenと書き込まれました。こちらはBufWritePostで非同期に動くので待たされませんが、1回の:wでファイルが2回書き込まれます。ファイルの変更を監視して再ビルドするツールを動かしていると、整形前の内容で1回走ることになるので、遅いフォーマッタに限って使うのが向いています。
| 設定 | :wで待つか | ディスクへの書き込み |
|---|---|---|
format_on_save(間に合う) | 整形が終わるまで | 1回(整形後) |
format_on_save(タイムアウト) | timeout_msまで | 1回(整形前) |
format_after_save | 待たない | 2回(整形前→整形後) |
プラグインを入れずに本体だけで保存時に整形する
最初の節の表で1行目に当てはまるなら、本体だけで済みます。goplsを起動したうえで、init.luaに次を書きました。
vim.api.nvim_create_autocmd("BufWritePre", {
callback = function(args)
vim.lsp.buf.format({ bufnr = args.buf, timeout_ms = 1000 })
end,
})
Goのファイルでは:wでgofmtと同じ形に整形されて"m.go" 5L, 42B writtenと書き込まれました。import "os"は足されないので、goimportsの働きまで欲しいなら、この形では足りません。
言語サーバが付いていないファイルでは、保存はされますが整形はされず、:messagesに[LSP] Format request failed, no matching language servers.が残りました。:wの直後は"n.lua" 1L, 15B writtenに上書きされて見えなくなるので、気づきにくい点はconform.nvimのFormatters unavailableと同じです。このautocmdは全ファイルに掛かるので、言語サーバの無いファイルを保存するたびにこのメッセージが積もります。気になるならpatternで対象の拡張子を絞ります。
本体だけで済ませる条件をまとめると、「使う言語のすべてで言語サーバが整形に対応していて、CLIのツールを別に流す必要が無いこと」です。1つでもCLIのツールが要る言語があれば、その言語のためだけにBufWritePreで:%!を組むより、conform.nvimを入れたほうが前の節で見たマークや折りたたみの問題を避けられます。
カーソルやマークの比較は、最初の1回で「差は無い」と結論しかけました。最初に用意したファイルは崩れた関数が折りたたみより下にあり、gofmtの前後で行数も14行のまま変わらなかったので、:%!gofmtでもマークaと折りたたみは元の位置にそのまま残って見えたからです。崩れた部分を上に移し、整形で行数が増えるようにして並べ直したところで、ようやく:%!のマークが別の行を指すようになりました。普段の保存で崩れるかどうかが、カーソルより上で行数が変わったかどうかで決まると分かったのはこのときです。
まとめ
言語サーバだけで整形が済むなら、BufWritePreでvim.lsp.buf.format()を呼ぶ数行で足ります。CLIのフォーマッタが1つでも要るなら、差分だけを当てるconform.nvimのほうが、保存のたびにカーソルやマーク、折りたたみが崩れずに済みます。
conform.nvimで迷ったら、lsp_formatの既定が"never"であること、"fallback"は使えるフォーマッタが1つも無いときにしかLSPへ回さないことの2点を思い出してください。それでも整形されないときは、:ConformInfoでunavailableの行とPATHを見て、遅いツールならtimeout_msを延ばすかformat_after_saveに移します。