こんにちは、@koga1020_です。

以前、「Elixirのパーサーコンビネータライブラリ Combine入門」の記事でElixirのパーサーコンビネータライブラリであるCombineについて書きました。

今回は別のライブラリ「NimbleParsec」についてまとめてみます。

NimbleParsecとは

README によると

NimbleParsec is a simple and fast library for text-based parser combinators.

NimbleParsecはテキストベースのパーサーコンビネータを提供するシンプルかつ高速なライブラリであると。いいですね。

Nimbleには「軽快な」「すばやい」といった意味があるようです。ParsecについてはHaskellに同名のライブラリ「parsec」があるようなのでそれにあやかったのでしょうか。

ReadMeのより詳細な説明 + 他ライブラリとの比較がgumiさんの記事にまとまっていますので、ぜひ見てみると良いと思います。

Elixir: パーサコンビネータライブラリNimbleParsec

「パーサーコンビネータってそもそも何?」という方は、「Elixirのパーサーコンビネータライブラリ Combine入門」の記事で触れてますのでそちらをご覧ください。

試してみる

お試し用にプロジェクトを作成します。

$ mix new nimble_parsec_demo
$ cd nimble_parsec_demo/

その後、depsにnimble_parsecを追加してください。

  defp deps do
    [
      {:nimble_parsec, "~> 0.5.0"}
    ]
  end

記述したら、deps.getで取得します

mix deps.get

環境が整ったら、まずはREADMEのサンプルコードを持ってきて動かしてみましょう。

lib/nimble_parsec_demo.ex の初期コードをまっさらに消し、サンプルコードをそのまま貼り付けます。

defmodule NimbleParsecDemo do
  import NimbleParsec

  date =
    integer(4)
    |> ignore(string("-"))
    |> integer(2)
    |> ignore(string("-"))
    |> integer(2)

  time =
    integer(2)
    |> ignore(string(":"))
    |> integer(2)
    |> ignore(string(":"))
    |> integer(2)
    |> optional(string("Z"))

  defparsec :datetime, date |> ignore(string("T")) |> concat(time), debug: true

end

defparsecというマクロによって、NimbleParsecDemodatetime関数が生えます。

試しにiexを起動し日時のフォーマットを関数に渡すと、うまくパースされていることが確認できます。

$ iex -S mix
iex> NimbleParsecDemo.datetime("2010-04-17T14:12:34Z")
{:ok, [2010, 4, 17, 14, 12, 34, "Z"], "", %{}, {1, 0}, 20}

ちなみに、コンパイル時に以下のようなコードがだーっと表示されたはずです。これはdefparsecのオプションにdebug:trueを渡していたため、生成される関数が表示されたのでした(こんなコードがシュッと生成されるマクロの威力を感じる…)

defp datetime__0(<<x0::integer, x1::integer, x2::integer, x3::integer, "-", x4::integer, x5::integer, "-", x6::integer, x7::integer, "T", x8::integer, x9::integer, ":", x10::integer, x11::integer, ":", x12::integer, x13::integer, rest::binary>>, acc, stack, context, comb__line, comb__offset) when x0 >= 48 and x0 <= 57 and (x1 >= 48 and x1 <= 57) and (x2 >= 48 and x2 <= 57) and (x3 >= 48 and x3 <= 57) and (x4 >= 48 and x4 <= 57) and (x5 >= 48 and x5 <= 57) and (x6 >= 48 and x6 <= 57) and (x7 >= 48 and x7 <= 57) and (x8 >= 48 and x8 <= 57) and (x9 >= 48 and x9 <= 57) and (x10 >= 48 and x10 <= 57) and (x11 >= 48 and x11 <= 57) and (x12 >= 48 and x12 <= 57) and (x13 >= 48 and x13 <= 57) do
  datetime__1(rest, [x13 - 48 + (x12 - 48) * 10, x11 - 48 + (x10 - 48) * 10, x9 - 48 + (x8 - 48) * 10, x7 - 48 + (x6 - 48) * 10, x5 - 48 + (x4 - 48) * 10, x3 - 48 + (x2 - 48) * 10 + (x1 - 48) * 100 + (x0 - 48) * 1000] ++ acc, stack, context, comb__line, comb__offset + 19)
end

defp datetime__0(rest, _acc, _stack, context, line, offset) do
  {:error, "expected byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by string \"-\", followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by string \"-\", followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by string \"T\", followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by string \":\", followed by byte in the range ?0..?9, followed by byte in the range ?0..?9, followed by string \":\", followed by byte in the range ?0..?9, followed by byte in the range ?0..?9", rest, context, line, offset}
end

defp datetime__1(<<"Z", rest::binary>>, acc, stack, context, comb__line, comb__offset) do
  datetime__2(rest, ["Z"] ++ acc, stack, context, comb__line, comb__offset + 1)
end

defp datetime__1(rest, acc, stack, context, line, offset) do
  datetime__2(rest, acc, stack, context, line, offset)
end

defp datetime__2(rest, acc, _stack, context, line, offset) do
  {:ok, acc, rest, context, line, offset}
end

コードを軽く解説

先ほどのコードをコメント付きで再掲します。


defmodule NimbleParsecDemo do
  import NimbleParsec

  date =
    integer(4)  # 整数4個
    |> ignore(string("-"))  # - を無視
    |> integer(2)  # 整数2個
    |> ignore(string("-"))  # - を無視
    |> integer(2) # 整数2個

  time =
    integer(2)  # 整数2個
    |> ignore(string(":"))  # : を無視
    |> integer(2)  # 整数2個
    |> ignore(string(":"))  # : を無視
    |> integer(2)  # 整数2個
    |> optional(string("Z"))  # 文字列Zがあればマッチ(必須ではない(optional))

  defparsec :datetime, date |> ignore(string("T")) |> concat(time), debug: true

end

defparsecを読むと「:datetime という名前で定義して、処理の中身は 日付部分を処理 して、文字列Tを無視(ignore) して、さらに 時刻部分の処理 をつなげている(concat)っぽい」というのが読み取れると思います。

このように、datetimeのような処理を組み合わせて(まさにcombineさせて?)datetimeという処理を定義しています。

個人的にはElixirらしい(というより、関数型らしい?)すごく直感的なコードだと感じます。

ログのパース処理やクエリストリングのパースなどで威力を発揮しそうです。

書き方の例をもっとみたい方は、ライブラリのリポジトリにサンプルが公開されていますので、参照されてください。

https://github.com/plataformatec/nimble_parsec/tree/master/examples

Tips

先ほどのコードで datetime をコード内にべたっと書いていたのが若干気持ち悪かったのですが、別モジュールに逃がすことも可能です。

コード例は以下のuse-casesの章あたりに記載されています。

https://hexdocs.pm/nimble_parsec/NimbleParsec.html#parsec/2-use-cases

このように、defparsec以外を別モジュールに切ってあげれば、見通しがよくなりそうですね。

defmodule MyParser.Helpers do
  import NimbleParsec

  def date do
    integer(4)
    |> ignore(string("-"))
    |> integer(2)
    |> ignore(string("-"))
    |> integer(2)
  end

  def time do
    integer(2)
    |> ignore(string(":"))
    |> integer(2)
    |> ignore(string(":"))
    |> integer(2)
    |> optional(string("Z"))
  end
end

defmodule MyParser do
  import NimbleParsec
  import MyParser.Helpers

  defparsec :datetime,
            date() |> ignore(string("T")) |> concat(time())
end

まとめ

パーサ書くの、むずたのしいですね!これから初めて利用する人はNimbleParsecで始めるのが良さそうです!

おまけ

この記事を書くにあたりdocを読んでいたところ、typoを発見したのでプルリク出したら通りました、やったね😎

https://github.com/plataformatec/nimble_parsec/pull/57

コミュニティに貢献できるようにもっと精進してまいります🙇