Current section

Files

Jump to
brimstone_conditional lib brimstone_conditional.ex
Raw

lib/brimstone_conditional.ex

defmodule BrimstoneConditional do
@moduledoc """
Evaluate conditions defined in a logical structure.
This module allows to evaluate complex tree conditional structures and digest
them to get a final output. The main function is `evaluate/2` which will digest
the struct and return the values with the conditional switches resolved.
The struct itself can be converted to a string to be stored, using
`:erlang.term_to_binary/1` and `Base.encode64/1` under the hood. The struct
can be retrieved later using `from_string/1`, which performs the mirror
operation. These strings include a version at the begining to acomodate the
possibility of altering this struct in the future and allowing migrations
from previous stringified conditionals.
"""
require Integer
@module_name String.Chars.to_string(__MODULE__)
@valid_condition_args ~w{
and nand or xor not nor xnor
eq neq gt ge lt le in match
cond fn var count each sum
}a
defstruct Enum.map(@valid_condition_args, &{&1, nil})
@doc """
Evaluate conditions defined in a condition structure, returning the computed
value.
This uses recursion to evaluate its parameters. A plain boolean will return
itself, a list will be assumed to be an `and` structure, and a map or keyword
list will traverse itself as a list of key/value tuples, using the key as the
operation and the value as parameters.
Known map operators are the logic gates `and`, `or`, `xor`, `not`, `nor` and
`xnor`, the comparison operators `eq`, `neq`, `gt`, `ge`, `lt` and `le`, the
check operators `in` and `match`, the disambiguator `cond`, the scape hatch
`fn`, and the utility operators `var`, `count`, `each` and `sum`.
The comparison operators assume that the first element is the topic that we
are comparing, and any other element is what are we comparing it to,
therefore is possible to ask if something is greather that "this thing" and
also to "that other thing" in the same step, or if "this thing" if different
to "this other thing" and also to "that other thing".
The check operator `match` will check if the string provided as the first
element of the argument list matches all regex and strings provided as the
rest of arguments. Strings will be compiled to regex, and other data types
will try to convert themselves to a string, then surround themselves with the
regex operators `^` and `$` and compile the resulting string to a regex, thus
if you try to match "22" and "2" this will return true, as "2" is a string
contained in "22", but if you try to match "22" and 2 it will return false,
as the integer 2 will become the regex "^2$". This is intentional. If you
require to pass aditional options to the regex you wish to compile, you may
do so with a tuple of size two where each element correspond to the arguments
of `Regex.compile!/2`.
The check operator `in` will test membership of elements on the first element
provided. It works on Lists, Strings and Maps. Maps are a special case, as it
will check if the specified map arguments are a subset of the topic map.
The disambiguator operator `cond` will operate exactly as an elixir `cond`.
It expects to receive a list of tuples, where each tuple is a pair with a
condition that will be evaluated with this very same function, and a value.
It will substitute itself with the first element of this list that return
`true` after checking its condition.
The utility operators will perform common basic tasks, usually on the state
provided (an empty map as default) to fetch data. `var` will perform a
`Map.get/2` using the provided atom or string as key. `each` will turn itself
into a list containing all values of the state map which had a key begining
with the atom or string provided, followed with an index inside brackets
(like the accessor syntax). `count` will return the size of the provided
list, and `sum` will asume the provided list contains numbers and will add
them up. All these tasks could be handled as functions, but they are so
common that including them make the struct way more usable.
Any other operator that receives a list as a parameter will be handled as an
`and` operation of the result of applying the specfied operator to each
element in the list, with the exception of the scape hatch `fn`, which will
assume that the first element of the provided list is a function and will
apply using the rest of the list as arguments. The `fn` operator also can
acceps a different syntax using tuples, where you may specify `{module,
function_name, arg_list}` or `{function, arg_list}`. In any case, it will
check the arity of the relevant function. If the arity is equal to the
arguments provided it will call it only with the provided arguments, and
prepend the entire state to the argument list otherwise.
"""
def evaluate(%__MODULE__{} = condition, %{} = state \\ %{}),
do: digest(condition, state, false)
defp is_met?(%__MODULE__{} = condition, state),
do: Enum.all?(digest(condition, state), &is_met?(&1, state))
defp is_met?({key, nil}, _state) when key in @valid_condition_args,
do: true
defp is_met?({key, value}, state) when is_bitstring(key),
do: is_met?({String.to_existing_atom(key), value}, state)
defp is_met?({:not, item}, state) when not is_list(item),
do: !is_met?(item, state)
defp is_met?({:and, list}, state) when is_list(list),
do: Enum.all?(digest(list, state), &is_met?(&1, state))
defp is_met?({:nand, list}, state) when is_list(list),
do: !Enum.all?(digest(list, state), &is_met?(&1, state))
defp is_met?({:or, list}, state) when is_list(list),
do: Enum.any?(digest(list, state), &is_met?(&1, state))
defp is_met?({:nor, list}, state) when is_list(list),
do: !Enum.any?(digest(list, state), &is_met?(&1, state))
defp is_met?({:xor, list}, state) when is_list(list),
do: Enum.reduce(digest(list, state), false, &if(is_met?(&1, state), do: !&2, else: &2))
defp is_met?({:xnor, list}, state) when is_list(list) do
list
|> digest(state)
|> Enum.map(&is_met?(&1, state))
|> Enum.frequencies()
|> Map.get(true, 0)
|> Integer.is_even()
end
defp is_met?({key, list}, state) when (is_atom(key) or is_bitstring(key)) and is_list(list),
do: Enum.all?(digest(list, state), &is_met?({key, &1}, state))
defp is_met?(anything_else, _state),
do: anything_else
defp is_in?(content, container) when is_bitstring(container) and is_bitstring(content),
do: String.contains?(container, content)
defp is_in?(content, container) when is_map(container) and is_map(content),
do: MapSet.subset?(MapSet.new(content), MapSet.new(container))
defp is_in?(content, container) when is_list(container),
do: Enum.member?(container, content)
# Replace any `cond: [{conditions, value}, ...]` occurence with the first valid
# option in maps and lists, recursively. Return any other value unchanged.
defp apply_cond_switch({:cond, map}, state) when is_map(map) do
map
|> Map.to_list()
|> apply_cond_switch(state)
|> Map.new()
end
defp apply_cond_switch(list, state) when is_list(list),
do: Enum.map(list, &apply_cond_switch(&1, state))
defp apply_cond_switch({:cond, items}, state) when is_list(items) do
Enum.find(items, {nil, nil}, &is_met?(elem(&1, 0), state))
|> elem(1)
|> apply_cond_switch(state)
end
defp apply_cond_switch({:cond, item}, state),
do: apply_cond_switch({:cond, [item]}, state)
defp apply_cond_switch({key, value}, state),
do: {key, apply_cond_switch(value, state)}
defp apply_cond_switch(value, _state),
do: value
defp digest(item, state, dont_recurse \\ false)
defp digest(%__MODULE__{} = condition, state, false) do
condition =
condition
|> Map.from_struct()
|> Enum.reject(&is_nil(elem(&1, 1)))
cond do
Enum.count(condition) == 1 && is_tuple(List.first(condition)) &&
elem(List.first(condition), 0) in @valid_condition_args ->
{head, tail} = List.first(condition)
digest({head, tail}, state)
true ->
digest(condition, state)
end
end
defp digest(map, state, false) when is_map(map) do
list =
map
|> Map.to_list()
|> digest(state)
if is_list(list) &&
Enum.all?(list, &is_tuple/1) &&
Enum.all?(list, &(tuple_size(&1) == 2)) &&
Enum.all?(list, &(is_atom(elem(&1, 0)) || is_bitstring(elem(&1, 0)))),
do: Map.new(list),
else: list
end
defp digest({:match, [other | patterns]}, state, false) when not is_bitstring(other),
do: digest({:match, ["#{other}" | patterns]}, state)
defp digest({:match, [binary | patterns]}, state, false) when is_bitstring(binary) do
Enum.reduce(
digest(patterns, state),
true,
&if(
&2 &&
String.match?(
binary,
cond do
match?(%Regex{}, &1) -> &1
is_bitstring(&1) -> Regex.compile!(&1)
is_tuple(&1) -> Regex.compile!(elem(&1, 0), elem(&1, 1))
true -> Regex.compile!("^#{&1}$")
end
),
do: true,
else: false
)
)
end
defp digest({:match, anything_else}, state, false),
do: digest({:match, digest(anything_else, state)}, state)
defp digest({:eq, list}, state, false) do
list = digest(list, state)
Enum.reduce_while(list, true, fn item, _ ->
if(List.first(list) == item, do: {:cont, true}, else: {:halt, false})
end)
end
defp digest({:neq, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &(digest(head, state) != &1))
end
defp digest({:gt, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &(digest(head, state) > &1))
end
defp digest({:ge, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &(digest(head, state) >= &1))
end
defp digest({:lt, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &(digest(head, state) < &1))
end
defp digest({:le, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &(digest(head, state) <= &1))
end
defp digest({:in, list}, state, false) do
[head | tail] = digest(list, state)
Enum.all?(digest(tail, state), &is_in?(digest(head, state), &1))
end
defp digest({key, nil}, state, false),
do: {digest(key, state), nil}
defp digest({:var, key}, state, false)
when is_map(state) and (is_atom(key) or is_bitstring(key)),
do: Map.get(state, digest(key, state))
defp digest({:count, variables}, state, false),
do: Enum.count(digest(variables, state))
defp digest({:each, key}, state, false)
when is_map(state) and (is_atom(key) or is_bitstring(key)) do
state
|> Enum.filter(
&String.match?("#{elem(&1, 0)}", Regex.compile!("#{digest(key, state)}\[[0-9]+\]$"))
)
|> Enum.map(&digest(elem(&1, 1), state))
end
defp digest({:sum, variables}, state, false),
do: Enum.sum(Enum.map(digest(variables, state), &digest(&1, state)))
defp digest({:fn, {module, name}}, state, false) when is_atom(module) and is_atom(name),
do: digest({:fn, {module, name, []}}, state)
defp digest({:fn, {module, name, args}}, state, false)
when is_atom(module) and is_atom(name) and is_list(args) do
arity = Enum.count(args)
arity =
if Code.ensure_loaded?(module) && function_exported?(module, name, arity + 1),
do: arity + 1,
else: arity
digest({:fn, [Function.capture(module, name, arity)] ++ args}, state)
end
defp digest({:fn, function}, state, false) when is_function(function),
do: digest({:fn, [function]}, state)
defp digest({:fn, [function | args]}, state, false)
when is_function(function) and is_list(args) do
args = Enum.map(args, &digest(&1, state))
if(:erlang.fun_info(function)[:arity] == Enum.count(args),
do: apply(function, args),
else: apply(function, [state] ++ args)
)
end
defp digest({:cond, value}, state, false),
do: apply_cond_switch({:cond, digest(value, state)}, state)
defp digest({:not, item}, state, false),
do: is_met?({:not, item}, state)
defp digest({:and, list}, state, false),
do: is_met?({:and, list}, state)
defp digest({:nand, list}, state, false),
do: is_met?({:nand, list}, state)
defp digest({:or, list}, state, false),
do: is_met?({:or, list}, state)
defp digest({:nor, list}, state, false),
do: is_met?({:nor, list}, state)
defp digest({:xnor, list}, state, false),
do: is_met?({:xnor, list}, state)
defp digest({:xor, list}, state, false),
do: is_met?({:xor, list}, state)
defp digest({key, value}, state, false),
do: {digest(key, state), digest(value, state)}
defp digest(list, state, false) when is_list(list),
do: digest(Enum.map(list, &digest(&1, state)), state, true)
defp digest(anything_else, _state, _),
do: anything_else
@doc """
Restore a conditional struct from its string form.
"""
def from_string("module:#{@module_name};version:0.1.0;" <> data64),
do: :erlang.binary_to_term(Base.decode64!(data64))
defimpl String.Chars, for: __MODULE__ do
@module_name String.replace("#{__MODULE__}", "Elixir.String.Chars", "Elixir")
@current_struct_version "0.1.0"
def to_string(instance),
do:
"module:#{@module_name};" <>
"version:#{@current_struct_version};" <>
Base.encode64(:erlang.term_to_binary(instance))
end
end