lib/expression/parser.ex

defmodule Expression.Parser do
  @moduledoc """
  Expression.Parser is responsible for accepting a string
  containing an expression and returning the abstract syntax
  tree (AST) representing the expression.

  The AST generated by this module can be evaluated by
  Expression.Eval

  # Example

    iex(1)> Expression.Parser.parse("hello @world")
    {:ok, [text: "hello ", expression: [atom: "world"]], "", %{}, {1, 0}, 12}

  """
  import NimbleParsec
  import Expression.BooleanHelpers
  import Expression.DateHelpers
  import Expression.LiteralHelpers
  import Expression.OperatorHelpers

  # literal = 1, 2.1, "three", 'four', true, false, ISO dates
  defparsec(
    :literal,
    choice([
      datetime(),
      decimal(),
      int(),
      boolean(),
      single_quoted_string(),
      double_quoted_string()
    ])
    |> unwrap_and_tag(:literal)
  )

  # atom = atom
  atom =
    ascii_string([?a..?z, ?A..?Z, ?0..?9], min: 1)
    |> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-], min: 0)
    |> map({String, :downcase, []})
    |> reduce({Enum, :join, []})

  range =
    int()
    |> ignore(string(".."))
    |> concat(int())
    |> optional(
      ignore(string("//"))
      |> concat(int())
    )
    |> tag(:range)

  whitespace = choice([string(" "), string("\n"), string("\r")])

  ignore_surrounding_whitespace = fn p ->
    ignore(repeat(whitespace))
    |> concat(p)
    |> ignore(repeat(whitespace))
  end

  # argument separator = ", "
  argument_separator =
    string(",")
    |> ignore_surrounding_whitespace.()

  lambda_capture =
    ignore(string("&"))
    |> concat(int())
    |> unwrap_and_tag(:capture)

  # arguments = expression "," expression
  defparsec(
    :arguments,
    parsec(:aexpr)
    |> optional(ignore(argument_separator) |> parsec(:arguments))
  )

  primitives =
    choice([
      lambda_capture,
      range,
      parsec(:lambda),
      parsec(:function),
      parsec(:literal),
      parsec(:variable),
      parsec(:list)
    ])

  defparsec(
    :aexpr_factor,
    choice([
      primitives,
      ignore(string("(")) |> parsec(:aexpr) |> ignore(string(")"))
    ])
    |> ignore_surrounding_whitespace.()
  )

  defparsec(
    :key,
    ignore(ascii_char([91]))
    |> replace(:key)
    |> parsec(:aexpr)
    |> ignore(ascii_char([93]))
    |> label("[..]")
  )

  defparsec(
    :attribute,
    ascii_char([?.])
    |> replace(:attribute)
    |> label(".")
  )

  attribute_or_key =
    repeat(
      choice([
        parsec(:attribute) |> parsec(:aexpr_factor),
        parsec(:key)
      ])
    )

  # The difference between this one and the one above
  # is that this one does not allow for spaces or arithmatic
  # which makes it suitable for use in `@foo` type expressions
  # because otherwise `info@support.com for` (note the space)
  # is parsed as being part of the expression.
  #
  # That would be wrong since spaces are only allowed in
  # expressions starting with brackets like `@( ... )`
  attribute_or_key_with_primitives_only =
    repeat(
      choice([
        parsec(:attribute) |> concat(primitives),
        parsec(:key)
      ])
    )

  defparsec(
    :aexpr_exponent,
    parsec(:aexpr_factor)
    |> optional(attribute_or_key)
    |> repeat(
      exponent()
      |> parsec(:aexpr_factor)
      |> optional(attribute_or_key)
    )
    |> reduce(:fold_infixl)
  )

  defparsec(
    :aexpr_term,
    parsec(:aexpr_exponent)
    |> repeat(
      choice([times(), divide()])
      |> ignore_surrounding_whitespace.()
      |> parsec(:aexpr_exponent)
    )
    |> reduce(:fold_infixl)
  )

  defparsec(
    :aexpr,
    parsec(:aexpr_term)
    |> repeat(
      choice([
        plus(),
        minus(),
        concatenate(),
        gte(),
        lte(),
        neq(),
        gt(),
        lt(),
        eq()
      ])
      |> parsec(:aexpr_term)
      |> ignore_surrounding_whitespace.()
    )
    |> reduce(:fold_infixl)
  )

  def fold_infixl(acc) do
    acc
    |> Enum.reverse()
    |> Enum.chunk_every(2)
    |> List.foldr([], fn
      [l], [] -> l
      [r, op], l -> {op, [l, r]}
    end)
  end

  defparsec(
    :list,
    ignore(string("["))
    |> optional(parsec(:arguments) |> tag(:args))
    |> ignore(string("]"))
    |> tag(:list)
  )

  # function  = "(" arguments ")"
  defparsec(
    :function,
    atom
    |> unwrap_and_tag(:name)
    |> ignore(string("("))
    |> optional(parsec(:arguments) |> tag(:args))
    |> ignore(string(")"))
    |> tag(:function)
  )

  defparsec(
    :lambda,
    ignore(string("&"))
    |> optional(parsec(:arguments) |> tag(:args))
    |> tag(:lambda)
  )

  # variable = atom
  defparsec(
    :variable,
    atom
    |> unwrap_and_tag(:atom)
  )

  expression_block =
    ignore(string("@"))
    |> lookahead_not(string("@"))
    |> ignore(string("("))
    |> parsec(:aexpr)
    |> ignore(string(")"))
    |> tag(:expression)

  expression =
    ignore(string("@"))
    |> lookahead_not(string("@"))
    |> repeat(
      choice([
        parsec(:list),
        parsec(:function),
        parsec(:variable)
      ])
      |> optional(attribute_or_key_with_primitives_only)
    )
    |> reduce(:fold_infixl)
    |> tag(:expression)

  escaped_at =
    ignore(string("@"))
    |> string("@")
    |> unwrap_and_tag(:text)

  text =
    empty()
    |> lookahead_not(string("@"))
    |> utf8_string([], 1)
    |> times(min: 1)
    |> reduce({Enum, :join, []})
    |> unwrap_and_tag(:text)

  defparsec(:parse, repeat(choice([expression_block, expression, escaped_at, text])))
end