LoginSignup
3
3

最終行に改行がないテキストファイルをシェルスクリプトで上手く扱う方法

Last updated at Posted at 2023-12-01

はじめに

一般的にファイルの最終行に改行がないデータを扱うのは良い考えとは言えません(よくない理由は別記事で書きます)。しかしそれでも扱いたい場合もあります。この記事ではその方法をまとめました。

この記事は シェルスクリプト&PowerShell Advent Calendar 2023 2日目の記事です。

1. read コマンドで改行がないデータを扱う

この read コマンドを使った方法は

  • POSIX で標準化された範囲のシェルの機能とコマンドの中では
  • 最終行の末尾に改行がある場合とない場合を正しく区別できるという点で

(おそらく)最も自然な形でシェルスクリプトで最終行に改行がないデータを扱う方法です。

改行がないデータとして扱う

ときどき read コマンドは終端に改行が無いデータを読み込めないと言われますが、実はそれは正しくありません。データは読み込んでいるのですが最終行の改行がない場合に read コマンドが偽を返すからループが実行されないだけなのです。ループを抜けた後に変数の中身を見てみるとちゃんと最後の行が読み込まれていることがわかります。つまり read コマンドが最後の行を読み込めないのではなく、最後に読み込んだ改行がない行を処理していないあなたのバグということです。

test.sh
#!/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 コマンドが偽を返しても読み込んだデータがあれば繰り返す」と書くだけで扱うことができます。

test2.sh(最終行に改行があってもなくても同じように扱う)
#!/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 のループの外でも変数は見えます

①パイプの先を直接 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" # 正しい結果が得られる
③別解 bash、ksh、zshなどのプロセス置換でも問題なく変数が見える
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 ループの外からループの中の変数が見えないのかというと「パイプで繋げたコマンド(whiledone の部分)がサブシェルになっている」からです。サブシェルの詳しい説明は割愛しますが、要するに、パイプで繋げたコマンドの部分が子プロセスで実行しているのとほぼ同じ状態になっているのでループを抜けた先(親プロセス)には反映されません。

余談ですが、古い Bourne シェル時代(現在では Bourne シェルが使われているのは Solaris 10 の /bin/sh ぐらい)では標準入力から入力しただけ(上記の②の例)でサブシェルになっていました。現在の /bin/sh (POSIX シェル) ではそんなことはないので安心してください。

while ループの外で変数が見えない理由は seq コマンドのデータを受け取って処理しているサブシェル部分(パイプの先)が 「whiledone 部分だけだから」です。したがってデータを受け取って処理している全体を { ... } で括り、パイプの先に書くだけでループの外からループの中の変数を参照することができます

変数にアクセスする部分全体をサブシェルにすれば良い
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

短くて簡単なのでおすすめです。

「grep ^」や「grep ''」の注意点

他に見かけるやり方には grep ^ または grep '' を使う方法があります。

$ printf 'foo\nbar' | grep ^ # 行頭にマッチする
foo
bar

$ printf 'foo\nbar' | grep '' # grep の場合、空の正規表現は必ずマッチする
foo
bar

grep コマンドを使う方法には一つ注意点があり、この方法は Solaris 10 / 11 のデフォルトの grep コマンドではうまくいきません。Solaris のデフォルトの環境の grep コマンドは POSIX に準拠していない旧 System V コマンド版が使われています。

Solaris 11の場合
$ 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 はファイルが一つの場合と複数の場合の違いに注意が必要

awkgrep は複数のファイルをまとめて処理することができます。

$ 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 が後方互換性を保つべきかどうかの判断次第です。

GNU sed、FreeBSD 11、macOS 11、NetBSD 10、OpenBSDでは改行が付け加えられない
$ printf 'foo\nbar\nbaz' | sed 's/a/A/'
foo
bAr
bAz      ← 改行なしにしたい
FreeBSD 10.4、macOS 10.15、NetBSD 9.3では改行が付け加えられてしまう
$ printf 'foo\nbar\nbaz' | sed 's/a/A/'
foo
bAr
bAz      ← 改行が付け加えられてしまう

最終行の末尾に改行を付け加える sed の実装がある

BSD 系の Unix では GNU sed と同等の処理へと変わっているので(いずれは)気にしなくて良くなりそうですが、System V 系 の Unix ではおそらく今度も最終行の末尾に改行を付け加えるのではないかと思います。

Solaris 11では行が無視される(旧System V版)かエラー(POSIX版)になる
$ 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 コマンドを実行するだけです。最終行の改行を削除するには次のようなコードを使います。

最終行の改行を削除する方法(POSIX準拠)
$ 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

一律で改行を付けてから削除する(うまく行かない例)

改行を付ける処理と改行を削除する処理は用意したので、一律で改行を付けて改行を消すというのは難しい話ではありません。

改行を付けて処理して削除する(Solaris 11 での実行)
$ 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]      ← 改行なし(問題なく動いている?)

この方法を使えばうまくいくように思えますが、最終行が改行で終わっている場合にうまく動作しません。

最終行が改行で終わっている場合に一行増えてしまう(Solaris 11 での実行)
$ 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 の最初の行がつながってしまうからです。

file1.txtの最後の行とfilr2.txtの最初の行がつながるのでダメ
cat file1.txt file2.txt | sed 's/\(.*\)/[\1]/' 

つまり一ファイルずつループで処理しなければならないということを意味しています。しかし一ファイルずつ処理をするのと複数ファイルを同時に処理するのでは意味が違います。もう少し頑張れる余地はありそうですが、さすがにこれ以上やりたくはありません。どちらにしろ実現不可能な例があるのは目に見えています。最終行の改行なしデータを扱えないコマンドをなんとかする完璧な方法はありません

まとめ

最終行に改行がないテキストファイルをシェルスクリプトで上手く扱う方法は以下の2つです。

  1. シェル言語の read コマンドを使う(最終行の改行の有無を検出できる)
  2. awk 1 で最終行の改行を補完する(最終行の改行を有無を検出できない)

これ以外の方法も考えられなくはないですが、おそらくこの方法よりも面倒なものばかりでしょう。また「最終行に改行がないデータを扱えないコマンドで、最終行に改行がないデータとして扱う」のは困難です。素直に GNU コマンドをインストールするなり、他の言語を使ったほうが楽です。そしてそもそも最終行に改行が無いデータが悪いので「そのようなデータには対応していません」で終わらせたほうが簡単です。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3