Vimで行を上下に移動する :mコマンドとddpの違い、選択範囲の入れ替え
VS CodeならAlt-↑で済む「行を1つ上へずらす」操作が、Vimには専用のキーとして用意されていません。代わりにあるのが:move(略して:m)というExコマンドで、今いる行を下へ1行動かすなら:m+1、上へ1行なら:m-2と打ちます。
下が+1なのに上が-1ではなく-2になる。ここで引っかかってddpに戻る人が多いのですが、ddpにはレジスタを書き換える・最終行で入れ替わらないといった別の弱点があります。この記事では:mの数え方を図で確かめたうえで、両者の差と、よく見かけるキーマップ:m '>+1<CR>gv=gvの中身を部品ごとに分解します。
検証環境:Vim 9.1.1752とNVIM v0.12.6(macOS)。どちらも設定を読まない状態(-u NONE)で、一時ディレクトリに置いたテキストを相手に:mやddpを実行し、行の並び・カーソル位置・エラーメッセージ・:registersの表示を記録しました。Altキーの割り当てはtmuxの中で実際にキーを送って確かめています。掲載している行の並びと出力はすべて実行結果です。
目次(10項目)
| やりたいこと | コマンド |
|---|---|
| 今の行を下へ1行 | :m+1 |
| 今の行を上へ1行 | :m-2 |
| 選択範囲を下へ1行 | :'<,'>m'>+1 |
| 選択範囲を上へ1行 | :'<,'>m'<-2 |
| 先頭・末尾へ | :m 0 / :m$ |
| 全行を逆順に | :g/^/m0 |
行を上下に動かす最短の操作
4行のファイルで、カーソルを1行目に置いて:m+1を実行すると、1行目と2行目が入れ替わります。カーソルは動かした行に付いていくので、続けて同じコマンドを打てばaはさらに下へ降りていきます。
a
b
c
d
:m+1b
a
c
d
毎回コマンドラインを打ち直す必要はありません。直前のExコマンドを繰り返す@:があり、2回目以降は@@でも同じものが走ります。手元で:m+1→@:→@@と打つと、a b c dがb c d aになり、aが最下段まで降りました。
上へ動かすほうは:m-2です。最終行で実行するとa b c dがa b d cになり、dが1つ上がります。なぜ-1ではないのかが、次の節の話です。
:mの目標行は「この行の下」 +1と-2が非対称な理由
:m {address}は「指定した行の下に置く」という意味です。+1や-2はカーソル行からの相対位置で、その行番号を求めてから、その行の下へ差し込みます。カーソルを2行目のbに置いた状態で、指定ごとに何が起きるかを並べると次のとおりです。
| 指定 | 目標の行 | 結果 | 意味 |
|---|---|---|---|
:m+1 | 3行目(c)の下 | a c b d | 下へ1行 |
:m-1 | 1行目(a)の下 | a b c d | 変化なし |
:m-2 | 0行目の下 | b a c d | 上へ1行 |
:m 0 | 0行目の下 | b a c d | 先頭へ |
:m-1は「1つ上の行の下」、つまり今いる場所そのものを指すので何も起きません。実際に2行目で実行しても並びはa b c dのままで、エラーも出ませんでした。上へ1行ずらすには、1つ上の行のさらに上の行(-2)の下へ置く必要があります。下方向は「1つ下の行の下」がそのまま1行下がる位置なので+1で済みます。非対称なのは数字の付け方ではなく、基準が常に「その行の下」だからです。
端では目標の行が存在しなくなり、E16: Invalid rangeで止まります。最終行(4行目)での:m+1は5行目を指し、1行目での:m-2は-1行目を指すので、どちらもE16が出て並びは変わりませんでした。キーマップに組み込んだときに最終行でエラーが出るのはこのためで、壊れているわけではありません。
もう1つ見落としやすいのが、+1の基準は動かす行ではなくカーソル行だという点です。カーソルを1行目に置いたまま:$m+1(最終行を+1へ)と打つと、6行のファイルa b c d e fがa b f c d eになりました。最終行は「2行目の下」へ飛んでいます。範囲を付けて使うときは相対指定ではなく、行番号かマークで目標を書くほうが安全です。これが後で出てくる'>+1の理由にもなります。
ddp / ddkPとの違い(レジスタ・最終行・undo)
行の入れ替えといえばddp(今の行を切り取って1つ下に貼る)とddkP(切り取って1つ上に貼る)が昔からの定番です。Vimのコピーと貼り付け(yy・p)を覚えた直後なら自然に出てくる手ですが、:mと比べると挙動が3か所で違います。
1つ目はレジスタです。3行目のcをyyでヤンクしてから1行目で:m+1を実行し、:registers "01で中身を見ました。
Type Name Content
l "" c^J
l "0 c^J
ヤンクしたcがそのまま残っています。:mはレジスタに一切触れません。同じ状態からuで戻し、今度はddpで入れ替えると次のようになりました。
Type Name Content
l "" a^J
l "0 c^J
l "1 a^J
無名レジスタ""がaに上書きされ、"1にもaが入っています。"0にはcが残っているので"0pなら取り出せますが、ただのpでは入れ替えた行が出てきます。削除のたびに"1から"9へ順送りされる仕組みは番号付きレジスタ「"0〜"9」の記事に書いたとおりで、ddpを数回使うと番号付きレジスタの古い削除が押し出されていきます。
2つ目は端の挙動です。最終行でddpを押すと、ddで最終行が消えたあとカーソルが1つ上の行へ移り、pがその下に貼るので、行は元の位置に戻ってきます。手元でもa b c dはa b c dのままでした。エラーが出ないので、入れ替わらなかったことに気づきにくいのが厄介です。
上方向のddkPはもっと分かりにくい動きをします。途中の行(3行目のc)で使うとa c b dになって正しく1つ上がりますが、最終行で使うとa d b cになり、dが2行上まで飛びました。最終行を消した時点でカーソルがすでに1つ上がっていて、そこからさらにkで上がるためです。1行目でddkPを使った場合はkが動けずに失敗し、並びは変わりませんでした。
3つ目はundoです。ddpは削除と貼り付けの2つの変更なので、uを1回押した時点ではb c d(貼り付ける前の、行が消えた状態)になり、2回目でようやく元のa b c dに戻りました。:mは1回のuで元に戻ります。
| 比べる点 | :m+1 / :m-2 | ddp / ddkP |
|---|---|---|
| レジスタ | 触れない | ""と"1を上書き |
| 最終行で下へ | E16で止まる | 元の位置に戻る(変化なし) |
| 最終行で上へ | 1行上がる | ddkPは2行上がる |
| 元に戻す | u1回 | u2回 |
| 打鍵数 | 多い(Enterまで5打) | 3〜4打 |
打鍵数だけはddpが勝ちます。ヤンクしたものを抱えていない場面で、真ん中あたりの行を1つ下げるだけならddpで困ることはありません。困るのはレジスタに貼りたいものが入っているときと、端の行を扱うときです。
複数行・選択範囲を動かす(:'<,'>m)
:mは範囲を受け取れます。Vで行を選んでから:を押すと、コマンドラインに:'<,'>が自動で入ります。'<は選択の先頭行、'>は末尾行を指すマークです(選択の操作そのものはVimの範囲選択(v・V・Ctrl-v)の記事で扱っています)。
:'<,'>m'>+1 " 選択範囲を下へ1行
:'<,'>m'<-2 " 選択範囲を上へ1行
6行のファイルa b c d e fで2〜3行目(b c)を選び、:'<,'>m'>+1を実行するとa d b c e f、:'<,'>m'<-2ならb c a d e fになりました。下へは「末尾行の1つ下の行の下」、上へは「先頭行の2つ上の行の下」で、1行のときと同じ考え方です。
ここで'>+1と書くのは、前の節で見た「+1はカーソル行が基準」という性質を避けるためです。Vで選んだあとのカーソルは、下へ広げたなら末尾、上へ広げたなら先頭にあります。3GVk(3行目から上へ選択)のあとに:'<,'>m+1と打つとカーソルが2行目にあるので目標は3行目になり、範囲の中へ動かすことになって何も変わりませんでした。'>+1なら選び方に関係なくa d b c e fになります。
範囲の内側を目標にすると、端でなくても失敗します。12行のファイルで:2,4m 3を実行するとE134: Cannot move a range of lines into itselfが出ました。一方で:2,4m 1や:2,4m 4は、移動先がもとの位置と同じになるだけなのでエラーにならず、並びも変わりません。
実行が終わるとビジュアルモードは解除され、カーソルは動かした範囲の最終行に移ります。続けてもう1行動かしたいときは選び直しが必要で、これを自動にするのが次の節のgvです。
Alt-j / Alt-kに割り当てるキーマップ(gv=gvの分解)
よく紹介されている設定が次の4行です。
nnoremap <M-j> :m .+1<CR>==
nnoremap <M-k> :m .-2<CR>==
xnoremap <M-j> :m '>+1<CR>gv=gv
xnoremap <M-k> :m '<-2<CR>gv=gv
ビジュアルモード側の:m '>+1<CR>gv=gvを分解すると、こうなります。
:m '>+1<CR>::で'<,'>が自動で入り、選択範囲を末尾行の1つ下の行の下へ動かす。ここでビジュアルモードが終わる- gv:直前の選択範囲を選び直す。
'<と'>は行と一緒に動いているので、移動後の位置が選ばれる - =:選んだ行を再インデントする。インデントの深さが違うブロックへ入ったとき、その場の深さに合わせる
- gv:=で選択が解けるので、もう一度選び直す。これでAlt-jを連打できる
ノーマルモード側の.+1の.はカーソル行のことで、:m+1と同じ意味です。最後の==は今の1行だけを再インデントします。
次のCのコードで確かめました。インデントは空白4つずつです。
int main(void) {
if (x) {
foo(x);
}
bar(x);
baz(x);
}
ifブロックの外にあるbar(x);(5行目)でAlt-kを押すと、閉じ括弧の上へ上がると同時にfoo(x);と同じ8桁目まで字下げされ、ブロックの中へ入りました。uは1回で、移動と再インデントの両方が戻ります。ビジュアルモードでbar(x);とbaz(x);の2行を選んで押した場合も、2行ともブロックの中へ入り、選択は-- VISUAL LINE --のまま残っていました。VimとNeovimで結果は同じです。
素のVimではAlt-kが効かない
同じ設定をtmuxの中のVimで試すと、Alt-kを押しても行は動かず、カーソルが1行上がっただけでした。端末はAlt-kをEscとkの2文字として送ることが多く、Vimはそれを「ノーマルモードへ戻ってk」と読んでしまいます。Neovimは同じ環境でそのまま動きました。
Vimで使うなら、Esc jの並びを<M-j>として認識させる設定を先に足します。
execute "set <M-j>=\ej"
execute "set <M-k>=\ek"
set ttimeoutlen=50
この3行を加えたところ、VimでもAlt-jとAlt-kで行が動くようになりました。ttimeoutlenは、Escのあとに続く文字をキーコードの一部として何ミリ秒待つかの設定です。Escで始まる並びを登録したぶん、インサートモードをEscで抜けたときの待ち時間に効いてくるので、短めの値を一緒に入れています。
=が崩すインデント
=はそのバッファのインデント設定に従います。設定を何も読まない素のVimはshiftwidth=8・noexpandtabです。上の例でbar(x);が8桁目に揃ったのも、ファイルに書き込んで中身を見ると空白8つではなくタブ1文字\tbar(x);になっていました。bar(x);とbaz(x);をif (x) {の直後まで上げた場合はさらにずれて、12桁目の\t bar(x);(タブ1つと空白4つ)になりました。
ファイルタイプごとのインデント設定(filetype plugin indent onとshiftwidth)がファイルに合っていれば問題になりませんが、合っていない環境では動かすたびにインデントが崩れます。その場合はgv=gvをgvだけにして、再インデントを外すほうが安全です。set shiftwidth=4 expandtabにしてから同じ操作をすると、どちらも空白8つに揃いました。
Neovimでinit.luaに書くなら次の形で、手元のNeovim 0.12.6で4つとも動きました。ノーマルモード側は<Cmd>で書けますが、ビジュアルモード側は:のまま残します。試しにビジュアルモード側も<Cmd>m '>+1<CR>gv=gvにしたところ、起動して最初の選択でE20: Mark not setが出て動きませんでした。'<と'>はビジュアルモードを抜けたときに記録されるマークで、<Cmd>はモードを抜けずに実行するため、まだ記録されていないのです。
vim.keymap.set('n', '<M-j>', '<Cmd>m .+1<CR>==')
vim.keymap.set('n', '<M-k>', '<Cmd>m .-2<CR>==')
vim.keymap.set('x', '<M-j>', ":m '>+1<CR>gv=gv")
vim.keymap.set('x', '<M-k>', ":m '<-2<CR>gv=gv")
このキーマップで3回続けてAlt-kを押したあとuを押すと、戻るのは最後の1回分だけでした。1回のキー操作が1つの変更として記録されるので、5行動かしたらuも5回です。
先頭・末尾・指定行へ飛ばす(:m 0 / :m$ / :m 15)
1行ずつではなく、離れた場所へ一気に動かすこともできます。:m 0は先頭へ、:m$は末尾へ動かします。a b c dの2行目で実行すると、:m 0はb a c d、:m$はa c d bになりました。
行番号を書く場合も「その行の下」です。1〜12の数字が並んだファイルで、2行目にいるときに:m 5を実行すると1 3 4 5 2 6 7 …になりました。2は「5の下」に入っていて、5行目にはなっていません。動かしたあとの行番号は5で、元の番号で数えた5行目の下に入ったぶん、1つ詰まった形です。逆に12行目で:m 5を実行すると1 2 3 4 5 12 6 …となり、こちらは動かした行が6行目に収まります。行番号で書くときは「移動先の上に来る行」を指定すると覚えると迷いません。
存在しない行を指定すると、ここでもE16です。12行しかないファイルで:m 15を実行するとE16: Invalid rangeが出て、何も変わりませんでした。
行の順番を逆にする(:g/^/m0)
m0(:m 0と同じ。数字の前の空白は省けます)を全行に順番に適用すると、ファイル全体が逆順になります。それをやるのが:g/^/m0です。:gは条件に合う行に印を付けてから上から順にコマンドを実行し、^(行頭)はすべての行に一致します。1行目を先頭へ、次に2行目を先頭へ、と積み上げていくので、最後に先頭へ来るのが元の最終行になります。a b c dはd c b aになりました。
範囲を付ければ一部だけ逆にできます。目標を範囲の1つ上の行にするのがコツで、1〜12のファイルで:5,10g/^/m4を実行すると1 2 3 4 10 9 8 7 6 5 11 12になりました。5〜10行目を順に「4行目の下」へ差し込むので、範囲の中だけが反転します。:gのほかの使い道は:globalコマンド活用集にまとめてあります。
:g/^/m0のあとでuを押すと、1回で元の並びに戻りました。行ごとに何十回も:mが走っていても、:g全体で1つの変更として扱われます。
プラグイン(vim-move / mini.move)が要る場面
ここまでの:mとキーマップで、行の上下移動は一通りできます。それでもプラグインを入れる意味があるのは、標準の:mでは手が届かない操作があるときです。以下は各プラグインのREADMEに書かれている機能で、この記事のために導入して動かしてはいません。mini.moveはNeovim向けのmini.nvimに含まれるモジュールです。
| 機能 | 自前のキーマップ | vim-move | mini.move |
|---|---|---|---|
| 既定のキー | 自分で決める | Alt+hjkl | Alt+hjkl |
| 左右(文字単位) | できない | できる | できる(v・Ctrl-vの選択も) |
| 回数指定 | できない | 5<A-k>で5行 | できる |
| 連続移動のundo | 1回ごと | まとめて1回 | まとめて1回 |
違いが出るのは、選択した単語を左右へずらしたいとき、何度も動かした結果をu1回で取り消したいとき、5<A-k>のように回数で飛ばしたいときの3つです。行を上下へ1行ずつ動かすだけなら、4行のキーマップで足ります。どちらのプラグインも既定のキーがAltなので、端末のVimで使う場合は前の節のAltの問題を同じように抱えます。
長いことddkPで行を上げていて、たまに行が思った場所に来ないことがありました。打ち間違いだと思ってuでやり直していたのですが、今回1行ずつ並びを記録して、それが毎回最終行で起きていたと分かりました。もう1つはAlt-kの設定です。記事を書くためにtmuxのVimで試したら行が動かずカーソルだけが上がり、しばらくマッピングの書き間違いを疑っていました。Neovimに同じ設定を読ませたら動いたので、そこでようやく端末側の問題だと気づきました。
まとめ
Vimで行そのものを上下に動かすなら:m+1と:m-2です。目標は常に「指定した行の下」で、基準はカーソル行なので、上へ1行は-2になります。選択範囲なら'>+1と'<-2のようにマークを基準にすると、選び方に左右されません。
ddpは打鍵が少ない代わりに、レジスタを上書きし、最終行では入れ替わらず、uも2回要ります。よく動かすなら:m '>+1<CR>gv=gvの形でキーに割り当てるのが楽ですが、端末のVimではAltの認識と、=が従うインデント設定の2点を先に確かめておくと、思わぬ動きに悩まされずに済みます。