Vimのタブをスペースに変換する :retab と expandtab の違い
set expandtab を .vimrc に書いたのに、開いたファイルのインデントがタブのままで戸惑ったことがあります。この設定はこれから打つキーにしか効きません。すでに入っているタブを置き換えるには、別のコマンドが要ります。
目次(8項目)
| やること | コマンド |
|---|---|
| これから打つタブをスペースにする | set expandtab |
| すでにあるタブをスペースにする | :retab |
| 行頭のタブだけを変換する | :retab -indentonly |
| スペースをタブに戻す | set noexpandtab + :retab! |
| Tab1回で進む幅を決める | set softtabstop=4 |
| タブが残っていないか数える | :%s/\t//gn |
set expandtab は既存のタブを変えない
set expandtab " Tabキーを押したらスペースを入れる
set tabstop=4 " タブ1つを何桁として表示するか
set shiftwidth=4 " >> や自動インデントの1段の幅
expandtab が効くのはTabを押した瞬間だけです。インサートモードで文字を作るときの分岐を決めるだけなので、すでにバッファに入っている文字には触りません。保存もバイト列をそのまま書き出します。タブでインデントされたファイルを set expandtab のまま開いて保存し直しても、od -c で見た中身はVim 9.1.1752でもNeovim 0.12.4でも1バイトも変わりませんでした。設定を入れた覚えがあるのにタブが残っているなら、たいていこれが理由です。
インデントに関わるオプションは4つあり、名前が似ているので役割が混ざりがちです。効く場面は分かれています。
| オプション | 決めるもの |
|---|---|
tabstop | タブ文字1つを画面で何桁ぶんに見せるか |
expandtab | Tabでタブ文字ではなくスペースを入れるか |
softtabstop | Tab1回で進む桁数 |
shiftwidth | 自動インデントと>>の1段の幅 |
tabstop=8 は固定したまま残りを変えて、Vim 9.1.1752でTabを1回押した結果を実測しました。
| expandtab | softtabstop | 入った文字 |
|---|---|---|
| なし | 0 | タブ1個 |
| あり | 0 | スペース8個 |
| あり | 4 | スペース4個 |
| なし | 4 | スペース4個(もう1回押すとタブ1個にまとまる) |
| あり | -1 | スペース3個(shiftwidth=3 のとき) |
スペースが何個入るかを決めているのは softtabstop で、それが0のときだけ tabstop の幅が使われます。shiftwidth=4 を与えてもTabの結果は動きませんでした。shiftwidth が働くのは自動インデントと>>のほうで、その動きはインデントを増減する方法にまとめています。softtabstop を -1 にすると shiftwidth に従うので、幅を1か所で管理したいときに便利です。
Tabの挙動にはVimとNeovimの差もあります。smarttab の既定値が、Vim 9.1.1752は無効でNeovim 0.12.4は有効です。有効だと、行頭の空白の中で押したTabだけが shiftwidth の幅で進みます。両方に set expandtab tabstop=8 shiftwidth=4 を与えて行頭でTabを1回押すと、Vimは8スペース、Neovimは4スペースが入りました。行の途中ではどちらも6スペースで、差が出るのは行頭だけです。
すでにあるタブをスペースにする :retab
:set expandtab tabstop=4
:%retab
:retab は現在の tabstop の幅でタブを展開し、expandtab が有効ならスペースに置き換えます。範囲を書かなければ % と同じでファイル全体が対象です。2行目から3行目だけなら :2,3retab と書きます。4行すべてがタブで字下げされたファイルで試すと、指定した2行だけがスペースになりました。
:retab が見ているのは空白の個数ではなく、画面上の桁です。空白の並びを現在の tabstop で桁数に換算し、同じ桁に着くように空白を入れ直します。だから tabstop を変えずにファイル全体へかけるかぎり、見た目は動きません。この性質は、タブを決め打ちのスペースに置換するやり方との差に表れます。a のうしろにタブ、abcdef のうしろにタブを置いた2行のファイルで比べました。
| 変換のしかた | 1行目の b | 2行目の g |
|---|---|---|
| 元のまま(8桁表示) | 9桁目 | 9桁目 |
8桁で :%retab | 9桁目 | 9桁目 |
4桁で :%retab | 5桁目 | 9桁目 |
| 置換で4スペース | 6桁目 | 11桁目 |
タブは「次の停止位置まで進む」文字なので、a のうしろの1つは7桁、abcdef のうしろの1つは2桁ぶん進み、どちらも9桁目に着きます。:retab はこの桁を計算し直すため列がそろったままですが、:%s/\t/ /g はタブ1つを必ず4スペースに置き換えるので、そろっていた列が6桁目と11桁目にばらけました。タブで位置をそろえてあるファイルでは、置換を使うとそこが崩れます。
:retab 8 のように数値を取る形もありますが、これは「8桁として変換する」という意味ではありません。既存のタブは実行時点の tabstop で解釈し、書き戻したあとで tabstop のほうを8に設定します。Vim 9.1.1752で tabstop=4 の状態から :%retab 8 を実行すると、インデントは4桁と8桁のまま保たれ、実行後の :set tabstop? は tabstop=8 を返しました。幅を指定したつもりで書くと、桁はそのままでオプションだけが書き換わります。
文字列の中のタブまで潰れる
ここが :retab のいちばん危ないところです。行頭のインデントだけを直したいつもりでも、行の途中にあるタブまで書き換えられます。Neovim 0.12.4で次のファイルを試しました。
def f():
→ print("a→b")
→ if x:
→ → return "c→d"
矢印がタブです。:%retab をかけると、インデントに加えて "a→b" の中のタブまでスペースになりました。文字列やコメントにタブを含むファイルでは、見た目が変わらないまま中身だけが書き換わります。差分を見るまで気づけません。
入るスペースの数が行ごとに違うのも、この壊れ方が厄介な理由です。同じファイルを set expandtab tabstop=4 で変換すると、"a→b" の中のタブは4スペースになり、1段深い "c→d" の中のタブは3スペースになりました。行頭からの桁を数えて次の停止位置まで詰める以上、文字列の始まる桁が違えば結果の長さも変わります。テストの期待値がここでずれると、決め打ちの文字列で探しても見つけられません。
Warning: This command modifies any <Tab> characters inside of strings in a C program. Use "\t" to avoid this (that's a good habit anyway).
ヘルプはCのプログラムを例に、文字列の中のタブも書き換わることを警告として明記しています。避けるにはソースの側でエスケープ表記を使え、という指示です。Vimの側は仕様として承知のうえの動作なので、バージョンを上げれば直るものではありません。
:%retab -indentonly
-indentonly を付けると、行頭の空白だけが対象になります。同じファイルで試すと、インデントは4スペースになり、"a→b" のタブはタブのまま残りました。Vim 9.1.1752とNeovim 0.12.4の両方で同じ結果です。
-indentonly はVim 9.1のパッチ1544で追加された指定なので、それより前のVimではE488になります。複数のファイルにまとめてかけるなら、引数リストと :argdo の使い方の要領で対象を絞ってから回します。
:args **/*.py
:argdo %retab -indentonly | update
tabstop は自分の好みではなくファイルに合わせる
:retab がスペースを何個入れるかは、そのとき設定されている tabstop で決まります。タブ1つが必ず4スペースになるわけではありません。
行頭にタブが1つと2つ入ったファイルを、設定だけ変えて変換しました。
| 設定 | 変換後のインデント |
|---|---|
tabstop=2 | 2スペース / 4スペース |
tabstop=4 | 4スペース / 8スペース |
tabstop=8 | 8スペース / 16スペース |
つまり、8桁タブで書かれたファイルを tabstop=4 のまま変換すると、インデントが半分の深さになります。自分の好みの幅ではなく、そのファイルがもともと何桁で書かれていたかに合わせてから実行してください。書いた人の設定が分からないときは、変換せずタブのまま扱うほうが安全です。
元の幅を知る手がかりは、ファイルそのものよりプロジェクトの設定にあります。.editorconfig が置いてあれば、字下げの種類と幅を書いた2行がその答えです。扱いはVimとNeovimで違います。
Neovim 0.12.4はプラグインを足さずに .editorconfig を読みます。スペース字下げ・幅2を書いて同じディレクトリのPythonファイルを開くと、expandtab が有効になり shiftwidth と tabstop はどちらも2になりました。同じファイルをVim 9.1.1752で開くと置いても消しても値は変わらず、Python用のftpluginが決めた4のままでした。Vimでも:packadd! editorconfigを書けば揃います。Vim 9.1には同梱のパッケージとして入っているので、外から入れる必要はありません。
変換を既存のリポジトリで流すと、差分の見え方も変わります。13行のうち12行がタブで字下げされたファイルに :%retab -indentonly をかけたところ、git diff --stat は12行の追加と12行の削除を出しました。中身は1文字も変えていないのに、git blame ではその12行の担当がこちらに置き換わります。
空白だけの変更かどうかは git diff -w で確かめられます。先ほどのファイルでは出力が空になりました。git blame -w も同じで、変換前のコミットを表示し直します。ただしレビューで最初に見るのは素の差分なので、変換だけを単独のコミットに分けておくほうが通りやすくなります。
スペースをタブに戻す
:set noexpandtab tabstop=4
:%retab!
逆向きは ! を付けます。expandtab を切ってから :retab! を実行すると、tabstop の幅ぶんのスペースがタブに戻ります。4スペースのインデントで書かれたファイルで試したところ、1段目がタブ1つ、2段目がタブ2つになりました。
! が要るのは、素の :retab が「タブを含む空白の並び」しか対象にしないからです。スペースだけの並びは最初から見ていません。set noexpandtab tabstop=4 にして4スペース字下げのファイルに :%retab をかけても、手元では1バイトも変わりませんでした。! を付けて初めて、スペースだけの並びも置き換えの対象に入ります。
逆向きも文字列の中を書き換えます。print("a b") のように文字列の中に4スペースが並んだ行を tabstop=4 で :%retab! にかけると、その4スペースがタブ1つになりました。:%retab! -indentonly なら文字列の中は4スペースのまま残ります。ヘルプにも :retab! が printf() を壊しうると書かれています。
この向きが要るのは、タブでないと動かないファイルを直すときです。Makefileのレシピ行は先頭がタブでなければならず、スペース4個に変わっていると、手元のGNU makeは missing separator で止まり、タブに戻すと通りました。
変換できたかを目で確かめる
:set list
:set listchars=tab:>-,space:.
タブとスペースは画面上で見分けが付かないので、変換したあとは記号で表示させて確認します。設定の詳しい書き方はタブ・改行・行末スペースを可視化するにまとめています。インデントの深さ自体を変えたいだけなら、インデントを増減する方法のほうが目的に合います。
目で見るより確実なのは数えることです。:%s/\t//gn は n フラグが付いているので置換せず、当たった件数だけを返します。先ほどのPythonのファイルでは、実行前が「6 matches on 3 lines」で、:%retab -indentonly のあとは「2 matches on 2 lines」になりました。残った2つが文字列の中のタブで、行頭だけを変換できたことがこの数字で分かります。行頭のタブだけを数えたいなら :%s/^\t\+//gn で、同じファイルでは「3 matches on 3 lines」でした。
共同で触っているリポジトリで、インデントをそろえるつもりで :%retab をかけたことがあります。差分の行数は多かったものの空白しか変えていないはずだと思い込み、そのままプルリクエストに出しました。返ってきたのはテストの失敗でした。そのファイルはtabstopが2で書かれていたのに、自分の設定の4で解釈したまま流していたのが原因です。開いているファイルがどの幅で書かれているかを先に確かめるべきでした。
まとめ
set expandtab はこれから打つタブ、:retab はすでにあるタブを扱います。インデントだけを直したいなら -indentonly を付け、実行前に tabstop がそのファイルの幅に合っているかを確かめてください。この2つを押さえておけば、意図しない差分を出さずに変換できます。