Current section
Files
Jump to
Current section
Files
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 | tuple(),
module: nil | atom(),
variables: nil | [atom()],
eval: nil | (keyword() -> any())
}
defstruct formula: nil, ast: nil, module: nil, eval: nil, variables: nil
@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(string :: binary(), bindings :: keyword()) :: term()
def eval(string, bindings \\ []),
do: with(f <- Formulae.compile(string), do: f.eval.(bindings))
@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()) :: boolean()
def compiled?(input) when is_binary(input),
do: Formulae |> Module.concat(input) |> Code.ensure_loaded?()
def compiled?(%Formulae{module: nil}), do: false
def compiled?(%Formulae{module: _}), do: true
@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()) :: Formulae.t()
def compile(input) when is_binary(input) do
Formulae
|> Module.concat(input)
|> Code.ensure_loaded()
|> maybe_create_module(input)
end
def compile(%Formulae{formula: input}), do: compile(input)
@doc """
Purges and discards the module for the formula given (if exists.)
"""
@spec purge(Formulae.t() | binary()) :: :ok | {:error, :not_compiled} | {:error, :code_delete}
def purge(input) when is_binary(input),
do: Formulae |> Module.concat(input) |> 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))
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()) ::
Formulae.t()
defp maybe_create_module({:module, module}, input),
do: %Formulae{
formula: input,
module: module,
ast: module.ast(),
variables: module.variables(),
eval: &module.eval/1
}
defp maybe_create_module({:error, _}, input) do
with {:ok, macro} <- Code.string_to_quoted(input),
{^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(),
vars = Enum.map(variables, &{&1, Macro.var(&1, nil)}),
ast =
(quote generated: true do
@variables unquote(variables)
def ast, do: unquote(escaped)
def variables, do: @variables
defmacrop do_eval(unquote(vars)), do: {unquote(escaped), unquote(vars)}
def eval(unquote(vars)), do: unquote(vars) |> do_eval() |> elem(0)
def eval(%{} = binding),
do: eval(for k <- @variables, do: {k, Map.get(binding, k)})
def eval(args),
do:
{:error, {:invalid_arguments, [given: args, expected_keys: unquote(variables)]}}
end),
{:module, module, _, _} <-
Module.create(Module.concat(Formulae, input), ast, __ENV__),
do: %Formulae{
formula: input,
ast: macro,
module: module,
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.evaluate(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 binding[var], do: {binding[var], acc}, else: {v, [var | acc]}
v, acc ->
{v, acc}
end)
{ast, Enum.reverse(vars)}
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(), binding :: keyword()) :: keyword()
def bindings?(formula, bindings \\ [])
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, {1, 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.evaluate(Formulae.unit("3 > 2"))
true
iex> Formulae.evaluate(Formulae.unit("3 < 2"))
false
iex> Formulae.evaluate(Formulae.unit("a < 2"), [a: 1])
true
iex> Formulae.evaluate(Formulae.unit("a > 2"), [a: 1])
false
iex> Formulae.evaluate(Formulae.unit("a < 2"), [])
** (Formulae.RunnerError) Formula failed to run (compile): incomplete binding to evaluate a formula, lacking: [:a].
iex> Formulae.evaluate(Formulae.unit("a + 2 = 3"), [a: 1])
true
iex> Formulae.evaluate(Formulae.unit("a + 2 = 3"), [a: 2])
false
iex> Formulae.evaluate(Formulae.unit(~S|a = "3"|), [a: "3"])
true
iex> Formulae.evaluate(Formulae.unit(~S|a = "3"|), [a: 3])
false
iex> Formulae.evaluate(Formulae.unit(~S|a = "3"|), [a: "hello"])
false
iex> Formulae.evaluate("a + 2 = 3", [a: 2])
false
iex> Formulae.evaluate(~S|a = "3"|, [a: "3"])
true
iex> Formulae.evaluate(Formulae.unit("a_b_c_490000 > 2"), [a_b_c_490000: 3])
true
"""
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})
end
end
##############################################################################
defp double_quote(string) when is_binary(string), do: "“" <> string <> "”"
defp double_quote(string), do: double_quote(to_string(string))
end