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"
#ℱ<[
sigil: "~F[a + :math.sin(3.14 * div(b, 2)) - c]",
eval: &:"Elixir.Formulae.a + :math.sin(3.14 * div(b, 2)) - c".eval/1,
formula: "a + :math.sin(3.14 * div(b, 2)) - c",
guard: nil,
module: :"Elixir.Formulae.a + :math.sin(3.14 * div(b, 2)) - c",
variables: [:a, :b, :c],
options: [defaults: [], imports: [:...], evaluator: :function, alias: nil]
]>
```
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
```
Whether one needs to use external modules in formulas, these modules
must be explicitly imported via `imports: [Mod1, Mod2]`. In case of clash
against `Kernel` functions, the latter might be “unimported” explicitly.
```elixir
iex|3 ▶ Formulae.compile("div(100, 2)", imports: [Decimal], unimports: [div: 2])
Decimal.new("50")
```
The formulae might be curried.
```elixir
iex|4 ▶ Formulae.curry(f, a: 3, b: 4)
#ℱ<[
sigil: "~F[3 + :math.sin(3.14 * div(4, 2)) - c]",
eval: &:"Elixir.Formulae.3 + :math.sin(3.14 * div(4, 2)) - c".eval/1,
formula: "3 + :math.sin(3.14 * div(4, 2)) - c",
guard: nil,
module: :"Elixir.Formulae.3 + :math.sin(3.14 * div(4, 2)) - c",
variables: [:c],
options: [defaults: [], imports: [:...], evaluator: :function, alias: nil]
]>
```
Since `v0.10.0` there is an ability to pass `defaults` via `options`.
_Examples:_
iex> "z + t" |> Formulae.compile(defaults: [t: 5]) |> Formulae.eval(t: 10, z: 3)
13
iex> "z + t" |> Formulae.compile(defaults: [t: 5]) |> Formulae.eval(z: 3)
8
"""
@typedoc false
@type option ::
{:eval, :function | :guard}
| {:alias, module()}
| {:imports, :none | :all | [module()] | [{module(), keyword()}] | [list()]}
| {:defaults, keyword()}
@typedoc false
@type options :: [option()]
@options_schema NimbleOptions.new!(
alias: [
required: false,
default: nil,
doc: "The alias to be used as a generated module name",
type: :atom
],
evaluator: [
required: false,
default: :function,
doc: "The type of the evaluation to generate",
type: {:in, [:function, :guard]}
],
imports: [
required: false,
default: nil,
doc: "The list of modules to allow remote calls from, or `:all | :none`",
type: {:or, [{:in, [nil, :none, :all]}, {:list, :any}, :atom]}
],
unimports: [
required: false,
default: [],
doc:
"The list of functions from `Kernel` module to *not* import automatically",
type: {:or, [{:in, [nil, :none, :all]}, :keyword_list]}
],
defaults: [
required: false,
default: [],
doc:
"The default values to be used with formula when not suppled in binding"
]
)
@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(),
eval: nil | (keyword() -> any()),
guard: nil | Macro.t(),
module: nil | atom(),
variables: nil | [atom()],
options: options()
}
defstruct formula: nil,
ast: nil,
eval: nil,
guard: nil,
module: nil,
variables: nil,
options: NimbleOptions.validate!([], @options_schema)
@doc """
Lists all the compiled formulas.
"""
@spec formulas(include_internals? :: boolean()) :: %{optional(binary) => module()}
def formulas(include_internals? \\ false) do
host_modules = [
Formulae,
Formulae.Combinators,
Formulae.Combinators.H,
Formulae.Combinators.Stream,
Formulae.Compiler,
Formulae.Compiler.AST,
Formulae.MixProject,
Formulae.RunnerError,
Formulae.Sigils,
Formulae.SyntaxError
]
:code.all_loaded()
|> Enum.map(&elem(&1, 0))
|> Enum.filter(&match?("Elixir.Formulae." <> _, to_string(&1)))
|> Kernel.--(host_modules)
|> Map.new(&{&1, Macro.to_string(&1.ast())})
|> then(fn result ->
if include_internals?, do: Map.merge(result, Map.new(host_modules, &{&1, &1})), else: result
end)
end
@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
iex> Formulae.eval("rem(a, 5) == b", [a: 8], defaults: [b: 3])
true
iex> Formulae.eval("rem(a, 5) == c", [a: 8, c: 3], defaults: [b: 3])
true
iex> Formulae.eval("to_integer(s) == i", [s: "42", i: 42], imports: [String])
true
Binary input is deprecated, create a formula explicitly with `Formulae.compile/2`
and then pass it as the first argument to `eval/2`".
The call to `eval/3` would compile the formulae with default options.
"""
@spec eval(input :: binary() | Formulae.t(), bindings :: keyword(), options :: options()) ::
term() | {:error, any()}
def eval(input, bindings \\ [], options \\ [imports: :none])
def eval(%Formulae{eval: eval, options: options} = formula, bindings, _options) do
options[:defaults] |> Keyword.merge(bindings) |> eval.()
rescue
UndefinedFunctionError ->
eval(formula.formula, bindings, formula.options)
end
def eval(input, bindings, options) when is_binary(input) do
input |> Formulae.compile(options) |> eval(bindings, options)
end
@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 ~F[rem(a, 5) == 0] failed to run (compile): [:missing_arguments], wrong or incomplete evaluator call: [given_keys: [], expected_keys: [:a]].
"""
@spec eval!(input :: binary() | Formulae.t(), bindings :: keyword()) :: term() | no_return()
def eval!(input, bindings \\ [], options \\ []) do
with {:error, {error, data}} <- Formulae.eval(input, bindings, options) do
raise(
Formulae.RunnerError,
formula: input,
error:
{:compile, "[#{inspect(error)}], wrong or incomplete evaluator call: #{inspect(data)}"}
)
end
end
@spec dry_ast(binary() | {:ok, Macro.t()} | {:error, tuple()} | Macro.t()) ::
{:ok, Macro.t()} | {:error, tuple()}
defp dry_ast(input) when is_binary(input), do: input |> Code.string_to_quoted() |> dry_ast()
defp dry_ast({:error, _} = error), do: error
defp dry_ast({:ok, input}) when is_tuple(input) do
result =
Macro.prewalk(input, fn
{arg, [_ | _], content} -> {arg, [], content}
other -> other
end)
{:ok, result}
end
defp dry_ast(input) when is_tuple(input), do: dry_ast({:ok, input})
@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
module = module_name(input, options)
sanitized = Map.get(formulas(true), module)
is_binary(sanitized) and
match?([{:ok, quoted}, {:ok, quoted}], Enum.map([sanitized, input], &dry_ast/1)) and
Code.ensure_loaded?(module)
end
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
| {:already_taken, module()}
| {:external_module, module()}}
def ensure_compiled(input, options \\ [])
def ensure_compiled(input, options) when is_binary(input) and is_list(options) do
module = module_name(input, options)
cond do
function_exported?(module, :ast, 0) ->
if {:ok, module.ast()} == Code.string_to_quoted(input),
do: {:module, module},
else: {:error, {:already_taken, module}}
Code.ensure_loaded?(module) ->
{:error, {:external_module, module}}
true ->
{:error, :nofile}
end
end
def ensure_compiled(%Formulae{module: nil}, _), do: {:error, :unavailable}
def ensure_compiled(%Formulae{module: module}, _), do: {:module, module}
@doc """
Compiles the formula into module.
The allowed imports must be specified explicitly with `imports: :all` or
a list of allowed imports `imports: [DateTime, Range]`.
_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(), options :: options()) ::
Formulae.t()
def curry(input, binding \\ [], options \\ [])
def curry(input, binding, options) when is_binary(input) do
curry(Formulae.compile(input, options), binding, options)
end
def curry(%Formulae{} = input, binding, options) do
{ast, vars} = ast_and_variables(input, binding, options)
%Formulae{variables: ^vars} = Formulae.compile(Macro.to_string(ast), options)
end
@spec maybe_create_module(
{:module, atom()} | {:error, any()},
input :: binary(),
options :: options()
) ::
Formulae.t()
defp maybe_create_module({:module, module}, input, options) do
with {:error, ex} <- validate_compatibility(module, options), do: raise(ex)
unless {:ok, module.ast()} == Code.string_to_quoted(input) do
raise Formulae.RunnerError,
formula: input,
error: {:incompatible, "Existing: " <> inspect(Macro.to_string(module.ast()))}
end
%Formulae{
formula: input,
module: module,
ast: module.ast(),
guard: module.guard_ast(),
variables: module.variables(),
options: module.options(),
eval: &module.eval/1
}
end
defp maybe_create_module({:error, {:already_taken, module}}, input, options) do
IO.warn(
"Redefining Formulae for alias " <>
inspect(options[:alias]) <>
", formula: ~F[#{Macro.to_string(module.ast())}] → ~F[#{input}] (module: " <>
inspect(module) <> ")",
[]
)
maybe_create_module({:error, :redefining}, input, options)
end
defp maybe_create_module({:error, {:external_module, module}}, input, options) do
IO.warn(
"Alias given for Formulae (" <>
inspect(options[:alias]) <>
") will overwrite existing module " <>
inspect(module),
[]
)
maybe_create_module({:error, :redefining}, input, options)
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp maybe_create_module({:error, _}, input, options) do
options =
options
|> NimbleOptions.validate!(@options_schema)
|> Keyword.update(:imports, [], &fix_imports/1)
imports =
case Keyword.fetch!(options, :imports) do
[] ->
nil
[:...] ->
nil
imports ->
Enum.map(imports, fn
[mod, {_, _} = arg] -> quote do: import(unquote(mod), unquote([arg]))
{mod, [{_, _} | _] = args} -> quote do: import(unquote(mod), unquote(args))
mod -> quote do: import(unquote_splicing(List.wrap(mod)))
end)
end
unimports =
case Keyword.fetch!(options, :unimports) do
nil -> []
:none -> []
:all -> Kernel.__info__(:functions)
list when is_list(list) -> list
end
unimports =
Enum.uniq(
unimports ++
[
apply: 3,
exit: 1,
raise: 1,
raise: 2,
reraise: 2,
reraise: 3,
spawn: 3,
spawn_link: 3,
spawn_monitor: 3,
throw: 1
]
)
{macro, variables} = reduce_ast!(input, options)
escaped = Macro.escape(macro)
variables = variables |> Enum.reverse() |> Enum.uniq()
guard = do_guard(options[:evaluator], variables, macro, input)
guard_ast = Macro.escape(guard)
eval = do_eval(options[:evaluator], variables, macro)
ast = [
guard,
quote generated: true do
@variables unquote(variables)
import Kernel, except: unquote(unimports)
unquote(imports)
def ast, do: unquote(escaped)
def guard_ast, do: unquote(guard_ast)
def options, do: unquote(options)
def variables, do: @variables
end,
eval
]
{:module, module, _, _} = input |> module_name(options) |> Module.create(ast, __ENV__)
%Formulae{
formula: input,
ast: macro,
module: module,
guard: guard,
variables: variables,
options: options,
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(), options :: options()) :: boolean()
def check(string, bindings \\ [], options \\ []) do
Formulae.eval(string, bindings, options)
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(),
options :: options()
) ::
{tuple(), keyword()}
defp ast_and_variables(input, binding, options) when is_binary(input) do
input
|> Formulae.compile(Keyword.delete(options, :alias))
|> ast_and_variables(binding, options)
end
defp ast_and_variables(
%Formulae{ast: nil, formula: input, options: options},
binding,
new_options
) do
options = Keyword.merge(new_options, options)
# [AM] FIX
ast_and_variables(input, binding, options)
end
defp ast_and_variables(%Formulae{ast: ast}, binding, _options) 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?([], imports: [:math])
~w|a b c|a
iex> "a + b * 4 - :math.pow(c, 2) / d > 1.0 * e" |> Formulae.bindings?([], imports: :math)
~w|a b c d e|a
"""
@spec bindings?(formula :: Formulae.t() | binary() | tuple(), binding :: keyword()) :: keyword()
def bindings?(formula, bindings \\ [], options \\ [imports: :none])
def bindings?(%Formulae{variables: variables}, [], _options),
do: variables
def bindings?(formula, bindings, options) when is_binary(formula),
do: with(f <- Formulae.curry(formula, bindings, options), do: f.variables)
def bindings?(formula, bindings, options) when is_tuple(formula),
do: bindings?(Macro.to_string(formula), bindings, options)
def bindings?(%Formulae{formula: formula}, bindings, options),
do: bindings?(formula, bindings, options)
##############################################################################
# @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 ~F[a < 2] failed to run (compile): [:missing_arguments], wrong or incomplete evaluator 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(), options :: options()) ::
boolean() | no_return()
def evaluate(input, binding \\ [], options \\ [])
def evaluate({_original, ast}, binding, options),
do: evaluate(ast, binding, options)
def evaluate(input, binding, options) when is_binary(input),
do: evaluate(unit(input), binding, options)
def evaluate(input, binding, options) when is_tuple(input) do
unresolved = bindings?(input, binding)
if Enum.empty?(unresolved) do
do_evaluate(input, binding, options)
else
raise(
Formulae.RunnerError,
formula: input,
error:
{:compile, "incomplete binding to evaluate a formula, lacking: #{inspect(unresolved)}"}
)
end
end
defp do_evaluate(input, binding, options) when is_tuple(input) do
binding = Enum.reject(binding, fn {_, v} -> is_nil(v) end)
try do
case Code.eval_quoted(input, binding, options) 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
safe_name =
case input do
<<_::binary-size(240), _::binary>> ->
"SHA_" <> (:sha256 |> :crypto.hash(input) |> Base.encode16())
_ ->
String.replace(input, <<?/>>, "÷")
end
Keyword.get(options, :alias) || Module.concat(Formulae, safe_name)
end
defp do_guard(:guard, variables, macro, _input) do
vars = Enum.map(variables, &Macro.var(&1, nil))
quote generated: true do
# @doc guard: true
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
defp fix_imports(imports) do
case imports do
nil -> fix_imports_warn()
:none -> []
:all -> [:...]
alias when is_atom(alias) -> [alias]
aliases when is_list(aliases) -> aliases
end
end
# [AM] Remove this when we disallow default value for it
defp fix_imports_warn do
IO.warn(
"Default value for `imports: :all` argument in a call to `Formulae.compile/2` is deprecated, " <>
"and will be changed to `:none` in v1.0.0",
[]
)
[:...]
end
defp validate_compatibility(module, options) do
with {:ok, validated} <- NimbleOptions.validate(options, @options_schema),
{:imports, true} <-
{:imports, validate_imports(module.options()[:imports], validated[:imports])},
{:alias, true} <- {:alias, module == Keyword.get(validated, :alias, module)},
{:defaults, true} <-
{:defaults, Keyword.equal?(module.options()[:defaults], validated[:defaults])},
{:evaluator, true} <- {:evaluator, validate_evaluator(validated[:evaluator], module)} do
{:ok, validated}
end
end
defp validate_imports(:..., _), do: true
defp validate_imports(imports, :...),
do:
{:error,
%Formulae.RunnerError{
error: :imports,
message: "Existing module has imports restricted to " <> inspect(imports)
}}
defp validate_imports(from_module, from_options) do
[from_module, from_options]
|> Enum.map(&fix_imports/1)
|> Enum.reduce(&Kernel.--/2)
|> Enum.empty?()
|> Kernel.||(
{:error,
%Formulae.RunnerError{
error: :imports,
message:
"Incompatible imports: " <> inspect(existing: from_module, supplied: from_options)
}}
)
end
defp validate_evaluator(:guard, module), do: is_nil(module.guard_ast())
defp validate_evaluator(:function, module), do: not is_nil(module.guard_ast())
# [AM] maybe warn
defp validate_evaluator(_, module), do: not is_nil(module.guard_ast())
if Version.match?(System.version(), ">= 1.17.0-dev") do
defp expand_or_concat(alias) do
{:__aliases__, meta, [_ | _] = list} = alias
:elixir_aliases.expand_or_concat(meta, list, __ENV__, nil)
end
else
defp expand_or_concat(alias) do
:elixir_aliases.expand_or_concat(alias, __ENV__)
end
end
defp reduce_ast!(input, options) do
macro = Code.string_to_quoted!(input)
imports = Keyword.fetch!(options, :imports)
{^macro, {^imports, issues, variables}} =
Macro.postwalk(macro, {imports, [], []}, fn
{var, _, nil} = v, {imports, issues, acc} ->
{v, {imports, issues, [var | acc]}}
v, {[:...], _, _} = acc ->
{v, acc}
{:__aliases__, _, [_ | _]} = alias, {imports, issues, acc} ->
wanna_import = expand_or_concat(alias)
do_wanna_import(:alias, wanna_import, alias, {imports, issues, acc})
{:., _, [{:__aliases__, _, [_ | _]} = alias, _fun]} = call, {imports, issues, acc} ->
wanna_import = expand_or_concat(alias)
do_wanna_import(:alias, wanna_import, call, {imports, issues, acc})
{:., _, [wanna_import, _fun]} = call, {imports, issues, acc} ->
do_wanna_import(:erlang, wanna_import, call, {imports, issues, acc})
{:import, _, wanna_import} = alias, {imports, issues, acc} ->
do_wanna_import(:import, wanna_import, alias, {imports, issues, acc})
v, acc ->
{v, acc}
end)
unless Enum.empty?(issues) do
raise %Formulae.SyntaxError{
formula: input,
error: "Restricted: " <> inspect(Enum.uniq(issues))
}
end
{macro, variables}
end
defp do_wanna_import(kind, wanna_import, alias, {imports, issues, acc}) do
if wanna_import in imports do
{alias, {imports, issues, acc}}
else
if Version.match?(System.version(), ">= 1.14.1") do
{alias, {imports, [{kind, Macro.expand_literals(wanna_import, __ENV__)} | issues], acc}}
else
{alias, {imports, [{kind, wanna_import} | issues], acc}}
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 = [
sigil: "~F[" <> 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,
options: f.options
]
concat(["#ℱ<", to_doc(inner, opts), ">"])
end
end
end
end