resession.nvimからdirenvを叩けるようにした

direnvとNeovim

direnvとその実行について

direnv.envrcの置かれたディレクトリで実行すると、記述された環境変数等の設定を適用したシェルに入ることができるツールです。 特にnix-direnvと連携させると.envrcに以下のように

1
use flake

と設定している場合では、flake.nixに設定したnix-shell入ることができるのでとても便利です。

direnvの実行ですが、actionshrimp/direnv.nvim(以下ではdirenv.nvimとする)というプラグインを使用すると、シェルからではなくNeovimから直接実行することができるようになります。

このプラグインでは、direnvを実行するフックとしてNeovimのカレントディレクトリの移動もしくはバッファ読み込みを使用するので、 Neovimでプロジェクトを開いた時に設定した環境に入ることができます。

ディレクトリ移動で、direnvの実行をフックする場合は以下のように設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 以下はlazy.nvimから読み込まれるとする
{
  'actionshrimp/direnv.nvim',
  opts = {
    async = true,
    type = 'dir', -- ディレクトリ移動でフックする場合
    --type = 'buffer', -- バッファの読み込みでフックする場合
    on_direnv_finished = function() -- 実行後に実行される関数
      print('direnv: ok!')
    end,
  },
}

Neovimでどうプロジェクトを開くか?

筆者は一旦プロジェクトのディレクトリに移動して、Neovimを開くのではなく、 適当なディレクトリ上で resession.nvimというプラグインを使用して開いています。 resession.nvimでは、前回の起動時に開いたwindow、バッファ等の状態をセッションとして保存しておいて、 起動時にロードして同じ状態を作ることができます。 なので、一旦ターミナルでプロジェクトのディレクトリまで移動してからNeovimを開くという二段階の作業が不要な点が魅力です。

筆者は以下のようなキーバインドで操作できるように設定しています。

キーバインド 動作
<leader>ss 現在のセッションを保存
<leader>sl 保存したセッションの読み込み
<leader>sd 保存したセッションの削除

その時に必要なLuaのコードは以下です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
-- 別のファイルに記述した以下が`lazy.nvim`によって読み込まれるように設定されているものとする
--[[ 
local ressesion = {
  'stevearc/resession.nvim',
  opts = {},
}
--]]

local resession = require('resession')

resession.setup({
  extensions = {
    actionshrimp_direnv = {},
  },
})

-- Resession does NOTHING automagically, so we have to set up some keymaps
vim.keymap.set('n', '<leader>ss', resession.save)
vim.keymap.set('n', '<leader>sl', resession.load)
vim.keymap.set('n', '<leader>sd', resession.delete)

今回のテーマはここまでで登場した三者を組み合せて上手く動かすことです。

resession.nvimとdirenv.nvimを併用することで起る問題について

こういうことを書いているくらいなので、単に両者をそれぞれ設定しただけでは想定した通りには動きません。

特にプロジェクトローカルにLanguage Serverをインストールしてdirenvの実行で有効化するようなケースでは、 direnvの実行されるタイミングがとても重要です。

direnv.nvimをディレクトリ移動でフックする場合

ディレクトリ移動でフックする場合では、resession.nvimでプロジェクトを開いてもdirenvは実行されませんでした。

そのため、保存したセッションでソースコードが開かれている状態だった場合ではLanguage Serverが有効化されず実行できないままで アタッチされてしまうのでエラーが発生してしまいます。

運用としては、セッションを保存する時はLSPが関係のないREADME.md等を開いた状態でセッションを保存して、 わざわざ:cd .を実行してディレクトリ移動のイベントを行っていました(面倒くさい!)。

direnv.nvimをバッファの読み込みでフックする場合

バッファ読み込みでフックする場合では、想定通りに動く場合と動かない場合とのランダムな結果となりました。 これはバッファが読み込まれた後での、direnv.nvimを通したdirenvの実行とLanguage Serverの初期化が競合するためだと考えられます。

解決策を探る

現状としては、

  • そもそも,direnv.nvimによる初期化を諦めてプロジェクトのディレクトリでdirenvを実行してからNeovimを開く
  • 先程のdirenv.nvimをディレクトリ移動でフックする場合のように、セッションの保存時にREADME.mdを開いた状態にして、開いてから:cd .でディレクトリ移動のイベントを発生させる

のようにして運用していました。

しかし、やはりresession.nvimからシームレスにプロジェクトを開きdirenvによって環境の初期化を行えるようにしたいものです。 そこで、resession.nvimでプロジェクトを開いた場合ではなぜ、direnvが実行されないのか、 どのような流れで、保存したセッションを読み込み、バッファ&ウィンドウとして開くのかを調べれば糸口を掴めそうだったので、ソースコードを読んでみました。

resession.nvimの勘所

resession.nvimの保存したセッションをロードする処理を読み挙動を探ってみます。

外部に公開されているload()はプラグインのソースコードの中ではlua/resession/init.lua中のM.load()として実装さています。 この関数のコードを全て解説するととても長くなるので勘所をかいつまんで見ていきます。

まず重要なのは、 487 ~ 488行目でイベント駆動で自動実行される処理を全て無効化していることです。 そのため、この無効化が解除されるまではLanguage Serverのアタッチのような自動実行される処理は実行されません。 当然direnv.nvimも実行されません。

1
2
3
  -- Don't trigger autocmds during session load
  local eventignore = vim.o.eventignore
  vim.o.eventignore = "all"

続いて、517 ~ 545行目ではセッションとして保存しておいたバッファを読み込みます。 多くのLanguage Serverは対象とするソースコードのカレントバッファへの読み込みをフックとしてアタッチ処理を行いますが、 今回は無効化されているのでバッファの読み込み中には実行されません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  local last_bufnr
  for _, buf in ipairs(data.buffers) do
    local bufnr = vim.fn.bufadd(buf.name)
    last_bufnr = bufnr

    if buf.loaded then
      vim.fn.bufload(bufnr)
      vim.b[bufnr]._resession_need_edit = true
      vim.b[bufnr].resession_restore_last_pos = true
      vim.api.nvim_create_autocmd("BufEnter", {
        desc = "Resession: complete setup of restored buffer",
        callback = function(args)
          if vim.b[args.buf].resession_restore_last_pos then
            pcall(vim.api.nvim_win_set_cursor, 0, buf.last_pos)
            vim.b[args.buf].resession_restore_last_pos = nil
          end
          -- This triggers the autocmds that set filetype, syntax highlighting, and checks the swapfile
          if vim.b._resession_need_edit then
            vim.b._resession_need_edit = nil
            vim.cmd.edit({ mods = { emsg_silent = true } })
          end
        end,
        buffer = bufnr,
        once = true,
        nested = true,
      })
    end
    util.restore_buf_options(bufnr, buf.options)
  end

547 ~ 550行では、カレントディレクトリを保存されたセッションでの場所に移動しています。 上では、ディレクトリ移動でdirenv.nvimをフックした場合、resession.nvimでプロジェクトを読み込むと実行されない とありましたが、このディレクトリ移動中は自動実行される処理が無効化されているためです。

1
2
3
4
  -- Ensure the cwd is set correctly for each loaded buffer
  if not data.tab_scoped then
    vim.api.nvim_set_current_dir(data.global.cwd)
  end

続く580 ~ 593行には以下のような処理がありました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  for ext_name in pairs(config.extensions) do
    if data[ext_name] then
      local ext = util.get_extension(ext_name)
      if ext and ext.on_post_load then
        local ok, err = pcall(ext.on_post_load, data[ext_name])
        if not ok then
          vim.notify(
            string.format('[resession] Extension "%s" on_post_load error: %s', ext_name, err),
            vim.log.levels.ERROR
          )
        end
      end
    end
  end

extensionson_post_loadというキーワードがあったので、ロードした後で実行が可能な処理をユーザーが定義して、差し込める機能があるように見えたのでREADMEを見てみました。

結果としては予測通りで、以下のような形式で実行したい処理を実装し、

1
2
3
4
5
6
7
8
9
local M = {}

---Restore the extension state
---@param data The value returned from on_save
M.on_post_load = function(data)
  -- This is run after the buffers, windows, and tabs are restored
end

return M

設定ファイル中のlua/resession/extensions/<extension name>.luaに配置して、resession.nvimの設定から

1
2
3
4
5
6
7
require("resession").setup({
  extensions = {
    <extension name> = {
      -- these args will get passed in to M.config()
    },
  },
})

のように有効化すると実行することが可能です。 今回は説明しませんが、on_save(セッションの保存時),on_pre_load(セッションをロードする前)等の他のイベントに対して何かユーザーの定義した処理を行うことも可能です。

最後に、610 ~ 611行で一旦全て無効化した自動実行を元々無効化されていた物のみに戻しています。

1
2
  vim.o.eventignore = eventignore
  vim.o.shortmess = shortmess

on_post_load()にうまいこと処理を実装すれば目的は達成できそうです。

解決策(1: 失敗)

ここまで読めば以下は明らかに失敗することが分ると思いますが、 作業していた当時はon_post_load()の実行されるタイミングを良く理解していなかったため以下を考えました。

  • ロードの後で再びディレクトリ移動を行いdirenvの実行をフックする

設定は以下です

  • lua/resession/extensions/actionshrimp_direnv.lua
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
local M = {}

M.on_save = function()
  local project_dir_prev_session = vim.fn.getcwd()
  return { project_dir = project_dir_prev_session }
end

M.on_post_load = function(data)
  print(string.format('move to project dir: %s', data.project_dir))
  require('direnv-nvim').hook()
end

return M
  • lua/session.lua
1
2
3
4
5
resession.setup({
  extensions = {
    actionshrimp_direnv = {},
  },
})

失敗した理由は明かで、on_post_load()が実行されるタイミングでは、自動実行は無効化されているためです。

direnvの勘所

ここで、もう諦めて自分で1からdirenvを叩くコードを書こうと考えたので、参考にするために、actionshrimp/direnv.nvimのコードを読んでみることにしました。

168~197行エントリーポイントは、読み込んだ時に実行されるM.setup()として公開されます。 ポイントとなるのは、設定でフックとなるイベントがバッファ読み込みな場合はsetup_buffer()が、ディレクトリ移動な場合はsetup_dir()が実行される点です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
M.setup = function(opts)
	OPTS = vim.tbl_deep_extend("force", OPTS, opts)
	M.OPTS = OPTS
	if OPTS.type == "buffer" then
		setup_buffer()
	end
	if OPTS.type == "dir" then
		setup_dir()
	end
	if OPTS.on_direnv_finished ~= nil then
		local au_opts = OPTS.on_direnv_finished_opts

		vim.api.nvim_create_autocmd("User", {
			pattern = au_opts["pattern"],
			group = "direnv-nvim",
			once = au_opts["once"],
			callback = function(args)
				if
					au_opts["filetype"] == nil
					or (au_opts["filetype"] == vim.bo[args.buf].filetype or _has_filetype(au_opts["filetype"], vim.bo[args.buf].filetype))
				then
					OPTS.on_direnv_finished({
						buffer = args.buf,
						filetype = vim.bo[args.buf].filetype,
					})
				end
			end,
		})
	end
end

それぞれの処理を見てみると、共通してそれぞれのイベントが発生した時にM.hook()という関数を実行するautocmdを登録する処理を実行していることが分ります。 つまり、M.hook()direnvの処理を実行するコードが実装されているはずです。

  • setup_buffer()
1
2
3
4
5
6
7
8
local setup_buffer = function()
	vim.api.nvim_create_autocmd(OPTS.buffer_setup.autocmd_event, {
		pattern = OPTS.buffer_setup.autocmd_pattern,
		callback = function()
			M.hook()
		end,
	})
end
  • setup_dir()
1
2
3
4
5
6
7
8
local setup_dir = function()
	vim.api.nvim_create_autocmd(OPTS.dir_setup.autocmd_event, {
		pattern = OPTS.dir_setup.autocmd_pattern,
		callback = function()
			M.hook()
		end,
	})
end

さらに、M.hook()の実装を見てみると、rc_found(),rc_allowed()direnvの状態確認を行なってからM.hook_()を実行しています。

  • M.book()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
M.hook = function()
	local cwd = get_cwd()
	if cwd ~= nil then
		if rc_found(cwd) and rc_allowed(cwd) then
			M.hook_(cwd)
		elseif rc_found(cwd) then
			vim.notify("direnv environment is blocked, please 'direnv allow' it (:DirenvAllow)", vim.log.levels.WARN)
			vim.api.nvim_exec_autocmds("User", { pattern = "DirenvBlocked", group = augroup })
		else
			vim.api.nvim_exec_autocmds("User", { pattern = "DirenvNotFound", group = augroup })
		end
	end
end

M.hook_()ではdirenvの実行をvim.system()を通じて外部コマンドとして実行しています。 続く処理では、非同期実行(async = true)な場合はvim.schedule()を通してM.hook_body()を実行し、同期実行(async = false)な場合は直接M.hook_body()を実行します。

  • M.hook_()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
M.hook_ = function(cwd)
	vim.api.nvim_exec_autocmds("User", { pattern = "DirenvStart", group = augroup })
	if OPTS.async then
		vim.system({ "direnv", "export", "json" }, { text = true, cwd = cwd }, function(export_result)
			vim.schedule(function()
				M.hook_body(export_result)
			end)
		end)
	else
		local res = vim.system({ "direnv", "export", "json" }, { text = true, cwd = cwd })
		local export_result = res:wait()
		M.hook_body(export_result)
	end
end

M.hook_body()はJSONとして返ってきたdirenvの実行結果をデシリアライズしてそれに基づいて処理を実行しています。

  • M.hook_body()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
M.hook_body = function(export_result)
	if export_result.stdout ~= "" then
		for k, v in pairs(vim.json.decode(export_result.stdout)) do
			if v == vim.NIL then
				vim.env[k] = nil
			else
				vim.env[k] = v
			end
		end

		if OPTS.hook.msg == "diff" then
			local display_msg = export_result.stderr
			if display_msg ~= nil then
				local lines = vim.split(display_msg, "\n", { trimempty = true })
				local diff_msg = lines[#lines]
				if string.len(diff_msg) > vim.o.columns then
					diff_msg = string.sub(diff_msg, 1, vim.o.columns - 20) .. "..."
				end
				vim.cmd("redraw")
				vim.notify(diff_msg, vim.log.levels.INFO)
			end
		elseif OPTS.hook.msg == "status" then
			M.status()
		end
		vim.api.nvim_exec_autocmds("User", { pattern = "DirenvUpdated", group = augroup })
	end
	vim.api.nvim_exec_autocmds("User", { pattern = "DirenvReady", group = augroup })
end

ここまで処理を追い掛けてきましたが、M.hook()の実行によるdirenvの外部コマンドとしての実行は問題なくできそうです。 しかし、この処理中でnvim_exec_autocmds()によって発生させているイベントはvim.o.eventignore = "all"によって無視されるのでそれによって何か処理を実行することは不可能です。

READMEを読んでみると以下のような記述がありました。

The plugin provides a convenience option, on_direnv_finished, which provides a simple easy way of running a callback when direnv has finished, but you may want a bit more control.

These plugin fires these, all under the ‘User’ event, and the ‘direnv-nvim’ autocmd group:

  • DirenvNotFound - when no direnv was found for the current context
  • DirenvBlocked - when a direnv was found, but has not been allowed with direnv allow yet
  • DirenvAllowed - when the direnv has been allowed via the :DirenvAllow function
  • DirenvStart - when direnv begins evaluation
  • DirenvUpdated - when vim’s environment was actually updated by direnv
  • DirenvReady - when direnv has finished evaluating - either the env was updated or left unchanged

やはり、direnvの実行の前後でイベントを発生させるので、何らかのユーザーの実装した処理を実行することを想定しているようです。 さらに、コードの例も用意されていました。

1
2
3
4
5
6
7
8
-- This snippet is more or less what `on_direnv_finished` runs under the hood.
vim.api.nvim_create_autocmd("User", {
        group = "direnv-nvim",
        pattern = { "DirenvReady", "DirenvNotFound" },
        callback = function()
        -- your action here
        end,
})

解決策(2: 成功)

ここまで読んできた、actionshrimp/direnv.nvim実装からすると以下のようにon_post_load()M.hook()を実行すれば目的を達成できそうです。

  • lua/resession/extensions/actionshrimp_direnv.lua
1
2
3
4
5
6
7
local M = {}

M.on_post_load = function(data)
  require('direnv-nvim').hook()
end

return M
  • lua/session.lua
1
2
3
4
5
resession.setup({
  extensions = {
    actionshrimp_direnv = {},
  },
})

これを設定してからresession.nvimからプロジェクトを開いてみると想定通りに動作し目的を達成できました。 しかし、direnvが発生させる各種イベントによるユーザーが実装した処理の実行と両立できないことには注意が必要です。

ハマりポイント&感想

ハマったポイント

direnv.nvimのソースを読みながら、挙動について色々考えていたら??な点があって調べたところ、direnv.nvimは二つ存在して 自分がインストールしているのはactionshrimp/direnv.nvimなのに、 NotAShelf/direnv.nvimのソースを読んでしまいました。

感想

  • これまで知らなかったイベント制御の方法を学ぶことができた。
  • コードを読んで、挙動を見抜いた上で必要なコードを書くという経験をすることができた。
CC BY
Hugo で構築されています。
テーマ StackJimmy によって設計されています。