bash

シェルスクリプト (bash) でファイル名が ASCII 文字から始まるファイルのみを抽出する

TL; DR

^[[:graph:][:space:][:cntrl:]]+$

ファイル名が上記の正規表現を満し、かつ下の条件を満たす場合に ASCII 文字から始まるファイル名と判断できます。

echo -n "${filename:0:1}" | wc -c` == "1"

[toc]

経緯

bash のシェルスクリプトでファイル名のマッチングを行っているときに正規表現の制限に見事にはまった記録です。

目的はディレクトリパスを除いたファイル名が ASCII 文字から始まるものだけを抽出することです。
ここで注意すべきなのは、Linux ではファイル名に含めない文字は NULL 文字(0x00)と /(0x4F) のみであることです。
意外に感じるかもしれませんが ASCII 制御文字もファイル名に含めます。

今回は作業を記録しながら進めたので、順を追って書いてあります。
結論だけ欲しい場合は最後だけ読んでください。

ディレクトリの中身を取得する

#!/bin/bash

for path in "$1"/*; do
    echo $path
done

これを "lsre.sh" と保存します。
次のように実行権限を与え、引数を与えて実行してみます。

$ chmod +x lsre.sh
$ ./lsre.sh .
./lsre.sh
./test_dir
$ ./lsre.sh ./test_dir
./test_dir/alnum
./test_dir/*asterisk
./test_dir/@atmark
./test_dir/+plus
./test_dir/日本語

ファイル名だけを抽出

basename というコマンドを使う手もありますが、bash には変数展開という便利なものがあるのでこれを利用します。
注意点としては、変数展開は正規表現ではなくてワイルドカードになります。

#!/bin/bash

for path in "$1"/*; do
        filename="${path##*/}"
        echo "$filename"
done

変数展開の前方除去(最長一致) ##

ここでの変数展開には前方除去(最長一致)の ## を使って パターン */ を指定していますが、考え方としては次のようになります。

例としてパス /foo/bar/hoge を考えます。
ワイルドカードでパターン */ にマッチする部分は次の通り3つあります。

  • /
  • /foo/
  • /foo/bar/

この内、最長のものは /foo/bar/ なので、その部分を除いたファイル名 hoge が得られます。

他にも後方除去や最短一致などの便利な変数展開があります。
詳しくは参考に挙げたページをご覧ください。

ファイル名が ASCII 文字だけで構成されているかの判定

本題です。
ここで前提として、正規表現は実装によって微妙に使える表現に違いがあります。
これが今回の落し穴でした。

ASCII 文字は16進表記で表すと、0x00 から 0x7F までです。
これをそのまま表せば次のように書けると思ったのです。

^[\x00-\x7F]+$

これを先程のシェルスクリプトに入れて、ファイル名が ASCII 文字のみの場合だった時に "[ASCII]" と表示するようにしたのが次のものになります。

#!/bin/bash

ASCII_PATTERN="^[\x00-\x7F]+$"

for path in "$1"/*; do
        filename=${path##*/}
        echo "$filename"
        if [[ "$filename" =~ "$ASCII_PATTERN" ]]; then
                echo "[ASCII]"
        fi
done

実行してみると、"[ASCII]" は一つも表示されません。

ここからいくつも試していたのですが、それを全部書いていたら無駄に長くなるので試してだめだったパターンだけを示します。

  • "^[\x00-\x7F]+$"
  • "^[\u0000-\u007F]+$"
  • "^[\u00-\u7F]+$"

$'\x00' の記法もだめでした(上に書こうとしたらドル記号が2個あると数式になるようで書けない)。

ちなみにサクラエディタは [\u00-\u7F]+ の記法を受け付けてくれました。

悩んだ挙句に「POSIX クラス」を発見して、次の正規表現に行き着きました。

"^[[:graph:][:space:][:cntrl:]]+$"

これを先程のシェルスクリプトに加えます。

#!/bin/bash

ASCII_PATTERN="^[[:graph:][:space:][:cntrl:]]+$"

for path in "$1"/*; do
        filename="${path##*/}"
        echo "${filename}"
        if [[ "$filename" =~ "$ASCII_PATTERN" ]]; then
                echo "[ASCII]"
        fi
done

動作確認もうまくいった、と思ったら日本語のファイル名も ASCII 扱いになってしまいました。

$ ./lsre ./test_dir
alnum
[ASCII]
*asterisk
[ASCII]
@atmark
[ASCII]
+plus
[ASCII]
日本語
[ASCII]

UTF-8 をちゃんと扱えていないのかと思って最初の1文字を取得してみたのが次です。

#!/bin/bash

ASCII_PATTERN="^[[:graph:][:space:][:cntrl:]]+$"

for path in "$1"/*; do
        filename="${path##*/}"
        echo "$filename"
        first="${filename:0:1}"
        echo "$first"
        if [[ "$filename" =~ "$ASCII_PATTERN" ]]; then
                echo "[ASCII]"
        fi
done
$ ./lsre ./test_dir
alnum
a
[ASCII]
*asterisk
*
[ASCII]
@atmark
@
[ASCII]
+plus
+
[ASCII]
日本語
日
[ASCII]

1文字目はちゃんと取れているので UTF-8 としては扱えているようなのですが……。

バイト数も考慮して ASCII と判別する

最終的には上記の1文字目を wc -c して何バイトかを見て判断するようにしました。

#!/bin/bash

ASCII_PATTERN="^[[:graph:][:space:][:cntrl:]]+$"

for path in "$1"/*; do
        filename="${path##*/}"
        echo "${filename}""
        if [[ "$filename" =~ "$ASCII_PATTERN" ]] && \
                 [ `echo -n "${filename:0:1}" | wc -c` == "1" ]; then
                echo "[ASCII]"
        fi
done
$ ./lsre ./test_dir
alnum
[ASCII]
*asterisk
[ASCII]
@atmark
[ASCII]
+plus
[ASCII]
日本語

ようやく欲しいものが得られました。

ちなみに [:punct:] も試してみましたが結果は同じだったので書いていません。

まとめ

  • bash の正規表現だけで制御文字の範囲まで含めた ASCII 文字は判定できない
  • 文字が何バイトかを求めて併せ技で解決
  • bash で ASCII 文字判定するのは大変

参考