改行コードを問わずにテキストファイル中の連続する空白行を1行に縮めて上書きしたい

注文の多いタイトルである。

  1. テキストファイル中の連続する空白行を1行に縮めたい。
  2. 縮めた結果を元ファイルに上書きしたい。元ファイルのバックアップは残さなくてよい。
  3. CR、LF、CRLFのいずれの改行コードのファイルでも使えるようにしたい。
    • 出力結果では元の改行コードを保持したい。
    • 複数種類の改行コードが混在しているファイルは、さすがに想定外とする。

なぜ、こんなに注文が多いのか?

元々は、astyle(1)(Artistic Style)を弄くっていて:

astyle --style=kr \
       --indent=tab=4 \
       --indent-namespaces \
       --indent-labels \
       --indent-preproc-define \
       --break-blocks \
       --pad-oper \
       --pad-header \
       --unpad-paren \
       --add-brackets \
       --align-method-colon \
       --unpad-method-prefix \
       --pad-method-colon=none \
       --max-code-length=120 \
       --errors-to-stdout \
       ${@+"$@"}

――こんなオプションでいい感じになるのだが、唯一気に入らないのが、2行以上の連続する空白行がそのまま残ってしまうこと。1行にしたいのだ。

astyle(1)には--delete-empty-linesというオプションがあるが、これだと1行だけの空白行も消えてしまう上に、関数内でしか有効にならない。そうじゃなくて、1行だけの空白行はそのまま残しつつ、2行以上の連続する空白行を1行にしてほしい。関数の中と外のどちらでも適用されてほしい。

astyle(1)にかけるソースファイルには、改行がCRLFのものとLFのものが混在している。なので、どの改行コードのファイルでも処理できるようにしたい。出力結果の改行コードは元ファイルに準じてほしい。

astyle(1)は、元のファイルのバックアップを作成しつつ、元のファイルの名前で処理結果を出力する。その出力結果を上書きする感じで、空白行の処理を行いたい。大本のファイルはastyle(1)が残しているので、空白行の処理を行う前のファイルは残さなくてもよい。

ということで、まずはこんな感じのコードを(「これ、遅いだろうな」と思いつつ)書いてみた。

for i; do
    cat "$i" | (rm -f "$i"; sed '/./,/^$/!d' >"$i")
done

しかし残念なことに、手元の環境(Cygwinsed(1)を使用)では、CRLFが問答無用でLFになってしまった。どうもsed(1)が原因のようだ。

sed(1)を止めてPerlにすることにした。最初に書いたのがコレ。

perl -0777 -pi -e 's/(\r\n|\n|\r){3,}/\1\1/g' ${@+"$@"}

-0777」で入力ファイルを一気に読み込むことで、正規表現を用いて連続する改行コードにマッチできるようにしている。また、オプションiを拡張子無しで用いて、入力ファイルを出力結果で上書きするようにしている。

これでうまくいくかと思いきや、例えば次のようなファイル(CRLF)にて:

1 aaaaa

2 bbbbb

1行目の末尾から3行目の頭の間の「\r\n\r\n」が「\n\n」になってしまった。つまり、CRLFのファイルを食わせると、CRLFとLFが混在したファイルができあがる可能性がある。

これは正規表現の問題で、本来なら、CRLFのファイルでは「CRLFが3つ以上」に、LFのファイルでは「LFが3つ以上」に……という具合にマッチさせなくてはならないところを、無理やり1つの正規表現にしたために、「\r\n\r\n」が「『CRかLF』が4つ」と解釈されてマッチしてしまう可能性が生じていたのだ。

上記を踏まえて書き直したのがコレ:

perl -0777 -pi -e '
  foreach my $nl ("\r\n", "\n", "\r") {
    s/(?:$nl){3,}/$nl$nl/g;
  }
' ${@+"$@"}

これで意図したとおりに動作するようになった。

ところで、大抵のファイルでは改行コードが混在している可能性は低い。なので、3種類の改行コード全てについてマッチングと置換をする必要もないだろう。

ということで、ファイル中の最初の改行コードを調べて、その改行コードでのみ置換を行うようにしてみた。

perl -0777 -pi -e '
  foreach my $nl ("\r\n", "\n", "\r") {
    if (/$nl/) {
      s/(?:$nl){3,}/$nl$nl/g;
      last;
    }
  }
' ${@+"$@"}

これで多少は速くなった……かもしれない。