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