たまには sandwich.vim の話をしたい

すこし前の話になりますが sandwich.vim というプラグインを作りました。有名なプラグイン surround.vim のようなもので、文字列を囲んだり、囲まれた文字列から囲みを消したり、置換したりします。 surround.vim に比べて、よりユーザーによる拡張性に重点を置いていて次のような点が特長です。

  • 囲み文字列のペアを式で指定できる
  • 削除/置換される囲み文字列のペアもユーザーが定義できる (surround.vim は追加するペアのみ)
    • 正規表現を使うこともできる
    • 他のテキストオブジェクトの選択範囲差分でも指定できる
  • ユーザーの定義した文字列に囲まれた領域を選択するテキストオブジェクトが使える

ユーザーが設定を追加する場合は、 g:sandwich#recipes というリストに追加していきます。ゼロから作ることもできますが、ひとまずデフォルトの設定をコピーするとよいでしょう。

let g:sandwich#recipes = deepcopy(g:sandwich#default_recipes)

私自身そこまでいろいろ設定しているわけではないのですが、いくつか紹介します。


\(, \) のペアを使う

Vim の正規表現におけるグループ化には \(, \) および、 \%(, \) を使うので Vim script を書いているときにこれらで囲めたり、囲みを消せたりできるといいな、と思うことがあります。

let g:sandwich#recipes += [
      \   {
      \     'buns': ['\(', '\)'],
      \     'filetype': ['vim'],
      \     'nesting': 1,
      \   },
      \
      \   {
      \     'buns': ['\%(', '\)'],
      \     'filetype': ['vim'],
      \     'nesting': 1,
      \   },
      \ ]

'nesting' キーは囲みがネスト構造をつくる場合は 1 、それ以外は 0 にしてください。例えば (, ) のような括弧はネストをつくるので 1、 ', ' のようなクオーテーションはネストをつくらないので 0 になります。

saiw\( と単語の上で入力するとその単語を \(, \) のペアで囲みます。また、 sd\%( と入力すると \%(, \) の囲みを消します。あるいは sdb という入力は常に一番近い囲みを消すのでこれを使うと便利でしょう。 \) という部分は二つの設定で被ってしまっていますが、 sandwich.vim はリスト後方の設定を優先するので saiw\) と入力すると単語を \%(, \) で囲むでしょう。別の入力を使いたければ 'input' キーを設定してください。

let g:sandwich#recipes += [
      \   {
      \     'buns': ['\%(', '\)'],
      \     'filetype': ['vim'],
      \     'nesting': 1,
      \     'input': ['%'],
      \   },
      \ ]

これで saiw% と入力すると単語を \%(, \) で囲むでしょう。

連続するシングルクオーテーション

Vim script だけではなかったと思うのですが、一部の言語にはシングルクオートに囲まれたリテラル文字列中の連続する二つのシングルクオートを一つのシングルクオートとみなす、というルールがあります。

echo 'Why don''t you try sandwich.vim?'     "--> Why don't you try sandwich.vim?

これは Vim の 'quoteescape' (:help 'quoteescape') オプションでは対応できないので、ちょっと不便ですね。 sandwich.vim で何とかしましょう。

let g:sandwich#recipes += [
      \   {
      \     'buns': ["'", "'"],
      \     'filetype': ['vim'],
      \     'skip_regex_head': ['\%(\%#\zs''\|''\%#\zs\)''\%(''''\)*[^'']'],
      \     'skip_regex_tail': ['[^'']\%(''''\)*\%(\%#\zs''\|''\%#\zs\)'''],
      \     'nesting': 0,
      \     'linewise': 0,
      \   },
      \ ]

これで例えば sd' と入力すると連続するクオーテーションをスキップして端のクオーテーションを消します。また、 sandwich.vim は囲まれた文字列を選択するテキストオブジェクトを提供するので、例えば vis' と入力すると連続するクオーテーションをスキップして文字列リテラルを選択します。

'skip_regex' は特定の正規表現にマッチする位置をスキップする機能で、 'skip_regex_head' および 'skip_regex_tail' はそれぞれ先頭・末尾の囲みを検索するときにのみ使われます。

実は文字列リテラルの端に連続するシングルクオートがあり、カーソルがその上にある場合は上手くいきません…。下の文字列リテラル中の # に示される位置がそれに当たりますが、間の文字列にカーソルがあれば大丈夫です。

"    ###              ###
echo '''string literal'''

関数で囲む

たまに関数名を指定して関数で囲みたいと思うことがあります。つまりこういうことです。

foo  --->  func(foo)

これは sandwich.vim の囲みを式で指定する機能を使うと実現できます。

let g:sandwich#recipes += [
      \   {
      \     'buns': ['FuncName()', '")"'],
      \     'expr': 1,
      \     'cursor': 'inner_tail',
      \     'kind': ['add', 'replace'],
      \     'action': ['add'],
      \     'input': ['f']
      \   },
      \ ]

function! FuncName() abort
  let funcname = input('funcname: ', '')
  if funcname ==# ''
    throw 'OperatorSandwichCancel'
  endif
  return funcname . '('
endfunction

'expr' キーが 0 でない値を持つとき 'buns' の要素はそれぞれ式として評価され、評価値で囲みます。 FuncName() 関数はユーザーに関数名を問い合わせ、空でなければ ( をつけて返します。

sandwich.vim は他のオペレータに倣って、デフォルトでは作用した文字列の先頭にカーソルを置きますが、 'cursor' キーを使うことで終了時のカーソル位置を指定できます。この場合は続けて引数を追加したい場合が多いので作用した文字列の末尾にカーソルを移動させます。

この設定は囲む動作でのみ使うので、 'kind', 'action' キーを使って必要になるシチュエーションを制限しています。

f:id:machakann:20160519180358g:plain

あまりキータイプ数の上では得をしていないようにも思われるかもしれませんが、矩形選択範囲に対して使えたり、ドットリピート可能だったりするので、対象が何か所もある場合に便利です。

関数囲みを消す

関数で囲めるのなら、これを消せてもいいと思うのが人情というものだと思います。関数を表す範囲を認識するのはなかなか複雑な仕事になるので既存のテキストオブジェクトを使いましょう。 sandwich.vim には既存のテキストオブジェクトの範囲差分から削除する文字列を指定する機能があるのでこれを使います。

例えば、HTMLなどのマークアップ言語のタグは Vim 組み込みのテキストオブジェクト it, at の差分で指定できます。 at の範囲から it の範囲を除いた部分がタグですね。 (:help it, :help at, :help tag-blocks)

      <--- it --->
<body>hello world!</body>
<--------- at ---------->

textobj-functioncall は関数囲み全体を、 textobj-parameter はカーソル下の引数を選択するので、カーソル直下の引数を残して関数囲みを消すことができます。

     <->    textobj-parameter
func(arg)
<------->   textobj-functioncall

既存のテキストオブジェクトを使う場合は 'buns' キーの代わりに 'external' キーを使います。 sdf と入力することで関数囲みを消します。

let g:sandwich#recipes += [
      \   {
      \     'external': ["\<Plug>(textobj-parameter-i)", "\<Plug>(textobj-functioncall-a)"],
      \     'noremap': 0,
      \     'kind': ['delete', 'replace', 'query'],
      \     'input': ['f']
      \   },
      \ ]

f:id:machakann:20160519180407g:plain

textobj-parameter の選択範囲はカーソル位置に依存するので、残念ながらビジュアルモードではうまく動きませんが、 itat はカーソル位置に寛容なので意図通りに動きます。また、矩形選択は必ずしも矩形である必要はありません。空行は無視されます。

let g:sandwich#recipes += [
      \   {
      \     'external': ['it', 'at'],
      \     'noremap': 1,
      \     'kind': ['delete', 'replace', 'query'],
      \     'input': ['t']
      \   },
      \ ]

f:id:machakann:20160519180411g:plain


複雑そうに見えますが、ひとまず 'buns' キーを設定するだけでも大体いい感じに動くと思います。他にも issue #1 #2 は sandwich.vim にどんなことができるかを端的に説明していて、どうやら Tex など書く場合に便利らしいです。よろしければ sandwich.vim を試してみてください。


おまけ: ハイライト機能

私は画面がぴかぴか光ればキャッキャッと喜ぶおさるさん並みの感性の持ち主なので、 sandwich.vim はこれでもか、というくらい画面がぴかぴかします。囲みを追加した場合には追加した文字列をハイライトするのですが、以前はユーザーが何かキーを押し次第ハイライトを消していたのをしばらく残るように最近書き換えました。 call operator#sandwich#set('all', 'all', 'hi_duration', 1000) と、極端に持続時間を長くしてみるとちょっと楽しいです。ハイライトが遅れて消えます。

f:id:machakann:20160519180416g:plain

なお、ユーザーが何らかの編集を開始した場合はハイライトを直ちに消します。ハイライトを消すのに timer 機能を使っているので新しめの Vim でのみこのように動きます、 timer 機能がない場合は以前と同じように動作します。正直なところ、新しい機能を使ってみたかっただけです。ハイライトなどいらぬ、という人間様は vimrc に次の先進的一行を堂々と刻んでください。

call operator#sandwich#set('all', 'all', 'highlight', 0)