motion、text-objects、operatorの実装の仕方とその例

Vimには [motion](https://vim-jp.org/vimdoc-ja/motion.html) や [text-objects](https://vim-jp.org/vimdoc-ja/motion.html#text-objects) という便利な仕組みが備わっています。
[operator](https://vim-jp.org/vimdoc-ja/motion.html#operator) と組み合わせ、正確かつ高速な編集操作を実現したり、更にそれを [.](https://vim-jp.org/vimdoc-ja/repeat.html#.) で繰り返したりととにかく便利に使え、VimをVimたらしめている要素の1つだと思います。[^vim]

さて、この `motion` や `text-objects` (以下モーションとテキストオブジェクト) 、ユーザーが定義できるのですが、その方法を探すのに意外と手間取る場合があります。
マッピングなのでマッピング側にあると言われればそれはそうなんですが、そのタグ [omap-info](https://vim-jp.org/vimdoc-ja/map.html#omap-info) はそれを知らない状態から直感で辿れる名前ではないと思います(これを書く時も探した)し、motion.txtには組み込みのコマンドの解説しかなく、どこを見ても知りたい情報には辿り着けません。

なので対となる [operator](https://vim-jp.org/vimdoc-ja/motion.html#operator) (以下オペレータ) と合わせて、簡単な例と共に定義方法を書いておこうと思います。

# motion
オペレータ待機モードで移動コマンドを実行するとVimはそれをモーションと解釈し、元の場所から移動した場所までをオペレータの適用範囲として扱います。
基本的にこのモードは移動コマンドが与えられた時点で終了しますが、`:` や `<Cmd>` によりExコマンドが与えられた場合はそれ自体を移動コマンドとして見なし、終了した場合の状態を使用します。(後述する `text-objects` は、現在のカーソル位置とは違う2箇所を選択するという、その性質上必ずこれを使うことになります)

例えばこのようにすると入力した文字列を一気に移動コマンドとして扱えます。(もっともこの例はドットリピートする度に `input()` が実行されて入力を尋ねられるのでそのまま使うべきではありませんが)

`onoremap I <Cmd>execute 'normal!' input('command?')<CR>`

(2023-12-18追記):[エンジニアの楽園](https://vim-jp.org/docs/chat.html)にて、この件について話をしていた中で、そういえばこれ結局どうやって解決するんだろうと思い、みなさんとお話ししつつ辿り付いた方法を紹介します。(正確にはmityuさんに書き直してもらった物になりますが)(ついでにiにマッピングするとinner系のマッピングが全て潰れることに気付いたためIに変えました)

一緒に案を出したり実装の改善や指摘をしてくださった[kawarimidoll](https://qiita.com/kawarimidoll)さん、[Hibiki](https://qiita.com/4513echo)さん、[atusy](https://qiita.com/Atsushi776)さん、[mityu](https://qiita.com/mityu)さん、[thinca](https://qiita.com/thinca)さん、ありがとうございました。

```vim
let s:id = -1
let s:cmd = ''

function s:input(id)
  if a:id != s:id
    let s:cmd = input('command?')
    let s:id = a:id
  endif
  execute "normal!" s:cmd
endfunction

function s:next_id()
  return s:id + 1
endfunction

onoremap <expr> I '<Cmd>call <SID>input(' .. <SID>next_id() .. ')<CR>'
```

ドットリピートは基本的にコマンドラインモードで実行した事は記録されませんが(そのため関数呼び出しも記録されない)、例外としてオペレータ待機モードで実行する物はオペレータ呼び出しの一部としてそのまま記録されます。そして `<expr>` マッピングでは記録される物は評価された後の物になります。
そのため、マッピングによる呼び出しの際だけ `<expr>` を使用して別のIDを振るようにしてしまえば関数の中でIDを比較することでドットリピートの判別ができます。[^textlock]
上記の例ではIDが違う時だけコマンドを聞き、それ以外の場合は保存しておいたコマンドを再利用することでリピートを実現しています。

# text-objects
helpに書いてある通り、オペレータ待機モードからビジュアルモードに入り、その後に移動コマンドが実行されるとVimはそれをテキストオブジェクトと解釈し、ビジュアルモードで選択されている範囲をオペレータの適用範囲として扱います。

helpには `:normal!` を使った例が載っていますが、汎用性を持たせるためには移動範囲を正確に指定できるべきで、なおかつ多くのテキストオブジェクトがそうであるようにビジュアルモードでも機能すると嬉しいです。それを踏まえ、範囲を2つ与えるとその間をビジュアル選択する関数を実装しました。

```vim
" cursor() が受け付ける形式で2つの位置を渡すとその間をビジュアル選択する
" 行ビジュアルや矩形ビジュアルにしたい場合は mode に `V` や `<C-v>` (制御コードで)を渡せばいい
function s:select(a, b, mode = 'v') abort
  call cursor(a:a)
  " ビジュアルモードでは v_o によりもう一方の端へ移動する
  " オペレータ待機モードではビジュアルモードを開始する
  execute 'normal!' mode() == 'v' ? 'o' : a:mode
  call cursor(a:b)
endfunction
```

この関数は

- `a` の位置にカーソルを移動(ビジュアルモードの場合は片方の端を移動)
- ビジュアルモードを開始(既にビジュアルモードの場合はもう片方の端に切り替え)
- もう片方の端を `b` の位置に移動

という動作を行います。
マッピング経由で呼び出すことになるのですが、最近のVimには [&lt;Cmd&gt;](https://vim-jp.org/vimdoc-ja/map.html#<Cmd>) という便利機能[^cmd]があるのでそれを使うと苦労することなく呼び出せます。

```vim
" getpos() でマークの位置を取って cursor() の形式に直す
onoremap A <Cmd>call <SID>select(
  \ getpos("'a")[1:2],
  \ getpos("'b")[1:2],
\ )<CR>
xnoremap A <Cmd>call <SID>select(
  \ getpos("'a")[1:2],
  \ getpos("'b")[1:2],
\ )<CR>
```

上記の関数を使い、`'a` と `'b` のマーク間を選択するテキストオブジェクトを書くとこうなります。
適当にマークをした上で適当なオペレータと組み合わせてAを押すとマークした範囲が使われます。

# operator
最後はモーションやテキストオブジェクトと合わせて使うオペレータです。
[:map-operator](https://vim-jp.org/vimdoc-ja/map.html#:map-operator)に詳細な実装例が載っているので大体これを参照したら済みますが、Hackyなことをやっている上に長いのでちょっと読みづらい~~し自前で実装すると面白いので~~yankの再現をしてみました。

```vim
function! s:yank(type) abort
  let a = getpos("'[")
  let b = getpos("']")
  let data = getline(a[1], b[1])
  if a:type ==# 'char'
    " 先頭からやると1行の時に位置がずれるので末尾からやること
    let data[-1] = data[-1][:b[2] - 1]
    let data[0] = data[0][a[2] - 1:]
  endif
  if a:type ==# 'block'
    let head = a[2] == 1 ? '' : data[0][:a[2] - 2]
    let headwidth = strdisplaywidth(head)
    let tail = data[-1][:b[2] - 1]
    let tailwidth = strdisplaywidth(tail)
    let textwidth = tailwidth - headwidth
    for i in range(len(data))
      let l = data[i]
      " 先頭(h)とパディング付き先頭(hp)
      " 矩形の端が二幅文字の重なりだとずれる
      let h = printf('%.' .. headwidth .. 'S', l)
      let hp = printf('%' .. headwidth .. '.' .. headwidth .. 'S', l)
      let datum = l[strlen(h):]
      " ので条件を満たしていたら最初の文字をスペースに置換する
      if h != hp
        let datum = ' ' .. strcharpart(datum, 1)
      endif
      " 末尾が二幅文字の半分を指していたら削る
      " printfのSは指定した分をスペースで埋めてくれる
      let datum = printf('%.' .. textwidth .. 'S', datum)
      let data[i] = datum
    endfor
  endif
  call setreg(v:register, data, a:type[0])
  if len(data) >= 3
    echomsg len(data) .. ' lines yanked'
  endif
endfunction

nnoremap Y <Cmd>set operatorfunc=<SID>yank<CR>g@
nnoremap YY <Cmd>set operatorfunc=<SID>yank<CR>g@_
xnoremap Y <Cmd>set operatorfunc=<SID>yank<CR>g@
"
```

矩形選択の実装がやたら面倒で、結局長くなってしまい、そこで力尽きたので色々と足りないですがこんな感じです。
要点は
- ['operatorfunc'](https://vim-jp.org/vimdoc-ja/options.html#'operatorfunc') にセットした関数が `g@` を押すと [:h g@](https://vim-jp.org/vimdoc-ja/map.html#g@) に書いてあるように呼び出されること
- レジスタの前置や ['clipboard'](https://vim-jp.org/vimdoc-ja/options.html#'clipboard') の指定により変動する、コマンドで使われるレジスタ名を [v:register](https://vim-jp.org/vimdoc-ja/eval.html#v:register) で取得できること
となります。

ここまで実装しておいてなんですが、yankの再現をしたければhelpに載っている例を改変する方が綺麗に書けると思います。

# 〆
ここでは実装方法だけを書きましたが、実用的なプラグインの作り方を知りたければ、愛用しているプラグインの中身やhelpの関連している部分を見たりしてみるのをおすすめします。

この記事は[The Engineer's Paradise](https://vim-jp.org/docs/chat.html)[^ryoppippi]でテキストオブジェクトの定義方法を軽く説明した時に書いたコードを元に加筆修正した物になりますが、書いてる本人も理解が深まってよかったです。
この楽園には~~質問に飢えた暇人~~優秀なVimmerがたくさんいるので質問の回答が時と場合によっては数秒で返ってきたりします。Vimmerとお話したい人は入ってみるといいと思います。

Happy Vimming!


[^vim]: 筆者はVimの真髄だと思っています。
[^cmd]: モードを一切変えずにマッピングの中でコマンドを実行できます。産業革命。
[^ryoppippi]: ryoppippiさんの[この記事](https://zenn.dev/vim_jp/articles/3b5125f9e06bf9)オマージュ
[^textlock]: そもそも全ての処理を `<expr>` 内でやってしまえば工夫の必要もありませんが、[textlock](https://vim-jp.org/vimdoc-ja/eval.html#textlock) がつらいのです。