lib/formulae.ex

defmodule Formulae do
  @moduledoc ~S"""
  A set of functions to deal with analytical formulae.

  The typical way of using this module would be to call `Formulae.compile/1`
  on the binary representing the string.

  ```elixir
  iex|1 ▶ f = Formulae.compile "a + :math.sin(3.14 * div(b, 2)) - c"

  %Formulae{
    ast: {:-, [line: 1],
    [
      {:+, [line: 1],
        [
          {:a, [line: 1], nil},
          {{:., [line: 1], [:math, :sin]}, [line: 1],
          [{:*, [line: 1], [3.14, {:div, [line: 1], [{:b, [line: 1], nil}, 2]}]}]}
        ]},
      {:c, [line: 1], nil}
    ]},
    eval: &:"Elixir.Formulae.a + :math.sin(3.14 * div(b, 2)) - c".eval/1,
    formula: "a + :math.sin(3.14 * div(b, 2)) - c",
    module: :"Elixir.Formulae.a + :math.sin(3.14 * div(b, 2)) - c",
    variables: [:a, :b, :c]
  }
  ```

  Now the formula is compiled and might be invoked by calling `Formulae.eval/2`
  passing a formula _and_ bindings. First call to `eval/2` would lazily compile
  the module if needed.

  ```elixir
  iex|2 ▶ f.eval.(a: 3, b: 4, c: 2)
  0.9968146982068622
  ```

  The formulae might be curried.

  ```elixir
  iex|3 ▶ Formulae.curry(f, a: 3, b: 4)
  %Formulae{
    ast: ...,
    eval: &:"Elixir.Formulae.3 + :math.sin(3.14 * div(4, 2)) - c".eval/1,
    formula: "3 + :math.sin(3.14 * div(4, 2)) - c",
    module: :"Elixir.Formulae.3 + :math.sin(3.14 * div(4, 2)) - c",
    variables: [:c]
  }
  ```
  """

  @typedoc """
  The formulae is internally represented as struct, exposing the original
  binary representing the formula, AST, the module this formula was compiled
  into, variables (bindings) this formula has _and_ the evaluator, which is
  the function of arity one, accepting the bindings as a keyword list and
  returning the result of this formula application.
  """
  @type t :: %{
          __struct__: atom(),
          formula: binary(),
          ast: nil | Macro.t(),
          guard: nil | Macro.t(),
          module: nil | atom(),
          variables: nil | [atom()],
          eval: nil | (keyword() -> any())
        }
  defstruct formula: nil, ast: nil, guard: nil, module: nil, eval: nil, variables: nil

  @typedoc false
  @type option :: {:eval, :function | :guard} | {:alias, module()}
  @typedoc false
  @type options :: [option()]

  @doc """
  Evaluates the formula returning the result back.

  _Examples:_

      iex> Formulae.eval("rem(a, 5) + rem(b, 4) == 0", a: 20, b: 20)
      true
      iex> Formulae.eval("rem(a, 5) == 0", a: 21)
      false
      iex> Formulae.eval("rem(a, 5) + rem(b, 4)", a: 21, b: 22)
      3
  """
  @spec eval(input :: binary() | Formulae.t(), bindings :: keyword()) :: term() | {:error, any()}
  def eval(input, bindings \\ [])
  def eval(%Formulae{eval: eval}, bindings), do: eval.(bindings)

  def eval(input, bindings) when is_binary(input),
    do: input |> Formulae.compile() |> eval(bindings)

  @doc """
  Evaluates the formula returning the result back; throws in a case of unseccessful processing.

  _Examples:_

      iex> Formulae.eval!("rem(a, 5) == 0", a: 20)
      true
      iex> Formulae.eval!("rem(a, 5) == 0")
      ** (Formulae.RunnerError) Formula failed to run (compile): [:missing_arguments] wrong or incomplete eval call: [given_keys: [], expected_keys: [:a]].
  """
  @spec eval!(input :: binary() | Formulae.t(), bindings :: keyword()) :: term() | no_return()
  def eval!(input, bindings \\ []) do
    with {:error, {error, data}} <- Formulae.eval(input, bindings) do
      raise(
        Formulae.RunnerError,
        formula: input,
        error: {:compile, "[#{inspect(error)}] wrong or incomplete eval call: #{inspect(data)}"}
      )
    end
  end

  @doc """
  Checks whether the formula was already compiled into module.

  Typically one does not need to call this function, since this check would be
  nevertheless transparently performed before the evaluation.

  _Examples:_

      iex> Formulae.compiled?("foo > 42")
      false
      iex> Formulae.compile("foo > 42")
      iex> Formulae.compiled?("foo > 42")
      true
  """
  @spec compiled?(binary() | Formulae.t(), options :: options()) :: boolean()
  def compiled?(input, options \\ [])

  def compiled?(input, options) when is_binary(input) and is_list(options),
    do: input |> module_name(options) |> Code.ensure_loaded?()

  def compiled?(%Formulae{module: nil}, _), do: false
  def compiled?(%Formulae{module: _}, _), do: true

  @doc """
  Checks whether the formula was already compiled into module.
  Similar to `compiled?/1`, but returns what `Code.ensure_compiled/1` returns.

  Typically one does not need to call this function, since this check would be
  nevertheless transparently performed before the evaluation.

  _Examples:_

      iex> Formulae.ensure_compiled("bar > 42")
      {:error, :nofile}
      iex> Formulae.compile("bar > 42")
      iex> Formulae.ensure_compiled("bar > 42")
      {:module, :"Elixir.Formulae.bar > 42"}
  """
  @spec ensure_compiled(binary() | Formulae.t(), options :: options()) ::
          {:module, module()}
          | {:error, :embedded | :badfile | :nofile | :on_load_failure | :unavailable}
  def ensure_compiled(input, options \\ [])

  def ensure_compiled(input, options) when is_binary(input) and is_list(options),
    do: input |> module_name(options) |> Code.ensure_compiled()

  def ensure_compiled(%Formulae{module: nil}, _), do: {:error, :unavailable}
  def ensure_compiled(%Formulae{module: module}, _), do: {:module, module}

  @doc """
  Compiles the formula into module.

  _Examples:_

      iex> f = Formulae.compile("rem(a, 5) - b == 0")
      iex> f.formula
      "rem(a, 5) - b == 0"
      iex> f.variables
      [:a, :b]
      iex> f.module
      :"Elixir.Formulae.rem(a, 5) - b == 0"
      iex> f.module.eval(a: 12, b: 2)
      true

      iex> f = Formulae.compile("rem(a, 5) + b == a")
      iex> f.variables
      [:a, :b]
      iex> f.eval.(a: 7, b: 5)
      true
      iex> f.eval.(a: 7, b: 0)
      false
  """
  @spec compile(Formulae.t() | binary(), options :: options()) :: Formulae.t()
  def compile(input, options \\ [])

  def compile(input, options) when is_binary(input) and is_list(options) do
    input
    |> ensure_compiled(options)
    |> maybe_create_module(input, options)
  end

  def compile(%Formulae{formula: input}, options), do: compile(input, options)

  @doc """
  Purges and discards the module for the formula given (if exists.)
  """
  @spec purge(Formulae.t() | binary(), options()) ::
          :ok | {:error, :not_compiled} | {:error, :code_delete}
  def purge(input, options \\ [])

  def purge(input, options) when is_binary(input) and is_list(options),
    do: input |> module_name(options) |> do_purge()

  def purge(%Formulae{module: nil}, _), do: {:error, :not_compiled}
  def purge(%Formulae{module: mod}, _), do: do_purge(mod)

  @spec do_purge(atom()) :: :ok | {:error, :not_compiled} | {:error, :code_delete}
  defp do_purge(mod) do
    :code.purge(mod)
    if :code.delete(mod), do: :ok, else: {:error, :code_delete}
  end

  @doc ~S"""
  Curries the formula by substituting the known bindings into it.

  ## Example

      iex> Formulae.curry("(temp - foo * 4) > speed / 3.14", temp: 7, speed: 3.14).formula
      "7 - foo * 4 > 3.14 / 3.14"
  """
  @spec curry(input :: Formulae.t() | binary(), binding :: keyword(), opts :: keyword()) ::
          Formulae.t()
  def curry(input, binding \\ [], opts \\ [])

  def curry(input, binding, opts) when is_binary(input) do
    {ast, vars} = ast_and_variables(input, binding)
    %Formulae{variables: ^vars} = Formulae.compile(Macro.to_string(ast), opts)
  end

  def curry(%Formulae{formula: formula}, binding, opts) when is_binary(formula),
    do: curry(formula, binding, opts)

  @spec maybe_create_module(
          {:module, atom()} | {:error, any()},
          input :: binary(),
          options :: options()
        ) ::
          Formulae.t()
  defp maybe_create_module({:module, module}, input, options) do
    eval = Keyword.get(options, :eval, :function)

    compatible =
      module == Keyword.get(options, :alias, module) and
        ((eval == :function and is_nil(module.guard_ast())) or
           (eval == :guard and not is_nil(module.guard_ast())))

    if not compatible,
      do:
        raise(Formulae.RunnerError,
          formula: input,
          error: {:incompatible_options, inspect(options)}
        )

    %Formulae{
      formula: input,
      module: module,
      ast: module.ast(),
      guard: module.guard_ast(),
      variables: module.variables(),
      eval: &module.eval/1
    }
  end

  defp maybe_create_module({:error, _}, input, options) do
    {:ok, macro} = Code.string_to_quoted(input)
    eval_kind = Keyword.get(options, :eval, :function)

    {^macro, variables} =
      Macro.prewalk(macro, [], fn
        {var, _, nil} = v, acc -> {v, [var | acc]}
        v, acc -> {v, acc}
      end)

    escaped = Macro.escape(macro)
    variables = variables |> Enum.reverse() |> Enum.uniq()

    guard = do_guard(eval_kind, variables, macro, input)
    guard_ast = Macro.escape(guard)

    eval = do_eval(eval_kind, variables, macro)

    ast = [
      guard,
      quote generated: true do
        @variables unquote(variables)

        def ast, do: unquote(escaped)
        def guard_ast, do: unquote(guard_ast)
        def variables, do: @variables
      end,
      eval
    ]

    {:module, module, _, _} = Module.create(module_name(input, options), ast, __ENV__)

    %Formulae{
      formula: input,
      ast: macro,
      module: module,
      guard: guard,
      variables: variables,
      eval: &module.eval/1
    }
  end

  ##############################################################################

  @doc deprecated: "Use `Formulae.eval/2` instead"

  @doc ~S"""
  Revalidates the formula with bindings given. Returns true if the formula
  strictly evaluates to `true`, `false` otherwise. Compiles the formula
  before evaluation if needed.
  """
  @spec check(string :: binary(), bindings :: keyword()) :: boolean()
  def check(string, bindings \\ []) do
    Formulae.eval(string, bindings)
  rescue
    Formulae.RunnerError -> false
  end

  @doc deprecated: "Use `Formulae.compile/1` and `%Formulae{}.variables` instead"

  @doc ~S"""
  Returns a normalized representation for the formula given.
  """
  def normalize(input) when is_binary(input) do
    with {normalized, {operation, _env, [formula, value]}} <- unit(input),
         bindings <- bindings?(formula) do
      {normalized, {operation, formula, value}, bindings}
    else
      _ -> raise(Formulae.SyntaxError, formula: input, error: {:unknown, inspect(input)})
    end
  end

  @spec ast_and_variables(input :: binary() | Formulae.t(), binding :: keyword()) ::
          {tuple(), keyword()}
  defp ast_and_variables(input, binding) when is_binary(input) do
    input
    |> Formulae.compile()
    |> ast_and_variables(binding)
  end

  defp ast_and_variables(%Formulae{ast: nil, formula: input}, binding),
    do: ast_and_variables(input, binding)

  defp ast_and_variables(%Formulae{ast: ast}, binding) do
    {ast, vars} =
      Macro.prewalk(ast, [], fn
        {var, _, nil} = v, acc when is_atom(var) ->
          if Keyword.has_key?(binding, var),
            do: {Keyword.fetch!(binding, var), acc},
            else: {v, [var | acc]}

        v, acc ->
          {v, acc}
      end)

    {ast, vars |> Enum.reverse() |> Enum.uniq()}
  end

  @doc deprecated:
         "Use `Formulae.compile/1` and `%Formulae{}.variables` or `Formula.curry/2` instead"

  @doc ~S"""
  Returns the binding this formula requires.

  ## Examples

      iex> "a > 5" |> Formulae.bindings?
      ~w|a|a

      iex> ":math.sin(a / (3.14 * b)) > c" |> Formulae.bindings?
      ~w|a b c|a

      iex> "a + b * 4 - :math.pow(c, 2) / d > 1.0 * e" |> Formulae.bindings?
      ~w|a b c d e|a
  """
  @spec bindings?(formula :: Formulae.t() | binary() | tuple(), binding :: keyword()) :: keyword()
  def bindings?(formula, bindings \\ [])

  def bindings?(%Formulae{variables: variables}, []),
    do: variables

  def bindings?(formula, bindings) when is_binary(formula),
    do: with(f <- Formulae.curry(formula, bindings), do: f.variables)

  def bindings?(formula, bindings) when is_tuple(formula),
    do: bindings?(Macro.to_string(formula), bindings)

  def bindings?(%Formulae{formula: formula}, bindings),
    do: bindings?(formula, bindings)

  ##############################################################################

  # @comparison [:<, :>, :=] # Damn it, José! :≠
  # @booleans   [:&, :|]

  ##############################################################################

  @deprecated "Use `Formulae.eval/2` instead"

  @doc ~S"""
  Produces the normalized representation of formula. If the _rho_ is
  an instance of [`Integer`](http://elixir-lang.org/docs/stable/elixir/Integer.html#content)
  or [`Float`](http://elixir-lang.org/docs/stable/elixir/Float.html#content),
  it’s left intact, otherwise it’s moved to the left side with negation.

  ## Examples

      iex > Formulae.unit("3 > 2")
      {"3 > 2", {:>, [], [3, 2]}}

      iex > Formulae.unit("3 - a > 2")
      {"3 - a > 2", {:>, [], [{:-, [line: 1], [3, {:a, [line: 1], nil}]}, 2]}}

      iex > Formulae.unit("3 > A + 2")
      {"3 > a + 2",
        {:>, [],
          [{:-, [context: Formulae, import: Kernel],
            [3, {:+, [line: 1], [{:a, [line: 1], nil}, 2]}]}, 0]}}

      iex > Formulae.unit("3 >= a + 2")
      {"3 >= a + 2",
        {:>=, [],
          [{:-, [context: Formulae, import: Kernel],
            [3, {:+, [line: 1], [{:a, [line: 1], nil}, 2]}]}, 0]}}

      iex > Formulae.unit("3 a > A + 2")
      ** (Formulae.SyntaxError) Formula [3 a > A + 2] syntax is incorrect (parsing): syntax error before: “a”.

      iex > Formulae.unit("a + 2 = 3")
      {"a + 2 = 3", {:==, [], [{:+, [line: 1], [{:a, [line: 1], nil}, 2]}, 3]}}

      iex > Formulae.unit(~S|A = "3"|)
      {"a = \"3\"", {:==, [], [{:a, [line: 1], nil}, "3"]}}
  """
  # credo:disable-for-lines:50
  def unit(input, env \\ []) when is_binary(input) do
    normalized = String.downcase(input)

    {
      normalized,
      case Code.string_to_quoted(normalized) do
        {:ok, {:>, _, [lh, rh]}} when is_integer(rh) or is_float(rh) ->
          {:>, env, [lh, rh]}

        {:ok, {:>, _, [lh, rh]}} ->
          {:>, env, [quote(do: unquote(lh) - unquote(rh)), 0]}

        {:ok, {:>=, _, [lh, rh]}} when is_integer(rh) or is_float(rh) ->
          {:>=, env, [lh, rh]}

        {:ok, {:>=, _, [lh, rh]}} ->
          {:>=, env, [quote(do: unquote(lh) - unquote(rh)), 0]}

        {:ok, {:<, _, [lh, rh]}} when is_integer(rh) or is_float(rh) ->
          {:<, env, [lh, rh]}

        {:ok, {:<, _, [lh, rh]}} ->
          {:<, env, [quote(do: unquote(lh) - unquote(rh)), 0]}

        {:ok, {:<=, _, [lh, rh]}} when is_integer(rh) or is_float(rh) ->
          {:<=, env, [lh, rh]}

        {:ok, {:<=, _, [lh, rh]}} ->
          {:<=, env, [quote(do: unquote(lh) - unquote(rh)), 0]}

        {:ok, {:=, _, [lh, rh]}} ->
          {:==, env, [lh, rh]}

        {:ok, {:==, _, [lh, rh]}} ->
          {:==, env, [lh, rh]}

        {:ok, {op, _, _}} ->
          raise(Formulae.SyntaxError, formula: input, error: {:operation, double_quote(op)})

        {:error, {_, message, op}} ->
          raise(
            Formulae.SyntaxError,
            formula: input,
            error: {:parsing, message <> double_quote(op)}
          )

        other ->
          raise(Formulae.SyntaxError, formula: input, error: {:unknown, inspect(other)})
      end
    }
  end

  @deprecated "Use `Formulae.eval/2` instead"

  @doc ~S"""
  Evaluates normalized representation of formula.

  ## Examples

      iex> Formulae.eval("3 > 2")
      true

      iex> Formulae.eval("3 < 2")
      false

      iex> Formulae.eval("a < 2", a: 1)
      true

      iex> Formulae.eval("a > 2", a: 1)
      false

      iex> Formulae.eval("a < 2", [])
      {:error, {:missing_arguments, [given_keys: [], expected_keys: [:a]]}}

      iex> Formulae.eval!("a < 2", [])
      ** (Formulae.RunnerError) Formula failed to run (compile): [:missing_arguments] wrong or incomplete eval call: [given_keys: [], expected_keys: [:a]].

      iex> Formulae.eval("a + 2 == 3", a: 1)
      true

      iex> Formulae.eval("a + 2 == 3", a: 2)
      false

      iex> Formulae.eval(~S|a == "3"|, a: "3")
      true

      iex> Formulae.eval(~S|a == "3"|, a: 3)
      false

      iex> Formulae.eval(~S|a == "3"|, a: "hello")
      false

      iex> Formulae.eval("a + 2 == 3", a: 2)
      false

      iex> Formulae.eval(~S|a == "3"|, a: "3")
      true

      iex> Formulae.eval("a_b_c_490000 > 2", a_b_c_490000: 3)
      true
  """
  @spec evaluate(input :: binary() | tuple(), binding :: keyword(), opts :: keyword()) ::
          boolean() | no_return()
  def evaluate(input, binding \\ [], opts \\ [])

  def evaluate({_original, ast}, binding, opts),
    do: evaluate(ast, binding, opts)

  def evaluate(input, binding, opts) when is_binary(input),
    do: evaluate(unit(input), binding, opts)

  def evaluate(input, binding, opts) when is_tuple(input) do
    unresolved = bindings?(input, binding)

    if Enum.empty?(unresolved) do
      do_evaluate(input, binding, opts)
    else
      raise(
        Formulae.RunnerError,
        formula: input,
        error:
          {:compile, "incomplete binding to evaluate a formula, lacking: #{inspect(unresolved)}"}
      )
    end
  end

  defp do_evaluate(input, binding, opts) when is_tuple(input) do
    binding = Enum.reject(binding, fn {_, v} -> is_nil(v) end)

    try do
      case Code.eval_quoted(input, binding, opts) do
        {false, ^binding} -> false
        {true, ^binding} -> true
        other -> raise(Formulae.RunnerError, formula: input, error: {:weird, inspect(other)})
      end
    rescue
      e in CompileError ->
        reraise(
          Formulae.RunnerError,
          [formula: input, error: {:compile, e.description}],
          __STACKTRACE__
        )
    end
  end

  ##############################################################################

  :formulae
  |> Application.compile_env(:generate_combinators, true)
  |> if do
    @max_combinations Application.compile_env(:formulae, :max_combinations, 42)
    @max_permutations Application.compile_env(:formulae, :max_permutations, 12)

    require Formulae.Combinators

    @spec combinations(list :: list(), count :: non_neg_integer()) :: [list()]
    @doc "Generated clauses for `n ∈ [1..#{@max_combinations}]` to be used with dynamic number"
    Enum.each(1..@max_combinations, fn n ->
      def combinations(l, unquote(n)), do: Formulae.Combinators.combinations(l, unquote(n))
    end)

    def combinations(_l, n),
      do: raise(Formulae.RunnerError, formula: :combinations, error: {:too_high, inspect(n)})

    @spec permutations(list :: list(), count :: non_neg_integer()) :: [list()]
    @doc "Generated clauses for `n ∈ [1..#{@max_permutations}]` to be used with dynamic number"
    Enum.each(1..@max_permutations, fn n ->
      def permutations(l, unquote(n)), do: Formulae.Combinators.permutations(l, unquote(n))
    end)

    def permutations(_l, n),
      do: raise(Formulae.RunnerError, formula: :permutations, error: {:too_high, inspect(n)})
  end

  ##############################################################################

  defp double_quote(string) when is_binary(string), do: "“" <> string <> "”"
  defp double_quote(string), do: double_quote(to_string(string))

  defp module_name(input, options) when is_binary(input) and is_list(options),
    do:
      Keyword.get_lazy(options, :alias, fn ->
        Module.concat(Formulae, String.replace(input, <<?/>>, "÷"))
      end)

  defp do_guard(:guard, variables, macro, _input) do
    vars = Enum.map(variables, &Macro.var(&1, nil))

    quote generated: true do
      defguard guard(unquote_splicing(vars)) when unquote(macro)
    end
  end

  defp do_guard(:function, _variables, _macro, _input), do: nil

  Enum.each(1..5, fn len ->
    defp do_eval(:guard, variables, _macro) when length(variables) == unquote(len) do
      vars = Enum.map(variables, &Macro.var(&1, nil))
      varsnames = variables |> Enum.zip(vars)

      require Formulae.Combinators

      varsnames
      |> Formulae.Combinators.permutations(unquote(len))
      |> Enum.map(fn varsnames ->
        quote generated: true do
          def eval(unquote(varsnames)) when guard(unquote_splicing(vars)), do: true
        end
      end)
      |> Kernel.++([
        quote generated: true do
          def eval(_), do: false
        end
      ])
    end
  end)

  defp do_eval(:guard, variables, macro), do: do_eval(:function, variables, macro)

  defp do_eval(:function, variables, macro) do
    vars = Enum.map(variables, &Macro.var(&1, nil))
    varsnames = Enum.zip(variables, vars)

    quote generated: true do
      def eval(unquote(varsnames)), do: unquote(macro)

      def eval(%{} = args) do
        bindings = for k <- @variables, v = Map.get(args, k), not is_nil(v), do: {k, v}

        if length(bindings) == length(@variables),
          do: eval(bindings),
          else:
            {:error,
             {:missing_arguments, [given_keys: Keyword.keys(bindings), expected_keys: @variables]}}
      end

      def eval(args) do
        if Keyword.keyword?(args),
          do: args |> Map.new() |> eval(),
          else: {:error, {:invalid_argument, [given: args, expected_keys: @variables]}}
      end
    end
  end

  defimpl String.Chars do
    @moduledoc false
    def to_string(%Formulae{formula: formula}) do
      "~F[" <> formula <> "]"
    end
  end

  defimpl Inspect do
    @moduledoc false
    import Inspect.Algebra

    def inspect(%Formulae{} = f, opts) do
      if Keyword.get(opts.custom_options, :sigil, false) do
        "~F[" <> f.formula <> "]"
      else
        inner = [
          ast: Macro.to_string(f.ast),
          eval: f.eval,
          formula: f.formula,
          guard: if(f.guard, do: Macro.to_string(f.guard)),
          module: f.module,
          variables: f.variables
        ]

        concat(["#ℱ<", to_doc(inner, opts), ">"])
      end
    end
  end
end