lib/abnf_parsec.ex

defmodule AbnfParsec do
  alias AbnfParsec.{Parser, Generator}

  @moduledoc """
  Generates a parser from ABNF definition - text (`:abnf`) or file path (`:abnf_file`)

  An entry rule can be defined by `:parse`. If defined, a `parse/1` function and a
  `parse!/1` function will be generated with the entry rule.

  By default, every chunk defined by a rule is wrapped (in list) and tagged by the
  rulename. Use the options to `:untag`, `:unwrap` or both (`:unbox`).

  Parsed chunks (rules) can be discarded `:ignore`.

  Transformations (`:map`, `:reduce`, `:replace`) can be applied by passing in a
  `:transform` map with keys being rulenames and values being 2-tuples of
  transformation type (`:map`, `:reduce`, `:replace`), and mfa tuple (for `:map` and `reduce`)
  or a literal value (for `:replace`)

  Example usage:

      defmodule JsonParser do
        use AbnfParsec,
          abnf_file: "test/fixture/json.abnf",
          parse: :json_text,
          transform: %{
            "string" => {:reduce, {List, :to_string, []}},
            "int" => [{:reduce, {List, :to_string, []}}, {:map, {String, :to_integer, []}}],
            "frac" => {:reduce, {List, :to_string, []}},
            "null" => {:replace, nil},
            "true" => {:replace, true},
            "false" => {:replace, false}
          },
          untag: ["member"],
          unwrap: ["int", "frac"],
          unbox: [
            "JSON-text",
            "null",
            "true",
            "false",
            "digit1-9",
            "decimal-point",
            "escape",
            "unescaped",
            "char"
          ],
          ignore: [
            "name-separator",
            "value-separator",
            "quotation-mark",
            "begin-object",
            "end-object",
            "begin-array",
            "end-array"
          ]
      end

      json = ~s| {"a": {"b": 1.2, "c": [true]}, "d": null, "e": "e\\te"} |

      JsonParser.json_text(json)
      # or
      JsonParser.parse(json)
      # => {:ok, ...}
      JsonParser.parse!(json)
      # =>

      [
        object: [
          [
            string: ["a"],
            value: [
              object: [
                [string: ["b"], value: [number: [int: 1, frac: ".2"]]],
                [string: ["c"], value: [array: [value: [true]]]]
              ]
            ]
          ],
          [string: ["d"], value: [nil]],
          [string: ["e"], value: [string: ["e\\te"]]]
        ]
      ]
  """

  @type rulename :: :binary
  @type rulenames :: [rulename()]
  @type transformation :: {:replace | :reduce | :map, mfa :: {module, atom, [term]}}
  @type transformations :: %{optional(rulename) => transformation | [transformation]}

  @doc """
  All rules by default are wrapped and tagged. See NimbleParsec for more details.

  Options:

    - `:abnf` (`binary`) - ABNF directly in string data
    - `:abnf_file` (`binary`) - ABNF file path
    - `:debug` (`boolean`) - whether to output generated parser code

    - `:skip` (`rulenames`) - rules to be skipped when generating parser code
      - so user can define their own parsec definition
    - `:ignore` (`rulenames`) - rules to be discarded after being parsed
    - `:untag` (`rulenames`) - rules to be wrapped only after being parsed
    - `:unwrap` (`rulenames`) - rules to be tagged only after being parsed
      - needs to be sure that there is only singular parsed entry
    - `:unbox` (`rulenames`) - made up term, to both untag and unwrap
    - `:transform` (`transformations`) - a map of `rulenames` to transformations
      - `{:map, mfa}`
      - `{:reduce, mfa}`
      - `{:replace, val}`
      - `{:post_traverse, mfa}`
      - `{:pre_traverse, mfa}`
    - `:parse` (`atom`) - described in module doc, needs to be an atom that is in
      normalized form of its original string rulename
  """
  defmacro __using__(opts) do
    abnf =
      case Keyword.fetch(opts, :abnf_file) do
        {:ok, filepath} -> File.read!(filepath)
        :error -> Keyword.fetch!(opts, :abnf)
      end

    debug? = Keyword.get(opts, :debug, false)

    parse = Keyword.get(opts, :parse)

    opts =
      opts
      |> Enum.into(%{})
      |> Map.update(:transform, %{}, &elem(Code.eval_quoted(&1), 0))

    code =
      abnf
      |> Parser.parse!()
      |> Generator.generate(opts)

    if debug? do
      code
      |> Macro.to_string()
      |> Code.format_string!()
      |> IO.puts()
    end

    quote do
      import NimbleParsec

      unquote(Generator.core())

      unquote(code)

      if unquote(parse) do
        def parse(text) do
          text |> unquote({parse, [], []})
        end

        def parse!(text) do
          case parse(text) do
            {:ok, syntax, "", _, _, _} ->
              syntax

            {:ok, _, leftover, _, _, _} ->
              raise AbnfParsec.LeftoverTokenError, "Leftover: #{leftover}"

            {:error, error, _, _, _, _} ->
              raise AbnfParsec.UnexpectedTokenError, error
          end
        end
      end
    end
  end
end