🧙

自作日本語入力Vimプラグインのコアであるfeedkeys末尾再帰lisp風DSLについて解説します

2023/12/31に公開

先日作成したtuskkという日本語入力プラグインで、feedkeysを使いやすくする仕組みをつくったので解説します。

https://zenn.dev/kawarimidoll/articles/f71236b43de0e2

feedkeysのつらみ

Vimには、ユーザーの入力をエミュレートするfeedkeysという関数があります。

feedkeys({string} [, {mode}])        *feedkeys()*
    {string}中の各文字を、あたかもマッピングまたはユーザーによって
    タイプされたかのように、処理キューに入れる。

    デフォルトではこれらの文字は先行入力バッファの末尾に付け足され
    る。そのためマッピングを展開している途中であれば、これらの文字
    はマッピングを展開した後に来ることになる。他の文字の前に挿入す
    るには、'i' フラグを使用する。それらはマッピングからの任意の文
    字の前の挿入の次に実行される。

この「処理キューに入れる」という点が曲者で、コードに書いた処理の順序と実際に実行される処理の順序が必ずしも一致しません。

例を示します。

function FeedByNormal()
  normal! ifoo
  echomsg getline('.')
endfunction

function FeedByFeedkeys()
  call feedkeys("ifoo\<esc>", 'ni')
  echomsg getline('.')
endfunction

FeedByNormal()も、FeedByFeedkeys()も、いずれも「挿入モードに入り、fooと入力し、その行の内容をエコーバックする」という処理をする関数…に見えます。
実行すると、確かに両方ともfooと入力されますが、前者ではfooがエコーされるのに対し、後者ではfooはエコーされません。

feedkeysはあくまで「処理キューに入れる」ため、それが実行されるのは関数内の処理がひととおり終わったあとになります。
たぶんtimer_startの時間を0にしたときと同じような動作だと思います。

この特徴があるため、何か機能を作りたくなったとき、原則としてまずfeedkeys以外の方法がないか検討したほうがよいと思います。
例えば、バッファを変更したい場合、normalコマンドか、setbuflineなどの関数を使える可能性があります。

しかし、feedkeysはユーザーの入力をエミュレートするため、モードに縛られず使うことができ、挿入モードで使うと「入力内容がドットレジスタに残る」という特徴があります。
ドットリピートできるというのはVimにおいて非常に重要ですから、作成したい機能によってはfeedkeysの使用は必須となります。

feedkeysを同期的に動かすアイデア

feedkeysを同期的に動作させる方法はないのでしょうか。

feedkeysの処理順が前後するのは、feedkeysのあとに他の関数を実行する場合です。当然ながら、ひとつのfeedkeysしか使わなければ、この問題は起こりません。
全てをfeedkysの中で完結せてしまえば良いのです。

そこで役に立つのが<cmd>マッピングです。

                                          *<Cmd>* *:map-cmd*
特別な文字列 <Cmd> は "コマンドマッピング" を開始し、モードを変化させずにコマ
ンドを直に実行します。マッピングの {rhs} で ":...<CR>" を使うなら、代わりに
"<Cmd>...<CR>" が使えます。例: >
noremap x <Cmd>echo mode(1)<CR>
<

これは本来はマッピングを行うための機能ですが、実はfeedkeysで直接入力して使うことができます。

前述のFeedByFeedkeysを書きかえましょう。こうなります。

function FeedByFeedkeys2()
  call feedkeys("ifoo\<esc>\<cmd>echomsg getline('.')\<cr>", 'ni')
endfunction

これで入力とエコーバックを正しい順序で実行することができます。
めでたしめでたし。

黒魔術: feedkeys末尾再帰lisp風DSL

tuskkでは、この発想で同期的にfeedkeysを実行することを試みました。
しかし、処理が増えてくると、このような文字列を作るのは大変かつ冗長になります。

ということで、文字列として一気に実行するのではなく、各処理を配列として渡すことにしました。

具体的に実装を紹介しましょう。

" 実行する処理のリスト
let s:recursive_feed_list = []

function Feed(feeds = []) abort
  if !empty(a:feeds)
    " 引数がある場合はリストに設定
    let s:recursive_feed_list = a:feeds
  elseif empty(s:recursive_feed_list)
    " リストがなくなったら終了
    return
  endif

  " リストの先頭の要素を取り出す
  let proc = remove(s:recursive_feed_list, 0)

  let feed = ''

  if type(proc) == v:t_string
    " 文字列ならそのままfeed
    let feed = proc
  elseif has_key(proc, 'call')
    " 以下は辞書の想定
    " callというキーがあれば実行して返り値は無視
    call call(proc.call, get(proc, 'args', []))
  elseif has_key(proc, 'expr')
    " exprというキーがあれば実行して返り値をfeed
    let feed = call(proc.expr, get(proc, 'args', []))
  elseif has_key(proc, 'eval')
    " evalというキーがあれば評価して返り値をfeed
    let feed = eval(proc.eval)
  elseif has_key(proc, 'exec')
    " execというキーがあれば実行して返り値は無視
    call execute(proc.exec, '')
  endif

  " feedしてからこの関数自体を再実行
  return feedkeys(feed .. $"\<cmd>call Feed()\<cr>", 'ni')
endfunction

引数の配列は、文字列または処理を記述した辞書を要素とします。
各項目をfeedし、要素を使い切るまで関数を再帰的に実行します。
また、feedkeys内で再起呼び出しを行っているため、'maxfuncdepth'の影響を受けず、いくらでも回数を増やせます。この特徴から、記事タイトルで「末尾再帰」と表現しています。

feedkeysを使いつつ、さらに再帰するという✝黒魔術✝的な関数なのですが、複雑な部分をこの関数に押し込むことで、呼び出し側はかなり単純に記述することができます。
こんな感じで使います。

call Feed([
  \ "ifoo\<esc>",
  \ { 'exec': "echomsg getline('.')" },
  \ ])

"ifoo\<esc>\<cmd>echomsg getline('.')\<cr>"のように全て文字列で表現するよりも見通しがよくなり、add()extend()など配列操作系の関数を使った調整もしやすくなります。

tuskkでは、「変換候補を確定→変換を開始→キーをバッファに反映」のように連続的に処理を行う必要があったため、この関数を作成しました。
これができたことで、この関数に渡す配列の生成が開発のメインになりました。Vim scriptというよりDSLになっています。

あと、ちょっとlispっぽくないでしょうか?

(feed
  'foo
  (exec (echomsg 'getline')))

本当はargsのようなキーを設けずに全部配列で表現するとよりlispっぽくなるのですが、キーで分けたほうが見やすかったのでこのようなインターフェースにしています。

tuskkの実際の使用箇所を示します。
内部関数を多く呼び出しているので処理の内容はわからないと思いますが、関数の呼び出しの雰囲気はわかっていただけるかと思います。

https://github.com/kawarimidoll/tuskk.vim/blob/ce2d97f61e21a25a3d1d2136920cc514b58194b8/autoload/tuskk.vim#L251-L261

tuskkでの実装

なお、tuskkで使用しているs:feedは前述のFeedよりも少し異なる実装になっています。

https://github.com/kawarimidoll/tuskk.vim/blob/ce2d97f61e21a25a3d1d2136920cc514b58194b8/autoload/tuskk.vim#L89-L107

  • 記述圧縮のために三項演算子を多用しています。また、[call(proc.call, get(proc, 'args', [])), ''][1]のように配列を使うことで、式内で処理を実行しつつ返り値を捨てています。
  • feedsの要素として配列も受け取れるようになっています。無限ループに陥る可能性はありますが、内部関数なので特に停止条件などは設けていません。

関連

https://zenn.dev/uga_rosa/articles/200ad8013db7a8

https://zenn.dev/kawarimidoll/articles/f80e2194303564

Discussion