Current section
Files
Jump to
Current section
Files
src/matcher.erl
%%%-------------------------------------------------------------------
%%% @author Ralf Th. Pietsch <ratopi@abwesend.de>
%%% @copyright (C) 2024-2026, Ralf Th. Pietsch
%%% @doc
%%% A simple expression matcher and evaluator for Erlang.
%%%
%%% Evaluates expressions given as tuples of the form:
%%% <ul>
%%% <li>`{Op, Arg}' - Unary operators (e.g. `not')</li>
%%% <li>`{Op, Arg1, Arg2}' - Binary operators (e.g. `eq', `lt')</li>
%%% <li>`{Op, [Arg, ...]}' - N-ary operators (e.g. `and', `or')</li>
%%% </ul>
%%%
%%% == 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 <ratopi@abwesend.de>").
%% 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}}.