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