multitarget-gn.vim を書きました
先に断っておくと、私としてはこれは邪道だと思っている。
これ何
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
の登場で気になっていた主なダメケースがそこそこ潰せることに気がついたので書いた。
やっとアイデアを供養できたのでちょっとすっきり。
ちなみに
言うまでもなくオペレーターと組み合わせて使用された場合にドットリピート可能である。