結論

  • 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 後の状態を記録した結果である。

前提: 起動時のイベントの順番と、遅延ロードのきっかけ

nvim a.md では a.md の BufReadPre、BufReadPost、FileType、BufEnter が先に済み、-c、VimEnter、UIEnter、VeryLazy はその後に来る。ft は FileType で、VeryLazy は最後に読む

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 も来ないので読まれない。既存のファイルなら BufReadPost、無いファイルなら BufNewFile で読まれる。VeryLazy と lazy = false は引数なしでも読まれる

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=true

PITFALL: 対策に VeryLazy を選ぶと、headless では VeryLazy が来ない (後の節) ので、直ったかを headless のテストで確かめると読まれないまま失敗する。対策を headless で確かめるなら、対策の側を lazy = false にする。

起動引数のファイルは、FileType より後で読むプラグインに取りこぼされる

nvim a.md のとき、ft と BufReadPost で読むプラグインは a.md に ftplugin、FileType、BufEnter のすべてが効く。BufEnter で読むと BufEnter だけ、VeryLazy で読むとどれも効かない。:edit a.md ならどれも全部効く

プラグインが読まれる前にそのバッファの 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 = “Tool” の起動直後は :Tool だけがあり、:ToolInstall と Leader t のキーマップは無い。:Tool を打つとプラグインが読まれ、3 つとも揃う

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 より前に終わる

実 UI では -c、VimEnter、UIEnter、VeryLazy の順に来て config が走る。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=0

PITFALL: headless で「読まれない」「VimEnter の処理が走らない」と出ても、実 UI で壊れているとは限らない。逆に、-c 'qa!' で終える headless のテストは VimEnter の処理を一度も走らせないので、テストが成功しても VimEnter の処理は何も確かめていない。

まとめ

きっかけ / 起動起きること対策
event = { "BufReadPost", "BufNewFile" } を引数なしで起動一度も読まれない起動直後から要るものは lazy = false か VeryLazy
event = "BufEnter" と nvim a.mda.md に ftplugin と FileType の autocmd が効かない最初のバッファに効かせるものは ft、BufReadPost、lazy = false
event = "VeryLazy" と nvim a.mda.md に ftplugin と FileType、BufEnter の autocmd が効かない同上。VeryLazy は開いているバッファを自分で探して処理するものに使う
ft / BufReadPost と nvim a.mda.md にも効くなし
cmd = "Tool":ToolInstall が読まれるまで無い (E492)使うコマンドを cmd に全部並べる
cmd と plugin/*.vim のキーマップ読まれるまでキーマップが無いVeryLazy か lazy = false
--headlessUIEnter と VeryLazy が来ない:Lazy! load、doautocmd User VeryLazy
-c 'qa!'VimEnter より前に終わるタイマーで後から qa!

参考