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.
"""
# @eval_in_place Application.get_env(:formulae, :eval_in_place, false)
@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.
_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 == 0")
iex> f.eval.(a: 11, b: 1)
true
iex> f.eval.(a: 12, b: 1)
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)
@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 = Enum.reverse(variables),
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
try do
Formulae.evaluate(string, bindings)
rescue
Formulae.RunnerError -> false
end
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 ~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)
@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"]}}
"""
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
# FIXME Make `nil` acceptable through SQL-like `IS NULL` syntax!
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 ->
raise(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