Vimのsurroundで括弧や引用符を付け替える(ys / cs / ds)
文字列のダブルクォートをシングルクォートに変えたい、関数呼び出しの括弧だけを外したい、という作業は素のVimだと意外に手数がかかります。中身を書き換えるci"やci(はあっても、囲みそのものを足す・外す・付け替えるコマンドは標準に無いからです。それをys・cs・dsの3つの操作にまとめたのが、Tim Popeのvim-surroundです。
Neovimには同じ発想のnvim-surroundとmini.surroundがあり、どれを入れても「囲みを扱う」ことはできます。ただ、打ったキーが同じでも、ドットで繰り返したときやカーソルが囲みの外にあるときの結果は揃っていません。3つを同じ行で打ち比べた結果を並べます。
検証環境:Vim 9.1.1752とNVIM v0.12.4(macOS)。vim-surround 2.2(コミット3d188ed、2022-10-25)、vim-repeat(6584602)、nvim-surround(main の8b47db6、CHANGELOG上の最新は4.0.5)、mini.surround(main の59ce0ac、2026-10-13)を一時ディレクトリにcloneし、runtimepathに足しただけの設定で起動しました。tmuxの中でキーを送り、画面に出た行をそのまま写しています。
目次(9項目)
| やりたいこと | vim-surround / nvim-surround | mini.surround(既定) |
|---|---|---|
| 囲みを足す | ys{モーション}{文字} | sa{モーション}{文字} |
| 付け替える | cs{今の文字}{新しい文字} | sr{今の文字}{新しい文字} |
| 外す | ds{文字} | sd{文字} |
| 選択範囲を囲む | ビジュアルモードでS{文字} | ビジュアルモードでsa{文字} |
素のVimで囲みを変えると何手かかるか
プラグインを入れる前に、素のVim 9.1でそれぞれの作業を実際に打ってみました。単語を引用符で囲むなら、ciwで単語を消して"を打ち、Ctrl-r "で消した単語を戻し、閉じの"を打ってEscです。hello worldのhelloの上でこれを打つと"hello" worldになります。
困るのはこの操作をドットで繰り返したときです。続けてWでworldへ移り.を押すと、結果は"hello" "hello"でした。Ctrl-rで差し込んだのは「その時点のレジスタの中身」ではなく、記録された入力の文字列なので、繰り返すと前の単語がそのまま入ります。
括弧を外すのはdi(で中身を消し、va(で括弧ごと選んでpで貼り戻す、という回り道になります。call(foo, bar)のfooの上でdi(va(pと打つとcallfoo, barになりました。引用符の付け替えはf"で移ってr'、;で次の"へ移ってもう一度r'です。どれも動きはしますが、カーソルの位置によって手順が変わり、1回の編集としてまとまりません。中身だけを変えるなら括弧・引用符の中身を書き換えるci系のコマンドで足ります。囲みのほうを変えたいときがsurroundの出番です。
3つの操作 ys / cs / ds(vim-surround)
vim-surroundの導入は、ほかのプラグインと同じく~/.vim/pack/plugins/start/にcloneするだけです。置き場所や:helptagsの流し方はVimにプラグインを入れる方法にまとめてあります。入れると、次の3つが使えるようになります。
- ys{モーション}{文字}:範囲を囲む。
hello worldでysiw"は"hello" world、a + bで行全体を囲むyss)は(a + b) - cs{今の文字}{新しい文字}:付け替える。
say "hello" nowでcs"'はsay 'hello' now - ds{文字}:外す。
call(foo, bar)でds(はcallfoo, bar
ysはオペレーターなので、後ろにはiwや$のような普段のモーションとテキストオブジェクトがそのまま来ます。csとdsは、カーソルを囲みの中に置いて使うのが基本です。
カーソルが囲みの外にあるときの挙動も確かめました。f(x) g(y)の行頭、fの上でds(を打つと、結果はfx g(y)で、右にある最初の括弧が外れました。これはvim-surroundが自分で探しているというより、手元のVim 9.1.1752では素のdi(も行頭から打つとf() g(y)になり、右の括弧を拾うためです。古いVimでは囲みの中にカーソルが無いと何も起きないことがあるので、版が違う環境では中に入ってから打つほうが確実です。
HTMLタグとビジュアル選択(cst / dst / S)
囲みの文字にtを指定するとHTMLタグが対象になります。<div>hello</div>のhelloの上でcstと打つと、下に入力欄が出ます。そこへ<p>と打ってEnterを押すと<p>hello</p>になり、開始タグと終了タグが同時に変わりました。dstはタグだけを外し、helloが残ります。タグの中身や属性値を書き換える話はHTMLタグの中身・属性値を書き換えるcit / datで扱っています。
属性の扱いは入力の終わり方で変わります。<div class="x">hello</div>でcstのあと<p>と閉じ括弧まで打つと<p>hello</p>で、classは消えました。<pで止めてEnterを押すと<p class="x">hello</p>で、属性が残ります。同梱のヘルプにも書かれている仕様です。
If replacing a tag, its attributes are kept in the new tag. End your input with > to discard the those attributes.
vim-surround doc/surround.txt
ビジュアルモードではSが囲む操作になります。helloをv eで選んでS}を打つと{hello} world、S{だと{ hello } worldでした。行単位のVで選んでSのあと<p>を入力すると、タグが前後の行に分かれ、中身は1段下げられて3行になります。vimrcなしで起動した手元では、字下げは既定のshiftwidthと同じ8桁でした。
開き括弧と閉じ括弧で結果が変わる
前の節のS{とS}の違いがsurround系に共通する決まりです。新しく付ける囲みに開き括弧を指定すると内側に空白が1つずつ入り、閉じ括弧を指定すると入りません。3つのプラグインで挙動が分かれるかを見るつもりでf(x, y)のxの上から打ち比べましたが、ここは3つとも同じ結果でした。mini.surroundはcsの代わりにsrで打っています。
| 打ったキー | vim-surround | nvim-surround | mini.surround |
|---|---|---|---|
| cs)( | f( x, y ) | f( x, y ) | f( x, y ) |
| cs)) | f(x, y) | f(x, y) | f(x, y) |
| cs)[ | f[ x, y ] | f[ x, y ] | f[ x, y ] |
| cs)] | f[x, y] | f[x, y] | f[x, y] |
| ysiw( | ( hello ) | ( hello ) | ( hello ) |
今ある囲みのほうを開き括弧で指定したときも、空白の扱いが変わります。f( x )でcs()を打つと、3つともf(x)になりました。開き括弧で指したときは「内側の空白まで含めた囲み」とみなされ、それごと閉じ括弧の囲みに置き換わるためです。空白入りの括弧を詰めたいときはこの形が1回で済みます。逆に空白を残したまま種類だけ変えたいなら、両方とも閉じ括弧で指定します。
ドットで繰り返すには repeat.vim が要る
vim-surroundは単体だと.で繰り返せません。同じ作者のvim-repeatを一緒に入れると繰り返せるようになります。どちらも入れた状態、vim-surroundだけの状態、Neovimの2つで、.を押したときの結果を比べました。ドットコマンドは直前の変更を1回ぶん繰り返す仕組みなので、プラグインの操作がその「1回」に数えられるかどうかが分かれ目です。
| 操作 | vim-surroundだけ | +vim-repeat | nvim-surround / mini.surround |
|---|---|---|---|
| ysiw" → W → . | 囲む文字をもう一度聞いてくる | "one" "two" three | "one" "two" three |
| cs"' → W → . | その前の素の変更が繰り返される | 'a' 'b' | 'a' 'b' |
| ds( → W → . | その前の素の変更が繰り返される | a b | a b |
vim-surroundだけのときの壊れ方は2通りありました。ysiw"のあとの.は、画面右下にg@iwと出て止まり、ここで"を打てば"two"、]を打てば[two]になります。ysはVimのg@を使ったオペレーターなので、Vim自身が「g@iwをもう一度」までは覚えていますが、囲む文字はプラグインがその場で読み取るため、毎回聞き直しになります。
csとdsはもっと分かりにくく、.はsurroundの操作を飛ばして、その前に打った素の変更を繰り返します。z(a) (b) cでxを打って先頭のzを消し、ds(、W、.と続けると、結果はa b) cでした。2つめの括弧が外れるのではなく、xが繰り返されて開き括弧1文字だけが消えています。直前に素の変更が無ければ何も起きません。ソースを読むと、繰り返しの登録はsilent! call repeat#set(...)という形で書かれていて、vim-repeatが無いときはエラーも出さずに素通りします。
vim-repeatを足すと3つとも期待どおりに繰り返し、cstで<b>a</b> <b>c</b>の1つめを<i>に変えてから.を押すと、2つめも聞き直しなしで<i>になりました。nvim-surroundとmini.surroundは何も足さずに繰り返せます。
Neovim なら nvim-surround と mini.surround のどちらにするか
Neovimでもvim-surroundは動きますが、Lua製の2つはvim-repeatを要らず、それぞれ独自の機能を持っています。どちらもNeovim 0.12の標準プラグイン管理vim.packで入れる手順がREADMEに載っています。nvim-surroundはruntimepathに足しただけでysiw"が動き、setup()を呼ばなくても既定のキーが使えました。mini.surroundはrequire('mini.surround').setup()を呼ぶまで何も割り当てられません。
-- nvim-surround(READMEの例。4.x系に固定する)
vim.pack.add({ { src = "https://github.com/kylechui/nvim-surround", version = vim.version.range("4.x") } })
-- mini.surround(setup() が要る)
vim.pack.add({ "https://github.com/nvim-mini/mini.surround" })
require("mini.surround").setup()
nvim-surroundはキーも考え方もvim-surroundを踏襲していて、そこにエイリアスが加わっています。設定ファイルの既定値ではbが)、Bが}、rが]、aが>で、qは"と'とバッククォートの3つをまとめて指します。say 'hi' nowのhiの上でcsqbと打つとsay (hi) nowになり、引用符の種類を気にせずに丸括弧へ変えられました。バッククォートで囲んだ行でも同じ結果です。入れ子のx = "say 'hi'"でhiの上からdsqを打つと、外れたのは内側のシングルクォートで、x = "say hi"になりました。
qは囲みの外からでも働きます。say "hi" nowの行頭でcsqbを打っても、行末に移ってから打っても、結果はsay (hi) nowでした。mini.surroundもqとbを持っていて、srqbで同じ変換ができます。
2つがはっきり分かれたのは、カーソルが囲みの外にあるときです。mini.surroundは既定のsearch_methodがcover(覆っているものだけ)で、カーソルを覆っている囲みしか探しません。say "hi" nowの行頭でsr"'を打つと何も変わらず、(mini.surround) No surrounding '"' found within 20 linesというメッセージが出ました。入れ子でも差が出ます。
x = "a (b) c"のaの上で | 結果 |
|---|---|
| vim-surroundのds( | x = "a b c" |
| nvim-surroundのds( | x = "a b c" |
| mini.surroundのsd( | x = "a (b) c"(変わらない) |
| 3つともds" / sd" | x = a (b) c |
カーソルがbの上、つまり括弧の内側にあれば、3つともds(で括弧が、ds"で引用符が外れます。違うのは外から打ったときだけで、vim-surroundとnvim-surroundは右にある括弧を拾い、mini.surroundは拾いません。vim-surroundの手癖をそのまま持ち込みたいならnvim-surround、すでにmini.nvimの他のモジュールを使っていて揃えたいならmini.surround、という分け方が実際的です。
mini.surround の sa/sd/sr は s を潰す
mini.surroundの既定のキーはsa・sd・sr・sf・sF・shなどで、どれもsで始まります。素のVimのsは1文字消して挿入に入る、実質clのsですが、mini.surroundを既定のまま入れると、このsは使えなくなります。
遅れて動くのではなく、まったく動きません。:echo maparg('s','n')とmaparg('s','x')はどちらも<Nop>を返しました。abcの行でsを押して2秒待ち、Qと打っても行はabcのままで、挿入モードには入りません。timeoutlenは1000ミリ秒の既定値なので、待てば素のsに落ちるはずのところを、わざと何もしないキーにしてあります。ヘルプにも理由が書かれています。
Note: if any of the mappings start with "s" (as is by default), it is mapped to |<Nop>| to prevent accidental trigger of built-in |s| (can happen if there is a long enough delay between pressing "s" and the next key). Use `cl` instead.
mini.surround doc/mini-surround.txt
ヘルプの言うとおりclなら同じ編集ができ、abcでcl QはQbcになりました。sを手放したくない場合は、キーをvim-surroundと同じ形に変えます。ヘルプにある「vim-surroundに近い設定」から、キーの部分を使いました。
require("mini.surround").setup({
mappings = {
add = "ys", delete = "ds", replace = "cs",
find = "", find_left = "", highlight = "",
suffix_last = "", suffix_next = "",
},
search_method = "cover_or_next", -- 囲みの外からも右を探す
})
この設定ではsに何も割り当てられず、abcでs QはQbcと素の動きに戻りました。ysiw"のあとの.も"one" "two" threeと繰り返し、行頭からのcs"'もsay 'hi' nowと右の引用符を拾います。ヘルプの例にはビジュアルモードのSとyssを割り当て直す行も続いていますが、そちらは今回試していません。
打ち比べの途中で、vim-surroundだけの環境にds(と.を打ったら、z(a) (b) cがa b) cになりました。2つめの括弧は外れずに開き括弧だけが消えたので、最初は自分の打ち間違いだと思って3回やり直しています。手前でxを打っていたことに気づくまで、プラグインの不具合を疑っていました。普段の設定ではvim-repeatを入れっぱなしにしていたので、入れ忘れた環境でこう壊れるとは知りませんでした。
まとめ
囲みを足すのがys、付け替えるのがcs、外すのがdsで、タグはt、選択範囲はSです。開き括弧で指定すると内側の空白が付き、閉じ括弧だと付かないという決まりは3つのプラグインで共通でした。
差が出るのは繰り返しと探し方です。vim-surroundはvim-repeatと組にしないと.が別の変更を繰り返します。mini.surroundは既定だとカーソルを覆う囲みしか探さず、sも使えなくなるので、キーとsearch_methodを変えてから使うと手になじみます。