%%%------------------------------------------------------------------- %%% @author Ralf Th. Pietsch %%% @copyright (C) 2024-2026, Ralf Th. Pietsch %%% @doc %%% A simple expression matcher and evaluator for Erlang. %%% %%% Evaluates expressions given as tuples of the form: %%% %%% %%% == Quick Start == %%% %%% ``` %%% true = matcher:eval({'=', <<"Albert">>, <<"Albert">>}). %%% true = matcher:eval({'=~', <<"Albert">>, <<"albert">>}). %%% true = matcher:eval({'?', <<"bert">>, <<"Albert">>}). %%% ''' %%% %%% == Using a Provider == %%% %%% A provider resolves values that cannot be evaluated further: %%% %%% ``` %%% Map = #{name => <<"Albert">>, age => 42}. %%% true = matcher:eval(Map, {'=', name, <<"Albert">>}). %%% true = matcher:eval(Map, {'>', age, 18}). %%% ''' %%% %%% == eval vs match == %%% %%% `eval/1,2' returns the evaluated value as-is. %%% `match/1,2' enforces a boolean result and returns %%% `{error, {unevaluated_expression, Expr}}' for non-boolean results. %%% %%% @end %%% Created : 11. Mär 2024 06:38 %%%------------------------------------------------------------------- -module(matcher). -author("Ralf Th. Pietsch "). %% API -export([identity_provider/0, map_provider/1]). -export([eval/1, eval/2]). -export([match/1, match/2]). %% Types -export_type([expression/0, provider/0]). -type provider() :: fun((term()) -> term()). -type unary_op() :: 'not' | '!'. -type binary_op() :: 'eq' | '=' | '==' | '=~' | 'lt' | '<' | '<~' | 'gt' | '>' | '>~' | 'le' | '<=' | '=<' | '<=~' | '=<~' | 'ge' | '>=' | '=>' | '>=~' | '=>~' | 'part_of' | '?' | '?~' | {'eq', 'case_insensitive'} | {'lt', 'case_insensitive'} | {'gt', 'case_insensitive'} | {'le', 'case_insensitive'} | {'ge', 'case_insensitive'} | {'part_of', 'case_insensitive'}. -type nary_op() :: 'and' | '&' | 'or' | '|'. -type expression() :: {unary_op(), expression()} | {binary_op(), term(), term()} | {nary_op(), [expression()]} | term(). %%%=================================================================== %%% API %%%=================================================================== %% @doc Returns a provider that returns its argument unchanged. %% %% This is the default provider used by {@link eval/1} and {@link match/1}. %% %% ``` %% P = matcher:identity_provider(). %% <<"hello">> = P(<<"hello">>). %% ''' -spec identity_provider() -> provider(). identity_provider() -> fun(X) -> X end. %% @doc Returns a provider that looks up values in the given map. %% If the key is not found, the key itself is returned. %% %% ``` %% P = matcher:map_provider(#{name => <<"Alice">>}). %% <<"Alice">> = P(name). %% <<"literal">> = P(<<"literal">>). %% ''' -spec map_provider(map()) -> provider(). map_provider(Map) when is_map(Map) -> fun(X) -> maps:get(X, Map, X) end. %% @doc Evaluates the expression using the identity provider. %% %% ``` %% true = matcher:eval({'=', <<"A">>, <<"A">>}). %% false = matcher:eval({'<', 5, 3}). %% true = matcher:eval({'?', <<"ber">>, <<"Albert">>}). %% ''' -spec eval(expression()) -> term(). eval(Expression) -> eval(identity_provider(), Expression). %% @doc Evaluates the expression using the given provider or map. %% %% When a map is given, it is automatically wrapped with {@link map_provider/1}. %% %% ``` %% Map = #{sender => <<"WDR">>}. %% true = matcher:eval(Map, {'=', sender, <<"WDR">>}). %% true = matcher:eval(Map, {'=~', sender, <<"wdr">>}). %% ''' -spec eval(provider() | map(), expression()) -> term(). eval(Provider, Expression) when is_function(Provider) -> eval_(Provider, Provider(Expression)); eval(Map, Expression) when is_map(Map) -> eval(map_provider(Map), Expression). %% @doc Evaluates the expression and returns a strict boolean. %% %% Returns `true', `false', or `{error, {unevaluated_expression, Expr}}' %% if the expression cannot be fully evaluated to a boolean. %% %% ``` %% true = matcher:match({'=', <<"A">>, <<"A">>}). %% {error, _} = matcher:match({'*', <<"A">>, <<"B">>}). %% ''' -spec match(expression()) -> true | false | {error, {unevaluated_expression, term()}}. match(Expression) -> true_or_false(eval(Expression)). %% @doc Evaluates the expression with a provider and returns a strict boolean. %% %% Returns `true', `false', or `{error, {unevaluated_expression, Expr}}' %% if the expression cannot be fully evaluated to a boolean. %% %% ``` %% Map = #{name => <<"Alice">>}. %% true = matcher:match(Map, {'=', name, <<"Alice">>}). %% false = matcher:match(Map, {'=', name, <<"Bob">>}). %% ''' -spec match(provider() | map(), expression()) -> true | false | {error, {unevaluated_expression, term()}}. match(Provider, Expression) -> true_or_false(eval(Provider, Expression)). %%%=================================================================== %%% Internal functions %%%=================================================================== % replacements eval_(Provider, {'!', A}) -> eval_(Provider, {'not', A}); eval_(Provider, {'&', List}) -> eval_(Provider, {'and', List}); eval_(Provider, {'|', List}) -> eval_(Provider, {'or', List}); eval_(Provider, {'<', A, B}) -> eval_(Provider, {'lt', A, B}); eval_(Provider, {'<~', A, B}) -> eval_(Provider, {{'lt', 'case_insensitive'}, A, B}); eval_(Provider, {'>', A, B}) -> eval_(Provider, {'gt', A, B}); eval_(Provider, {'>~', A, B}) -> eval_(Provider, {{'gt', 'case_insensitive'}, A, B}); eval_(Provider, {'>=', A, B}) -> eval_(Provider, {'ge', A, B}); eval_(Provider, {'=>', A, B}) -> eval_(Provider, {'ge', A, B}); eval_(Provider, {'>=~', A, B}) -> eval_(Provider, {{'ge', 'case_insensitive'}, A, B}); eval_(Provider, {'=>~', A, B}) -> eval_(Provider, {{'ge', 'case_insensitive'}, A, B}); eval_(Provider, {'<=', A, B}) -> eval_(Provider, {'le', A, B}); eval_(Provider, {'=<', A, B}) -> eval_(Provider, {'le', A, B}); eval_(Provider, {'<=~', A, B}) -> eval_(Provider, {{'le', 'case_insensitive'}, A, B}); eval_(Provider, {'=<~', A, B}) -> eval_(Provider, {{'le', 'case_insensitive'}, A, B}); eval_(Provider, {'=', A, B}) -> eval_(Provider, {'eq', A, B}); eval_(Provider, {'==', A, B}) -> eval_(Provider, {'eq', A, B}); eval_(Provider, {'=~', A, B}) -> eval_(Provider, {{'eq', 'case_insensitive'}, A, B}); eval_(Provider, {'?', A, B}) -> eval_(Provider, {'part_of', A, B}); eval_(Provider, {'?~', A, B}) -> eval_(Provider, {{'part_of', 'case_insensitive'}, A, B}); % evaluations % eval_/2 calls eval/2 for replacement! % operators with single operands eval_(Provider, {'not', A}) -> case eval(Provider, A) of true -> false; false -> true; Other -> {'not', Other} end; % operators with any number of operands eval_(_Provider, {'and', []}) -> true; eval_(Provider, {'and', [H | T]}) -> case eval(Provider, H) of true -> eval(Provider, {'and', T}); false -> false; _ -> H % return the original expression -> eval/2 can return it, match/2 can handle it end; eval_(_Provider, {'or', []}) -> false; eval_(Provider, {'or', [H | T]}) -> case eval(Provider, H) of false -> eval(Provider, {'or', T}); true -> true; _ -> H % return the original expression -> eval/2 can return it, match/2 can handle it end; % case insensitive two operands eval_(Provider, {{'part_of', 'case_insensitive'}, A, B}) -> check_part_of(lowercase(eval(Provider, A)), lowercase(eval(Provider, B))); eval_(Provider, {{Op, 'case_insensitive'}, A, B}) -> VA = lowercase(eval(Provider, A)), VB = lowercase(eval(Provider, B)), compare(Op, VA, VB); % operators with two operands eval_(Provider, {'lt', A, B}) -> compare('lt', eval(Provider, A), eval(Provider, B)); eval_(Provider, {'gt', A, B}) -> compare('gt', eval(Provider, A), eval(Provider, B)); eval_(Provider, {'le', A, B}) -> compare('le', eval(Provider, A), eval(Provider, B)); eval_(Provider, {'ge', A, B}) -> compare('ge', eval(Provider, A), eval(Provider, B)); eval_(Provider, {'eq', A, B}) -> compare('eq', eval(Provider, A), eval(Provider, B)); eval_(Provider, {'part_of', Part, Full}) -> check_part_of(eval(Provider, Part), eval(Provider, Full)); % finally we assume to have a value (not an expression) eval_(_Provider, Expr) -> Expr. % --- helpers --- compare('lt', A, B) -> A < B; compare('gt', A, B) -> A > B; compare('le', A, B) -> A =< B; compare('ge', A, B) -> A >= B; compare('eq', A, B) -> A == B. check_part_of(P, F) -> case is_string(P) andalso is_string(F) of true -> case string:find(F, P) of nomatch -> false; _ -> true end; false -> false end. is_string(S) when is_binary(S); is_list(S) -> true; is_string(_) -> false. lowercase(S) when is_binary(S); is_list(S) -> string:lowercase(S); lowercase(X) -> X. true_or_false(true) -> true; true_or_false(false) -> false; true_or_false(Expr) -> {error, {unevaluated_expression, Expr}}.