LoginSignup
17
15

More than 5 years have passed since last update.

純BashなCSVパーサとIFSの不思議な挙動

Last updated at Posted at 2015-09-25

今日はbashのextension syntaxとbuilt inだけでCSVファイルをパースする話。
なお、それ、シェルスクリプトでやる必要あるの?という質問は締め切られております。

ソースコード: https://github.com/takei-yuya/csv2tsv.sh

で、どうにもこうにも世の中、私の知らないCSV亜種というのが沢山あるらしい。
というか、実際何種類かお目に掛かったことがある。
全部に対応するのはことなので、基本的にRFC4180なCSVだけを対象にしようと思う。
ということで、

まずはCSVのフォーマットの話

CSVマスターの方はスキップしてしまってokです。

CSVはComma-Separated Valuesの略で、読んで字の通り、カンマで区切られた値になっている。

……と思ってたら、カンマじゃない区切り文字が使われることもあって、Character-Separated Values(CSV)なんていうCSVもあるらしい……。
私が実際にみたことあるのは @ 区切りと <> 区切り。
あとなにかとんでもない区切り方でCSVと主張していたデータがあった気がしたけど忘れた、というか多分脳が理解を拒否して覚えていないのだと思う。

さすがに勘弁して欲しいので今回はカンマ , 区切りのみにします。

今回の仕様:
区切り文字は半角カンマ , を使う

カンマを含む値

んで、罠の多いエスケープまわり。
カンマで値を区切る訳なんだけど、カンマを含むデータを入れたい場合はどうすればよいだろう?
この場合は、値をダブルクォートで括る。ダブルクォートで括られたカンマは区切り文字ではない。

f1 f2 f3
foo,bar baz foobar

f1,f2,f3
"foo,bar",baz,foobar

となる。

……で、よくあるCSV亜種としては、エスケープなんだからバックスラッシュでエスケープすればいいじゃん、というパティーン。
RFC4180を読む限りバックスラッシュが何かしらの意味を持つ、なんてことは一切書かれておらず、CSVにおいて意味があるのは、改行、ダブルクォートとカンマだけ……。
というわけでNGです。
rubyとかでお手軽csv作成しようとするとよくはまる罠。

そんなわけで、今回の仕様では、
カンマを含む値はダブルクォートで括る
カンマはバックスラッシュでエスケープできない

ダブルクォートを含む値

カンマを含む値を使う時にダブルクォートで括るわけだけど、ダブルクォートを含む値はどうするか。
これまた、まず値をダブルクォートで括る必要がある(RFC的にはSHOULD)。
その上でダブルクォートをダブルクォートでエスケープする。

f1 f2 f3
"foo bar" baz foo"bar

f1,f2,f3
"""foo bar""",baz,"foo""bar"

となる。正直この辺、かなりめんどい、というか読みづらい。
ダブルクォートをダブルクォートでエスケープして、さらにダブルクォートで括るもんだから基本的にとんでもないダブルクォート密度になる。
ぶた肉と豚汁でぶたがかぶってしまった、感があるのだけど仕様だから仕方が無い。

んで、エスケープなんだからバックスラッ(略)は同様に仕様外。気持ちは分かるけど……。

そんなわけで、同様に
ダブルクォートを含む値はダブルクォートで括る
ダブルクォートはダブルクォートでエスケープする
ダブルクォートはバックスラッシュでエスケープできない

改行を含む値

エスケープなんだからバッ(略) → 仕様外です。

CSVにとって、 \n はタダのバックスラッシュとアルファベットのnで、改行と見なしてくれない。
もちろん上位のアプリケーション側で認識してあげるのは手なのだけど、CSVの仕様上では改行をinlineで表す方法はない。

つまり、改行は生の改行コードのまま入力スル必要がある。
もちろんそのまま改行を入れると行の終わりと区別が付かないので、値はダブルクォートで括る必要がある。
つまり、

f1 f2 f3
foo
bar
baz foo
bar

f1,f2,f3
"foo
bar",baz,"foo
bar"

となる。

CSVでの論理行とテキストの物理行があわない、行単位のソートやシャッフルとものすごく相性がわるい、途中からのパースができない(困難)、と正直嫌がらせにしか思えない仕様なのだけど、仕様だから仕方が無い。

そんなわけで、今回の仕様として
改行を含む値はダブルクォートで括る
改行はそのまま出力する
バックスラッシュによる制御文字の表現は使えない

その他

あと上記のパティーンに当てはまらない「普通の」値もダブルクォートで括ってもよいことになっている。

ので、
ダブルクォート、改行、カンマを含まない値はダブルクォートで括ってもよいし括らなくてもよい

あとRFC的にはCRLFと明示されているのだけど、この辺は正直面倒なので、単にラインフィード (LF)を使ってしまいます。この辺は手抜き

改行コードはラインフィード(LF)を使う

bashでパースしてみる。

パースの方針

まずは方針。
方針としてはだいたい大きく分けて二つぐらい考えられる。
一つは先頭から一文字ずつ読んで、ダブルクォートの中/外などのステートを持ちながらパースしていく方法。ステートマシンっぽくなる
もう一つは先にえいや、でカンマで区切ってしまってから、ダブルクォートの有無当たりを確認して適当に連結したりする方法。

前者の方がコーナーケースにはまりづらく堅実なんだけど、どうにも地味、というかオートマトンつくるだけなのでパス。
今回は後者の方針でやってみたいと思います。
コーナーケース祭り!!

パースの概要

まずはカンマでの分割。これはIFSという機構を使えばok
詳細は後述します、が、これが結構罠が盛りだくさん。
思った以上にはまりにはまって30分ぐらいで書けるかと思ったら、この辺の挙動を調べたりなんやりで2時間ぐらい掛かりましたorz

次に連結のルール。カンマ分割後の値は大きく5つぐらいの条件に分けられる。

1. 直前の値とくっつかず、かつダブルクォートを含まないパターン

これは単純にその値を読めばok

2. 直前の値とくっつかず、ダブルクォートで始まってダブルクォートで終わるパターン

これも↑と同じで、ダブルクォートを外してそのまま読めばok

と、思ったら意外にやっかいなパターンがあって、 "hoge" はそのまま hoge としていいのだけど、 "hoge"" は、末尾のダブルクォートがエスケープされているので、実は次の値と連結して hoge",... という値になるパターン。
んじゃ、末尾が二連続でダブルクォートなら、次の値にくっつければokなのか、というと "hoge"""hoge" という単独の値になる。
ダブルクォートを同じダブルクォートでエスケープするものだから、偶数個か奇数個かで挙動が変わる。ぐぬぬぬ。
後ろからだと判定が難しい。

そんわけで、最初にエスケープされた連続するダブルクォートをアンエスケープする必要がある。
のだけど、単にアンエスケープすると、 "hoge"""hoge" の区別が付かなくなってしまう。前者は次の値とくっつく値だし、後者は単独の hoge という単独の値になる。
そこで、二重ダブルクォートを一旦別のプレースホルダ文字に置き換えて、その上で末尾がダブルクォートかを判定することにする。
つまり、 "hoge"""hoge<DQ> となってダブルクォートの括りが閉じていないので次の繋がるパティーン、 "hoge" はアンエスケープされず、ダブルクォートが閉じているのでそのまま hoge という値になるパターン、と判定することができる。

ちなみに、アンエスケープするときは先頭のダブルクォートを外した上でやる必要があるのに注意。 "" は空の値、 """"<DQ> なので ",... と次の値と繋がるパターン、 """""<DQ>" なので、ダブルクォート一個の値 " になる、といった感じ。最初のダブルクォートは問答無用で外す。

ということで、
1. 先頭のダブルクォートを外す
2. エスケープされたダブルクォートをプレースホルダ文字に置き換える
3. 末尾がダブルクォートか判定して、
4a. 末尾がダブルクォートなら末尾のダブルクォートを外して、プレースホルダ文字をダブルクォートに戻して、値を出力
4b. 末尾がダブルクォートでないなら、プレースホルダ文字をダブルクォートに戻して、バッファか何かにため込んで次の値と連結させる
といった処理になる。

3. 直前の値とくっつかず、ダブルクォートで始まってダブルクォートで終わらないパターン

こっちは先ほどのケースに比べて簡単で、難しいことを考えずに次の値と繋げればいい。
つまり、上の4bと同じ。
というか、先にプレースホルダ文字への変換をやってしまえばさっきのケースに吸収される。

4. 直前の値とくっつき、ダブルクォートで終わらないパターン

先ほどのケースで出てきた次の値と繋がるパターン、の次の値。
このとき、ダブルクォートで終わっていないなら、まだまだダブルクォートで括られた中だと言えるので、アンエスケープしてバッファに値を追加してNEXT

5. 直前の値とくっつき、ダブルクォートで終わるパターン

同様に、続きの値となるパターンで、かつ、そこでダブルクォートが閉じる「可能性がある」パターン。
これも先ほどの「ダブルクォートで終わるパターン」と同様に、末尾のダブルクォートがエスケープされた値としてのダブルクォートなのか、ダブルクォートによる括りの終わりなのかを調べる必要がある。

なので
1. エスケープされたダブルクォートをプレースホルダ文字に置き換える
2. 末尾がダブルクォートか判定して、
3a. 末尾がダブルクォートなら末尾のダブルクォートを外して、プレースホルダ文字をダブルクォートに戻して、それまでのバッファと繋げて値を出力
3b. 末尾がダブルクォートでないなら、プレースホルダ文字をダブルクォートに戻して、バッファか何かにさらに追加して次の値と連結させる
で、ok

そんなわけでコード

今回はCSVをパースしてTSVに吐いてます。
TSVは区切り文字がTabで、値のタブや改行はバックスラッシュ記法を使うことにしています

csv2tsv() {
  local cont="no"   # 次に続くかどうか。bufferは空でも次に続く、みたいなパターンがあるので、bufferが空かどうかは判定に使えない
  local buffer=""
  local next_delim=""
  local -a row=()

  while read -d "${NL}" line; do
    line="${line//${TAB}/\t},EOS"  # IFSの謎挙動1: 末尾の空文字が消えることがるので、対策としてダミー文字列を追加
    # NOTE: IFS謎挙動2: IFSを変更してreadを使うと何故か末尾のIFSが消えたりする「ことがある」のでIFSは局所的に変更してすぐ元にもどす。
    IFS=","
    local cols=(${line})
    IFS="${original_IFS}"
    unset cols[${#cols[@]}-1]  # ダミー文字列を削除

    for col in "${cols[@]}"; do
      if [ "${cont}" == "no" ]; then
        # 直前の値からの連続ではない
        if [ "${col}" != "${col#${DQ}}" ]; then  # begin with double-quote?
          # ダブルクォートからはじまる
          col="${col#${DQ}}"
          col="${col//${DQ}${DQ}/${TAB}}"  # プレースホルダにはTABを使う
          if [ "${col}" != "${col%${DQ}}" ]; then  # end with double-quote?
            # ,"hoge", 単独値パターン
            col="${col%${DQ}}"
            row[${#row[@]}]="${col//${TAB}/${DQ}}"
          else
            # ,"hoge, 次に続くパターン
            buffer="${col//${TAB}/${DQ}}"
            next_delim=","
            cont="yes"
          fi
        else
          # ,hoge, ダブルクォートが出現しないパターン(先頭だけチェックすれば仕様的にok)
          col="${col//${DQ}${DQ}/${DQ}}"
          row[${#row[@]}]="${col}"
        fi
      else
        # 直前の値から繋がるパターン
        col="${col//${DQ}${DQ}/${TAB}}"  # プレースホルダにはTABを使う
        if [ "${col}" != "${col%${DQ}}" ]; then  # end with double-quote?
          # ... ,hoge", ここでクォートが終わるパターン
          col="${col%${DQ}}"
          row[${#row[@]}]="${buffer}${next_delim}${col//${TAB}/${DQ}}"
          cont="no"
          next_delim=","
        else
          # ... ,hoge, さらにクォートが続くパターン
          buffer="${buffer}${next_delim}${col//${TAB}/${DQ}}"
          next_delim=","
        fi
      fi
    done

    if [ "${cont}" == "no" ]; then
      (IFS="${TAB}"; echo "${row[*]}")
      row=()
    else
      next_delim=""
      buffer="${buffer}\\n"
    fi
  done
}

思った以上にでかくなった……orz
説明していない要素としては、値をバッファと繋げるときに、カンマで繋げるのか改行で繋ぐのかが実は地味にやっかいで、値が終わらないまま行末を向かえたときにはカンマじゃなくて改行文字(を表す文字)で連結スル必要があるのでちょこっと小細工しています。

あと今回のこだわりは、全てBashの拡張記法か、built-inコマンドだけで処理をしています。
( [echo は組み込みで用意されている。 /bin/[/bin/echo と別物)

以下細かい説明を載せてみます。

コードの説明: シェル変数展開時オプション

buffer="${col//${TAB}/${DQ}}"
col="${col%${DQ}}"
col="${col#${DQ}}"

まずはBashの変数展開時の文字列の加工機能。

Bashは変数展開時に変数名の後ろにオプションを加えることで、展開された文字列を加工することができる。
今回使っているのは置換(${parameter/pattern/string})、接頭辞除去(${parameter#word})、接尾辞除去(${parameter%word})の三つ。
その他にも部分文字列取得(${parameter:offset:length})や変数未定義時のデフォルトの値(${parameter:-word})とかあるのだけど今回は割愛。
man bash の EXPANSION / Parameter Expansionの項を参考。

で、置換や接頭辞/接尾辞除去の使い方はこんな

$ a="foobar"  # foobarという文字列を定義
$ echo "${a/o/O}"  # 小文字のoをOに置き換えてみる
fOobar
$ echo "${a//o/O}"  # パターンの先頭を / にすると全件置換になる
fOObar

$ echo "${a#foo}"  # 先頭からfooを除去
bar
$ echo "${a#*o}"  # *をつかったパターンも使える。「~o」を先頭から除去(最短マッチ)
obar
$ echo "${a##*o}"  # パターンの先頭を # にすると最長マッチになる
bar

$ a="foo.tar.gz"  # tarballっぽいファイル名
$ echo ${a%.gz}  # % は後ろから除去
foo.tar
$ echo ${a%.*}  # 同様にパターンが使える。拡張子を最短マッチで除去してみる
foo.tar
$ echo ${a%%.*}  # %を重ねれば最長マッチ。
foo

basename(1) なんていらなかったんや!! (いや、あった方が断然便利ですが)

今回は、先頭や末尾のダブルクォートの除去と、エスケープされたダブルクォートのプレースホルダへの変換、プレースホルダからのダブルクォートの復元に使っています。

ちょっとした加工だとわざわざ awk(1)sed(1) を使わなくてもできる、ということを覚えておくとシェル力アップですね。

コードの説明: bashの配列

local -a row=()
row[${#row[@]}]="${col//${TAB}/${DQ}}"

続いてBashの配列の機能。
Bashは配列をサポートしており、値を () で括ると配列として値を定義できる。

スペースで区切って複数の値を括弧でくくるとそれらが配列の要素になる。
その他、添え字でのアクセスや列挙などができる。

$ a=(foo bar baz)
$ declare -p a  # 添え字は自動で振られる。
declare -a a='([0]="foo" [1]="bar" [2]="baz")'

$ a=([1]=foo [2]=bar [5]=baz)  # 明示的に添え字を指定できる
$ declare -p a
declare -a a='([1]="foo" [2]="bar" [5]="baz")'

$ echo "${a[5]}"  # [添え字] で単一の値が取れる
baz

$ a[7]="foobar"  # 添え字を指定して代入もできる
$ declare -p a
declare -a a='([1]="foo" [2]="bar" [5]="baz" [7]="foobar")'

$ echo "${a[@]}"  # [@] で全要素列挙
foo bar baz foobar

$ echo "${a[*]}"  # [*] でもいい。ただしコレは後述するIFSが影響を与える
foo bar baz foobar

$ echo "${#a[@]}" # [@] に#を付けると要素数が取れる。(最大添え字ではない)
4

$ echo "${!a[@]}"  # !を付けると添え字の列挙
1 2 5 7

今回は、パースした値を保存するために、変数 row を配列として定義して、値を保持しています。

そしてもう一つ、配列を文字列分割や文字列結合としても使っています。
それは次節に。

コードの説明: 配列とIFS

IFS=","
local cols=(${line})

(IFS="${TAB}"; echo "${row[*]}")

文字列分割と結合をしているのがこの辺。

イキナリIFSという謎のシェル変数に値を代入して、配列の宣言や配列の要素列挙をしています。
このIFSというシェル変数はInternal Field Separatorの意味で、読んで字の通り、bashが内部で値の区切りとして使う文字を表しています。
より正確には、変数展開時と read(1) がこの値に影響を受けます。

IFSはデフォルトで、空白、タブ、改行、が指定され、これらが値の区切りとして使われる様になっていますが、IFSに任意の文字を指定することで空白やタブ文字以外を使って値を分割できるようになります。

$ line="a,b,c"
$ a=(${line})  # カンマは通常、値の区切りとして見なされない、
$ declare -p a  # ので単一要素の配列として定義される
declare -a a='([0]="a,b,c")'

$ IFS=","  # おもむろにIFSをカンマに変えてみる

$ a=(${line})  # すると${line}は三つの要素からなる文字列とみなされるので
$ declare -p a  # 三要素の配列になる
declare -a a='([0]="a" [1]="b" [2]="c")'

$ a=("${line}")  # 分割して欲しくない場合はダブルクォートで括る
$ declare -p a
declare -a a='([0]="a,b,c")'

$ a=(x,y,z)  # IFSはあくまで変数展開時に影響を与えるだけなのでこれは単一配列になる
$ declare -p a
declare -a a='([0]="x,y,z")'

コードではこのサンプル同様に、カンマをIFSに使って値を分割してます。

また、IFSは配列の [*] によるアクセスにも影響を与えます。

$ printf -v IFS " \t\n"  # IFSを元に戻す
$ a=(foo bar baz)  # 配列を宣言

$ echo "${a[@]}"  # [@] と [*] はぱっとみ
foo bar baz
$ echo "${a[*]}"  # 同じ結果をだしているように見えるけど……。
foo bar baz

$ IFS="XYZ"  # おもむろにIFSを設定してみると

$ echo "${a[@]}"  # [@] はそのままだけど
foo bar baz
$ echo "${a[*]}"  # [*] はIFSの一文字目で連結された!
fooXbarXbaz

$ echo ${a[*]}  # ちなみにダブルクォートで括らないとIFSで連結されない(様に見える)
foo bar baz
$ # これは、${a[*]} が fooXbarXbaz に展開されるのだけど、
$ # クォートされていないので、シェル変数展開時のIFSの処理が行われ、
$ # XYZで区切られて、 ["foo","bar","baz"] という引数がechoに渡されるから
$ # (空白を吐いているのはechoの仕業)
$ a=(for bar fooXbar)  # なので値の一部にIFSを含むと
$ echo ${a[*]}  # forXbarXfooXbar に展開されたあと、IFSで分割され
for bar foo bar
$ # ["for","bar","foo","bar"] がechoに渡されてこうなる。これは [@] も同じ

というような感じで、IFSを駆使すると任意の一文字での文字列分割・文字列結合ができたりします。便利!!

そんなわけで、あとはほとんど方針のところで説明したとおりです。
で、こんなコード書くのに2時間も掛かったワケ。IFSの落とし穴

IFSの謎挙動1: 分割時に末尾空白が消える

上述したように、IFSを使うと文字列の分割ができる。
のだけど、末尾に分割したときに空文字列になるような文字列を分割すると、最後の空文字列が消えてしまう。

$ IFS=","  # 分かりやすく , をIFSに

$ line="a,b,c"  # a,b,cという値をいれておくと、シェル変数lineの展開時にIFSで分割され
$ a=(${line})  # 3要素の配列を作ったり、
$ declare -p a
declare -a a='([0]="a" [1]="b" [2]="c")'
$ cat ${line}  # コマンドに3つ分の引数と認識させたりできる。
cat: a: No such file or directory
cat: b: No such file or directory
cat: c: No such file or directory

$ line="a,b,"  # a,b,にしてみると……
$ a=(${line}); declare -p a  # 二要素にしかならない
declare -a a='([0]="a" [1]="b")'
$ cat ${line}
cat: a: No such file or directory
cat: b: No such file or directory

$ # 空文字列はスキップされる?のかと思って
$ line="a,,"  # a,,という文字列にしてみると、
$ a=(${line}); declare -p a  # 一つ目の空要素はちゃんと出るし、
declare -a a='([0]="a" [1]="")'
$ cat ${line}  # ちゃんと2つの値に見なされる
cat: a: No such file or directory
cat: : No such file or directory
$ # ↑空文字列なファイル名にアクセスしようとしている。ちゃんと ["a",""] になっている

$ line=",,c"  # 先頭の空文字列は
$ a=(${line}); declare -p a  # ちゃんと個数分でてくる
declare -a a='([0]="" [1]="" [2]="c")'
$ cat ${line}  # 3つの値と見なされている
cat: : No such file or directory
cat: : No such file or directory
cat: c: No such file or directory

謎。最後の一つだけ特別視されるようです。
CSV的にはカラムが消えてしまうのは嬉しくないので、

line="${line//${TAB}/\t},EOS"
...
unset cols[${#cols[@]}-1]

こんな感じにダミー文字列を足したあとで、その分を消してます。
泥臭い……。素敵な解決策あったら教えてください。

IFSの謎挙動2: 入力行にIFSがただ一つだけ、末尾にでるときにreadが取りこぼす

こっちがことさら意味が分からない上に、どうしてこうなっているのかがとんと検討がつかない……。

$ IFS=","  # 分かりやすく , をIFSに

$ cat text  # こんなファイルを用意
foo
foo,
foo,,
foo,bar
foo,bar,
foo,bar,,

$ while read -d $'\n' line; do  # readで一行ずつ読みつつ、
    (IFS=" "; echo "${line}");  # 表示してみる。(念のためecho時にIFSをスペースに)
  done < text
foo
foo
foo,,
foo,bar
foo,bar,
foo,bar,,
$ # ……なにか足り無い。

$ while read -d $'\n' line; do
    declare -p line;  # 念のため、echoじゃなくて、decalare -pで変数の値を見てみる
  done < text
declare -- line="foo"
declare -- line="foo"
declare -- line="foo,,"
declare -- line="foo,bar"
declare -- line="foo,bar,"
declare -- line="foo,bar,,"
$ # ……二行目の末尾のカンマは?

なぜか取りこぼす。
条件としては、 1. read(1)-d オプションを渡している、2. IFSが末尾にある、 3. 末尾のIFSが唯一その行に含まれるIFSである、あたりっぽい。

これが、無条件に末尾のカンマが消える、とか foo,bar,foo,bar になる、というのであればある程度納得がいく挙動なのだけど、なぜか末尾以外にもIFSが入っていると末尾IFSは消えない。
まして、 read(1) には -d で区切り文字を指定しているのにもかかわらず……。

多分、↑の末尾空文字関係で、 read(1) というか read(1) が与えられたシェル変数に値をいれる当たりでなにか起きているんじゃないか、と思ってみているのだけど、さっぱりわからない……。
ど、どなたか、bashマスターの方、教えてください><

そんなわけで :wq

17
15
2

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
17
15