Erlangとパーサコンビネータ

Parsecの練習をしている最中、ふと「parsec erlang」で調べてみたら、Parsec Erlangを発見しました。
Parsec - HaskellWikiからもリンクされていますね。

ドキュメントがほとんどなく、サンプルもまったく見つからないという巨大な問題があり(こちらのバージョンのサンプルは何か所かで見つけられたのですが)、Makefileがいまひとついけてないので素直にビルドできなかったというのは些細な問題でした。

幸い(?)、ソース内のコメントは充実しているので、それを頼りに、山本さんの正規表現を超える--CSVファイル編 - あどけない話にあるCSVパーサを、Parsec Erlangで動くようにしてみました。

-module(testparse).
-export([test/1]).
-define(do(A,B,C), parsec:bind(B, fun(A) -> C end)).                         

test(String) ->                                                              
    Dquote = parsec:char($"), % "                                     
    Cr = parsec:char($\r),
    Lf = parsec:char($\n),
    Crlf = parsec:sequence([Cr, Lf]),
    Comma = parsec:char($,),
    Textdata = parsec:oneOf(lists:flatten([lists:seq(16#20, 16#21),
          lists:seq(16#23, 16#2B), lists:seq(16#2D, 16#7E)])),
    Nonescaped = parsec:many(Textdata),
    Escaped = ?do(_, Dquote,
                  ?do(Txt, parsec:many(parsec:choice(
                     [Textdata, Comma, Cr, Lf,
                       parsec:'try'(parsec:sequence([Dquote, Dquote]))])),
                      ?do(_, Dquote, parsec:return(Txt)))),

    Field = parsec:choice([Escaped, Nonescaped]),
    Record = parsec:sepBy1(Field, Comma),
    File = parsec:sepEndBy1(Record, Crlf),
    parsec:parse(File , "", String).      

早速動かしてみましょう...

131> {right, R2} = testparse:test("foo,\"bar,\"\"quoted,string\"\",baz\",ahya").                                    

{right,[["foo",                                                       
         [98,97,114,44,"\"\"",113,117,111,116,101,100,44,115,116,114,                                                                      
          105,110,103,"\"\"",44,98,97,122],                 
         "ahya"]]}                                                    
132> [[io:format("~s~n", [X]) || X <- RR] || RR <- R2].      
foo
bar,""quoted,string"",baz                                   
ahya
[[ok,ok,ok]]

ちゃんと動いてますね。すごい!
parsec_prim:bindの説明には、doというマクロがあるからそれ使うといいよ、と書いてあったのですが、見当たらなかったので自分で作りました。Escapedの意味値をどうやって返すのかが味噌であり、ここでさんざんソースを調べることになりました。そしてこれが最良の解法なのかと言われると自信がないです。

思ったより素直に書換えることができました。オリジナルのエレガントさにはかなわないものの、Parsec Erlangでの記述も決して悪くないように思います。

RFC4180 に従うと、ダブルクォート自体を表現するためにダブルクォートを二重にする記述はescapedの中でしか通用しないのですね。つまり、"a,""a,b"",c" はOKですが、 a""b みたいなのは駄目ということになります。

2> testparse:test("\"a,\"\"a,b\"\",c\"").                                                                           
{right,[[[97,44,"\"\"",97,44,98,"\"\"",44,99]]]}                                                                       
3> testparse:test("a\"\"b,c").                           
{right,[["a"]]}                                                       

また、行区切はCRLFに限られており、ダブルクォートで括られていればフィールド自体にCRやLFを含めることも可能。

4> testparse:test("a,b,c\nd,e,f").                                 
{right,[["a","b","c"]]}                           
5> testparse:test("a,b,\"c\nd\",e,f").                   
{right,[["a","b","c\nd","e","f"]]}                                                                           

などなど、知らなかったことが沢山ありました。真面目にサポートしようと思うと結構面倒ですね。

(追記) よく見たら二重のダブルクォートがそのまま戻ってきていますね。Escapedは下記のように直す必要があります。

    Escaped = ?do(_, Dquote,                                                    
                  ?do(Txt,                                                      
                      parsec:many(                                              
                        parsec:choice(                                          
                          [Textdata, Comma, Cr, Lf,                             
                           ?do(_, parsec:'try'(parsec:sequence([Dquote, Dquote])), parsec:return($"))])),     % "
                      ?do(_, Dquote, parsec:return(Txt)))),                     

実行してみると...

19> {right, R} = testparse:test("foo,\"bar,\"\"quoted,string\"\",baz\",ahya").
{right,[["foo","bar,\"quoted,string\",baz","ahya"]]}
20> [[io:format("~s~n", [X]) || X <- R2 ] || R2 <- R].
foo
bar,"quoted,string",baz
ahya
[[ok,ok,ok]]