direnvとNeovim
direnvとその実行について
direnvは.envrcの置かれたディレクトリで実行すると、記述された環境変数等の設定を適用したシェルに入ることができるツールです。
特にnix-direnvと連携させると.envrcに以下のように
と設定している場合では、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
|
extensionsとon_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
|
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の処理を実行するコードが実装されているはずです。
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
|
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_()を実行しています。
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()を実行します。
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の実行結果をデシリアライズしてそれに基づいて処理を実行しています。
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
|
1
2
3
4
5
|
resession.setup({
extensions = {
actionshrimp_direnv = {},
},
})
|
これを設定してからresession.nvimからプロジェクトを開いてみると想定通りに動作し目的を達成できました。
しかし、direnvが発生させる各種イベントによるユーザーが実装した処理の実行と両立できないことには注意が必要です。
ハマりポイント&感想
ハマったポイント
direnv.nvimのソースを読みながら、挙動について色々考えていたら??な点があって調べたところ、direnv.nvimは二つ存在して
自分がインストールしているのはactionshrimp/direnv.nvimなのに、 NotAShelf/direnv.nvimのソースを読んでしまいました。
感想
- これまで知らなかったイベント制御の方法を学ぶことができた。
- コードを読んで、挙動を見抜いた上で必要なコードを書くという経験をすることができた。