あまりにもカオスすぎるvim.on_keyの挙動と0.12以降の仕様変更について
VimConf 2025のhrsh7thさんの基調講演でvim.on_keyについて触れられていました。
ユーザーの入力をすべてキャプチャしてcallbackを呼んでくれる非常に便利なNeovim限定の関数です。実際、この関数を使うことでHackyになりがちな入力周りをシンプルに実装できるようになりました。ところが、いざ触ってみるとわかりますが、on_keyはon_keyで挙動がかなり複雑で、ドキュメントだけではその振る舞いを正確に理解するのが難しいです。さらに、0.12ではその複雑さを緩和するために仕様変更が行われることなりましt。というわけで本記事では上記の2点について詳しく解説していきたいと思います。
動作説明(0.11まで)
まずこの関数で登録されるコールバックの呼び出しタイミングですが、「ユーザーがキーを入力するたびにコールバックを呼び出す」ではありません。正しくは 「rhsでキーが入力されるたびにコールバックを呼び出す」 です。どういうことでしょう?0.11時点のドキュメントにはコールバックfnについて次のように書かれています。
{fn} (fun(key: string, typed: string): string??)
Function invoked for every input key, after mappings have been applied but before further processing. Arguments {key} and {typed} are raw keycodes, where {key} is the key after mappings are applied, and {typed} is the key(s) before mappings are applied. {typed} may be empty if {key} is produced by non-typed key(s) or by the same typed key(s) that produced a previous {key}. If {fn} returns an empty string, {key} is discarded/ignored. When {fn} is nil, the callback associated with namespace {ns_id} is removed.
重要なのは
- every input key(訳:入力されたキーごとに)
- after mappings have been applied(訳:マッピングが適用された後)
- {typed} may be empty ... から始まる一文(訳:{typed}は、実際に打鍵されていないキーによって{key}が生成された場合、もしくは既に{key}を生成させたと同じキーによって打鍵された場合、空になることがあります)
という部分です。
つまり、on_keyは「ユーザーのキーそのものに対してコールバックを呼び出す」のではなく「ユーザーのキーをキーマッピングを適用(rhsに変換)してから、 そのrhsの一文字ずつに対してコールバックを呼び出す」のです。そして同一のlhsに対する呼び出しでは2回目以降、{typed}が空文字列になります。いくつか例を紹介します。
まず、ユーザーがマッピングを行っていない場合を考えます。
ユーザーがjを入力すると、rhsは設定されていないためlhsと同じjとなりon_keyはjについてトリガーされます。このときコールバックの{key},{typed}にはともにjが入ります。ここまでは直感と一致しますね。
ではユーザーが
vim.keymap.set('n','j','lll')
と設定している場合ではどうでしょうか?
on_keyを触り始めたとき、ドキュメントを理解できていなかった私は次のように呼び出されると予想していました。
(key,typed): ('lll','j') -- 完全に間違った予想
「typedにはユーザーが本当に打ったキー入力(lhs)が格納され、keyには変換されたキーマップが格納される」と。しかしこれは誤った理解です。
実際には
(key,typed): ('l','j')→('l','')→('l','') -- 実際の挙動
のように。 rhsのキーマッピングに対して一括で呼ぶのではなく、rhsを1文字ずつ分解して3回 呼び出します。しかも、このとき2回目以降では{typed}が空文字列となるため、愚直にtyped=='j'のようにしてキーマップのrhsを受け取ろうとすると、最初の1文字以外が検知できなくなってしまいます。
では、ユーザーがコマンドを設定している場合はどうでしょうか?
vim.keymap.set('n','j','<cmd>echo<CR>')
vim.on_key(function (key, typed)
vim.notify("input:"..tostring(key)..":"..tostring(typed))
end)
このとき、jキーを押すと次のように呼び出されます。
(key,typed): ('<80><fd>h','j')
<80><fd>hというのは<cmd>に対応した内部表現(NeovimのソースコードではK_COMMANDと呼ばれているドキュメントには一切記述がない)です。<cmd>の形で取れないのはモヤモヤしなくもないですが、とにかくrhsがコマンドである場合はまとめて1文字として扱われるようです。なるほど...。
とはならないのが、on_key関数の恐ろしさです。今度はon_keyのコールバックで空文字列を返すようにします。
vim.keymap.set('n','j','<cmd>echo<CR>')
vim.on_key(function (key, typed)
vim.notify("input:"..tostring(key)..":"..tostring(typed))
return ""
end)
on_keyのコールバックは
- nilを返すときには入力をパススルー
- 空文字列を返すときには入力をせき止める
という動作をします。理屈から考えれば、コールバックの呼ばれる条件とは何も関連がないはずですね。
では実際の呼ばれ方と引数はどうなるでしょうか。
(key,typed): (K_COMMAND,'j')→('e','')→('c','')→...→('<CR>','')
😱
なんということでしょう!rhsが普通のキーマップであるときと同じように、コマンド入力のrhsが文字ごとに分解されてコールバックが呼ばれています!0.11時点でのon_keyはコールバックの返り値に依存して、何故かコールバック自体が呼び出され方が変化するのです。
まとめるとrhsが<cmd>を含む場合
- コールバックがnilを返すなら謎の
K_COMMANDだけについて呼び出される(ちなみにK_COMMANDについてはドキュメントでは言及なし) - コールバックが空文字列を返すなら謎の
K_COMMANDのあとに続いてコマンドの中身が1文字ずつ分解されて呼び出される(普通の文字をrhsにしたときと同じ動作)
となります。
さて、ここで1つ疑問が湧きます。rhsが複数のキーの列として表現できる、普通のキーマッピングや<cmd>の場合はともかくとして、Lua関数を設定した場合にはどうなるのでしょうか?コールバックがnilを返すなら、K_COMMANDのような「Lua関数であることを示す内部表現K_LUA」が返ってくることはなんとなく察しがつきますが、空文字列の場合はどうなるでしょうか?
最新のstableである0.11で実際に試してみましょう。
vim.keymap.set('n','j',function () end)
vim.on_key(function (key, typed)
vim.notify(tostring(key)..tostring(typed))
return ""
end)
vim.notifyがデフォルトだと流れてしまうので分かりづらいと思うのですが、コールバックは次のような引数で呼び出されています(実際にはK_COMMAND同様にK_LUAも対応する内部表現のバイト列です)。
(key,typed): (K_LUA,'j')→('6','')→('2','')→('<CR>','') --実際は中間の数字は実行環境によって変化する
😱
K_LUAはともかくとして、後続のキーマップとして謎の数字と改行が入力されてしまいました!
これを読み解くためにはNeovimがどのようにLua関数を呼び出しているかを知る必要があります。
NeovimはLua関数をrhsとして呼び出すとき{LuaRef}という数字を使って管理しています。キーマッピングでLua関数を指定していると、それに紐づいた{LuaRef}という番号を与え、rhsにK_LUA{LuaRef}<CR>と設定されていることにします。そして実際に入力が行われた場合には、対応するLuaRefの関数を呼び出すことでキーマッピングからLua関数を呼び出しています。
この状態でon_keyを呼び出すと、例えばLuaRefが62の関数のとき内部的なrhsは
K_LUA,6,2,<CR>
という文字列になります。on_keyは条件によってこの内部表現の文字列に基づいてコールバックをトリガーしてしまうのです。結果として、先程示したような非自明な動作を行うわけです。ちなみに{LuaRef}というのは環境依存の値なので「Lua関数の場合は何文字分無視する」みたいな実装でこの制約を回避するのも困難です。
入力を正しく握りつぶすには
今までの説明から分かるように、最初の「ユーザーの入力のたびに呼び出される関数」という直感に基づいて、on_keyを扱うと痛い目をみます。例えば、次のようにして入力を握りつぶすコードを実装しても、うまく動きません。
vim.on_key(function(k, t)
if t == "a" or t=="b" or t=="c" or t=="d" or t=="e" then
-- Swallow the key by returning an empty string
return ""
end
-- Let other keys pass through
-- return nil
end)
vim.keymap.set("i", "a", function()
-- This function should not be executed because on_key swallowed the key.
-- However, its mere presence as a Lua mapping seems necessary to trigger the bug.
end)
vim.keymap.set("i", "b", "<cmd>echo 'Hello'<CR>")
vim.keymap.set("i", "c", "<C-r>=1+1<CR>")
vim.keymap.set("i", "d", "123456789")
--- `echo 'Hello'<CR>` is inserted when the user typed 'b'
--- `=1+1` is inserted when the user typed 'c'
--- `23456789` is inserted when the user typed 'd'
vim.cmd("startinsert")
今までの説明からわかるとおり、2文字以降が空文字列であるために握りつぶすことができず、そこにon_keyが1文字ずつ分解する特性と合わさって予想外の文字が入力されます。
これを回避するためには、 同じtypedによってトリガーされたキー入力なのかを自分で判定したうえで握りつぶす という動作を実装する必要があります。
つまり、
local prev_t = ''
vim.on_key(function(k, t)
t = #t > 0 and t or prev_t
prev_t = t
if t == "a" or t=="b" or t=="c" or t=="d" or t=="e" then
-- Swallow the key by returning an empty string
return ""
end
-- Let other keys pass through
-- return nil
end)
のように、tが空文字列なら 同一のrhs起因のキーマッピングが続いてる とみなして処理する必要があるのです。とはいっても、<cmd>とLua関数の挙動についてはやっぱり変です。on_keyの返り値によってコールバックの呼び出され方が変わるというのは非常にわかりづらいですし、特にLua関数の挙動についてはドキュメントに記述がない内部表現が引数として入力されてしまうため、非常に扱いづらいと言わざるを得ません。
0.12以降での動作変更
というわけで、さすがにこの非直感的な動作が問題視され仕様変更が行われました
具体的にはLuaとCmdの場合には常に後続のrhsが全て破棄されるようになります。
要はon_keyのコールバックの返り値が空文字列であるときの挙動とnilであるときの挙動が、nilであるときの挙動に統一されたわけです。
Luaの例であれば、コールバックで空文字列を返したときだけK_LUA/6/2/<CR>で4文字扱いだったのが常にK_LUAの時点で破棄されるようになり、返り値にかかわらずon_keyが1回だけ呼び出されるようになります。
この変更は既にHEADではマージされており、0.12でリリースとなる予定です。
まとめ
vim.on_keyの挙動についてわかっていただけたでしょうか。
要点をまとめるとon_keyはコールバックを
- ユーザーのキー入力のたびにコールバックを呼ぶのではなくrhsに対して呼ぶ
- rhsに対して一括で呼び出すのではなく各文字について呼ぶ
- 同一のrhsによってon_keyが呼び出された場合、2回目以降はtypedが空文字列に設定する
- 0.11までは
<Cmd>,Lua関数でもコールバックの返り値によってはrhsが複数文字とみなされて呼び出していたが、0.12と現行HEADではそれらは常にまとまった1文字とみなされる
という動作をします。破壊的な変更ではありますが、現行があまりにもカオスすぎる感があるので個人的には妥当なアップデートだと思っています。
on_key自体は非常に便利な関数ですので、皆様もよく動作を確認したうえで活用してみてください。
Discussion