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(¬_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