-module(zz). -moduledoc """ Zod-like parsing and validation for Erlang. Each combinator returns a `t:parser/0` — a function from input to a `t:result/1`. Compose them, then run with `parse/2`: ```erlang Z = zz:map(#{name => zz:binary(), age => zz:integer(#{min => 0})}), {ok, _} = zz:parse(Z, #{name => <<"x">>, age => 1}). ``` """. -compile({no_auto_import, [float/1]}). -export([ atom/0, binary/0, binary/1, boolean/0, float/0, float/1, integer/0, integer/1, list/0, list/1, list/2, literal/1, map/0, map/1, map/2, optional/1, parse/2, tuple/0, tuple/1, union/1 ]). -export_type([ parser/0, optional_parser/0, result/1, errors/0, binary_options/0, integer_options/0, float_options/0, list_options/0, map_options/0, schema/0 ]). -type result(T) :: {ok, T} | {error, errors()}. -type errors() :: [error()]. -type error() :: atom() | {list, pos_integer(), errors()} | {tuple, pos_integer(), errors()} | {map, term(), errors() | missing_key} | {unknown_keys, [term()]} | {no_match, [errors()]}. -type parser() :: fun((term()) -> result(term())). -type optional_parser() :: {optional, parser()}. -type binary_options() :: #{ min => non_neg_integer(), max => non_neg_integer(), regex => iodata() }. -type integer_options() :: #{min => integer(), max => integer()}. -type float_options() :: #{min => float(), max => float()}. -type list_options() :: #{min => non_neg_integer(), max => non_neg_integer()}. -type map_options() :: #{unknown_keys => strip | passthrough | strict}. -type schema() :: #{term() => parser() | optional_parser()}. -doc "Run parser `Z` against `Input`.". -spec parse(parser(), term()) -> result(term()). parse(Z, Input) -> Z(Input). -doc "Validate that input is an atom.". -spec atom() -> parser(). atom() -> fun (Input) when is_atom(Input) -> {ok, Input}; (_Invalid) -> {error, [not_atom]} end. -doc "Validate that input is a binary.". -spec binary() -> parser(). binary() -> binary(#{}). -doc """ Validate that input is a binary, with optional `min`/`max` byte size and `regex` constraints. """. -spec binary(binary_options()) -> parser(). binary(Options) -> fun (Input) when is_binary(Input) -> Errors = maps:fold( fun (min, Min, Es) when byte_size(Input) < Min -> [binary_too_short | Es]; (min, _Min, Es) -> Es; (max, Max, Es) when byte_size(Input) > Max -> [binary_too_long | Es]; (max, _Max, Es) -> Es; (regex, Regex, Es) when is_binary(Regex); is_list(Regex) -> case re:run(Input, Regex) of nomatch -> [regex_mismatch | Es]; _ -> Es end end, [], Options ), case Errors of [] -> {ok, Input}; _ -> {error, Errors} end; (_Invalid) -> {error, [not_binary]} end. -doc "Validate that input is a boolean.". -spec boolean() -> parser(). boolean() -> fun (Input) when is_boolean(Input) -> {ok, Input}; (_Invalid) -> {error, [not_boolean]} end. -doc "Validate that input is an integer.". -spec integer() -> parser(). integer() -> integer(#{}). -doc "Validate that input is an integer, with optional `min`/`max`.". -spec integer(integer_options()) -> parser(). integer(Options) -> fun (Input) when is_integer(Input) -> Errors = maps:fold( fun (min, Min, Es) when Input < Min -> [integer_too_small | Es]; (min, _Min, Es) -> Es; (max, Max, Es) when Input > Max -> [integer_too_large | Es]; (max, _Max, Es) -> Es end, [], Options ), case Errors of [] -> {ok, Input}; _ -> {error, Errors} end; (_Invalid) -> {error, [not_integer]} end. -doc "Validate that input is a float.". -spec float() -> parser(). float() -> float(#{}). -doc "Validate that input is a float, with optional `min`/`max`.". -spec float(float_options()) -> parser(). float(Options) -> fun (Input) when is_float(Input) -> Errors = maps:fold( fun (min, Min, Es) when Input < Min -> [float_too_small | Es]; (min, _Min, Es) -> Es; (max, Max, Es) when Input > Max -> [float_too_large | Es]; (max, _Max, Es) -> Es end, [], Options ), case Errors of [] -> {ok, Input}; _ -> {error, Errors} end; (_Invalid) -> {error, [not_float]} end. -doc "Validate that input is a list (any contents).". -spec list() -> parser(). list() -> fun (Input) when is_list(Input) -> {ok, Input}; (_Invalid) -> {error, [not_list]} end. -doc """ With a list of parsers, validate a fixed-length tuple-like list where each element is parsed by the corresponding parser. With a single parser, validate a homogeneous list (equivalent to `list(Z, #{})`). """. -spec list([parser()] | parser()) -> parser(). list(Zs) when is_list(Zs) -> Length = length(Zs), fun (Input) when is_list(Input), length(Input) =:= Length -> Zip = lists:zip(Zs, Input), {_, O1, E1} = lists:foldl( fun({Z, I}, {N, Os, Es}) -> case Z(I) of {ok, O} -> {N + 1, [O | Os], Es}; {error, E} -> {N + 1, Os, [{list, N, E} | Es]} end end, {1, [], []}, Zip ), case E1 of [] -> {ok, lists:reverse(O1)}; _ -> {error, lists:reverse(E1)} end; (Input) when is_list(Input) -> {error, [length_mismatch]}; (_Invalid) -> {error, [not_list]} end; list(Z) -> list(Z, #{}). -doc """ Validate a homogeneous list, parsing each element with `Z`. Optional `min`/`max` constrain length. """. -spec list(parser(), list_options()) -> parser(). list(Z, Options) -> fun (Input) when is_list(Input) -> case maps:get(max, Options, infinity) of Max when is_integer(Max), length(Input) > Max -> {error, [list_too_long]}; _ -> case maps:get(min, Options, 0) of Min when is_integer(Min), length(Input) < Min -> {error, [list_too_short]}; _ -> {_, O1, E1} = lists:foldl( fun(I, {N, Os, Es}) -> case Z(I) of {ok, O} -> {N + 1, [O | Os], Es}; {error, E} -> {N + 1, Os, [{list, N, E} | Es]} end end, {1, [], []}, Input ), case E1 of [] -> {ok, lists:reverse(O1)}; _ -> {error, lists:reverse(E1)} end end end; (_Invalid) -> {error, [not_list]} end. -doc "Validate that input equals `Value` exactly (`=:=`).". -spec literal(term()) -> parser(). literal(Value) -> fun (Input) when Input =:= Value -> {ok, Input}; (_Invalid) -> {error, [not_literal]} end. -doc "Validate that input is a map (passthrough on contents).". -spec map() -> parser(). map() -> map(#{}, #{unknown_keys => passthrough}). -doc "Validate a map against `Schema` (equivalent to `map(Schema, #{})`).". -spec map(schema()) -> parser(). map(Schema) -> map(Schema, #{}). -doc """ Validate a map against `Schema`. `unknown_keys` controls handling of keys not in `Schema`: `strip` (drop, default), `passthrough` (keep), `strict` (error). """. -spec map(schema(), map_options()) -> parser(). map(Schema, Options) -> fun (Input) when is_map(Input) -> {Output1, RemainingMap, Errors1} = maps:fold( fun (K, {optional, Z}, {Os, Is, Es}) -> case maps:take(K, Is) of error -> {Os, Is, Es}; {Value, Rest} -> case Z(Value) of {ok, O} -> {Os#{K => O}, Rest, Es}; {error, E} -> {Os, Rest, [{map, K, E} | Es]} end end; (K, Z, {Os, Is, Es}) -> case maps:take(K, Is) of error -> {Os, Is, [{map, K, missing_key} | Es]}; {Value, Rest} -> case Z(Value) of {ok, O} -> {Os#{K => O}, Rest, Es}; {error, E} -> {Os, Rest, [{map, K, E} | Es]} end end end, {#{}, Input, []}, Schema ), {Output2, Errors2} = case maps:get(unknown_keys, Options, strip) of strip -> {Output1, Errors1}; passthrough -> {maps:merge(Output1, RemainingMap), Errors1}; strict -> {Output1, [{unknown_keys, maps:keys(RemainingMap)} | Errors1]} end, case Errors2 of [] -> {ok, Output2}; _ -> {error, lists:reverse(Errors2)} end; (_Invalid) -> {error, [not_map]} end. -doc """ Mark a parser as optional in a `t:schema/0`. Inside a `map/2` schema, an optional key may be absent without producing an error. """. -spec optional(parser()) -> optional_parser(). optional(Z) -> {optional, Z}. -doc "Validate that input is a tuple (passthrough on contents).". -spec tuple() -> parser(). tuple() -> fun (Input) when is_tuple(Input) -> {ok, Input}; (_Invalid) -> {error, [not_tuple]} end. -doc """ Validate a fixed-arity tuple where each element is parsed by the corresponding parser in `Zs`. Element errors are wrapped as `{tuple, Index, InnerErrors}` with 1-based `Index`. """. -spec tuple([parser()]) -> parser(). tuple(Zs) when is_list(Zs) -> Arity = length(Zs), fun (Input) when is_tuple(Input), tuple_size(Input) =:= Arity -> Zip = lists:zip(Zs, tuple_to_list(Input)), {_, O1, E1} = lists:foldl( fun({Z, I}, {N, Os, Es}) -> case Z(I) of {ok, O} -> {N + 1, [O | Os], Es}; {error, E} -> {N + 1, Os, [{tuple, N, E} | Es]} end end, {1, [], []}, Zip ), case E1 of [] -> {ok, list_to_tuple(lists:reverse(O1))}; _ -> {error, lists:reverse(E1)} end; (Input) when is_tuple(Input) -> {error, [arity_mismatch]}; (_Invalid) -> {error, [not_tuple]} end. -doc """ Validate against the first parser that succeeds. If none match, returns `{error, [{no_match, [Errors1, Errors2, ...]}]}` where each entry is the errors list from the corresponding parser, in input order. Empty union yields `{error, [{no_match, []}]}`. """. -spec union([parser()]) -> parser(). union(Zs) -> fun(Input) -> try_branches(Zs, Input, []) end. %%%=========================================================================== %%% Internal functions %%%=========================================================================== try_branches([], _Input, Errs) -> {error, [{no_match, lists:reverse(Errs)}]}; try_branches([Z | Rest], Input, Errs) -> case Z(Input) of {ok, _} = Ok -> Ok; {error, E} -> try_branches(Rest, Input, [E | Errs]) end.