Current section
Files
Jump to
Current section
Files
lib/formula2.ex
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Formula2 do
@type var_ref :: binary()
@type value :: integer() | float()
@type ast :: [var_ref() | value()]
@type env :: %{var_ref() => ([value()] -> value())}
@default_validate_opts [max_runs: 1_000]
def default_env do
%{
"+" => fn [x | xs] -> Enum.reduce(xs, x, &Decimal.add(&1, &2)) end,
"-" => fn [x | xs] -> Enum.reduce(xs, x, &Decimal.sub(&2, &1)) end,
"*" => fn [x | xs] -> Enum.reduce(xs, x, &Decimal.mult(&1, &2)) end,
# TODO: division is disabled until an if condition is added that works on numbers only (false on 0 only)
# TODO: so you can do (if var-a (/ 1 var-a) 1), avoiding division by 0 if var-a = 0
# "/" => fn [a, b] -> a / b end,
"max" => fn [a, b] -> Decimal.max(a, b) end,
"min" => fn [a, b] -> Decimal.min(a, b) end,
"round" => fn [x] -> Decimal.round(x) end,
"abs" => fn [x] -> Decimal.abs(x) end,
"negate" => fn [x] -> Decimal.negate(x) end
}
end
def parse_and_validate(formula, var_names, options \\ @default_validate_opts) do
formula
|> parse()
|> validate(
options[:env] || default_env(),
var_names,
options
)
|> case do
{:ok, _} -> :ok
e -> e
end
end
def parse_and_eval(formula, formula_env, options \\ []) do
formula
|> parse()
|> eval(Map.merge(options[:env] || default_env(), formula_env))
end
@doc "Return the AST for a binary"
@spec parse(binary()) :: ast()
def parse(raw_string) when is_binary(raw_string) do
raw_string
|> tokenize()
|> do_parse()
|> List.first()
end
@doc """
Run property tests against the given
"""
@spec validate(ast(), env(), [var_ref()]) :: :ok | {:error, term()}
def validate(ast, %{} = env, var_names, options \\ @default_validate_opts)
when is_list(var_names) do
value_gen =
[StreamData.float(), StreamData.integer()]
|> StreamData.one_of()
|> StreamData.map(&float_to_decimal/1)
env_gen = StreamData.fixed_map(for v <- var_names, do: {v, value_gen})
options = Keyword.merge([initial_seed: :os.timestamp()], options)
StreamData.check_all(env_gen, options, fn new_env ->
try do
eval(ast, Map.merge(env, new_env))
rescue
e ->
{:error, %{reason: e, env: new_env}}
end
end)
end
def decimal_to_float(x), do: Decimal.to_float(x)
def float_to_decimal(x) when is_float(x), do: Decimal.from_float(x)
def float_to_decimal(x), do: Decimal.new(x)
@doc "Execute the AST over the environment."
@spec eval(ast(), env()) :: {:ok, value()} | {:error, term()}
def eval(ast, %{} = env) do
case ast do
[operator | args] when is_list(args) ->
with {:ok, operator_fn} <- eval(operator, env),
{:ok, parameters} <- eval_parameters(args, env) do
{:ok, do_apply(operator_fn, parameters)}
end
%Decimal{} = value ->
{:ok, value}
variable when is_binary(variable) ->
lookup_variable_value(variable, env)
_ ->
{:error, "Unknown operation: #{inspect(ast)}"}
end
end
defp do_apply(operator, args) when is_function(operator, 1) do
operator.(args)
end
defp eval_parameters(args, env) do
Enum.reduce_while(args, [], fn arg, acc ->
case eval(arg, env) do
{:ok, val} -> {:cont, [val | acc]}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:error, _} = e -> e
val -> {:ok, Enum.reverse(val)}
end
end
defp lookup_variable_value(var_name, env) do
with :error <- Map.fetch(env, var_name) do
{:error, "Undefined variable: #{inspect(var_name)}"}
end
end
defp tokenize(str) do
# HACK
str
|> String.replace("(", " ( ")
|> String.replace(")", " ) ")
|> String.split()
|> Enum.map(&String.trim/1)
end
defp do_parse(tokens, acc \\ [])
defp do_parse(["(" | tail], acc) do
{tokens, sub_tree} = do_parse(tail, [])
do_parse(tokens, [sub_tree | acc])
end
defp do_parse([")" | tail], acc) do
{tail, Enum.reverse(acc)}
end
defp do_parse([], acc) do
Enum.reverse(acc)
end
# atom
defp do_parse([head | tail], acc) do
do_parse(tail, [atom(head) | acc])
end
# parse as an atom (a binary in erlang) or decimal
defp atom(token) do
case Decimal.parse(token) do
{value, ""} -> value
:error -> token
end
end
end