lib/expression/v2.ex

defmodule Expression.V2 do
  @moduledoc """
  A second attempt at the parser, hopefully a little easier to read & maintain.

  `parse/1` parsed an Expression into AST.
  `eval/3` evaluates the given AST using the context supplied.

  For details on how this is done please read `Expression.V2.Parser` and
  `Expression.V2.Compile`.

  This parser & evaluator supports the following:

  * [strings](https://hexdocs.pm/elixir/typespecs.html#basic-types) either double or single quoted.
  * [integers](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `1`, `2`, `40`, `55`
  * [floats](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `3.141592653589793`
  * [booleans](https://hexdocs.pm/elixir/typespecs.html#basic-types) which can be written in any mixed case such as `tRue` or `TRUE`, `False` etc
  * `Range.t` such as `1..10`, also with steps `1..10//2`
  * `Date.t` such as `2022-01-01` which is parsed into `~D[2022-01-01]`
  * `Time.t` such as `10:30` which is parsed into `~T[10:30:00]`
  * ISO formatted `DateTime.t` such as `2022-05-24T00:00:00` which is parsed into `~U[2022-05-24 00:00:00.0Z]`
  * US formatted `DateTime.t` such as `01-02-2020 23:23:23` which is parsed into `~U[2020-02-01T23:23:23Z]`
  * Lists of any of the above, such as `[1, 2, 3]` or `[1, 1.234, "john"]`
  * Reading properties off of nested objects such as maps with a full stop, such as `contact.name` returning `"Doe"` from `%{"contact" => %{"name" => "Doe"}}`
  * Reading attributes off of maps, such as `contact[the_key]` which returns `"Doe"` from `%{"contact" => %{"name" => "Doe"}, "the_key" => "name"}`
  * Anonymous functions with `&` and `&1` as capture operators, `&(&1 + 1)` is an anonymous function that increments the input by 1.

  The result of a call to `eval/3` is a list of typed evaluated items. It is up to the integrating library to determine how
  best to convert these into a final end user representation.

  # Examples

      iex> alias Expression.V2
      iex> V2.eval("the date is @date(2022, 2, 20)")
      ["the date is ", ~D[2022-02-20]]
      iex> V2.eval("the answer is @true")
      ["the answer is ", true]
      iex> V2.eval("22 divided by 7 is @(22 / 7)")
      ["22 divided by 7 is ", 3.142857142857143]
      iex> V2.eval(
      ...>   "Hello @proper(contact.name)! Looking forward to meet you @date(2023, 2, 20)",
      ...>   V2.Context.new(%{"contact" => %{"name" => "mary"}})
      ...> )
      ["Hello ", "Mary", "! Looking forward to meet you ", ~D[2023-02-20]]
      iex> V2.eval("@map(1..3, &date(2023, 1, &1))")
      [[~D[2023-01-01], ~D[2023-01-02], ~D[2023-01-03]]]
      iex> V2.eval(
      ...>   "Here is the multiplication table of @number: @(map(1..10, &(&1 * number)))",
      ...>   V2.Context.new(%{"number" => 5})
      ...> )
      [
        "Here is the multiplication table of ",
        5,
        ": ",
        [5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
      ]

  """

  alias Expression.V2.Compile
  alias Expression.V2.Context
  alias Expression.V2.Parser

  @doc """
  Parse a string with expressions into AST for the compile step
  """
  @spec parse(String.t()) ::
          {:ok, [term]}
          | {:error, reason :: String.t(), bad_parts :: String.t()}
  def parse(expression) do
    case Parser.parse(expression) do
      {:ok, ast, "", _, _, _} ->
        {:ok, ast}

      {:ok, _ast, remaining, _, _, _} ->
        {:error, "Unable to parse remainder", remaining}
    end
  end

  @spec escape(String.t()) :: String.t()
  def escape(expression) when is_binary(expression) do
    String.replace(expression, ~r/@([a-z]+)(\(|\.)/i, "@@\\g{1}\\g{2}")
  end

  @doc """
  This function is referenced by `Expression.V2.Compile` to
  make access to values in Maps or Lists easier
  """
  @spec read_attribute(map | list, binary | integer) :: term
  def read_attribute(map, item) when is_map(map), do: Map.get(map, item)

  def read_attribute(list, index) when is_list(list) and is_integer(index),
    do: Enum.at(list, index)

  def read_attribute(list, range) when is_list(list) and is_struct(range, Range),
    do: Enum.slice(list, range)

  @doc """
  Parse a string with an expression block into AST for the compile step
  """
  @spec parse_block(String.t()) ::
          {:ok, [term]} | {:error, reason :: String.t(), bad_parts :: String.t()}
  def parse_block(expression_block) do
    case Parser.expression(expression_block) do
      {:ok, ast, "", _, _, _} -> {:ok, ast}
      {:ok, _ast, _remainder, _, _, _} -> {:error, "Unable to parse", expression_block}
      {:error, _ast, remaining, _, _, _} -> {:error, "Unable to parse remainder", remaining}
    end
  end

  @doc """
  Evaluate a string with an expression block against a context
  """
  @spec eval_block(String.t(), context :: Context.t()) ::
          term | {:error, reason :: String.t(), bad_parts :: String.t()}
  def eval_block(expression_block, context \\ Context.new())

  def eval_block(expression_block, map_context)
      when is_map(map_context) and not is_struct(map_context, Context) do
    eval_block(expression_block, Context.new(map_context))
  end

  def eval_block(expression_block, context) do
    with {:ok, ast} <- parse_block(expression_block) do
      hd(eval_block_ast(ast, context))
    end
  end

  @doc """
  Evaluate a string with expressions against a given context
  """
  @spec eval(expression :: String.t(), context :: Context.t()) :: [term]
  def eval(expression, context \\ Context.new())

  def eval(expression, map_context)
      when is_map(map_context) and not is_struct(map_context, Context) do
    eval(expression, Context.new(map_context))
  end

  def eval(expression, context) when is_binary(expression) do
    with {:ok, parsed_parts} <- parse(expression) do
      eval_ast(parsed_parts, context)
    end
  end

  @doc """
  Evaluate a parsed Expression against a given context
  """
  @spec eval_ast([term], context :: Context.t()) :: [term()]
  def eval_ast(parsed_parts, context \\ Context.new()) do
    Enum.flat_map(parsed_parts, fn
      binary when is_binary(binary) -> [binary]
      ast when is_list(ast) -> eval_block_ast(ast, context)
    end)
  end

  @doc """
  Evaluate the given AST against a given context
  """
  @spec eval_block_ast([term], context :: Context.t()) :: [term]
  def eval_block_ast(ast, context) when is_list(ast) do
    function = Compile.compile(ast)
    resp = function.(context)

    if is_binary(resp) do
      # NOTE: if the response was a binary, the user is expecting a
      #       a string to be returned so make sure we do that.
      [eval_as_string(resp, context)]
    else
      [resp]
    end
  end

  @doc """
  Evaluate an expression and cast all items to strings before joining
  the full result into a single string value to be returned.

  This calls `eval/2` internally, maps the results with `default_value/2`
  followed by `stringify/1` and then joins them.
  """
  @spec eval_as_string(String.t(), Context.t()) :: String.t()
  def eval_as_string(expression, context \\ Context.new()) do
    {:ok, ast} = parse(expression)

    ast
    |> eval_ast(context)
    |> Enum.zip(ast)
    |> Enum.map_join("", fn
      {nil, [{"__property__", _parts} = property]} ->
        "@" <> unwrap_property(property)

      {value, _ast} ->
        value
        |> default_value(context)
        |> stringify()
    end)
  end

  defp unwrap_property({"__property__", parts}),
    do: Enum.map_join(parts, ".", &unwrap_property/1)

  defp unwrap_property(parts) when is_list(parts),
    do: Enum.map_join(parts, ".", &unwrap_property/1)

  defp unwrap_property(part), do: part

  @doc """
  Return the default value for a potentially complex value.

  Complex values can be Maps that have a `__value__` key, if that's
  returned then we can to use the `__value__` value when eval'ing against
  operators or functions.
  """
  @spec default_value(term) :: term
  def default_value(val, context \\ nil)
  def default_value(%{"__value__" => default_value}, _context), do: default_value
  def default_value(value, _context), do: value

  @spec stringify(term) :: String.t()
  def stringify(items) when is_list(items), do: Enum.map_join(items, "", &stringify/1)
  def stringify(binary) when is_binary(binary), do: binary
  def stringify(%DateTime{} = date), do: DateTime.to_iso8601(date)
  def stringify(%Date{} = date), do: Date.to_iso8601(date)
  def stringify(map) when is_map(map), do: "#{inspect(map)}"
  def stringify(other), do: to_string(other)

  @spec compile(expression :: String.t()) :: [term]
  def compile(expression) when is_binary(expression) do
    with {:ok, parts} <- parse(expression),
         parts <- Enum.map([parts], &compile_block/1) do
      hd(parts)
    end
  end

  def compile_block({function_name, arguments})
      when is_binary(function_name) and is_list(arguments) do
    [{function_name, arguments}]
    |> Compile.compile()
    |> compile_block()
  end

  def compile_block(final), do: final

  @doc """
  Return the code generated for the Abstract Syntax tree or
  Expression string provided.
  """
  @spec debug(String.t() | [term]) :: String.t()
  def debug(expression) when is_binary(expression) do
    with {:ok, ast, "", _, _, _} <- Parser.expression(expression) do
      debug(ast)
    end
  end

  def debug(ast) do
    ast
    |> Compile.to_quoted()
    |> Compile.wrap_in_context()
    |> Macro.to_string()
  end
end