CSVでカンマセパレータだけを置換する(フィールド内カンマはスルー)・・・SED版

この記事でわかること。
・sedを使ってカンマセパレータを別文字に置換する方法

CSVでカンマセパレータだけを置換する(フィールド内カンマはスルー)」の記事で紹介した正規表現は、sedでは使えない。 sedは先読みとか後読みに対応していないためだ。 別の方法でCSVのカンマセパレータの置換方法を検討する。 ロジックを作り上げるプロセスを載せて備忘録とし、今後の応用に活かしたい。

まずはテストデータと期待結果を再掲載する。
 テストデータ:a,"b,c",d,"e,f,g",h
 期待する結果:a@"b,c"@d@"e,f,g"@h

問題を単純化するために、一旦テストデータをシンプルなものに変更する。
 テストデータ:a,"b,c",h
 期待する結果:a@"b,c"@h

最初に、左端のカンマを置換するsedを考えて、以下のように試してみた。

1$ echo -e 'a,"b,c",h' | sed -E 's/^.*,/@/'
2@h

先頭から h の直前のカンマまで置換されてしまった(失敗)。
メタキャラクタを使って、置換してはいけない部分の出力を試みる。

1$ echo -e 'a,"b,c",h' | sed -E 's/^(.*),/\1@/'
2a,"b,c"@h

よくなったが、左端のカンマが置換されていない(失敗)。
最短マッチさせるため、? を使ってみる。

1$ echo -e 'a,"b,c",h' | sed -E 's/^(.*)?,/\1@/'
2a,"b,c"@h

出力結果は変わらない(失敗)。 sedは最短マッチに ? を使えないことを思い出した。
否定の文字クラスを使って最短マッチさせる。

1$ echo -e 'a,"b,c",h' | sed -E 's/^([^,]*),/\1@/'
2a@"b,c",h

左端のカンマを置換できた(OK)。
次はダブルクォートで括られた中のカンマはスルーして、セパレータである h の左隣りのカンマの置換を目指す。
ここで、テストデータを上記出力結果 a@"b,c",h とすることで問題を簡単にする。以下を試してみた。

1$ echo -e 'a@"b,c",h' | sed -E 's/^(([^,"]*)("[^"]*")),/\1@/'
2a@"b,c"@h

うまくいった(OK)。丸括弧()が三箇所に登場しているが、最も外側の丸括弧が \1 に対応する。
冗長と思われる丸括弧を削除して試してみる。

1$ echo -e 'a@"b,c",h' | sed -E 's/^([^,"]*("[^"]*")),/\1@/'
2a@"b,c"@h

同じ結果が得られたので、やはり冗長であった。
それではテストデータを元の a,"b,c",h に戻して、上記正規表現で試してみる。

1$ echo -e 'a,"b,c",h' | sed -E 's/^([^,"]*("[^"]*")),/\1@/'
2a,"b,c",h

何も置換されていない(失敗)。 この正規表現の [^,"]* の部分と ("[^"]*") の部分は、これまで見てきたように、それぞれダブルクォートで括られていない文字列、括られている文字列を表すパターンなのだが、この正規表現にはその両者の間のカンマが含まれていないため、マッチしなかったのだ。

但し、カンマが含まれていないからといって以下のように単純にカンマを追加しても、最長マッチにより右端のカンマだけが置換されるので失敗となる。

1$ echo -e 'a,"b,c",h' | sed -E 's/^([^,"]*,("[^"]*")),/\1@/'
2a,"b,c"@h

では、どうすれば良いか。
("[^"]*") の右側に ? を付けてみる。 ? は直前に記述したパターンの 0 回または 1 回にマッチする量指定子だ。

1$ echo -e 'a,"b,c",h' | sed -E 's/^([^,"]*("[^"]*")?),/\1@/'
2a@"b,c",h

右端のカンマが置換されていないが、実は想定どおり。
目論んでいるのは、このアウトプットに対して、繰り返し同じパターンで置換を実行することだ。
そうすれば上で試したように a,"b,c",ha@"b,c",ha@"b,c"@h と置換されるはずだ。

繰り返し置換するには、 t コマンドを使ってスクリプトの先頭に制御をジャンプさせる。 以下を試してみた。

1$ echo -e 'a,"b,c",h' | sed -e ':a' -Ee 's/^([^,"]*("[^"]*")?),/\1@/' -e 'ta'
2a@"b,c"@h

うまくいった(OK)。 なお、GNU sed であれば sed -E '{:a; s/^([^,"]*("[^"]*")?),/\1@/; ta}' と書くこともできる。

さて、それでは本来のテストデータで試してみよう。

1$ echo -e 'a,"b,c",d,"e,f,g",h' | sed -e ':a' -Ee 's/^([^,"]*("[^"]*")?),/\1@/' -e 'ta'
2a@"b,c"@d,"e,f,g",h

d の左側までしか置換されていない(失敗)。 パターンの繰り返しが漏れていたので追加する。

1$ echo -e 'a,"b,c",d,"e,f,g",h' | sed -e ':a' -Ee 's/^(([^,"]*("[^"]*")?)*),/\1@/' -e 'ta'
2a@"b,c"@d@"e,f,g"@h

OK。これで完全に期待通りの結果が得られた。

上記のように繰り返し制御を使用すれば、先読み・後読みができなくても対応可能性が高まる。

追記)QAサイトで以下の質問が投稿された。

カンマが空白に置き換わっただけの類似問題。 以下のような sed と awk の合わせ技で可能。
sed -e ':a' -Ee 's/^(([^ "]*("[^"]*")?)*) /\1@/' -e 'ta' | awk -F@ '{print NF}'
GNU sed であれば、少し縮められる。
sed -E '{:a; s/^(([^ "]*("[^"]*")?)*) /\1@/; ta}' | awk -F@ '{print NF}'

awk だけでも可能。 正規表現を知っていれば応用が効く。
awk '{ while(sub(r,"@"))i++ print i+1 }' r='^(([^ "]*("[^"]*")?)*) '

関連ページ