はじめに
一般的にファイルの最終行に改行がないデータを扱うのは良い考えとは言えません(よくない理由は別記事で書きます)。しかしそれでも扱いたい場合もあります。この記事ではその方法をまとめました。
この記事は シェルスクリプト&PowerShell Advent Calendar 2023 2日目の記事です。
ファイルの末尾に改行が必要なのは「POSIXの仕様」だとする説がありますが、これは間違いで POSIX は標準規格で使用している「テキストファイル」の用語を定義しているだけです。
POSIX では「テキストファイル」という用語を、0行以上の行で構成されたファイルと定義しており、行とはヌル文字がを含まず末尾に改行がある LINE_MAX
バイト(一般的に2048バイト)以内の文字列のことです。
詳細は以下を参照してください。
1. read コマンドで改行がないデータを扱う
この read
コマンドを使った方法は
- POSIX で標準化された範囲のシェルの機能とコマンドの中では
- 最終行の末尾に改行がある場合とない場合を正しく区別できるという点で
(おそらく)最も自然な形でシェルスクリプトで最終行に改行がないデータを扱う方法です。
改行がないデータとして扱う
ときどき read
コマンドは終端に改行が無いデータを読み込めないと言われますが、実はそれは正しくありません。データは読み込んでいるのですが最終行の改行がない場合に read
コマンドが偽を返すからループが実行されないだけなのです。ループを抜けた後に変数の中身を見てみるとちゃんと最後の行が読み込まれていることがわかります。つまり read コマンドが最後の行を読み込めないのではなく、最後に読み込んだ改行がない行を処理していないあなたのバグということです。
#!/bin/sh
# データに行番号をつけて行数(改行の数)を数える処理
i=0
while IFS= read -r line; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done
# 最終行のデータがある場合は if の中を実行
if [ "$line" ]; then
printf "%d: %s" $((i + 1)) "$line"
echo # 改行を補完
echo "Missing newline at end of file"
fi
echo "total: $i lines" # wc -l と同じく改行の数
$ printf 'line1\nline2' | ./test.sh
1: line1
2: line2
Missing newline at end of file
total: 1 lines
補足ですが read
コマンドの前にある IFS=
と read
コマンドの -r
オプションは読み込んだデータをそのままの形で扱うためのものです。IFS=
は行の前後の空白を維持するために必要で、-r
は末尾の \
を次の行に継続するという意味ではなくそのまま \
の文字として扱うために必要です。
改行があるかのように扱う
最終行に改行あってもなくても同じように扱いたいのであれば「read
コマンドが偽を返しても読み込んだデータがあれば繰り返す」と書くだけで扱うことができます。
#!/bin/sh
i=0
while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done
echo "total: $i lines"
$ printf 'line1\nline2' | ./test2.sh
1: line1
2: line2
total: 2 lines
$ printf 'line1\nline2\n' | ./test2.sh
1: line1
2: line2
total: 2 lines
補足 while ループの外で変数が見えない問題の解決策
よく以下のようなコードを書いて while
ループの外で変数が見えないと困っている人を見かけますが、それは書き方に問題がありパイプを直接 while
につなげているのが原因です。パイプを使わずに標準入力(ファイル)から直接読み取れば while
のループの外でも変数は見えます。
i=0
cat file.txt | while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done
echo "total: $i lines" # 0 lines と出力される(期待していない結果)
i=0
while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done < file.txt
echo "total: $i lines" # 正しい結果が得られる
i=0
while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done < <(cat file.txt)
echo "total: $i lines" # 正しい結果が得られる
なぜ while
ループの外からループの中の変数が見えないのかというと「パイプで繋げたコマンド(while
〜 done
の部分)がサブシェルになっている」からです。サブシェルの詳しい説明は割愛しますが、要するに、パイプで繋げたコマンドの部分が子プロセスで実行しているのとほぼ同じ状態になっているのでループを抜けた先(親プロセス)には反映されません。
余談ですが、古い Bourne シェル時代(現在では Bourne シェルが使われているのは Solaris 10 の /bin/sh
ぐらい)では標準入力から入力しただけ(上記の②の例)でサブシェルになっていました。現在の /bin/sh
(POSIX シェル) ではそんなことはないので安心してください。
while
ループの外で変数が見えない理由は seq
コマンドのデータを受け取って処理しているサブシェル部分(パイプの先)が 「while
〜 done
部分だけだから」です。したがってデータを受け取って処理している全体を { ... }
で括り、パイプの先に書くだけでループの外からループの中の変数を参照することができます。
seq -f 'line%g' 2 | {
i=0
while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done
echo "total: $i lines" # 2 lines と出力される(期待した通り)
}
もう一つの解決策は、パイプラインの最後の部分がサブシェルにならないシェルを使うことです。これは ksh と zsh、そして lastpipe
が使える bash 4.2 以降が該当します。bash 4.2 は 2011 年にリリースされていますが、macOS の /bin/bash
は 3.2.57 と古いので、Homebrew などで最新の bash をインストールするか(標準でインストールされている)ksh または zsh を使ってください。
shtopt -s lastpipe # bash では lastpipe を有効にする(ksh と zsh では不要)
i=0
seq -f 'line%g' 2 | while IFS= read -r line || [ "$line" ]; do
printf '%d: %s\n' $((i = i + 1)) "$line"
done
echo "total: $i lines" # 2 lines と出力される(期待した通り)
ちなみに POSIX ではパイプラインの各コマンドがサブシェルになるかどうかは未指定です。これは ksh が昔(POSIX 標準化以前)からパイプラインの最後の部分がサブシェルにならなかったため未指定とする他なかったからでしょう。POSIX としての正しい動作は未指定なのでどちらの動作でも POSIX 違反ではありません。明示的にサブシェルにしたい場合は該当部分を (...)
で括ればよいです。
2. 強制的に最終行の末尾に改行を追加する
この方法は別のコマンドを使って最終行の末尾に改行がなくても強制的に改行を追加するという方法です。当然ですがこの方法を使うと最終行の末尾に改行がある場合とない場合を区別できません。
「awk 1」で簡単に改行を補完できる
最終行の末尾に改行がないときに、改行を補完するには awk
コマンドを利用します。
$ printf 'foo\nbar' | awk 1 # 補足: '1 { print $0 }' の省略形
foo
bar
短くて簡単なのでおすすめです。ただし POSIX の仕様上は POSIX awk
は「POSIX 用語のテキストファイル」を読み込むことになっているので注意してください。一般的に使用されている awk は問題なさそうですが、最終行の末尾に改行がないファイルを入力するのは POSIX の仕様上は認められておらず移植性がない場合があります。
「grep ^」や「grep ''」の注意点
他に見かけるやり方には grep ^
または grep ''
を使う方法があります。
$ printf 'foo\nbar' | grep ^ # 行頭にマッチする
foo
bar
$ printf 'foo\nbar' | grep '' # grep の場合、空の正規表現は必ずマッチする
foo
bar
POSIX grep
も POSIX の仕様上は「POSIX 用語のテキストファイル」を読み込むことになっているので注意してください。
grep
コマンドを使う方法には一つ注意点があり、この方法は Solaris 10 / 11 のデフォルトの grep
コマンドではうまくいきません。Solaris のデフォルトの環境の grep
コマンドは POSIX に準拠していない旧 System V コマンド版が使われています。
$ printf 'foo\nbar' | grep '' # 旧 System V版は空文字だとエラーになる
grep: RE error 41: No remembered search string.
$ printf 'foo\nbar' | grep ^ # 期待している末尾に改行を付けるという動作を行わない
foo
bar 【←末尾に改行がない】
$ printf 'foo\nbar' | /usr/xpg4/bin/grep '' # POSIX 準拠版なら問題ない
foo
bar
ただし POSIX に準拠していれば必ず改行が付け加えられるかと言うと微妙な話で(おそらく実際の実装は大丈夫だと思うのですが)POSIX ではそもそも行の最後には改行があることになっているので、改行を出力しない POSIX 準拠の grep
の実装があってもおかしくはないでしょう。
したがって awk
を使ったほうがより安全だと言えます。awk
の場合は改行を付けているのは print
ステートメントなのでデータを読み込んでさえいれば動くはずです。もっとも改行がないデータを awk
は読み込んくれるのか?という疑問がありますが、Solaris 10 の POSIX に準拠していない古い awk
でも最終行に改行がないデータを読むことができており、awk
の実装の種類が少ないことを考えるとおそらく大丈夫ではないかと考えられます。
grep はファイルが一つの場合と複数の場合の違いに注意が必要
awk
や grep
は複数のファイルをまとめて処理することができます。
$ awk 1 file1.txt file2.txt
foo1
bar1
foo2
bar2
しかし grep
コマンドの場合はちょっと困ったことになります。
$ grep ^ file1.txt file2.txt
file1.txt:foo1
file1.txt:bar1
file2.txt:foo2
file2.txt:bar2
このようにファイル名が出力されてしまいます。このファイル名を表示しない -h
オプションがあるのですが POSIX では標準化されていません。回避策は cat
コマンドでつなげて一つのファイルのようにパイプでまとめて渡すか、出力した後で切り取るかです。
$ cat file1.txt file2.txt | grep ^ # cat を使ってまとめて渡す
foo1
bar1
foo2
bar2
$ grep ^ file1.txt file2.txt | cut -d: -f2- # cut でファイル名を切り取る
foo1
bar1
foo2
bar2
ここでもし file1.txt
の最終行の行末に改行がなかったらどうなるでしょうか? cat
を使うとこうなってしまいます。
$ cat file1.txt file2.txt | grep ^
foo1
bar1foo2
bar2
それでは cut
コマンドを使う方法を使えば良いのかと言うと、grep
コマンドはファイルを一つだけ指定した場合はファイル名を出力しませんし、常に表示する -H
オプションは POSIX で標準化されていません。もしファイル名が出力されない場合、テキストファイルに :
が含まれていると誤爆してしまいます。ここで常にファイル名を表示する裏技を使います。空のファイルである /dev/null
を指定すると常にファイル名が出力されます。一応これである程度は回避可能なのですが、ファイル名に「:
」が含まれているとうまくいきません。
$ grep ^ /dev/null file1.txt
file1.txt:foo1
file1.txt:bar1
$ grep ^ /dev/null file:1.txt
file:1.txt:foo
file:1.txt:bar
このように grep
コマンドを使う手法は問題点があるため、やはり改行を補完するには awk
コマンドを使うほうが簡単です。
3. 最終行の改行なしデータを扱えないコマンドをなんとかする
GNU sed と BSD 系 sed の多くは最終行に改行がない場合に改行を勝手に付け加えることはありません。しかし一部の sed の実装では勝手に改行を付け加えてしまいます。これをどうにかする方法はないかという話です。最初に言っておくと、この問題を解決する完璧な方法はありません。どんな問題にも適用可能な汎用的な方法ではなく場合によっては使えるかもしれないというテクニックです。
GNUと多くのBSD系sedは最終行の改行なしデータに改行を追加しない
かつては GNU sed は改行を付けない、BSD sed は改行を付けるという動作でしたが、最近の BSD 系 sed は GNU sed の挙動へと仕様が変更されています。POSIX では行の最後には改行が含まれることになっているので、改行で終わらない行をどう扱おうが自由です。なので仕様を変更することは POSIX 的には問題なく、あとは各 OS が後方互換性を保つべきかどうかの判断次第です。
$ printf 'foo\nbar\nbaz' | sed 's/a/A/'
foo
bAr
bAz ← 改行なしにしたい
- FreeBSD: おそらく FreeBSD 11.0 (2016-10-10)から改行がつかなくなる
- FreeBSD 11.4 で改行なしであることを確認(FreeBSD 10.4 では改行が付け加えられる)
- 2014-08-08: sed(1): Don't force a newline on last line, if input stream doesn't have one
- macOS: おそらく macOS 11 (2020-11-12)から改行がつかなくなる
- NetBSD: NetBSD 10 (2023-12予定?) から改行がつかなくなる
- OpenBSD: おそらく OpenBSD 5.8 (2015-10-18) から改行がつかなくなる
- OpenBSD 6.6 (2019-10-17)で改行なしであることを確認(それ以前は未調査)
- 2015-07-18: add -i flag to sed to do in-place editing; mostly based on freebsd
$ printf 'foo\nbar\nbaz' | sed 's/a/A/'
foo
bAr
bAz ← 改行が付け加えられてしまう
最終行の末尾に改行を付け加える sed の実装がある
BSD 系の Unix では GNU sed と同等の処理へと変わっているので(いずれは)気にしなくて良くなりそうですが、System V 系 の Unix ではおそらく今度も最終行の末尾に改行を付け加えるのではないかと思います。
$ printf 'foo\nbar\nbaz' | sed 's/a/A/'
foo
bAr
← 旧SystemV版では最終行が出力されない
$ printf 'foo\nbar\nbaz' | /usr/xpg4/bin/sed 's/a/A/'
foo
bAr
sed: Missing newline at end of file standard input.
bAz ← POSIX版では改行が付け加えられてしまう上にエラーになる
$ echo $?
2
最終行に改行がないなら追加して処理から削除する?
最終行に改行が無いデータを扱うための考え方は「最終行に改行がないなら付けてから処理して後から消せばいいじゃない?」です。繰り返しますが、この方法である程度は動きますが完璧な方法ではないので注意してください。話を進める前に前準備として、改行を付け加える方法と最終行の改行を削除する方法を用意します。改行を付け加えるのは echo
コマンドを実行するだけです。最終行の改行を削除するには次のようなコードを使います。
$ seq 3 | awk '{ printf f "%s", $0; f="\n" }'
1
2
3 ← 最終行に改行がない
$ seq 3 | awk '{ printf f "%s", $0; f="\n" }' | od -tx1
0000000 31 0a 32 0a 33 ← 最後に改行(0a)がない
0000005
別解として POSIX 準拠ではなく macOS などで動きませんが、head -c -1
を使った 10 倍ぐらい速い方法もあります。ただしこの方法は最終行の改行ではなく任意の一文字を削除する方法です。
$ seq 3 | head -c -1
1
2
3 ← 最終行に改行がない
$ seq 3 | head -c -1 | od -tx1
0000000 31 0a 32 0a 33 ← 最後に改行(0a)がない
0000005
一律で改行を付けてから削除する(うまく行かない例)
改行を付ける処理と改行を削除する処理は用意したので、一律で改行を付けて改行を消すというのは難しい話ではありません。
$ printf 'foo\nbar\nbaz' | /usr/xpg4/bin/sed 's/a/A/'
[foo]
[bar]
sed: Missing newline at end of file standard input.
[baz] ← 改行が付け加えられ、↑ エラーになる
$ printf 'foo\nbar\nbaz' \ ← 最終行の末尾に改行がないデータ
| { cat; echo; } \ ← 最終行の末尾に改行を付ける
| /usr/xpg4/bin/sed 's/\(.*\)/[&]/' \
| awk '{ printf f "%s", $0; f="\n" }' ← 最終行の末尾の改行を削除する
[foo]
[bar]
[baz] ← 改行なし(問題なく動いている?)
この方法を使えばうまくいくように思えますが、最終行が改行で終わっている場合にうまく動作しません。
$ printf 'foo\nbar\nbaz\n' \ ← 最終行の末尾に改行があるデータ
| { cat; echo; } \ ← 最終行の末尾に改行を付ける
| /usr/xpg4/bin/sed 's/\(.*\)/[&]/' \ ← 〘余計な行がある状態で処理する〙
| awk '{ printf f "%s", $0; f="\n" }' ← 最終行の末尾の改行を削除する
[foo]
[bar]
[baz]
[] ← 余計な行がでている
考えてみれば当然ですね。元から改行で終わっている場合は一行余計な行が追加されることになるので追加の行まで sed
で処理してしまいます。一律で改行を付け加えてから削除するのではうまく行いきません。
最終行が改行で終わっていない場合にのみ改行を付けて削除する
一律で追加する方法が駄目なので、次の手段は最終行が改行で終わっていない場合にのみ改行を付けるというアイデアです。アイデア自体は思いついてもこれが結構難しい。処理する対象がファイルなら簡単なのですがパイプで渡されるデータは最終行は読み込まなければわかりません。一つの手は一時ファイルにファイルを保存することです。一時ファイルに保存してしまえば最後に改行があるかを調べるのは難しくありません。ただし一時ファイルを使う場合はファイルに保存し終わらなければならないのでパイプラインの並行動作性が失われてしまいます。それをなんとかしたようとしたのが以下のコードです。
#!/bin/sh
set -eu
# 最終行が改行で終わっていない場合にのみ改行を付けて指定した
# コマンドを実行し、処理完了後に最終行の改行を削除するラッパー
wrapper() (
set +e
{
xs=1
# ret には最終行で改行するかどうかの情報(正確にはコード)と
# sedコマンドの終了ステータスが返ってくる
ret=$(
{
{
eof="echo"
while IFS= read -r line; do
printf "%s\n" "$line"
done
# 最終行が改行でない場合は"echo"を出力しない
[ "$line" ] && printf "%s\n" "$line" && eof=
# 最終行の処理のためのコード("echo" or 空文字)の出力
echo "$eof" >&3
} | ("$@"; echo "xs=$?" >&3) | {
# とりあえず一律で最終行の改行を削除しておく
awk '{ printf f "%s", $0; f="\n" }' >&4
}
} 3>&1
)
} 4>&1
eval "$ret" # 最終行に改行がある場合はここで改行を加える
exit "$xs" # コマンドの終了ステータス
)
wrapper sed 's/\(.*\)/[\1]/'
実現はできましたが、あまり書きたくないコードですね。このコードは完璧ではなく次のようにファイルを書き換える場合には対応していません。
wrapper sed 's/\(.*\)/[\1]/' file1.txt file2.txt
対応方法ですが内部で cat
コマンドを使って置き換える方法はうまくいきません。なぜなら file1.txt
の最後の行に改行がない場合に最後の行と file2.txt
の最初の行がつながってしまうからです。
cat file1.txt file2.txt | sed 's/\(.*\)/[\1]/'
つまり一ファイルずつループで処理しなければならないということを意味しています。しかし一ファイルずつ処理をするのと複数ファイルを同時に処理するのでは意味が違います。もう少し頑張れる余地はありそうですが、さすがにこれ以上やりたくはありません。どちらにしろ実現不可能な例があるのは目に見えています。最終行の改行なしデータを扱えないコマンドをなんとかする完璧な方法はありません。
まとめ
最終行に改行がないテキストファイルをシェルスクリプトで上手く扱う方法は以下の2つです。
- シェル言語の
read
コマンドを使う(最終行の改行の有無を検出できる) -
awk 1
で最終行の改行を補完する(最終行の改行を有無を検出できない)
これ以外の方法も考えられなくはないですが、おそらくこの方法よりも面倒なものばかりでしょう。また「最終行に改行がないデータを扱えないコマンドで、最終行に改行がないデータとして扱う」のは困難です。素直に GNU コマンドをインストールするなり、他の言語を使ったほうが楽です。そしてそもそも最終行に改行が無いデータが悪いので「そのようなデータには対応していません」で終わらせたほうが簡単です。