💤

Neovimプラグインの遅延読み込みの仕組みについて

2024/04/05に公開

これは Vim 駅伝 2024/04/05 の記事です。

はじめに

メジャーな Neovim 用のプラグインマネージャは、プラグインの読み込みを必要なときまで遅らせる、遅延読み込みの機能を持っています。
この記事では、遅延読み込みの中で最も使われている(気がする)、イベントの発火に合わせてプラグインを読み込む遅延読み込みの仕組みについて説明します。

プラグイン

Neovim は起動後、runtimepath に入っているディレクトリにある、以下のファイルを読み込みます。

  • ftdetect/*.{vim,lua}
  • plugin/**/*.{vim,lua}

そのため、遅延読み込みを行わない場合プラグインを読み込む方法は簡単で、runtimepath にプラグインの path を追加するだけです。

init.lua
-- runtimepath の先頭に myplugin.nvim の path を追加する
vim.opt.runtimepath:prepend('/path/to/myplugin.nvim')

遅延読み込み

例として、今回は lexima.vim を使用します。
閉じ括弧や do / end など、自分の入力に対応するものを自動で補完してくれるプラグインです。

https://github.com/cohama/lexima.vim

遅延読み込みの最初のステップとして、プラグインの読み込みを行うための自動コマンドを作ってみましょう。
lexima.vim は設定すれば Cmdline モードでも使えますが、一旦 Insert モードでしか使わないということにします。

init.lua
vim.api.nvim_create_autocmd('InsertEnter', {
    once = true,
    callback = function()
        vim.opt.runtimepath:prepend('/path/to/lexima.vim')
    end,
})

これで、最初に Insert モードに入ったときに、lexima.vim の path が runtimepath に追加されるようになりました。

しかし、このままだと lexima.vim は動きません。
Neovim がプラグイン内のファイルを読み込むのは起動時のみのため、遅延読み込みをする場合はそれを自分で行う必要があります。

init.lua
--- {plugin_path}/plugin/ の中のファイルを読み込む
---@param plugin_path string
local function load_plugin_files(plugin_path)
    local files = vim.fn.glob(plugin_path .. '/plugin/**/*.{vim,lua}', nil, true)
    for _, file in ipairs(files) do
        vim.cmd.source(file)
    end
end

--- {plugin_path}/ftdetect/ の中のファイルを、
--- 自動コマンドのグループが filetypedetect にセットされた状態で読み込む
---@param plugin_path string
local function load_ftdetect_files(plugin_path)
    local files = vim.fn.glob(plugin_path .. '/ftdetect/*.{vim,lua}', nil, true)
    vim.cmd.augroup('filetypedetect')
    for _, file in ipairs(files) do
        vim.cmd.source(file)
    end
    vim.cmd.augroup('END')
end

load_ftdetect_files('/path/to/lexima.vim')
vim.api.nvim_create_autocmd('InsertEnter', {
    once = true,
    callback = function()
        vim.opt.runtimepath:prepend('/path/to/lexima.vim')
        load_plugin_files('/path/to/lexima.vim')
    end,
})

Insert モードに入ったに、lexima.vim を読み込むことができるようになりました。
これで、簡単な遅延読み込みは完成です。

問題点

上のコードでは、一度 Insert モードを離れ、もう一度 InsertEnter が発火しないと lexima.vim は動きません。

理由は、lexima.vim が InsertEnter で処理を行っているからです。

https://github.com/cohama/lexima.vim/blob/5513d686801993b40c55baa65602f79cd3cf3c77/plugin/lexima.vim#L41-L50

遅延読み込みを行わなかった場合、以下のような処理の流れになります。

  1. Neovim を起動する
    1. lexima.vim が読み込まれる
      1. InsertEnter で実行される自動コマンドが作成される
  2. Insert モード
    1. InsertEnter
      1. lexima.vim の初期化・セットアップが行われる
    2. lexima.vim が動作する

特定のイベントが発火したタイミングでプラグインが動作する場合、このように事前に自動コマンドを作成していることが多いです。

しかし、そのタイミングでプラグインを読み込んだ場合、プラグインが自動コマンドを作成するのはプラグインを読み込んだ後、つまりイベントが発火した後になります。

  1. Neovim を起動する
  2. Insert モード
    1. InsertEnter
      1. lexima.vim を読み込む
        1. InsertEnter で実行される自動コマンドが作成される ← 実行してない
    2. まだ lexima.vim は動作しない!
  3. Insert モード
    1. InsertEnter
      1. lexima.vim の初期化・セットアップが行われる
    2. lexima.vim が動作する

そのため、プラグインを動作させるには、もう一度イベントを発火させる必要があったのです。

自動コマンドの実行

InsertEnter より前に lexima.vim を読み込むとこれを修正することができますが、残念ながら InsertEnterPre というイベントはありません。

そこで、lexima.vim が作成した自動コマンドを勝手に実行することにします。

  1. Neovim を起動する
  2. Insert モード
    1. InsertEnter
      1. lexima.vim を読み込む
        1. InsertEnter で実行される自動コマンドが作成される
      2. 読み込んだ際に作成された自動コマンドを実行する
        1. lexima.vim の初期化・セットアップが行われる
    2. lexima.vim が動作する!
init.lua
local function load_plugin_files(plugin_path)
    -- 省略
end

local function load_ftdetect_files(plugin_path)
    -- 省略
end

---@param autocmd vim.api.keyset.get_autocmds.ret
---@param old_autocmds vim.api.keyset.get_autocmds.ret[]
---@return bool
local function is_new_autocmd(autocmd, old_autocmds)
    for _, old in ipairs(old_autocmds) do
        if autocmd.id and autocmd.id == old.id then
            return false
        end
        if autocmd.group and autocmd.group == old.group then
            return false
        end
    end
    return true
end

load_ftdetect_files('/path/to/lexima.vim')
vim.api.nvim_create_autocmd('InsertEnter', {
    once = true,
    callback = function(ctx)
        vim.opt.runtimepath:prepend('/path/to/lexima.vim')

        -- 元の自動コマンドを取得しておく
        local old_autocmds = vim.api.nvim_get_autocmds({
            event = 'InsertEnter',
        })

        load_plugin_files('/path/to/lexima.vim')

        -- old_autocmds + lexima.vim が作成した自動コマンド
        local autocmds = vim.api.nvim_get_autocmds({
            event = 'InsertEnter',
        })

        local executed = {}
        for _, autocmd in ipairs(autocmds) do
            local group = autocmd.group
            if is_new_autocmd(autocmd, old_autocmds) and not executed[group] then
                executed[group] = true
                vim.api.nvim_exec_autocmds(ctx.event, {
                    group = group,
                    buffer = autocmd.buffer,
                    modeline = false,
                    data = ctx.data,
                })
            end
        end
    end,
})

これで、ちゃんと動くようになりました!

おまけ

lexima.vim 以外でも使えるようにするために、上のコードを関数にまとめてみました。
InsertEnter のハードコーティングを消したので、CmdlineEnter でも読み込めるようにしてあります。

ついでに雰囲気を出すために dein.vim の hooks のようなものも追加しているので、そこそこ使えるものになったと思います。

コピペして最後を変えれば動くはず
init.lua
---@param plugin_path string
local function load_plugin_files(plugin_path)
    local files = vim.fn.glob(plugin_path .. '/plugin/**/*.{vim,lua}', nil, true)
    for _, file in ipairs(files) do
        vim.cmd.source(file)
    end
end

---@param plugin_path string
local function load_ftdetect_files(plugin_path)
    local files = vim.fn.glob(plugin_path .. '/ftdetect/*.{vim,lua}', nil, true)
    vim.cmd.augroup('filetypedetect')
    for _, file in ipairs(files) do
        vim.cmd.source(file)
    end
    vim.cmd.augroup('END')
end

---@param autocmd vim.api.keyset.get_autocmds.ret
---@param old_autocmds vim.api.keyset.get_autocmds.ret[]
---@return bool
local function is_new_autocmd(autocmd, old_autocmds)
    for _, old in ipairs(old_autocmds) do
        if autocmd.id and autocmd.id == old.id then
            return false
        end
        if autocmd.group and autocmd.group == old.group then
            return false
        end
    end
    return true
end

---@param plugin_path string
---@param event string|string[]
---@param hooks? { add?: function, source?: function, post_source?: function }
local function lazy_load(plugin_path, event, hooks)
    hooks = hooks or {}
    if hooks.add then
        hooks.add()
    end

    load_ftdetect_files(plugin_path)

    local function callback(ctx)
        vim.opt.runtimepath:prepend(plugin_path)
        if hooks.source then
            hooks.source()
        end

        local old_autocmds = vim.api.nvim_get_autocmds({
            event = ctx.event,
        })

        load_plugin_files(plugin_path)

        local autocmds = vim.api.nvim_get_autocmds({
            event = ctx.event,
        })

        local executed = {}
        for _, autocmd in ipairs(autocmds) do
            local group = autocmd.group
            if is_new_autocmd(autocmd, old_autocmds) and not executed[group] then
                executed[group] = true
                vim.api.nvim_exec_autocmds(ctx.event, {
                    group = group,
                    buffer = autocmd.buffer,
                    modeline = false,
                    data = ctx.data,
                })
            end
        end

        if hooks.post_source then
            hooks.post_source()
        end
    end

    vim.api.nvim_create_autocmd(event, {
        once = true,
        callback = callback,
    })
end

lazy_load(
    '/path/to/lexima.vim',
    { 'InsertEnter', 'CmdlineEnter' },
    {
        add = function()
            -- 最初に呼ばれる
        end,
        source = function()
            -- プラグインを読み込む直前
        end,
        post_source = function()
            -- プラグインを読み込んだ直後
        end,
    }
)

Discussion