multitarget-gn.vim を書きました

先に断っておくと、私としてはこれは邪道だと思っている。

GitHub - machakann/vim-multitarget-gn: Yet another gn command taking a count as a number of operation

これ何

Vim の組み込みテキストオブジェクト gn の亜種である。

gn って何

:help gn

を参照のこと。

gn は最後に検索したパターンにマッチする文字列のうち、カーソル前方最寄りのものを対象とするテキストオブジェクトである。 cgn などとして文字列を置換した後、. を連打するとどんどん文字列が置換されるので、数が少なければ :s コマンドよりも手軽で使い勝手が良い。 現在では組み込みのテキストオブジェクトとなっているが、歴史的にはkana氏のプラグインvim-textobj-lastpatが本体に逆輸入されたものである。 動作を理解するハードルがかなり高く、私も最初は使い方すらよくわからなかった。 しかし、氏こそ私にとってのスタープレイヤーだったし熟練のVimmerが愛用していると漏れ聞いていたので信じて試行錯誤して使い方を覚えた。 便利さに気がついた瞬間の感動は忘れられない。

オリジナルのgnとの違い

基本的にノーマルモードでの動きはオリジナルと同じ挙動をとり、ビジュアルモードではちょっと異なる挙動をとる。 とはいえこの違いはそんなに重要ではないので後述することにする。 最大の焦点はオペレーター待機モードでの動作である。

例えば次のようなバッファがあり、すべての foo をバッファから消したいとする。

foo bar
bar foo
foo bar

/foo<CR> などしてすでに対象を検索した状態で、カーソルが1行目の1列目にある。 このとき 3dgn とするとどうなるだろうか。 答えはこうである。

foo bar
bar foo
 bar

宜なるかな、これがあるべき挙動だと思う。 3つ目 の候補を消すというわけだ。 しかし、以下のような挙動を期待する人間もいるのだ。

 bar
bar 
 bar

こちらは 3つ の候補を消している。 ちなみに私はこれを期待してカウントを与えたことがある。 私以外にもいるようである。

"gn" with [count] is useless · Issue #632 · vim/vim · GitHub

テキストオブジェクトとしては前者のオリジナルの挙動が正しいと思う。 しかし、後者ができれば便利というのもわかる。 というわけで、できるようにしたのが今回のプラグインというわけだ。

使い方

自分でマップして使ってください。

nmap gn <Plug>(multitarget-gn-gn)
xmap gn <Plug>(multitarget-gn-gn)
omap gn <Plug>(multitarget-gn-gn)

nmap gN <Plug>(multitarget-gn-gN)
xmap gN <Plug>(multitarget-gn-gN)
omap gN <Plug>(multitarget-gn-gN)

動作要件

  • v8.2.0877かそれより新しい Vim
  • SafeState autocmd
  • +textprop feature

各モードでの挙動

ノーマルモード

オリジナルと同じになることを意図している。

ビジュアルモード

ビジュアルモードで使用した場合、オリジナルの gn コマンドは次の検索対称を含むまで選択範囲を 拡張 する。これに対して multitarget-gn は次の対象に ジャンプ してその対象のみを選択する。とはいえどっちも使ったことない。

オペレーター待機モード

カウント n が与えられた場合、オリジナルの gn コマンドはカーソル位置から n 個目 の検索対象を編集する。これに対して multitarget-gn は n 個 の対象を編集する。

余談

基本的にテキストオブジェクトとはカレントバッファ上の一部のテキストを指定する機能である。 ユーザー定義テキストオブジェクトを実装する場合、テキストの指定はビジュアル選択を使って行われる。 このため、テキストオブジェクトの指定する範囲には次のような制約がある。

  • カレントバッファ上のテキストであること。
  • 2つの座標と範囲形状の情報のみで表現できること。

前者については特に解説の必要はないと思う。 後者における座標とは行番号と列番号の組 [lnum, col]のことであり、範囲形状は3つのビジュアルモードに対応する、つまり文字単位、行単位、矩形のどれかになる。 さて、multitarget-gnは一見テキストオブジェクトっぽく振る舞うが明らかに後者のルールを破っている。 つまり、multitarget-gnが編集する範囲を表現するにはどうしても2つの座標では足りない。 この点において、冒頭の「邪道」であったり、「テキストオブジェクトとしては前者のオリジナルの挙動が正しいと思う」という表現があったというわけである。 同じ理由でテキストオブジェクトプラグインに慣習的につけられる textobj- というプレフィックスもつけなかった。 なのでこのプラグインはまあ、テキストオブジェクトっぽいものと言うことで。

最初のコミットは今とは違う方式で実装されていたが、欠陥が多かったため次のコミットでほぼ書き直された。 この2つめのコミットの時点で基本的な機能は完成していたと思うが、コーナーケースを潰すためにどんどんコードが増えてしまったのは悩ましい。 そもそも、キーストロークとは反対に処理の順番はテキストオブジェクトが先でオペレーターが後なのでほとんどの点においてオペレーターに主導権がある。 テキストオブジェクトからみたオペレーターは指定したテキストをこれから編集するかもしれないし、しないかもしれない、テキストを削除するかもしれないし、挿入するかもしれない、なんならテキストオブジェクトの指定した編集範囲を無視することすらできる、とにかく何をするかわからないやべーやつなのだ。 ユーザー定義オペレーターを考慮に入れるといくらでもコーナーケースを思いつくし、100%の実装ってない気がする。 せめてオペレーター毎の専用設定を可能にするぐらいならできるだろうけれど。

複数範囲を対象に取るテキストオブジェクトっぽいもののアイデアはずっとあった。 しかし、いくつかの技術的な問題から無視できない欠陥があり塩漬けしていた。 例えばオペレーターの処理が終わったあとに確実に処理をフックする方法が昔はなかった。 オペレーターは必ず編集をするとは限らないので TextChanged では満足できなかった。 今ではこの問題はおおむね SafeState が解決したと思うが、タイマーでも代用できるかもしれない。 またユーザー定義オペレーターを考慮に入れると処理後のカーソル位置は予測不可能なので、できれば次の編集位置は追跡したいがマークは汚したくない、など。 今回考え直してみると、SafeState イベントや text property の登場で気になっていた主なダメケースがそこそこ潰せることに気がついたので書いた。

やっとアイデアを供養できたのでちょっとすっきり。

ちなみに

言うまでもなくオペレーターと組み合わせて使用された場合にドットリピート可能である。