Elm

パーサコンビネータ elm-combine を使ってみた!

 Elmのパーサコンビネータelm-combineを、簡単に使ってみたので、簡単な報告です。

 パーサ関係では、今までPrologやYacc/Lexを使った経験があります。これは結構使いました。Haskellではparsecとattoparsecを使ったことがります。これはちょっと使った程度ですね。その経験で言えば、ちょっと使っただけですがelm-combineは「敷居の低い」感じがしました。LALRやmonadも出てこないしね。

目標: "2018/05/02"と"2018-05-06"の文字列をパースするパーサを作る

解答:以下のコードです。

ymdParser
ymdParser = ymdParser1 '/' <|> ymdParser1 '-' 

ymdParser1 x =
    ddddParser
      |> andThen (sepParser x)
      |> andThen ddParser
      |> andThen (sepParser x)
      |> andThen ddParser 


ddddParser = count 4 digit -- [d,d,d,d]

ddParser = \dlst -> ddp dlst
ddp dlst = map (List.append dlst) <| count 2 digit

sepParser x = \dlst -> sp x dlst
sp x dlst = map (repN (List.append dlst ['/'])) <| char x
repN n _ = n

 コードが短いのでアレですが、一応説明します。

 まずに以下の定義から説明します。

ymdParser = ymdParser1 '/' <|> ymdParser1 '-' 

(ymdParser1 '/') が"2018/05/02"のフォーマットのパーサで、(ymdParser1 '-' ) が"2018-05-06"のフォーマットのパーサです。2つのパーサが OR (<|>)で組み合わせられています。この定義から"2018-05/06"はパースできないことに注意してください。

 ymdParser1の定義ではandThenで小さなparser combinatorを連結しています。ymdParser1 xのxはseparator charです。x='/' or '-'ということです。

ymdParser1 x = ddddParser |> andThen (sepParser x) |> andThen ddParser |> andThen (sepParser x) |> andThen ddParser 

これは優先度を明示するためにカッコをつけると以下のようになります。|> は左結合ですね。Elmのパイプライン演算子と関数合成 - Qiita

ymdParser1 x = (((ddddParser |> andThen (sepParser x)) |> andThen ddParser) |> andThen (sepParser x)) |> andThen ddParser 

 andThenの型は以下の通りです。Combine

andThen : (a -> Parser s b) -> Parser s a -> Parser s b

 この場合、まず(Parser s a)が評価されて、その結果が(a -> Parser s b) に渡され、Parser s bとなります。このように定義すれば、パイプ|> で左から右につなげていけます。|>の左側は第2引数ですね。つまり p1 |> andThen p2 |> andThen P3... で説明すれば、パーサp1のパース結果がp2に渡され、新たなパーサp2'を生成し、p2'のパース結果がp3に渡され、新たなパーサp3'を生成し...などと連鎖していくわけです。

 ddddParserは"2018"のパーサです。ddParser は"05"と"02"のパーサです。sepParser x は'/'または'-'のパーサですが、パース結果は常に['/']であることに注意してください。

ddddParser = count 4 digit

ddParser = \dlst -> ddp dlst
ddp dlst = map (List.append dlst) <| count 2 digit

 ddddParserは一番最初のパーサなので、(count 4 digit)の定義通りパース結果を['2','0','1','8']を生み出します。
 ddParserは前のパーサの結果dlstを受け取ります。ここでのパーサは(count 2 digit)なのですが、パース結果をmapを使って変更しているのがポイントです。つまりパース結果をmap (List.append dlst) で変更します。(count 2 digit)の結果はParserタグの中にあるリストなのですが、これにdlstをappendします。タグの中のリストにappendするのでmapを使います。(count 2 digit)で"05"をパースすると['0','5']になるのでdlst(=['2','0','1','8','/'])をappendして、['2','0','1','8','/','0','5']になります。

 sepParser x の定義でわかりにくいのが、repNかもしれません。これは(char x)のパース結果を無視して、パース結果を(List.append dlst ['/'])で上書きするものです。

sepParser x = \dlst -> sp x dlst
sp x dlst = map (repN (List.append dlst ['/'])) <| char x
repN n _ = n

 さてそれでは試してみましょう。以下のコマンドを打ってelm-combineをインストールし、シェルを立ち上げます。必要なライブラリをimportします。

mkdir elm-combine
cd elm-combine
elm-package install Bogdanp/elm-combine

elm-repl
import Combine exposing (..)
import Combine.Char exposing (..)

 まず関数の定義を行います。

> import Combine exposing (..)
> import Combine.Char exposing (..)
> repN n _ = n
<function> : a -> b -> a
> sp x dlst = map (repN (List.append dlst ['/'])) <| char x
<function> : Char -> List Char -> Combine.Parser s (List Char)
> sepParser x = \dlst -> sp x dlst
<function> : Char -> List Char -> Combine.Parser s (List Char)
> ddp dlst = map (List.append dlst) <| count 2 digit
<function> : List Char -> Combine.Parser s (List Char)
> ddParser = \dlst -> ddp dlst
<function> : List Char -> Combine.Parser s (List Char)
> ddddParser = count 4 digit
Parser <function> : Combine.Parser s (List Char)
> ymdParser1 x = ddddParser |> andThen (sepParser x) |> andThen ddParser |> andThen (sepParser x) |> andThen ddParser
<function> : Char -> Combine.Parser s (List Char)
> ymdParser = ymdParser1 '/' <|> ymdParser1 '-'
Parser <function> : Combine.Parser s (List Char)

 "2018/05/02"をパースします。成功ですね。

> result = parse ymdParser "2018/05/02"
Ok ((),{ data = "2018/05/02", input = "", position = 10 },['2','0','1','8','/','0','5','/','0','2'])
    : Result.Result
        (Combine.ParseErr ()) (Combine.ParseOk () (List Char))

 "2018-05-02"をパースします。これも成功です。しかもseparatorが'/'に置き換えられていることに注意して下さい。意図したとおりの結果です。

> result = parse ymdParser "2018-05-02"
Ok ((),{ data = "2018-05-02", input = "", position = 10 },['2','0','1','8','/','0','5','/','0','2'])
    : Result.Result
        (Combine.ParseErr ()) (Combine.ParseOk () (List Char))

 "2018/05-02"はちゃんとエラーとなります。

> result = parse ymdParser "2018/05-02"
Err ((),{ data = "2018/05-02", input = "2018/05-02", position = 0 },["expected '/'","expected '-'"])
    : Result.Result
        (Combine.ParseErr ()) (Combine.ParseOk () (List Char))

 "2018/05/2"もちゃんとエラーとなりますね。めでたしです。

> result = parse ymdParser "2018/05/2"
Err ((),{ data = "2018/05/2", input = "2018/05/2", position = 0 },["expected a digit","expected '-'"])
    : Result.Result
        (Combine.ParseErr ()) (Combine.ParseOk () (List Char))

 今回は以上で終了です。数時間の学習でelm-combineのだいたいの感じがつかめました(ような気がします)。

 今回の実験は正規表現を使えばもっと簡単にできるでしょうが、elm-combineの使い方を試す目的なので問題はありません。また今回の目的のためにはelm-combineのもっと便利な使い方があるかもしれませんが、それも問題ではありません。あくまで基本的な使い方を試した次第ですので。