シェルスクリプトのreadを末尾改行なしやWindows改行コードに対応させる方法


はじめに

シェルスクリプトの read を使ってファイルを読み込む場合、対象のファイルの最後の行は末尾に改行がなければ読み込めないというのはある程度シェルスクリプトを書いてる人なら一度はハマったことがあるかと思います。一般的には改行で終わらせましょうという話ですが、対応せざるを得ない場合もあるかと思います。その場合の対応のさせかたです。なお(いつもどおり)POSIX 準拠かつ外部コマンド呼び出しなしでシェルスクリプトの機能だけで実装します。

実装

末尾改行なしに対応させる

しばしば勘違いされていると思いますが read は最後の行は末尾に改行がなければ読み込めないというのは正確ではなく、読み込んではいます。ただ read 関数が正常終了になっていないだけです。なので判定方法を工夫すれば読み込めます。

# これだと最後の行は改行がなければならない
while IFS= read -r line; do
  echo "$line"
done

# これならOK。最後の行は実は読み込まれてるので $line に何かしら入っている
while IFS= read -r line || [ "$line" ]; do
  echo "$line"
done

# 最後の行が改行で終わってない場合を区別したい場合
while IFS= read -r line; do
  echo "$line"
done
if [ "$line" ]; then
  # 最後の行が改行で終わってない場合
  echo "$line"
fi

Windows改行コードに対応させる

Linux / Unix / macOS の改行コードは LF ですが、Windowsの改行コードは CR LF です。余計な CR があるのでこれを削除するだけです。

CR=$(printf '\r')

while IFS= read -r line && line=${line%"$CR"} || [ "$line" ]; do
  echo "$line ${#line}"
done

関数化

ここまでの話ならただの雑学。ここからが本題です。上記のコードを何度も書くのは面倒ですよね?そこで関数化したいと考えます。read 関数が少し特徴的なのもあってぱっと思い浮かばないのではないでしょうか?まあ引っ張る話でもないのでさくっと実装です。

readline() {
  IFS= read -r "$1" || eval "[ \"\${$1}\" ]"
}

line='' # shellcheck に未定義の変数を使用していると怒られないようにするため
while readline line; do
  echo "$line"
done

次はWindowsの改行コードに対応です。先程の応用です。

CR=$(printf '\r')

readline() {
  # shellcheck が SC2015 の警告を出すので { } でくくる
  { IFS= read -r "$1" && eval "$1=\${$1%\"\$CR\"}"; } || eval "[ \"\${$1}\" ]"
}

line='' # shellcheck に未定義の変数を使用していると怒られないようにするため
while readline line; do
  echo "$line"
done

pdksh, loksh ワークアラウンド

基本的には上記のコードで良いのですが、pdksh と lokshでバグがあります。(loksh は Alpine Linux にパッケージがあります。loksh には loksh is a Linux port of OpenBSD's ksh と書いてあり、OpenBSD の kshpublic domain Korn shell と書いてあるので同じバグなのだと思います。)

pdksh, loksh は set -e した状態だと途中でスクリプトが終了してしまいます。以下はそれを再現する簡単なコードです。本来ならば FALSE と END が表示されなければいけないはずですが eval の中で [ ] を使用すると途中で中断してしまうのです。(TRUE も表示されません。)

set -e
eval "[ ]" && echo TRUE || echo FALSE
echo END

ワークアラウンドは簡単で eval の中の [ ] の後ろに &&: (&& true) をつけるだけです。

set -e
eval "[ ] &&:" && echo TRUE || echo FALSE
echo END

ということで関数化したコードにも追加します。

# 末尾改行なし対応
readline() {
  IFS= read -r "$1" || eval "[ \"\${$1}\" ] &&:"
}

# 末尾改行なし + Windows改行コード対応
readline() {
  { IFS= read -r "$1" && eval "$1=\${$1%\"\$CR\"}"; } || eval "[ \"\${$1}\" ] &&:"
}

以上で完成です。