🎛

Vim の増減操作をより快適にする dps-dial.vim

16 min read

この記事は Vim advent calendar 2021 その1 の18日目の記事です。
昨日は OhaTak さんの記事「LunarVimのすゝめ」でした。
明日は uyo さんの記事が公開される予定です。

本記事では、今年の夏頃から開発している dps-dial.vim というプラグインを紹介します。

https://github.com/monaqa/dps-dial.vim

dps-dial.vim の機能は一言で言うと「<C-a>/<C-x> コマンドを拡張するプラグイン」です。
そこで本記事では、まず Vim の <C-a> / <C-x> がどういった機能を持つコマンドか簡単に述べてから dps-dial.vim の機能の説明に入ります。 <C-a> / <C-x> コマンドの機能を既にご存じの方は最初の章を読み飛ばしても問題ありません。

<C-a> / <C-x> コマンドとは

:help CTRL-A でヘルプを参照すると、以下のように書いてあります。

change.jax
CTRL-A                  カーソルの下または後の数字またはアルファベットに
                        [count] を加える。

...(中略)...

CTRL-X                  カーソルの下または後の数字またはアルファベットから
                        [count] を減じる。

とてもシンプルな説明ですね。たとえば

|もうすぐ2021年ですね!

というテキストがバッファ上にあるとします(| はカーソル位置を表すとします)。この状態からノーマルモードで <C-a> を押すとカーソルが 2021 の上に移動し、さらに数値が1つインクリメントされます。

もうすぐ202|2年ですね!

この操作をより単純な操作に分解してみると、

  1. 同じ行のカーソル下 or カーソル後にある数値を検索し、
  2. 数値が見つかればそこに移動し、
  3. 数値を加算後の値に置換する

ということになります。 <C-a> の挙動は言葉で説明すればシンプルですが、実は3つの処理を一度に行ってくれる複合的なコマンドなのです。

もう少し実用的な例として、以下の Markdown 文書を考えてみます。

# ゴーヤチャンプルの作り方

[白ごはん.com](https://www.sirogohan.com/recipe/go-yachan/)
にあるレシピを元に、説明用に書き換えたもの。

## Step1. 豆腐を焼く

- フライパンに油をしいて中火にかけ、豆腐を焼く。
- 軽く焼き色が付いたら取り出す。 この工程は後の Step3 で使う。

## Step2. ゴーヤを炒める

- ゴーヤを炒める。
- 軽く火が通ったらゴーヤを取り出す。この工程は後の Step3 で使う。

## Step3. 仕上げる

- 豚バラ肉を一枚ずつ入れ、両面をさっと焼く。
- 豚肉の色が全体的に変わってきた段階で豆腐とゴーヤを戻し入れる。
- 全体的にさっと炒め合わせて塩コショウをふる。
- 溶き卵を流し入れ、少し待ってから絡める。
- 醤油を回し入れてさっと炒めれば完成。

ここまで書いたところで、 Step1 の前に手順が3つ追加で必要だった、と思い出したとします[1]。今まで Step1 だった内容は Step4 に、 Step2 だった内容は Step5 に、と既存のステップの番号を3つずつズラさないといけません。"Step1" "Step2" などの数字の前にいちいちカーソルを合わせ、いちいち頭の中で計算をして正しい値に書き換える…想像するだけで面倒ですね。

しかし、Vim なら /Step<CR>3<C-a>n.n.n.n. で終わりです。面倒なカーソル位置の調整も、一桁の足し算も人間が行う必要はありません。

ストロークの解説
  1. Step で検索し、 6行目の Step1S に移動
  2. 3<C-a> とすれば Step1Step4 に置き換わる(カウンタ指定
  3. n で 9行目の Step3S に移動
  4. .Step3Step6 に置き換わる(ドットリピート
  5. n で 11行目の Step2S に移動
  6. .Step2Step5 に置き換わる
  7. 以下繰り返し

慣れないうちは複雑に感じるかもしれませんが、慣れればこの程度の操作は頭で考えるより先に手が動くようになります。

このように、 <C-a> は「/ 検索」「カウンタ指定」「ドットリピート」といった要素と組み合わせることでかなり強力な効果を発揮します。カーソル下に数字がなくても自動的に数字のある場所を見つけて移動する、という <C-a> の仕様が地味に手数削減に効いていますね。

なお、標準の <C-a> / <C-x> が増減できるものは以下のとおりです。

  • 10進数の整数/非負整数
  • 2進数 (e.g. 0b00110b0100)
  • 8進数 (e.g. 007010)
  • 16進数 (e.g. 0x5a5a0x5a5b)
  • アルファベット1文字 (a ←→ b ←→ c ←→ ... ←→ z)

つまり、数値とアルファベット一文字が対象です。詳細は :help 'nrformats' を参照してください。

dps-dial.vim とは

今まで散々 <C-a><C-x> が便利だと熱弁してきました。この便利さを「数字の増減」だけにとどめておくのは勿体ありません。世の中には日付や Hex color など、数値とは若干異なるものの増減を考えることができる文字列がたくさんありますよね。それらも操作できるようにすれば、Vim の利便性がさらに上がるのではないでしょうか。
そんな思想から開発されたのが dps-dial.vim です。dps-dial.vim は標準の <C-a><C-x> を拡張し、以下のような文字列の増減操作を実現します。

  • n 進数の整数(2 \leq n \leq 36
  • 特定のフォーマットに従う日付・時刻(現時点では 2021/12/18, 12/18 といった形式をサポート)
  • Hex color
  • 定数 (true/false のトグル、 let/const のトグルなど)
  • 識別子の命名規則の切り替え(camelCase / snake_case の切り替えなど)

その他、カスタマイズ次第では Markdown のヘッダの数など、ユーザ指定のルールで自由に増減操作を実装することもできます。

インストール方法

dps-dial.vim は denops.vim を用いて実装されており、動作に Deno ランタイム及び denops.vim プラグインを必要とします。Deno ランタイムを入れた上で、自身の vim にてプラグインをインストール・有効化してください。たとえばパッケージマネージャとして vim-plug を使用している場合は、設定ファイルに以下のように書くこととなるでしょう。

.vimrc
Plug 'vim-denops/denops.vim'
Plug 'monaqa/dps-dial.vim'

インストール後の事前準備

dps-dial.vim ではキーマッピングの上書きを自身で行わないため、プラグインの機能を有効化するには自身でマッピングを割り当てる必要があります。デフォルトの <C-a> などの挙動を変更する形で有効化するには、設定ファイル中で以下のように書いてください。

.vimrc
nmap  <C-a>  <Plug>(dps-dial-increment)
nmap  <C-x>  <Plug>(dps-dial-decrement)
xmap  <C-a>  <Plug>(dps-dial-increment)
xmap  <C-x>  <Plug>(dps-dial-decrement)
xmap g<C-a> g<Plug>(dps-dial-increment)
xmap g<C-x> g<Plug>(dps-dial-decrement)

デモ

実際に日付が増減できている様子を映した動画です。 2021/12/20 など複数の場所にある日付が <C-a> を用いてインクリメントされていることに注目してください。デモ動画では最初に全ての日付を1ヶ月インクリメントした後、続いて21日だけインクリメントします。

以下のような点に dps-dial.vim の特徴が現れています:

  • 日付が1日単位・1ヶ月単位でインクリメントできる
  • ドットリピートを用いることで、離れた場所にある日付も同じ日数・月数だけインクリメントされている

dps-dial.vim の特徴

操作対象(被加数)の柔軟な指定

dps-dial.vim では数値や日付といった増減対象のことを 被加数 (augend) と呼びます[2]
操作したい被加数は人によって大きく異なります。数値と日付だけ増減できればよい人もいれば、#c4bfab といった Hex color をいじりたい人もいるでしょう。そこで dps-dial.vim では、 g:dps_dial#augends というグローバル変数を用いて操作の対象となる被加数を指定できるようにしています。たとえばバッファ上に以下のテキストがあるとします(| はカーソル位置):

|due_date: 2021/12/18

g:dps_dial#augends の設定値を変化させると、<C-a> を押したときの挙動も変化します。

g:dps_dial#augends の値 適用後のテキスト
['decimal'] due_date: 2022/12/18
['date-slash'] due_date: 2021/12/19
['decimal', 'date-slash'] due_date: 2021/12/19
['case'] dueDate: 2021/12/18

このように、 g:dps_dial#augends に被加数の名前の配列を指定することで、操作対象を自分の好みに合うようにカスタマイズできます。

ちょっと詳しめの解説

上の表での挙動は以下のように説明できます。

  • let g:dps_dial#augends = [ 'decimal' ] とすると十進数の非負整数のインクリメントのみ有効となり、上の例ではカーソル直後の 2021 がインクリメントされます。
  • let g:dps_dial#augends = [ 'date-slash' ] とすると yyyy/MM/dd 形式の日付のインクリメントのみ有効となり、上の例ではカーソル直後の 2021/12/18 が1日インクリメントされます。
  • let g:dps_dial#augends = [ 'decimal', 'date-slash' ] とすると 'decimal''date-slash' のどちらも有効となります。上の例では 'date-slash' のマッチ対象が 'decimal' のマッチ対象を含んでいるため、 'date-slash' が優先してインクリメントされます。
  • let g:dps_dial#augends = [ 'case' ] とすると、識別子のキャメルケース・スネークケース変換のみが有効となります。上の例ではカーソル下の due_date がキャメルケースに変更されます。

ちなみに g:dps_dial#augends の配列の要素には、文字列だけでなく所定の形式の辞書を指定することもできます。

let g:dps_dial#augends = [
\  'decimal',
\  'date-slash',
\  {
\    "kind": "constant",
\    "opts": {
\      "elements": ["const", "let"],
\      "cyclic": v:true,
\      "word": v:true
\    }
\  },
\ ]

より正確な仕様の話をすると、実は 'decimal' などの文字列は全てある辞書のスタイルで指定した場合のエイリアスとなっています。辞書の詳細については :h dps-dial-augends を参照してください。

カウンタやカーソル位置で増減量を調整できる

デフォルトの <C-a> / <C-x> 同様、カウンタを用いて増減量を調整することができます。これについては具体例は特に必要ないでしょう。

また日付のインクリメントやデクリメントを行うとき、増減の単位は「1日」「1ヶ月」など複数考えられますが、dps-dial.vim ではいずれも簡単に行うことができます。先程と同様の例で見てみましょう。 let g:dps_dial#augends = [ 'date-slash' ] が指定されているとします。

|due_date: 2021/12/18
カーソル位置 単位 適用後のテキスト
2021 のどこか 1年 due_date: 2022/12/18
/12 のどこか 1ヶ月 due_date: 2022/01/18
/18 のどこか 1日 due_date: 2021/12/19

なお、カーソルが日付よりも手前にある場合やビジュアルモードで <C-a> を押下した場合などは1日単位となります。

当たり前といえば当たり前ですが、1日単位でインクリメントされる場所にカーソルを移動させてから 7<C-a> とすれば、1週間単位で日付を動かすこともできます。

ドットリピート対応

<C-a><C-x> を行った際の編集操作は . を押すことで繰り返すことができます。dps-dial.vim もドットリピートに対応していますが、「直前の増減操作を忠実に再現しようとする」という点が特徴的です。たとえば直前の操作が「日付を7日増加させる」だった場合、カーソル以降にある日付を探して7日だけ増加しようとします。

let g:dps_dial#augends = [ 'decimal', 'date-slash' ] が指定されているとして、以下の例を考えてみましょう。

due date (1st report): 2021/12/1|8
due date (2nd report): 2022/03/05

1行目の末尾にカーソルがある状態で <C-a> を押すと、「日付のを1日インクリメントする」操作が実行され以下のようになります。

due date (1st report): 2021/12/1|9
due date (2nd report): 2022/03/05

この状態で2行目の頭に移動し、

due date (1st report): 2021/12/19
|due date (2nd report): 2022/03/05

ここでドットリピートを実行してみると、2022/03/05 がインクリメントされ 2022/03/06 となります。

due date (1st report): 2021/12/19
due date (2nd report): 2022/03/0|6

ここで注目すべきは、カーソルから最も近い数字は 2nd2 があったにもかかわらず、ドットリピートを行ったときは 2nd を飛ばして日付がインクリメントされたという点です。ドットリピートでは直前の被加数の種類を記憶し、その増減操作を再現しようと試みます。
その代わり、2行目頭にカーソルがある状態で(ドットリピートではなく)改めて <C-a> と押せば、 2nd のほうがインクリメントされ 3nd となります。

due date (1st report): 2021/12/19
due date (|3nd report): 2022/03/06

Tips

dps_dial.vim に搭載されている、少し発展的な機能を紹介します。

ファイルタイプごとに増減対象を変更する

前述の通り、増減対象は基本的に g:dps_dial#augends で決まります。しかし、b:dps_dial_augends が定義されているバッファでは、バッファローカルな変数のほうの値が優先して使われます。特定のファイルタイプをもつバッファでのみ有効にしたい被加数があるときに便利です。

.vimrc
augroup dps_dial
  autocmd!
  autocmd FileType css let b:dps_dial_augends = g:dps_dial#augends + [ 'color' ]
  autocmd FileType typescript let b:dps_dial_augends = g:dps_dial#augends + [
  \   {
  \     'kind': 'constant', 'opts': {
  \       'elements': ['let', 'const'],
  \       'cyclic': v:true,
  \       'word': v:true,
  \   }}
  \ ]
augroup END

レジスタ指定による挙動の変更

dps-dial.vim では様々なモノを対象に増減操作を行うことができます。
しかし、もし g:dps_dial#augends に全ての対象を詰め込んでしまうと、意図しないものが増減されてしまうことがあります。
たとえば以下のように設定している場合を考えてみましょう。

.vimrc
let g:dps_dial#augends = ['decimal', 'hex', 'date-slash', 'alpha', 'case']
被加数名 対象
decimal 十進数の非負整数
hex 16進数
date-slash yyyy/MM/dd 形式の日付
alpha 独立した小文字のアルファベット1文字
case camelCase または snake_case の識別子
|Select a due_date: 2021/12/23

被加数がたくさんある状態だと、以下のようにマッチする箇所が大量に生じてしまいます。

被加数 対象
decimal 2021, 12, 23
hex なし
date-slash 2021/12/23
alpha a
case due_date

マッチする箇所が大量にあると、本来の対象と異なる文字列がインクリメントされるリスクが高くなります。今回のケースで最もカーソルに近い候補は a ですから、以下のようになります。

Select |b due_date: 2021/12/23

そこじゃない感…ありますよね。自動的に増減可能な場所を探す <C-a> の気遣いが裏目に出てしまったわけです。もちろんカーソルを移動させてから <C-a> を発動すればよいのですが、それはそれで <C-a> の便利さが少し損なわれてしまいます。

この問題を緩和するため、 dps_dial.vim の <C-a> ではアルファベットレジスタを指定して挙動を変更できるようにしました。この機能を使用するには g:dps_dial#augends#register#* を使います(* には小文字のアルファベット1文字が入る)。

.vimrc
let g:dps_dial#augends = ['decimal', 'hex', 'date-slash' ] " デフォルト
let g:dps_dial#augends#register#n = [ 'decimal', 'hex' ]  " 数値のみ
let g:dps_dial#augends#register#d = [ 'date-slash' ]  " 日付のみ
let g:dps_dial#augends#register#i = [ 'case' ]  " identifier のみ
let g:dps_dial#augends#register#c = [ 'alpha' ]  " アルファベットのみ

このように指定しておけば、<C-a> を押すときにアルファベットレジスタを指定する("n<C-a> のように打つ)ことで所望の文字列を操作できるようになります。

ストローク 結果
<C-a> Select a due_date: 2021/12/24
"n<C-a> Select a due_date: 2022/12/23
"d<C-a> Select a due_date: 2021/12/24
"i<C-a> Select a dueDate: 2021/12/23
"c<C-a> Select b due_date: 2021/12/23

"i<C-a> のようなキーストロークが面倒な人はマッピングを変更するのも手です。

.vimrc
" gc と打つことでキャメルケースとスネークケースがトグルされるようになる
let g:dps_dial#augends#register#i = [ 'case' ]
nmap gc "i<Plug>(dps-dial-increment)

なおここで用いたアルファベットレジスタの名前は被加数の種類を指定するために便宜上ラベルとして用いているだけであり、レジスタの中身を読み書きするわけではありません。

ユーザ固有の増減ルールを設定する

Vim script で関数を書けば、ユーザ固有の増減ルールを設定して使うことも可能です。手順は以下の通り。

  1. findadd の2種類の関数を定義する
  2. dps_dial#register_callback() 関数を使って先程の関数を登録し、返り値である ID を適当な変数に入れておく。
  3. {"kind": "user", "opts": {"find": (find関数のID), "add": (add関数のID)}} という augend を登録する。
具体例:Markdown のヘッダのレベルを <C-a>/<C-x> で調整できるようにするユーザ設定
.vimrc
" カーソル行を検索し、
" 増減させたいパターンにマッチする場所があればその開始・終了インデックスを返す関数
"
" Args:
"   line: カーソル行の文字列
"   cursor: カーソル列のインデックス(バイトインデックス)
" Returns:
"   from: 増減させたいパターンにマッチする場所があれば、その開始インデックス
"   to: 増減させたいパターンにマッチする場所があれば、その終了インデックス
function! MarkdownHeaderFind(line, cursor)
  let match = matchstr(a:line, '^#\+')
  if match !=# ''
    return {"from": 0, "to": strlen(match)}
  endif
  return v:null
endfunction

" 指定されたパターンを増減させる
"
" Args:
"   text: パターンにマッチしたテキスト
"   addend: 加数(通常は1だがカウンタ指定で変化)
"   cursor: (テキスト開始位置を0とする相対的な)カーソル位置
" Returns:
"   text: 増加後の文字列
"   cursor: 新しいカーソル位置
function! MarkdownHeaderAdd(text, addend, cursor)
  let n_header = strlen(a:text)
  let n_header = min([6, max([1, n_header + a:addend])])
  let text = repeat('#', n_header)
  let cursor = 1
  return {'text': text, 'cursor': cursor}
endfunction

let s:id_find = dps_dial#register_callback(function("MarkdownHeaderFind"))
let s:id_add = dps_dial#register_callback(function("MarkdownHeaderAdd"))

augroup dps_dial
  autocmd!
  autocmd FileType markdown let b:dps_dial_augends_register_h = [
  \   {"kind": "user", "opts": {"find": s:id_find, "add": s:id_add}}
  \ ]
  autocmd FileType markdown nmap <buffer> <Space>a "h<Plug>(dps-dial-increment)
  autocmd FileType markdown nmap <buffer> <Space>x "h<Plug>(dps-dial-decrement)
  autocmd FileType markdown vmap <buffer> <Space>a "h<Plug>(dps-dial-increment)
  autocmd FileType markdown vmap <buffer> <Space>x "h<Plug>(dps-dial-decrement)
augroup END

このように設定すると、 Markdown のヘッダ行の上にカーソルを合わせて <Space>a と押すことでヘッダのレベルを簡単に増減できるようになります。

実装裏話

数値増減を拡張するためのプラグインとして、実は1年ほど前から dial.nvim というプラグインを作っていました。

https://github.com/monaqa/dial.nvim

こちらの開発言語は Lua でした。これはこれでそれなりに動くものができていたものの、いくつか開発において不満を感じていました。

  • Lua の開発体験が自分にあまり合わなかった
    • もっと型に守られた言語で開発したかった
    • null[3]安全でないため、デバッグに苦労した
  • Vim にも対応してほしいという声があった
    • 実装言語で Lua を選択すると、基本的に v0.5.0 以上の Neovim しかサポートされない

このまま Lua で開発を続けるべきか、それとも別の言語で実装し直すべきか…と悩んでいたところに denops.vim が現れました。

https://github.com/vim-denops/denops.vim

denops.vim は Vim/Neovim 両方に対応したプラグイン開発用のフレームワークであり、 TypeScript を用いて Vim プラグインを書くことができます。つまり、「型に守られた言語で Vim/Neovim 両方に対応したい」という私の願望を見事に叶えるものでした。もともと TypeScript を書いたことが無かったため多少戸惑いもあったものの、最終的には denops.vim を用いて開発をすすめることにしました。

結果的に denops.vim を選んだのは正解だったと思います。Deno の資産が比較的自由に使えること、メインの開発者が日本人であることなども嬉しいポイントでしたが、何より TypeScript による型の縛りを入れながらの開発が非常に快適でした。少なくとも、 Vim プラグインを書く上で生じる様々なハードルを下げる役割は十分に果たしていると思います。

denops.vim を採用することでユーザに Deno や denops.vim のインストールを強いる形にはなってしまいましたが、そこは今後 denops.vim がますますメジャーになっていくことに期待したいところです。実際、 Shougo さんの ddc.vim などの動向を見ていると、Vim プラグインの開発スタイルの常識を変えてしまいそうな勢いすら感じられます。皆さんもぜひ denops.vim 製のプラグインを使い、 denops.vim でプラグイン開発をしてみてください。

おわりに

<C-a><C-x> は Vim を使い始めた当初から好きで、「日付をはじめとする様々な対象の柔軟な増減操作」も2年以上前からほしいと思っていたものでした。
Lua 製プラグインも経ましたが、今回の dps-dial.vim でようやく自分が実現したかったものが概ね形になったかなと思っています。やはりドットリピートは欲しかった機能でした。余力があれば Lua 版にもドットリピートを入れたいと思っていますが…一度 TypeScript に慣れてしまうとなかなか Lua には戻り難くなりますね。

皆さんも是非使ってみてください!

余談

最初の方の例で紹介したレシピは 白ごはん.com というレシピサイトのものを参考にして作りました。
このサイトは個人的にお気に入りで、料理初心者でも美味しい料理ができます。ぜひリンク先の正しいレシピを参考にして、ゴーヤチャンプルを作ってみてください。

脚注
  1. 実際のレシピと照らし合わせても、「豆腐の水切り」「ゴーヤの下ごしらえ」「豚肉を切る」の3手順が不足しています。 ↩︎

  2. 小学校で習った懐かしい表現を使うなら「足される数」のことです。 ↩︎

  3. Lua なので nil と言うべきかもしれません。 ↩︎

Discussion

ログインするとコメントできます