Haskell, Scala によるパーサーコンビネータを使った CSV ファイルのパース処理

以前(id:fits:20101129, id:fits:20101204)試したような CSV ファイルのパース処理を書籍 Real World Haskell―実戦で学ぶ関数型言語プログラミング を参考に HaskellScala のパーサーコンビネータでやってみました。
Haskell の方は本の内容ほとんどそのままなので簡単に動作しましたが、Scala の方は挙動が良く分からなくて結構苦労しました。

環境は以下の通り。

  • HaskellPlatform 2010.2.0.0 (GHC 6.10.4)
  • Scala 2.8.1

サンプルのソースは http://github.com/fits/try_samples/tree/master/blog/20101226/

単純なCSV

まずは、以下のような単純な CSV ファイルをパースするサンプルです。

test_simple.csv
1,test1,10.5
2,"test2",-123,abc

Haskell版は基本的に本の内容そのままです。(出力は手抜きしてます)
sepBy でコンマ区切りの繰り返し、endBy で後ろに改行が付く行の繰り返し、noneOf でコンマと改行以外の文字、many で繰り返しを指定しています。

Haskell版 parse_simple_csv.hs
import Text.ParserCombinators.Parsec

csvFile = endBy line eol
line = sepBy cell (char ',')
cell = many (noneOf ",\n")
eol = char '\n'

main = do
    cs <- getContents
    let res = parse csvFile "" cs

    case res of
        Left err -> print err
        Right x -> putStrLn $ show x
Haskell版実行結果
> runhaskell parse_simple_csv.hs < test_simple.csv
[["1","test1","10.5"],["2","\"test2\"","-123","abc"]]


次に、Scala です。
Scala のパーサコンビネータには、scala.util.parsing.combinator パッケージのトレイト(JavaTokenParsers, RegexParsers 等)を使います。

今回は JavaTokenParsers を使い、Haskell の sepBy の代わりに repsep、 many と noneOf の代わりに正規表現、endBy は rep で実現しました。

注意点として、RegexParsers では文字列(String)を指定した際にデフォルトで空白や改行をスキップするようになっているので、eol の実装に対して以下のような方法を取る必要がありました。(JavaTokenParsers は RegexParsers を extends している)

  • eol の値に文字(Char)としての改行 '\n' を指定する
  • skipWhitespace を false にして eol の値に文字列としての改行 "\n" を指定する

これは、文字の場合 Parsers の accept()、文字列の場合 RegexParsers の literal() の暗黙変換が適用される事に起因します。(literal で空白や改行をスキップする処理 handleWhiteSpace を呼ぶ Parser[String] が作成される)

また、行毎の cell の最後の要素に何故か改行が付くので、trim で取り除くようにしました。

Scala版 parse_simple_csv.scala
import scala.io.Source
import scala.util.parsing.combinator._

object SimpleCsv extends JavaTokenParsers {
    def csvFile = rep(line <~ eol)
    def line = repsep(cell, ',')
    //最後のセル要素に改行が含まれるので trim で取り除く
    def cell = """[^,\n]*""".r ^^ (_.trim)
    //'\n' は Char(= Elem)である点に注意
    def eol = '\n'

    //"\n" とするには以下のように skipWhitespace を false にする必要あり
    //override val skipWhitespace = false
    //def eol = "\n"
}

val csv = Source.stdin.mkString
println(SimpleCsv.parseAll(SimpleCsv.csvFile, csv))
Scala版実行結果
> scala parse_simple_csv.scala < test_simple.csv
[3.1] parsed: List(List(1, test1, 10.5), List(2, "test2", -123, abc))

複雑なCSV

次に、以前使用した CSV ファイル(要素内にコンマ・改行・ダブルクォーテーションあり)をパースするサンプルです。

test.csv
1,テスト1,"改行
含み"
2,test2,"カンマ,含み"
3,てすと3,"ダブルクォーテーション""含み"


単純なCSVと同様に、Haskell版は本の内容とほとんど同じです。
try を使ってダブルクォーテーションを要素内に含むケースに対応しています。

Haskell版 parse_csv.hs
import Text.ParserCombinators.Parsec

csvFile = endBy line eol
line = sepBy cell (char ',')
cell = quotedCell <|> many (noneOf ",\n")
eol = char '\n'
quotedCell = do 
    char '"'
    content <- many quotedChar
    char '"'
    return content

quotedChar = noneOf "\"" <|> try (string "\"\"" >> return '"')

main = do
    cs <- getContents
    let res = parse csvFile "" cs

    case res of
        Left err -> print err
        Right x -> putStrLn $ show x|
Haskell版実行結果
> runhaskell parse_csv.hs < test.csv
[["1","\131e\131X\131g1","\137\252\141s\n\138\220\130\221"],
["2","test2","\131J\131\147\131},\138\220\130\221"],
["3","\130\196\130\183\130\198\&3","\131_\131u\131\139\131N\131H\129[\131e\129[\131V\131\135\131\147\"\138\220\130\221"]]

分かりにくいかと思いますが、一応 \n , " が要素内に含まれている事が確認できます。


次に、Scala版です。
Haskell の try の代わりに guard() を使って同等の実装ができると思ったのですが、Haskell と同じように実装すると処理が返って来なくなってしまったので(実装の仕方が間違っている可能性あり)、とりあえず別の処理内容で実装する事にしました。

一応、ダブルクォーテーションの含まない文字列同士の区切りに "" が使われるという考え方で実装してみました。(要素内の " が消えるので repsep は使用せず)

なお、Scala では行の最後の要素がダブルクォーテーションで囲まれている場合に、改行が \r\n にマッチするという不可解な現象が発生したため、eol に | "\r\n" を加えました。(ダブルクォーテーションで囲まれていない場合は \n にマッチする)

また、今回は whiteSpace(正規表現的には \s+)がスキップされては困るので、skipWhitespace を false にしています。

Scala版 parse_csv.scala
import scala.io.Source
import scala.util.parsing.combinator._

object Csv extends JavaTokenParsers {
    override def skipWhitespace = false

    def csvFile = rep(line <~ eol)
    def line = repsep(cell, ',')
    //最後のセル要素に改行が含まれるので trim で取り除く
    def cell = quotedCell | """[^,\n]*""".r ^^ (_.trim)
    //quotedCellが行の最後に来た場合のみ \r\n になる
    def eol = "\n" | "\r\n"
    def quotedCell = '"' ~> quotedChars ~ rep(escapeQuotedChars) <~ '"' ^^ {case(x~xs) => x + xs.mkString}
    def quotedChars = """[^"]*""".r
    def escapeQuotedChars = "\"\"" ~> quotedChars ^^ ('"' + _)
}

val csv = Source.stdin.mkString
println(Csv.parseAll(Csv.csvFile, csv))
Scala版実行結果
> scala parse_csv.scala < test.csv
[5.1] parsed: List(List(1, テスト1, 改行
含み), List(2, test2, カンマ,含み), List(3, てすと3, ダブルクォーテーション"含み))