結論
- lazy.nvim の遅延ロードは、spec (プラグインごとに書く設定の表) に書いたきっかけが来るまでプラグインを読まない。きっかけが来ない起動の仕方では一度も読まれず、エラーも出ない
event = { "BufReadPost", "BufNewFile" }で遅延したプラグインは、引数なしで起動すると読まれない。起動直後から要る statusline などはlazy = falseかevent = "VeryLazy"にする- プラグインが読まれる前にそのバッファの FileType が済んでいると、FileType はやり直されない。
nvim a.mdでは a.md の FileType が BufEnter より先に済むので、event = "BufEnter"とevent = "VeryLazy"で読むプラグインは、a.md に ftplugin も FileType の autocmd も効かない。:edit a.mdで開けば効き、ft = "markdown"とevent = "BufReadPost"ならnvim a.mdでも効く cmd = "Tool"で起動時に用意されるのは:Toolだけで、:ToolInstallのような他のコマンドとplugin/*.vimが張るキーマップは、:Toolを打って読まれるまで無いnvim --headlessでは UIEnter が来ないので、VeryLazyのプラグインは読まれない。-c 'qa!'は VimEnter より前に終わる。headless で読まれないことは、UI を attach した起動で壊れている証拠にならない
以下は Neovim 0.12.5 と lazy.nvim v11.17.5 (の 4 commit 後、306a055) で確かめた。プラグインは、ローカルのディレクトリを spec の dir で読ませた最小のものである。「実 UI」は、script コマンドが作る疑似端末 (pty) の中で nvim を画面付きで起動し、起動 300ms 後の状態を記録した結果である。
前提: 起動時のイベントの順番と、遅延ロードのきっかけ

lazy.nvim は Neovim のプラグインマネージャで、プラグインを起動時にまとめて読まず、spec に書いたきっかけが来たときに読む (遅延ロード)。読まれるまでは、プラグインの plugin/ の下にある起動時用のスクリプトも、ftplugin/ の下にあるファイルの種類 (filetype) ごとのスクリプトも実行されない。
きっかけの書き方は次のとおり。
event: autocmd のイベント名。autocmd は「ファイルを読んだ後」「ファイルの種類が決まったとき」のようなイベントに処理を結び付ける Neovim の仕組みであるft: ファイルの種類。その種類の FileType イベントが来たときに読むcmd: コマンド名。そのコマンドを打ったときに読むevent = "VeryLazy": lazy.nvim が起動の最後、UIEnter (UI が attach されたときのイベント。attach は nvim 本体に画面を描く UI が接続されること) の後に出すイベント。VeryLazy は Neovim 組み込みのイベントでなく、User イベント (プラグインが名前を付けて自分で出す autocmd のイベント) の 1 つなので、autocmd やコマンドではUser VeryLazyと書く
確かめに使った spec は次の形である。
require("lazy").setup({
{
dir = vim.fn.getcwd() .. "/plugins/statusline", -- ローカルのディレクトリをプラグインとして読む
name = "statusline",
event = { "BufReadPost", "BufNewFile" },
},
})nvim a.md の起動では、a.md のイベントが先に来て、起動が終わったことを示す VimEnter と UIEnter はその後に来る。-c (起動時に : で打つコマンドを実行する引数) は、a.md を開いた後、VimEnter の前に走る。各イベントに autocmd を張り、来た順に記録した。行頭の ui は UI が attach されていた印で、-c は最初の -c が走った位置である。
$ nvim a.md (実 UI)
ui BufReadPre(a.md) BufReadPost(a.md) FileType BufEnter(a.md) -c VimEnter UIEnter
$ nvim (実 UI。起動後に :edit a.md)
ui BufEnter() -c VimEnter UIEnter BufReadPre(a.md) BufReadPost(a.md) FileType BufEnter(a.md)BufReadPre と BufReadPost はファイルを読む前と後、FileType はファイルの種類が決まったとき、BufEnter はバッファに入ったときのイベントである。2 行目の BufEnter() は、引数なしで起動したときの名前の無い空のバッファに入ったことを示す。:edit a.md で開くと、a.md のイベントは UIEnter より後に来る。
BufReadPost と BufNewFile で読むプラグインは、引数なしの起動で読まれない

BufReadPost は既存のファイルを読んだ後、BufNewFile は存在しないファイルを開いたときに来る。引数なしで起動したときの空のバッファにはどちらも来ないので、この 2 つで遅延したプラグインは、引数なしで起動すると一度も読まれない。statusline (画面下の状態表示) を描くプラグインをこう遅延すると、引数なしの起動では素の statusline のまま残る。
プラグインの代わりに、読まれたら印を残すだけのファイルを置いた。
-- plugins/statusline/plugin/statusline.lua
vim.g.statusline_loaded = true
vim.o.statusline = "%f [statusline plugin]"--headless は UI を attach せずに起動する引数、-u init.lua は設定ファイルの指定である。exist.txt は存在するファイル、new.txt は存在しないファイルで、nil は読まれていないことを示す。
$ nvim --headless -u init.lua -c 'lua print(vim.g.statusline_loaded)' -c 'qa!'
nil
$ nvim --headless -u init.lua exist.txt -c 'lua print(vim.g.statusline_loaded)' -c 'qa!'
true
$ nvim --headless -u init.lua new.txt -c 'lua print(vim.g.statusline_loaded)' -c 'qa!'
true
実 UI でも同じで、spec を lazy = false (起動時に読む) か event = "VeryLazy" に変えると、引数なしでも読まれる。1 列目は spec (event は上の spec、eager は lazy = false)、ui=1 は UI が attach されていること、loaded は起動 300ms 後の vim.g.statusline_loaded である。
event ui=1 args=(none) loaded=nil
event ui=1 args=exist.txt loaded=true
VeryLazy ui=1 args=(none) loaded=true
VeryLazy ui=1 args=exist.txt loaded=true
eager ui=1 args=(none) loaded=true
eager ui=1 args=exist.txt loaded=truePITFALL: 対策に VeryLazy を選ぶと、headless では VeryLazy が来ない (後の節) ので、直ったかを headless のテストで確かめると読まれないまま失敗する。対策を headless で確かめるなら、対策の側を lazy = false にする。
起動引数のファイルは、FileType より後で読むプラグインに取りこぼされる

プラグインが読まれる前にそのバッファの FileType が済んでいると、FileType はやり直されず、プラグインの ftplugin も FileType の autocmd もそのバッファに効かない。nvim a.md の a.md は FileType が BufEnter より先に済むので、event = "BufEnter" で読んだプラグインは、a.md に ftplugin も FileType の autocmd も効かない。一方、プラグインの BufEnter の autocmd は a.md にも効く。プラグインの autocmd は読まれた後に張られるが、lazy.nvim はきっかけのイベントでプラグインを読んだ後、そのイベントをもう一度出し直すので、張られたばかりの autocmd にも届く (再現の結果の BufEnter=true)。出し直すのはきっかけのイベントで、それより前に済んだ FileType は出し直さない。VeryLazy は UIEnter の後なので、読まれた時点で a.md の FileType も BufEnter も済んでおり、どちらの autocmd も ftplugin も効かない。
:edit a.md で開くと結果が変わる。引数なしの起動では空のバッファの BufEnter が来て、VeryLazy も起動の最後に来るので、a.md を開く前にプラグインは読み終わっている。
確かめたプラグインは、3 つの経路で a.md のバッファに印を付ける。spec のきっかけは ft = "markdown"、event = "VeryLazy"、event = "BufReadPost"、event = "BufEnter" の 4 通りに変えた。
-- plugins/mdtool/ftplugin/markdown.lua
vim.b.mdtool_ftplugin = true-- plugins/mdtool/plugin/mdtool.lua
vim.g.mdtool_loaded = true
local group = vim.api.nvim_create_augroup("mdtool", { clear = true })
vim.api.nvim_create_autocmd("FileType", {
group = group,
pattern = "markdown",
callback = function(ev)
vim.b[ev.buf].mdtool_filetype = true
end,
})
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*.md",
callback = function(ev)
vim.b[ev.buf].mdtool_bufenter = true
end,
})open=argv は nvim a.md、open=edit は引数なしで起動してから :edit a.md で開いた結果で、どちらも起動 300ms 後の a.md のバッファを見た。ftplugin、FileType、BufEnter の列は、それぞれの経路の印が a.md に付いたか (nil は付いていない) を示す。
$ ./run.sh | grep 'ui=1'
ft ui=1 open=argv loaded=true ftplugin=true FileType=true BufEnter=true
ft ui=1 open=edit loaded=true ftplugin=true FileType=true BufEnter=true
VeryLazy ui=1 open=argv loaded=true ftplugin=nil FileType=nil BufEnter=nil
VeryLazy ui=1 open=edit loaded=true ftplugin=true FileType=true BufEnter=true
BufReadPost ui=1 open=argv loaded=true ftplugin=true FileType=true BufEnter=true
BufReadPost ui=1 open=edit loaded=true ftplugin=true FileType=true BufEnter=true
BufEnter ui=1 open=argv loaded=true ftplugin=nil FileType=nil BufEnter=true
BufEnter ui=1 open=edit loaded=true ftplugin=true FileType=true BufEnter=true
headless では、VeryLazy のプラグインが読まれないことを除いて同じ結果になる。
ft = "markdown" は a.md の FileType で読まれ、event = "BufReadPost" は FileType より前に読まれるので、どちらも nvim a.md の a.md に効く。最初のバッファに効かせたいプラグイン (行番号の横に変更の印を出すもの、コメント記号を設定するもの、前回のカーソル位置へ戻すもの) は、ft か BufReadPost で読むか、lazy = false にする。VeryLazy は、読まれた時点で開いているバッファを自分で探して処理するプラグインに向く。例えば LSP (Language Server Protocol。補完や定義へのジャンプを、言語ごとのサーバに問い合わせる仕組み) のクライアントの設定は、読まれた後に、開いているバッファへサーバを attach (バッファとサーバを結び付けること) する。
PITFALL: :edit a.md では起きず nvim a.md でだけ起きるので、プラグインの設定の誤りと取り違えやすい。
cmd で遅延すると、他のコマンドとキーマップは読まれるまで無い

cmd に書いたコマンドだけが起動時に用意される
cmd = "Tool" と書くと、lazy.nvim は起動時に :Tool の代わりのコマンドを作り、打たれたらプラグインを読んでから本物の :Tool を実行する。代わりを作るのは cmd に書いた名前だけなので、同じプラグインの :ToolInstall は、読まれるまで存在しない。
-- plugins/tool/plugin/tool.lua (spec は cmd = "Tool")
vim.api.nvim_create_user_command("Tool", function()
print("Tool ran")
end, {})
vim.api.nvim_create_user_command("ToolInstall", function()
print("ToolInstall ran")
end, {})exists(":名前") は、その名前のコマンドがあれば 2、無ければ 0 を返す。E492 は、そういう名前のコマンドが無いというエラーである。実在のプラグインでも同じことが起きる。mason.nvim は、LSP のサーバなどの外部ツールを入れるプラグインで、:Mason (一覧の画面を開く) のほかに :MasonInstall などのコマンドを持つ。手元に入っている v2.3.1 を、上の Tool と同じ init.lua に cmd = "Mason" で足し、読まずに exists() だけを見ると、起動直後は :Mason だけがある。
$ nvim --headless -u init.lua \
-c 'lua print(("Tool=%d ToolInstall=%d"):format(vim.fn.exists(":Tool"), vim.fn.exists(":ToolInstall")))' \
-c 'lua print(("Mason=%d MasonInstall=%d"):format(vim.fn.exists(":Mason"), vim.fn.exists(":MasonInstall")))' \
-c 'qa!'
Tool=2 ToolInstall=0
Mason=2 MasonInstall=0
$ nvim --headless -u init.lua -c 'ToolInstall' -c 'qa!'
Error in command line:
E492: Not an editor command: ToolInstall
$ nvim --headless -u init.lua -c 'Tool' \
-c 'lua print(("Tool=%d ToolInstall=%d"):format(vim.fn.exists(":Tool"), vim.fn.exists(":ToolInstall")))' \
-c 'ToolInstall' -c 'qa!'
Tool ran
Tool=2 ToolInstall=2
ToolInstall ran
使うコマンドを全部 cmd に並べると、起動直後から打てる。mason.nvim なら cmd = { "Mason", "MasonInstall", "MasonUpdate", "MasonUninstall", "MasonLog" } のように書く。
$ nvim --headless -u init.lua \
-c 'lua print(("Tool=%d ToolInstall=%d"):format(vim.fn.exists(":Tool"), vim.fn.exists(":ToolInstall")))' \
-c 'ToolInstall' -c 'qa!' # spec は cmd = { "Tool", "ToolInstall" }
Tool=2 ToolInstall=2
ToolInstall ran
PITFALL: 一度読まれれば全部のコマンドが現れるので、起きるのは起動直後だけである。手順書で「:ToolInstall を打つ」と案内すると、起動直後に打った人だけが E492 で弾かれる。
plugin/*.vim が張るキーマップも、読まれるまで無い
キーマップ (キーの組み合わせに操作を割り当てる設定) を plugin/*.vim で張るプラグインを cmd で遅延すると、そのファイルは :Tool を打つまで走らないので、キーマップも無い。lazy.nvim が代わりを作るのはコマンドだけである。
" plugins/tool/plugin/tool.vim
command! Tool echo 'Tool ran'
nnoremap <silent> <Plug>(tool-run) :<C-u>echo 'tool-run'<CR>
nmap <Leader>t <Plug>(tool-run)<Plug>(tool-run) はプラグインが用意する操作の名前で、<Leader>t はそれを呼ぶキーである (<Leader> はここではスペースにした)。maparg() はキーに割り当てられた内容を返し、無ければ空文字を返す。
$ nvim --headless -u init.lua -c 'lua print(("Tool=%d <Leader>t=%q"):format(vim.fn.exists(":Tool"), vim.fn.maparg("<Leader>t", "n")))' -c 'qa!'
Tool=2 <Leader>t=""
$ nvim --headless -u init.lua -c 'Tool' -c 'lua print(("Tool=%d <Leader>t=%q"):format(vim.fn.exists(":Tool"), vim.fn.maparg("<Leader>t", "n")))' -c 'qa!'
Tool ran
Tool=2 <Leader>t="<Plug>(tool-run)"
起動直後からキーマップが要るなら event = "VeryLazy" か lazy = false にする。VeryLazy にした実 UI では、起動 300ms 後にキーマップがある。
$ event = "VeryLazy": 実 UI で起動して 300ms 後
Tool=2 <Leader>t="<Plug>(tool-run)"PITFALL: 「:Tool は動くのにキーマップだけが効かない」が典型の症状である。起動時に全部のプラグインを読む素の Vim の設定から lazy.nvim へ移したときに出る。
headless では UIEnter と VeryLazy が来ず、-c 'qa!' は VimEnter より前に終わる

nvim --headless は UI を attach しないので、UIEnter が来ない。lazy.nvim は UIEnter の後に VeryLazy を出すので、event = "VeryLazy" のプラグインは読まれず、config (spec に書く、読んだ後に走る関数) も走らない。VimEnter は headless でも来る。ただし -c 'qa!' で終えると、-c は VimEnter より前に走るので、VimEnter が来る前に nvim が終わる。
-- init.lua (R.setup は require("lazy").setup を呼ぶ関数、R.log は out.txt に 1 行足す関数、here は init.lua のあるディレクトリ)
for _, e in ipairs({ "VimEnter", "UIEnter" }) do
vim.api.nvim_create_autocmd(e, { callback = function() R.log(" " .. e) end })
end
vim.api.nvim_create_autocmd("User", { pattern = "VeryLazy", callback = function() R.log(" User VeryLazy") end })
R.setup({
{
dir = here .. "/plugins/lsp-setup",
name = "lsp-setup",
event = "VeryLazy",
config = function()
R.log(" config ran")
end,
},
})各行は out.txt に記録された順である。-c ran は最初の -c が走った印で、quit (...) は起動 300ms 後に終わる直前の印である (loaded はプラグインが読まれたか)。
$ nvim --headless -c 'qa!'
-c ran
$ nvim --headless (300ms 後に終了)
-c ran
VimEnter
quit (loaded=nil)
$ nvim (実 UI。300ms 後に終了)
-c ran
VimEnter
UIEnter
User VeryLazy
config ran
quit (loaded=true)headless で VeryLazy のプラグインを確かめるなら、:Lazy! load <名前> でその場で読むか、doautocmd User VeryLazy (doautocmd はイベントを手で起こすコマンド) で VeryLazy を出す。どちらでも config が走る。
$ nvim --headless -c 'Lazy! load lsp-setup'
-c ran
config ran
VimEnter
quit (loaded=true)
$ nvim --headless -c 'doautocmd User VeryLazy'
-c ran
User VeryLazy
config ran
VimEnter
quit (loaded=true)VimEnter の後の処理を headless で確かめるなら、-c 'qa!' ではなく、vim.defer_fn (指定したミリ秒の後に関数を呼ぶタイマー) で後から終える。こうすると VimEnter が来てから終わり (out.txt に VimEnter が記録される)、終了コードは 0 になる。
$ nvim --headless -u init.lua -c 'lua vim.defer_fn(function() vim.cmd("qa!") end, 300)'
VimEnter
exit=0PITFALL: headless で「読まれない」「VimEnter の処理が走らない」と出ても、実 UI で壊れているとは限らない。逆に、-c 'qa!' で終える headless のテストは VimEnter の処理を一度も走らせないので、テストが成功しても VimEnter の処理は何も確かめていない。
まとめ
| きっかけ / 起動 | 起きること | 対策 |
|---|---|---|
event = { "BufReadPost", "BufNewFile" } を引数なしで起動 | 一度も読まれない | 起動直後から要るものは lazy = false か VeryLazy |
event = "BufEnter" と nvim a.md | a.md に ftplugin と FileType の autocmd が効かない | 最初のバッファに効かせるものは ft、BufReadPost、lazy = false |
event = "VeryLazy" と nvim a.md | a.md に ftplugin と FileType、BufEnter の autocmd が効かない | 同上。VeryLazy は開いているバッファを自分で探して処理するものに使う |
ft / BufReadPost と nvim a.md | a.md にも効く | なし |
cmd = "Tool" | :ToolInstall が読まれるまで無い (E492) | 使うコマンドを cmd に全部並べる |
cmd と plugin/*.vim のキーマップ | 読まれるまでキーマップが無い | VeryLazy か lazy = false |
--headless | UIEnter と VeryLazy が来ない | :Lazy! load、doautocmd User VeryLazy |
-c 'qa!' | VimEnter より前に終わる | タイマーで後から qa! |