2026-08-02

F# で command-line predictor を書いてる Part 10

PSReadLine custom key bindings

krymtkts/SnippetPredictor の開発をした。

tab で候補が補完できるようにした。 厳密には PSReadLine 用の custom key binding を作った。

SnippetPredictor は毎日便利に利用させてもらってるが、 command-line predictor 単体では補完周りがないのでイマイチな点だ。 毎日 :snp を手で打たなければならないし、 :{group id} なんかいちいち覚えてない。 ListView で候補が表示されるとしても、その中から目当てのものを手動で type するなり ListView から選択しなければならない。

そこで : を type したら SnippetPredictor で管理している :snp とか :{group id} を補完できれば便利そうだと思いついた。 しかし Register-ArgumentCompleter は決まった command に続けて space 区切りした場合のみ文字を補完できるため、目的にそぐわない。 この要求を満たすには Set-PSReadLineKeyHandlerScriptBlock を使った custom key binding を登録するしかないみたいだった。 よってその方向で実現することを考えた。

この方法だと custom key binding の中から PSConsoleReadLine.GetBufferState とかの PSReadLine の操作が必要になる。 当然 F# binary module にその依存関係を参照させて操作するのは、重いし嫌だなと考えた。 なので PowerShell でこの補助機能を実装することにした。 SnippetPredictor の F# binary module へは PSReadLine 依存を追加しない。 代わりに PowerShell の script module を作成し、 Module Manifest で NestedModules として組み込む hybrid module 構成にした。

script module は PSReadLine の TabCompleteNext TabCompletePrevious key bindings 用の script block を提供する。 これらを Enable-SnippetPredictorKeyHandler を実行して、既定で Tab, Shift+Tab に割り当てる。 snippet や group ID の取得はコアである F# binary module で行うが、その得た値を PSReadLine へ受け渡すのは script module の役割だ。 custom key bindings にまつわる状態の管理も script 内に閉じている。 PSReadLine の Set-PSReadLineKeyHandler も session の間しか有効じゃないし、揮発性がある script scoped な状態管理で十分だった。

cross-platform 対応において少し厄介だった点がある。 動作上の問題でなくて単に Pester での end-to-end testing が面倒だっただけだが。 どうも PSReadLine では enum 形式の D0D9 chord が Unix 上で null KeyChar から @ へ正規化されるらしい。 それらの異なる digit key が同じ binding へ alias され、期待の結果が得られなくなるため test が失敗しまくった。 PSReadLine のコード的には以下のあたりっぽい。

よって実 binding を使う test では、名前が維持される F1F24 を使用することで問題を回避している。 一応テスト的には cross-platform 対応できてるけど、手動での動作確認は Windows でしかできてない。

りょっと触ってみるだけでも、この小さな改善で前よりいい感じがしてる。 特に group ID を補完できるのがすごくいい。 もう少し文書とかを小綺麗にしたら、次のリリースで公開したい。