← Vim研究所

Vimのsurroundで括弧や引用符を付け替える(ys / cs / ds)

2026-10-17 公開 ・

文字列のダブルクォートをシングルクォートに変えたい、関数呼び出しの括弧だけを外したい、という作業は素の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-surroundmini.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はオペレーターなので、後ろには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-surroundnvim-surroundmini.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-repeatnvim-surround / mini.surround
ysiw" → W → .囲む文字をもう一度聞いてくる"one" "two" three"one" "two" three
cs"' → W → .その前の素の変更が繰り返される'a' 'b''a' 'b'
ds( → W → .その前の素の変更が繰り返されるa ba 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を変えてから使うと手になじみます。

関連記事

← Vim研究所 トップへ戻る