lib/expression/eval.ex

defmodule Expression.Eval do
  @moduledoc """
  Expression.Eval is responsible for taking an abstract syntax
  tree (AST) as generated by Expression.Parser and evaluating it.

  At a high level, an AST consists of a Keyword list with two top-level
  keys, either `:text` or `:expression`.

  `Expression.Eval.eval!/3` will return the output for each entry in the Keyword
  list. `:text` entries are returned as regular strings. `:expression` entries
  are returned as typed values.

  The returned value is a list containing each.

  # Example

      iex(1)> Expression.Eval.eval!([text: "hello"], %{})
      ["hello"]
      iex(2)> Expression.Eval.eval!([text: "hello", expression: [literal: 1]], %{})
      ["hello", 1]
      iex(3)> Expression.Eval.eval!([
      ...(3)>   text: "hello",
      ...(3)>   expression: [literal: 1],
      ...(3)>   text: "ok",
      ...(3)>   expression: [literal: true]
      ...(3)> ], %{})
      ["hello", 1, "ok", true]

  """

  alias Expression.DateHelpers

  @numeric_kernel_operators [:+, :-, :*, :/, :>, :>=, :<, :<=]
  @kernel_operators @numeric_kernel_operators ++ [:==, :!=]
  @allowed_nested_function_arguments [:function, :lambda] ++ @kernel_operators

  def eval!(ast, context, mod \\ Expression.Callbacks)

  def eval!({:expression, [ast]}, context, mod) do
    eval!(ast, context, mod)
  end

  def eval!({:atom, atom}, {:not_found, history}, _mod),
    do: {:not_found, history ++ [atom]}

  def eval!({:atom, atom}, context, _mod) when is_map(context) do
    Map.get(context, atom, {:not_found, [atom]})
  end

  def eval!({:atom, _atom}, _context, _mod), do: nil

  def eval!({:attribute, [{:attribute, ast}, literal: literal]}, context, mod) do
    # When we receive a key for an attribute, at times this could be a literal.
    # The assumption is that all attributes are going to be string based so if we receive
    # "@foo.123.bar", `123` will be parsed as a literal but the assumption is that the
    # context will look like:
    #
    # %{"foo" => %{
    #   "123" => %{   <--- notice the string key here
    #     "bar" => "the value"
    #   }
    # }}
    eval!({:attribute, [{:attribute, ast}, atom: to_string(literal)]}, context, mod)
  end

  def eval!({:attribute, ast}, context, mod) do
    Enum.reduce(ast, context, &eval!(&1, &2, mod))
  end

  def eval!({:function, opts}, context, mod) do
    function_name = opts[:name] || raise "Functions need a name"
    args = opts[:args] || []
    arguments = Enum.reduce_while(args, [], &args_reducer(&1, function_name, context, mod, &2))

    case mod.handle(function_name, arguments, context) do
      {:ok, value} -> value
      {:error, reason} -> "ERROR: #{inspect(reason)}"
    end
  end

  def eval!({:lambda, [{:args, ast}]}, context, mod) do
    fn arguments ->
      lambda_context = Map.put(context, "__captures", arguments)

      eval!(ast, lambda_context, mod)
    end
  end

  def eval!({:capture, index}, context, _mod) do
    Enum.at(Map.get(context, "__captures"), index - 1)
  end

  def eval!({:range, [first, last]}, _context, _mod),
    do: Range.new(first, last)

  def eval!({:range, [first, last, step]}, _context, _mod),
    do: Range.new(first, last, step)

  def eval!({:list, []}, _context, _mod), do: []

  def eval!({:list, [{:args, ast}]}, context, mod) do
    ast
    |> Enum.reduce([], &[eval!(&1, context, mod) | &2])
    |> Enum.reverse()
    |> Enum.map(&not_founds_as_nil/1)
  end

  def eval!({:key, [subject_ast, key_ast]}, context, mod) do
    subject = eval!(subject_ast, context, mod)
    key = eval!(key_ast, context, mod)

    read_key_from_subject(subject, key)
  end

  def eval!({:literal, literal}, context, mod) when is_binary(literal) do
    Expression.evaluate_as_string!(literal, context, mod)
  end

  def eval!({:literal, literal}, _context, _mod), do: literal

  def eval!({:text, text}, _context, _mod), do: text

  def eval!({operator, [a, b]}, ctx, mod) when operator in @kernel_operators do
    a = eval!(a, ctx, mod)
    b = eval!(b, ctx, mod)
    op(operator, a, b)
  end

  def eval!({:^, [a, b]}, ctx, mod), do: :math.pow(eval!(a, ctx, mod), eval!(b, ctx, mod))
  def eval!({:&, [a, b]}, ctx, mod), do: [a, b] |> Enum.map_join("", &eval!(&1, ctx, mod))

  def eval!(ast, context, mod) do
    result =
      ast
      |> Enum.reduce([], fn ast, acc -> [eval!(ast, context, mod) | acc] end)
      |> Enum.reverse()

    case result do
      [result] -> result
      chunks -> chunks
    end
  end

  defp read_key_from_subject(_subject, {:not_found, _}), do: nil

  defp read_key_from_subject(subject, index)
       when is_number(index) and (is_list(subject) or is_map(subject)),
       do: get_in(subject, [Access.at(index)])

  defp read_key_from_subject(subject, range) when is_struct(range, Range) and is_list(subject),
    do: Enum.slice(subject, range)

  defp read_key_from_subject(subject, binary) when is_binary(binary) and is_map(subject),
    do: Map.get(subject, binary)

  defp read_key_from_subject(_subject, _other), do: nil

  # when acting on integer or Elixir literal numeric types
  def op(operator, a, b)
      when operator in @numeric_kernel_operators and
             (is_number(a) or is_float(a)) and
             (is_number(b) or is_float(b)) do
    [a, b] = Enum.map([a, b], &guard_type!(&1, :num))
    apply(Kernel, operator, [a, b])
  end

  def op(:>, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: DateTime.compare(a, b) == :gt

  def op(:>=, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: DateTime.compare(a, b) in [:gt, :eq]

  def op(:<, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: DateTime.compare(a, b) == :lt

  def op(:<=, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: DateTime.compare(a, b) in [:lt, :eq]

  def op(:==, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: DateTime.compare(a, b) == :eq

  def op(:=, a, b) when is_struct(a, DateTime) and is_struct(b, DateTime),
    do: Date.compare(a, b) == :eq

  def op(:>, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) == :gt

  def op(:>=, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) in [:gt, :eq]

  def op(:<, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) == :lt

  def op(:<=, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) in [:lt, :eq]

  def op(:==, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) == :eq

  def op(:=, a, b) when is_struct(a, Date) and is_struct(b, Date),
    do: Date.compare(a, b) == :eq

  # Support comparing a `Date`/`DateTime` value with an ISO8601 date/datetime string
  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  def op(operator, a, b)
      when operator in [:=, :==, :!=, :<, :<=, :>, :>=] and
             (((is_struct(a, Date) or is_struct(a, DateTime)) and is_binary(b)) or
                (is_binary(a) and (is_struct(b, Date) or is_struct(b, DateTime)))) do
    {date_a, date_b} =
      cond do
        is_struct(a, Date) -> {a, DateHelpers.extract_dateish(b)}
        is_struct(a, DateTime) -> {a, DateHelpers.extract_datetimeish(b)}
        is_struct(b, Date) -> {DateHelpers.extract_dateish(a), b}
        is_struct(b, DateTime) -> {DateHelpers.extract_datetimeish(a), b}
      end

    comparison_result =
      if is_struct(date_a, Date) do
        Date.compare(date_a, date_b)
      else
        DateTime.compare(date_a, date_b)
      end

    case {operator, comparison_result} do
      {:=, :eq} -> true
      {:==, :eq} -> true
      {:!=, :lt} -> true
      {:!=, :gt} -> true
      {:<, :lt} -> true
      {:<=, :lt} -> true
      {:<=, :eq} -> true
      {:>, :gt} -> true
      {:>=, :gt} -> true
      {:>=, :eq} -> true
      _ -> false
    end
  end

  # when acting on any other supported type but still expected to be numeric
  def op(operator, a, b) when operator in @numeric_kernel_operators do
    args =
      [a, b]
      |> Enum.map(&guard_type!(&1, :num))
      |> Enum.map(&default_value/1)

    apply(Kernel, operator, args)
  end

  # just leave it to the Kernel to figure out at this stage
  def op(operator, a, b) when operator in @kernel_operators do
    args = Enum.map([a, b], &default_value/1)
    apply(Kernel, operator, args)
  end

  @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.
  """
  def default_value(val, opts \\ [])
  def default_value(%{"__value__" => default_value}, _opts), do: default_value

  def default_value({:not_found, attributes}, opts) do
    if(opts[:handle_not_found], do: "@#{Enum.join(attributes, ".")}", else: nil)
  end

  def default_value(items, opts) when is_list(items),
    do: Enum.map(items, &default_value(&1, opts))

  def default_value(value, _opts), do: value

  def not_founds_as_nil({:not_found, _}), do: nil
  def not_founds_as_nil(other), do: other

  defp guard_type!(v, :num) when is_number(v), do: v

  defp guard_type!({:not_found, attributes}, :num),
    do: raise("attribute is not found: `#{Enum.join(attributes, ".")}`")

  defp guard_type!({:not_found, attributes}, _),
    do: raise("attribute is not found: `#{Enum.join(attributes, ".")}`")

  defp guard_type!(v, :num), do: raise("expression is not a number: `#{inspect(v)}`")

  def handle_not_found({:not_found, _}), do: nil
  def handle_not_found(value), do: value

  defp args_reducer({type, _args} = function, function_name, context, mod, acc)
       when type in @allowed_nested_function_arguments do
    value = eval!(function, context, mod)
    flag = if value == true && function_name == "or", do: :halt, else: :cont

    {flag, acc ++ [[literal: value]]}
  end

  defp args_reducer(function, _function_name, _context, _mod, acc),
    do: {:cont, acc ++ [function]}
end