🔁

Vim script問題:任意の関数をdot repeat可能にせよ

に公開

この記事はVim駅伝の2026-02-04の記事です。
前回の記事はmikoto2000さんのファイル名補完を「カレントディレクトリから」ではなく「開いているファイルのディレクトリから」にしたいです。

Vim駅伝は常に参加者を募集しています。詳しくはこちらのページをご覧ください。


本記事はVim scriptを自分で書いたり、Vim/Neovimの設定やプラグインを自分で作ったりしたことのある中級者以上向けです。そうでない方は記事のいいねだけして閉じてください。あなたがVimを使いまくって、ドットリピートしたくなったとき、またお目にかかりましょう。

何らかの編集機能を持った関数を考えます。
サンプルとして以下の関数を使います。:substituteコマンドを呼び出して現在行の末尾1字を削除する関数です。あくまで例なので、処理の内容は特に重要ではありません。

行末文字を削除する関数
function! TrimTail() abort
  s/.$//e
endfunction

さて、この関数はコマンドラインから:call TrimTail()で実行できますが、これはドットリピートすることはできません。ヘルプに「コマンドラインコマンドは繰り返さない。」と書いてある通りです。

https://vim-jp.org/vimdoc-ja/repeat.html#single-repeat

以下のようにしてマッピングすると、-キーを押すことでこの処理を呼び出すことができます。あくまで例なので、マップするキーは特に重要ではありません。

マッピングから関数を実行
nnoremap - <cmd>call TrimTail()<cr>

これで簡単に実行できるようにはなったものの、やはりドットリピートすることはできません。<cmd>マッピングのヘルプには「モードを変化させずにコマンドを直に実行します。」とありますが、繰り返しの対象とはならないようです。

https://vim-jp.org/vimdoc-ja/map.html#<Cmd>

本記事では、以下のようにフラグを与えることで、関数をドットリピート可能にする手法を考えます。

2種類のマッピング
" Not repeatable...
nnoremap - <cmd>call TrimTail(v:false)<cr>

" Dot repeatable!!!
nnoremap + <cmd>call TrimTail(v:true)<cr>

v:falseの場合はリピートしないので、先程までと同じ動作です。v:trueの場合のみ特殊な処理を行うことでリピートを実現します。関数の実装はこんな感じになります。

問題
function! TrimTail(repeatable) abort
  if a:repeatable
    " リピート可能にする処理
  endif
  s/.$//e
endfunction

さて問題です。
前述の+のマッピングをドットリピート可能にするためには、このif-endifの分岐の中にどのようなVim scriptを書いたらよいでしょうか??
分岐の部分は機能の本体であるs/.$//eの部分とは独立しているので、これができたら任意の処理をリピートできることになりそうですね。

ちなみにドットリピートできない処理を可能にする機構といえば https://github.com/tpope/vim-repeat が有名ですが、これは.などのキーにマッピングを行っています。
本記事の手法は、追加のマッピングを行わずに関数の側を工夫してドットリピート可能にするものです。

わからない場合は以下の折りたたみを一つずつ開いていってください。

ヒント1

フラグが真の場合、関数実行はこのif分岐内で終了します。

ヒント
function! TrimTail(repeatable) abort
  if a:repeatable
    " リピート可能にする処理
    return
  endif
  s/.$//e
endfunction
ヒント2

解答の手法を使うと、コマンドラインから:call TrimTail(v:true)を実行した場合にもドットリピート対象になります。

ヒント3

ドットリピート可能な機能を自作する場合にはオペレータが使えます。筆者も過去にこういうものを作っていました。

https://zenn.dev/kawarimidoll/articles/7ae1e7a485d882

詳細は:h :map-operatorと:h 'operatorfunc'を見てください。

https://vim-jp.org/vimdoc-ja/map.html#:map-operator
https://vim-jp.org/vimdoc-ja/options.html#'operatorfunc'

ヒント1の解説

ヒント1では、フラグが真のときに関数が処理の本体の手前で終了することを示しました。しかし処理の本体は実行されるはずです。ここから、if分岐内で何らかの手法でTrimTail自身を再度実行する必要があると推測されます。

ヒント2の解説

ヒント2では、コマンドラインから実行してもドットリピートされることを示しました。ここから、この関数自体がドットリピート可能な処理を呼び出していることが推測されます。

ヒント3の解説

ヒント3では、オペレータを使用することが示されました。ヘルプから、オペレータはg@で呼び出せること、オペレータとなる関数は引数を一つ取ることがわかります。
いったん、フラグ引数のことを忘れて、TrimTail()をオペレータにする実験をしてみましょう。
引数個数のエラーを防ぐためにTrimTail()にダミー引数を追加し、operatorfuncに指定します。

" 渡される引数は無視
function! TrimTail(_) abort
  s/.$//e
endfunction
" オペレータに設定
let &operatorfunc = function('TrimTail')

" g@lで実行できる

この状態でg@lを入力すると、この関数が呼び出されます。そして、これはオペレータとモーションの組み合わせなので、ドットリピートが可能です!!

ただし、問題の関数のようにフラグで切り替えるにはもう一段階調整が必要です。

ヒント3の解説の補足

普通はオペレータ関数がg@を返し、マッピングの方でモーションを接続します。以下が典型例です。

普通のオペレータの使い方
function! TrimTailOperator(type = '') abort
  if a:type
    let &operatorfunc = function('TrimTailOperator')
    return 'g@'
  endif
  s/.$//e
endfunction

" これは普通にドットリピート可能
nnoremap <expr> + TrimTailOperator() .. 'l'
ヒント1~3の解説の解説 手がかりまとめ

「ヒント1の解説」で言及したTrimTail自身を再度実行するための「何らかの手法」とは、「ヒント3」で示したoperatorfuncです。

  • 手がかり1
    • フラグが真のときにoperatorfuncを設定する

「ヒント3の解説」で、operatorfuncにTrimTailを指定すればドットリピートできることを示しました。しかし、operatorfuncにはオペレータのための引数が渡され、TrimTailが期待するフラグ引数とは異なります。したがって、直接呼び出すのではなく、中間関数を用意し、operatorfuncに渡される引数を無視する必要があります。

  • 手がかり2
    • operatorfuncは、1つの引数を取り、その引数を無視してTrimTailを実行する関数である

「ヒント3の解説」で、オペレータとモーションの組み合わせを使えばドットリピートできることを示しました。「ヒント2の解説」から、この操作が関数自身の内部で完結していることがわかります。

  • 手がかり3
    • operatorfuncを設定したうえで、関数自身がg@lのキー入力を行う
解答
  • 手がかり1
    • フラグが真のときにoperatorfuncを設定する
  • 手がかり2
    • operatorfuncは、1つの引数を取り、その引数を無視してTrimTailを実行する関数である
  • 手がかり3
    • operatorfuncを設定したうえで、関数自身がg@lのキー入力を行う

これらをまとめることで目的の実装となります。以下が筆者の用意した解です。operatorfunc専用の別関数を用意しても良いですが、lambdaを使うのが簡潔です。記述を減らせるだけでなく、引数を自動で読み飛ばせるためです。

解答
function! TrimTail(repeatable) abort
  if a:repeatable
    let &operatorfunc = {-> TrimTail(v:false)}
    call feedkeys('g@l', 'n')
    return
  endif
  s/.$//e
endfunction

nnoremap - <cmd>call TrimTail(v:false)<cr>
nnoremap + <cmd>call TrimTail(v:true)<cr>

プラグイン等として公開するとしたら、引数にデフォルト値を設定して、ユーザーには引数を省略した形で案内すればいいと思います。

プラグインにするイメージ
function! TrimTail(set_operator = true) abort
  if a:set_operator
    let &operatorfunc = {-> TrimTail(v:false)}
    call feedkeys('g@l', 'n')
    return
  endif
  s/.$//e
endfunction

" 使用者はフラグを意識しない
nnoremap + <cmd>call TrimTail()<cr>

もし外側の関数がフラグ以外の引数を取る場合、スクリプトローカルにキャッシュを持っておけば可能です。

引数を使う例
" 引数のキャッシュ
let s:msg = ''
function! TrimTail(msg, set_operator = true) abort
  if a:set_operator
    " 引数を保存
    let s:msg = a:msg
    let &operatorfunc = {-> TrimTail('', v:false)}
    call feedkeys('g@l', 'n')
    return
  endif
  s/.$//e
  " 引数を使用
  execute 'echo' s:msg
endfunction

nnoremap + <cmd>call TrimTail('ok!')<cr>

この場合、最初からフラグをv:falseにして実行してしまうと、引数がs:msgに入らないので正しく実行できなくなります。フラグをbooleanではなく3状態とかにしたらうまくいけるかもしれませんが。

ということで、フラグ引数を使えば大体の関数をドットリピート可能にできるんじゃないかと思っています。考えられる例外は以下です。

  1. g@lで発生する移動が問題になる場合
  2. 処理の本体でfeedkeysなどの特殊な処理が行われる場合
  3. 引数使用とドットリピート可変を両立したい場合

この辺が問題になるケースがどれくらいあるのかは不明です。

オペレータとモーションを別にせず、オペレータ関数内でモーションまでやったらどうなる?と思って試したらドットリピートできて面白かったので記事にしました。
feedkeys使いまくった経験があってよかった〜〜✌️

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

任意の関数がドットリピートできると夢が広がりますね。

別解や誤りがあればネタバレにならない経路でコメントもらえると嬉しいです。

dot repeatに関しては以下の記事もどうぞ。

https://satorunooshie.medium.com/designing-repeatable-edits-8ec574ff0acd

Discussion