src/yval.erl

%%%-------------------------------------------------------------------
%%% @author Evgeny Khramtsov <ekhramtsov@process-one.net>
%%% @copyright (C) 2002-2020 ProcessOne, SARL. All Rights Reserved.
%%%
%%% Licensed under the Apache License, Version 2.0 (the "License");
%%% you may not use this file except in compliance with the License.
%%% You may obtain a copy of the License at
%%%
%%%     http://www.apache.org/licenses/LICENSE-2.0
%%%
%%% Unless required by applicable law or agreed to in writing, software
%%% distributed under the License is distributed on an "AS IS" BASIS,
%%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%%% See the License for the specific language governing permissions and
%%% limitations under the License.
%%%
%%%-------------------------------------------------------------------
-module(yval).

%% API
-export([validate/2, fail/2]).
-export([format_error/1, format_error/2, format_ctx/1]).
%% Simple types
-export([pos_int/0, pos_int/1, non_neg_int/0, non_neg_int/1]).
-export([int/0, int/2, number/1, number/2, pos_number/0, octal/0]).
-export([binary/0, binary/1, binary/2]).
-export([string/0, string/1, string/2]).
-export([enum/1, bool/0, atom/0, any/0]).
%% Complex types
-export([url/0, url/1]).
-export([file/0, file/1]).
-export([directory/0, directory/1]).
-export([ip/0, ipv4/0, ipv6/0, ip_mask/0, port/0]).
-export([re/0, re/1, glob/0, glob/1]).
-export([path/0, binary_sep/1]).
-export([beam/0, beam/1, base64/0]).
-export([hex/0]).
-export([timeout/1, timeout/2]).
-export([rfc3339_time/1]).
-export([term/0, percent/0, percent/2]).
%% Composite types
-export([list/1, list/2]).
-export([list_or_single/1, list_or_single/2]).
-export([map/2, map/3]).
-export([either/2, and_then/2, non_empty/1]).
-export([options/1, options/2]).

-define(is_validator(Term), is_function(Term, 1)).

-type infinity() :: infinity | infinite | unlimited.
-type timeout_unit() :: millisecond | second | minute | hour | day.
-type time_unit() :: microsecond | millisecond | nanosecond | second.
-type exports() :: [{atom(), arity()} | [{atom(), arity()}]].
-type options() :: [{atom(), term()}] |
                   #{atom() => term()} |
                   dict:dict(atom(), term()).
-type return_type() :: list | map | dict | orddict.
-type unique_opt() :: unique | {unique, boolean()}.
-type sorted_opt() :: sorted | {sorted, boolean()}.
-type ctx() :: [atom() | binary() | integer()].
-type yaml_val() :: atom() | number() | binary().
-type yaml_list() :: [yaml()].
-type yaml_map() :: [{yaml_val(), yaml()}].
-type yaml() :: yaml_val() | yaml_list() | yaml_map().
-type validator_option() :: {required, [atom()]} |
                            {defaults, #{atom() => term()}} |
                            {disallowed, [atom()]} |
                            unique | {unique, boolean()} |
                            {return, return_type()}.
-type validator() :: fun((yaml()) -> term()).
-type validator(T) :: fun((yaml()) -> T).
-type validators() :: #{atom() => validator()}.
-type error_reason() :: term().
-type error_return() :: {error, error_reason(), ctx()}.

-export_type([validator/0, validator/1, validators/0, validator_option/0]).
-export_type([error_return/0, error_reason/0, ctx/0]).

%%%===================================================================
%%% API
%%%===================================================================
-spec validate(validator(), yaml()) -> {ok, any()} | error_return().
validate(Validator, Y) ->
    try {ok, Validator(Y)}
    catch _:{?MODULE, Why, Ctx} ->
            {error, Why, Ctx};
          Class:Reason:Stacktrace ->
            _ = erase_ctx(),
            erlang:raise(Class, Reason, Stacktrace)
    end.

-spec fail(module(), term()) -> no_return().
fail(Mod, Reason) ->
    fail({Mod, Reason}).

%%%===================================================================
%%% Validators
%%%===================================================================
-spec enum([atom() | binary()]) -> validator(atom() | binary()).
enum([H|_] = List) when is_atom(H); is_binary(H) ->
    fun(Val) ->
            Member = if is_binary(H) -> to_binary(Val);
                        is_atom(H) -> to_existing_atom(Val)
                     end,
            case lists:member(Member, List) of
                true -> Member;
                false -> fail({bad_enum, List, Member})
            end
    end.

-spec bool() -> validator(boolean()).
bool() ->
    fun(Val) ->
            case to_existing_atom(Val) of
                on -> true;
                off -> false;
                yes -> true;
                no -> false;
                y -> true;
                n -> false;
                true -> true;
                false -> false;
                Bad -> fail({bad_bool, Bad})
            end
    end.

-spec pos_int() -> validator(pos_integer()).
pos_int() ->
    fun(Val) ->
            case to_int(Val) of
                I when I>0 -> I;
                Bad -> fail({bad_pos_int, Bad})
            end
    end.

-spec pos_int(infinity()) -> validator(pos_integer() | infinity()).
pos_int(Inf) when Inf == infinity; Inf == infinite; Inf == unlimited ->
    fun(Val) ->
            case to_int(Val, Inf) of
                I when I>0 -> I;
                Bad -> fail({bad_pos_int, Inf, Bad})
            end
    end.

-spec non_neg_int() -> validator(non_neg_integer()).
non_neg_int() ->
    fun(Val) ->
            case to_int(Val) of
                I when I>=0 -> I;
                Bad -> fail({bad_non_neg_int, Bad})
            end
    end.

-spec non_neg_int(infinity()) -> validator(non_neg_integer() | infinity()).
non_neg_int(Inf) when Inf == infinity; Inf == infinite; Inf == unlimited ->
    fun(Val) ->
            case to_int(Val, Inf) of
                I when I>=0 -> I;
                Bad -> fail({bad_non_neg_int, Inf, Bad})
            end
    end.

-spec int() -> validator(integer()).
int() ->
    fun to_int/1.

-spec int(integer(), integer() | infinity) -> validator(integer()).
int(Min, Max) when is_integer(Min) andalso
                   (is_integer(Max) orelse Max == infinity) andalso
                   Min =< Max ->
    fun(Val) ->
            case to_int(Val) of
                I when I>=Min, I=<Max -> I;
                Bad -> fail({bad_int, Min, Max, Bad})
            end
    end.

-spec number(number()) -> validator(number()).
number(Min) ->
    number(Min, infinity).

-spec number(number(), number() | infinity) -> validator(number()).
number(Min, Max) when is_number(Min) andalso
                      (is_number(Max) orelse Max == infinity) andalso
                      Min =< Max ->
    fun(Val) ->
            case to_number(Val) of
                N when N >= Min, N =< Max -> N;
                Bad -> fail({bad_number, Min, Max, Bad})
            end
    end.

-spec pos_number() -> validator(number()).
pos_number() ->
    fun(Val) ->
            case to_number(Val) of
                N when N>0 -> N;
                Bad -> fail({bad_pos_number, Bad})
            end
    end.

-spec percent() -> validator(number()).
percent() ->
    percent(0.0, 1.0).

-spec percent(number(), number() | infinity) -> validator(number()).
percent(Min, Max) ->
    fun(Val) when is_number(Val) ->
            (number(Min, Max))(Val);
       (Val) ->
            case string:trim(to_string(Val)) of
                "" -> fail(empty_string);
                S ->
                    case lists:reverse(S) of
                        [$%|T] ->
                            Num = string_to_number(string:trim(lists:reverse(T)))/100,
                            (number(Min, Max))(Num);
                        _ ->
                            fail({bad_number, list_to_binary(S)})
                    end
            end
    end.

-spec binary() -> validator(binary()).
binary() ->
    fun to_binary/1.

-spec binary(iodata()) -> validator(binary()).
binary(Regexp) ->
    binary(Regexp, [unicode]).

-spec binary(iodata(), [proplists:property()]) -> validator(binary()).
binary(Regexp, Opts) when is_list(Regexp) orelse is_binary(Regexp) ->
    {ok, Re} = re:compile(Regexp, Opts),
    fun(Val) ->
            Bin = to_binary(Val),
            case re:run(Bin, Re) of
                {match, _} -> Bin;
                nomatch ->
                    case lists:member(unicode, Opts) of
                        true ->
                            case is_unicode(Bin, utf8) of
                                true -> fail({nomatch, Regexp, Bin});
                                false -> fail({bad_unicode, Bin})
                            end;
                        false ->
                            fail({nomatch, Regexp, Bin})
                    end
            end
    end.

-spec atom() -> validator(atom()).
atom() ->
    fun to_atom/1.

-spec string() -> validator(string()).
string() ->
    fun to_string/1.

-spec string(iodata()) -> validator(string()).
string(Regexp) ->
    string(Regexp, [unicode]).

-spec string(iodata(), [proplists:property()]) -> validator(string()).
string(Regexp, Opts) when is_list(Regexp) orelse is_binary(Regexp) ->
    and_then(
      binary(Regexp, Opts),
      fun binary_to_list/1).

-spec term() -> validator(term()).
term() ->
    fun(Val) ->
            case string:trim(to_string(Val)) of
                "" -> fail(empty_string);
                Str1 ->
                    Str2 = case lists:last(Str1) of
                               $. -> Str1;
                               _ -> Str1 ++ "."
                           end,
                    case erl_scan:string(Str2) of
                        {ok, Tokens, _} ->
                            case erl_parse:parse_term(Tokens) of
                                {ok, Term} -> Term;
                                {error, Reason} -> fail({bad_term, Reason})
                            end;
                        {error, Reason, _} ->
                            fail({bad_term, Reason})
                    end
            end
    end.

-spec binary_sep(iodata()) -> validator([binary()]).
binary_sep(Sep) ->
    fun(Val) ->
            Bin = to_binary(Val),
            lists:filtermap(
              fun(<<>>) -> false;
                 (S) -> {true, S}
              end, re:split(Bin, Sep))
    end.

-spec path() -> validator(binary()).
path() ->
    fun prep_path/1.

-spec file() -> validator(binary()).
file() ->
    file(read).

-spec file(read | write) -> validator(binary()).
file(read) ->
    fun(Val) ->
            Path = prep_path(Val),
            case file:open(Path, [read]) of
                {ok, Fd} ->
                    _ = file:close(Fd),
                    Path;
                {error, Why} ->
                    fail({read_file, Why, Path})
            end
    end;
file(write) ->
    fun(Val) ->
            Path = prep_path(Val),
            case filelib:ensure_dir(Path) of
                ok ->
                    case file:open(Path, [append]) of
                        {ok, Fd} ->
                            _ = file:close(Fd),
                            Path;
                        {error, Why} ->
                            fail({create_file, Why, Path})
                    end;
                {error, Why} ->
                    fail({create_dir, Why, filename:dirname(Path)})
            end
    end.

-spec directory() -> validator(binary()).
directory() ->
    directory(read).

-spec directory(read | write) -> validator(binary()).
directory(read) ->
    fun(Val) ->
            Path = prep_path(Val),
            case filelib:is_dir(Path) of
                true ->
                    Path;
                false ->
                    case file:list_dir(Path) of
                        {error, Why} ->
                            fail({read_dir, Why, Path});
                        {ok, _} ->
                            Path
                    end
            end
    end;
directory(write) ->
    fun(Val) ->
            Path = prep_path(Val),
            case filelib:ensure_dir(filename:join(Path, "foo")) of
                ok ->
                    Path;
                {error, Why} ->
                    fail({create_dir, Why, Path})
            end
    end.

-spec url() -> validator(binary()).
url() ->
    url([http, https]).

-spec url([atom()]) -> validator(binary()).
url(Schemes0) ->
    Schemes = [atom_to_binary(S, latin1) || S <- Schemes0],
    fun(Val) ->
            URL = to_binary(Val),
            case uri_string:parse(URL) of
                #{port := Port} when Port < 1;
                                     Port > 65535 ->
                    fail({bad_url, bad_port, URL});
                #{} = Parsed ->
                    case {maps:get(scheme, Parsed, <<>>),
                          maps:get(host, Parsed, <<>>)} of
                        {<<>>, _} ->
                            fail({bad_url, no_scheme, URL});
                        {_, <<>>} ->
                            fail({bad_url, empty_host, URL});
                        {Scheme, _} when Schemes /= [] ->
                            case lists:member(Scheme, Schemes) of
                                true ->
                                    URL;
                                false ->
                                    fail({bad_url, {unsupported_scheme, Scheme},
                                          URL})
                            end;
                        {_, _} ->
                            URL
                    end;
                {error, Why, _Info} ->
                    fail({bad_url, Why, URL})
            end
    end.

-spec octal() -> validator(non_neg_integer()).
octal() ->
    fun(Val) ->
            Bin = to_binary(Val),
            try binary_to_integer(Bin, 8)
            catch _:_ -> fail({bad_octal, Bin})
            end
    end.

-spec ipv4() -> validator(inet:ip4_address()).
ipv4() ->
    fun(Val) ->
            S = to_string(Val),
            case inet:parse_ipv4_address(to_string(Val)) of
                {ok, IP} -> IP;
                _ -> fail({bad_ipv4, S})
            end
    end.

-spec ipv6() -> validator(inet:ip6_address()).
ipv6() ->
    fun(Val) ->
            S = to_string(Val),
            case inet:parse_ipv6strict_address(S) of
                {ok, IP} -> IP;
                _ -> fail({bad_ipv6, S})
            end
    end.

-spec ip() -> validator(inet:ip_address()).
ip() ->
    fun(Val) ->
            S = to_string(Val),
            case inet:parse_address(S) of
                {ok, IP} -> IP;
                _ -> fail({bad_ip, S})
            end
    end.

-spec ip_mask() -> validator(
                     {inet:ip4_address(), 0..32} |
                     {inet:ip6_address(), 0..128}).
ip_mask() ->
    fun(Val) ->
            S = to_string(Val),
            case parse_ip_netmask(S) of
                {ok, IP, Mask} -> {IP, Mask};
                _ -> fail({bad_ip_mask, S})
            end
    end.

-spec port() -> validator(1..65535).
port() ->
    int(1, 65535).

-spec timeout(timeout_unit()) -> validator(pos_integer()).
timeout(Unit) ->
    fun(Val) ->
            to_timeout(Val, Unit)
    end.

-spec timeout(timeout_unit(), infinity()) -> validator(pos_integer() | infinity()).
timeout(Unit, Inf) ->
    fun(Val) ->
            to_timeout(Val, Unit, Inf)
    end.

-spec rfc3339_time(time_unit()) -> validator(non_neg_integer()).
rfc3339_time(Unit) ->
    fun(Val) ->
            S = to_string(Val),
            try calendar:rfc3339_to_system_time(S, [{unit, Unit}]) of
                Int -> Int
            catch _:_ ->
                    fail({bad_rfc3339_time, S})
            end
    end.

-spec re() -> validator().
re() ->
    re([unicode]).

-spec re([proplists:property()]) -> validator().
re(Opts) ->
    fun(Val) ->
            Bin = to_binary(Val),
            case re:compile(Bin, Opts) of
                {ok, RE} -> RE;
                {error, Why} -> fail({bad_regexp, Why, Bin})
            end
    end.

-spec glob() -> validator().
glob() ->
    glob([]).

-spec glob([proplists:property()]) -> validator().
glob(Opts) ->
    fun(Val) ->
            S = to_string(Val),
            case re:compile(sh_to_awk(S), Opts) of
                {ok, RE} -> RE;
                {error, Why} -> fail({bad_glob, Why, S})
            end
    end.

-spec beam() -> validator(module()).
beam() ->
    beam([]).

-spec beam(exports()) -> validator(module()).
beam(Exports) ->
    fun(Val) ->
            Mod = to_atom(Val),
            case code:ensure_loaded(Mod) of
                {module, Mod} ->
                    lists:foreach(
                      fun([]) ->
                              ok;
                         (L) when is_list(L) ->
                              case lists:any(
                                     fun({F, A}) ->
                                             erlang:function_exported(Mod, F, A)
                                     end, L) of
                                  true -> ok;
                                  false -> fail({bad_export, hd(L), Mod})
                              end;
                         ({F, A}) ->
                              case erlang:function_exported(Mod, F, A) of
                                  true -> ok;
                                  false -> fail({bad_export, {F, A}, Mod})
                              end
                      end, Exports),
                    Mod;
                _ ->
                    fail({bad_module, Mod})
            end
    end.

-spec base64() -> validator(binary()).
base64() ->
    fun(Val) ->
            B = to_binary(Val),
            try base64:decode(B)
            catch _:_ -> fail({bad_base64, B})
            end
    end.

-spec hex() -> validator(binary()).
hex() ->
    fun(Val) ->
            B = to_binary(Val),
            try from_hex(B)
            catch _:_ -> fail({bad_hex, B})
            end
    end.

-spec non_empty(validator(T)) -> validator(T).
non_empty(Fun) ->
    fun(Val) ->
            case Fun(Val) of
                '' -> fail(empty_atom);
                <<"">> -> fail(empty_binary);
                [] -> fail(empty_list);
                Ret -> Ret
            end
    end.

-spec list(validator(T)) -> validator([T]).
list(Fun) ->
    list(Fun, []).

-spec list(validator(T), [unique_opt() | sorted_opt()]) -> validator([T]).
list(Fun, Opts) when ?is_validator(Fun) ->
    fun(L) when is_list(L) ->
            {L1, _} = lists:mapfoldl(
                        fun(Val, Pos) ->
                                Ctx = get_ctx(),
                                put_ctx([Pos|Ctx]),
                                Val1 = Fun(Val),
                                put_ctx(Ctx),
                                {Val1, Pos+1}
                        end, 1, L),
            L2 = unique(L1, Opts),
            case proplists:get_bool(sorted, Opts) of
                true -> lists:sort(L2);
                false -> L2
            end;
       (Bad) ->
            fail({bad_list, Bad})
    end.

-spec list_or_single(validator(T)) -> validator([T]).
list_or_single(Fun) ->
    list_or_single(Fun, []).

-spec list_or_single(validator(T), [unique_opt() | sorted_opt()]) -> validator([T]).
list_or_single(Fun, Opts) when ?is_validator(Fun) ->
    fun(L) when is_list(L) ->
            (list(Fun, Opts))(L);
       (V) ->
            [Fun(V)]
    end.

-spec map(validator(T1), validator(T2)) -> validator([{T1, T2}] | #{T1 => T2}).
map(Fun1, Fun2) ->
    map(Fun1, Fun2, [{return, list}]).

-spec map(validator(T1), validator(T2),
          [{return, return_type()} | unique_opt()]) ->
                 validator([{T1, T2}] | #{T1 => T2} | dict:dict(T1, T2)).
map(Fun1, Fun2, Opts) when ?is_validator(Fun1) andalso
                           ?is_validator(Fun2) ->
    fun(L) when is_list(L) ->
            M1 = lists:map(
                   fun({Key, Val}) ->
                           Key1 = Fun1(Key),
                           Ctx = get_ctx(),
                           put_ctx([Key|Ctx]),
                           Val1 = Fun2(Val),
                           put_ctx(Ctx),
                           {Key1, Val1};
                      (_) ->
                           fail({bad_map, L})
                   end, L),
            M2 = unique(M1, Opts),
            case proplists:get_value(return, Opts, list) of
                list -> M2;
                map -> maps:from_list(M2);
                orddict -> orddict:from_list(M2);
                dict -> dict:from_list(M2)
            end;
       (Bad) ->
            fail({bad_map, Bad})
    end.

-spec either(atom(), validator(T)) -> validator(atom() | T);
            (validator(T1), validator(T2)) -> validator(T1 | T2).
either(Atom, Fun) when is_atom(Atom) andalso ?is_validator(Fun) ->
    either(enum([Atom]), Fun);
either(Fun1, Fun2) when ?is_validator(Fun1) andalso
                        ?is_validator(Fun2) ->
    fun(Val) ->
            Ctx = get_ctx(),
            try Fun1(Val)
            catch _:_ ->
                    put_ctx(Ctx),
                    Fun2(Val)
            end
    end.

-spec and_then(validator(T1), fun((T1) -> T2)) -> validator(T2).
and_then(Fun, Then) when ?is_validator(Fun) andalso
                         is_function(Then, 1) ->
    fun(Val) -> Then(Fun(Val)) end.

-spec any() -> validator(yaml()).
any() ->
    fun(Val) -> Val end.

-spec options(validators()) -> validator().
options(Validators) ->
    options(Validators, [unique]).

-spec options(validators(), [validator_option()]) -> validator().
options(Validators, Options) ->
    fun(Opts) when is_list(Opts) ->
            Required = proplists:get_value(required, Options, []),
            Defaults = proplists:get_value(defaults, Options, #{}),
            Disallowed = proplists:get_value(disallowed, Options, []),
            CheckDups = proplists:get_bool(unique, Options),
            Return = proplists:get_value(return, Options, list),
            DefaultValidator = maps:get('_', Validators, undefined),
            validate_options(Opts, Validators, DefaultValidator,
                             Required, Defaults, Disallowed, CheckDups, Return);
       (Bad) ->
            fail({bad_map, Bad})
    end.

%%%===================================================================
%%% Formatters
%%%===================================================================
-spec format_error(error_reason(), ctx()) -> string().
format_error(Why, []) ->
    format_error(Why);
format_error(Why, Ctx) ->
    format_ctx(Ctx) ++ ": " ++ format_error(Why).

-spec format_error(error_reason()) -> string().
format_error({bad_atom, Bad}) ->
    format("Expected string, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_binary, Bad}) ->
    format("Expected string, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_unicode, _}) ->
    "Non UTF-8 string";
format_error({bad_bool, Bad}) ->
    format("Expected boolean, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_base64, _}) ->
    format("Invalid Base64 string", []);
format_error({bad_hex, _}) ->
    format("Invalid hexadecimal string", []);
format_error({bad_cwd, Why}) ->
    format("Failed to get current directory name: ~s",
           [file:format_error(Why)]);
format_error({bad_enum, _Known, Bad}) ->
    format("Unexpected value: ~s", [Bad]);
format_error({bad_export, {F, A}, Mod}) ->
    format("Module '~s' doesn't export function ~s/~B", [Mod, F, A]);
format_error({bad_glob, {Reason, _}, _}) ->
    format("Invalid glob expression: ~s", [Reason]);
format_error({bad_int, Bad}) ->
    format("Expected integer, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_int, Min, infinity, Bad}) ->
    format("Expected integer >= ~B, got: ~B", [Min, Bad]);
format_error({bad_int, Min, Max, Bad}) ->
    format("Expected integer from ~B to ~B, got: ~B", [Min, Max, Bad]);
format_error({bad_ip_mask, S}) ->
    format("Invalid IP address or network mask: ~s", [S]);
format_error({bad_ip, S}) ->
    format("Invalid IP address: ~s", [S]);
format_error({bad_ipv4, S}) ->
    format("Invalid IPv4 address: ~s", [S]);
format_error({bad_ipv6, S}) ->
    format("Invalid IPv6 address: ~s", [S]);
format_error({bad_length, Limit}) ->
    format("The value must not exceed ~B octets in length", [Limit]);
format_error({bad_list, Bad}) ->
    format("Expected list, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_map, Bad}) ->
    format("Expected map, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_module, Mod}) ->
    format("Unknown module: ~s", [Mod]);
format_error({bad_non_neg_int, Bad}) ->
    format("Expected non negative integer, got: ~B", [Bad]);
format_error({bad_non_neg_int, Inf, Bad}) ->
    format("Expected non negative integer or '~s', got: ~B", [Inf, Bad]);
format_error({bad_number, Bad}) ->
    format("Expected number, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_number, Min, infinity, Bad}) ->
    format("Expected number >= ~p, got: ~p", [Min, Bad]);
format_error({bad_number, Min, Max, Bad}) ->
    format("Expected number from ~p to ~p, got: ~p", [Min, Max, Bad]);
format_error({bad_pos_number, Bad}) ->
    format("Expected positive number, got: ~p", [Bad]);
format_error({bad_octal, Bad}) ->
    format("Expected octal, got: ~s", [Bad]);
format_error({bad_pos_int, Bad}) ->
    format("Expected positive integer, got: ~B", [Bad]);
format_error({bad_pos_int, Inf, Bad}) ->
    format("Expected positive integer or '~s', got: ~B", [Inf, Bad]);
format_error({bad_regexp, {Reason, _}, _}) ->
    format("Invalid regular expression: ~s", [Reason]);
format_error({bad_timeout, Bad}) ->
    format("Expected positive integer, got ~s instead", [format_yaml_type(Bad)]);
format_error({bad_timeout, Inf, Bad}) ->
    format("Expected positive integer or '~s', got ~s instead",
           [Inf, format_yaml_type(Bad)]);
format_error({bad_timeout_unit, Bad}) ->
    format("Unexpected timeout unit: ~s", [Bad]);
format_error({bad_timeout_min, Unit}) ->
    format("Timeout must not be shorter than one ~s", [Unit]);
format_error({bad_rfc3339_time, S}) ->
    format("Expected RFC 3339 timestamp, got: ~s", [S]);
format_error({bad_url, empty_host, URL}) ->
    format("Empty hostname in the URL: ~s", [URL]);
format_error({bad_url, no_scheme, URL}) ->
    format("No scheme in the URL: ~s", [URL]);
format_error({bad_url, {unsupported_scheme, Scheme}, URL}) ->
    format("Unsupported scheme '~s' in the URL: ~s", [Scheme, URL]);
format_error({bad_url, bad_port, URL}) ->
    format("Invalid port number in the URL: ~s", [URL]);
format_error({bad_url, _, URL}) ->
    format("Invalid URL: ~s", [URL]);
format_error({bad_term, {LineNo, Module, Reason}}) ->
    format("Invalid Erlang term: at line ~B: ~s", [LineNo, Module:format_error(Reason)]);
format_error({create_dir, Why, Path}) ->
    format("Failed to create directory '~s': ~s",
           [Path, file:format_error(Why)]);
format_error({create_file, Why, Path}) ->
    format("Failed to open file '~s' for writing: ~s",
           [Path, file:format_error(Why)]);
format_error({disallowed_option, Opt}) ->
    format("Parameter '~s' is not allowed in this context", [Opt]);
format_error({duplicated_key, Key}) ->
    format("Duplicated key: ~s", [format_yaml(Key)]);
format_error({duplicated_value, Val}) ->
    format("Duplicated value: ~s", [format_yaml(Val)]);
format_error({duplicated_option, Opt}) ->
    format("Duplicated parameter: ~s", [Opt]);
format_error(empty_atom) ->
    format("Empty string is not allowed", []);
format_error(empty_binary) ->
    format("Empty string is not allowed", []);
format_error(empty_list) ->
    format("Empty list is not allowed", []);
format_error(empty_string) ->
    format("Empty string is not allowed", []);
format_error({missing_option, Opt}) ->
    format("Missing required parameter: ~s", [Opt]);
format_error({nomatch, Regexp, Data}) ->
    format("String '~s' doesn't match regular expression: ~s",
           [Data, Regexp]);
format_error({read_dir, Why, Path}) ->
    format("Failed to read directory '~s': ~s",
           [Path, file:format_error(Why)]);
format_error({read_file, Why, Path}) ->
    format("Failed to read file '~s': ~s",
           [Path, file:format_error(Why)]);
format_error({unknown_option, _Known, Opt}) ->
    format("Unknown parameter: ~s", [Opt]);
format_error({Mod, Reason}) when is_atom(Mod) ->
    Mod:format_error(Reason);
format_error(Unexpected) ->
    format("Unexpected error reason: ~p", [Unexpected]).

-spec format_ctx(ctx()) -> string().
format_ctx([]) ->
    "Validation error";
format_ctx([_|_] = Ctx) ->
    format("Invalid value of parameter '~s'",
           [string:join(
              lists:map(
                fun(A) when is_atom(A) ->
                        atom_to_list(A);
                   (B) when is_binary(B) ->
                        "'" ++ binary_to_list(B) ++ "'";
                   (I) when is_integer(I) ->
                        integer_to_list(I);
                   (Unexpected) ->
                        lists:flatten(io_lib:format("~p", [Unexpected]))
                end, Ctx),
              "->")]).

-spec format(iodata(), list()) -> string().
format(Fmt, Args) ->
    lists:flatten(io_lib:format(Fmt, Args)).

-spec format_yaml_type(yaml()) -> string().
format_yaml_type(<<>>) ->
    "empty string";
format_yaml_type('') ->
    "empty string";
format_yaml_type([]) ->
    "empty list";
format_yaml_type(I) when is_integer(I) ->
    "integer";
format_yaml_type(N) when is_number(N) ->
    "number";
format_yaml_type(B) when is_binary(B) ->
    "string";
format_yaml_type(A) when is_atom(A) ->
    "string";
format_yaml_type([{_, _}|_]) ->
    "map";
format_yaml_type([_|_]) ->
    "list";
format_yaml_type(Unexpected) ->
    lists:flatten(io_lib:format("~p", [Unexpected])).

-spec format_yaml(yaml()) -> iodata().
format_yaml(I) when is_integer(I) ->
    integer_to_list(I);
format_yaml(B) when is_atom(B) ->
    try erlang:atom_to_binary(B, latin1)
    catch _:badarg -> erlang:atom_to_binary(B, utf8)
    end;
format_yaml(Y) ->
    S = try iolist_to_binary(Y)
        catch _:_ -> list_to_binary(io_lib:format("~p", [Y]))
        end,
    case binary:match(S, <<"\n">>) of
        nomatch -> S;
        _ -> [io_lib:nl(), S]
    end.

%%%===================================================================
%%% Internal functions
%%%===================================================================
%%%===================================================================
%%% Auxiliary functions
%%%===================================================================
-spec to_binary(term()) -> binary().
to_binary(A) when is_atom(A) ->
    atom_to_binary(A, latin1);
to_binary(B) when is_binary(B) ->
    B;
to_binary(Bad) ->
    fail({bad_binary, Bad}).

-spec to_atom(term()) -> atom().
to_atom(B) when is_binary(B) ->
    try binary_to_atom(B, latin1)
    catch _:system_limit -> fail({bad_length, 255})
    end;
to_atom(A) when is_atom(A) ->
    A;
to_atom(Bad) ->
    fail({bad_atom, Bad}).

-spec to_existing_atom(term()) -> atom() | binary().
to_existing_atom(B) when is_binary(B) ->
    try binary_to_existing_atom(B, latin1)
    catch _:_ -> B
    end;
to_existing_atom(A) ->
    to_atom(A).

-spec to_string(term()) -> string().
to_string(A) when is_atom(A) ->
    atom_to_list(A);
to_string(S) ->
    binary_to_list(to_binary(S)).

-spec from_hex(binary()) -> binary().
from_hex(B) when (size(B) rem 2) == 0 ->
    << <<(hexchar_to_digit(Hi)*16 + hexchar_to_digit(Lo))>> || <<Hi, Lo>> <= B >>.

-spec hexchar_to_digit(char()) -> byte().
hexchar_to_digit(C) when C >= $0 andalso C =< $9 ->
    C - $0;
hexchar_to_digit(C) when C >= $a andalso C =< $f ->
    C - 87;
hexchar_to_digit(C) when C >= $A andalso C =< $F ->
    C - 55.

-spec to_int(term()) -> integer().
to_int(I) when is_integer(I) ->
    I;
to_int(Bad) ->
    fail({bad_int, Bad}).

-spec to_int(term(), infinity()) -> integer() | infinity().
to_int(I, _) when is_integer(I) -> I;
to_int(infinity, Inf) -> Inf;
to_int(infinite, Inf) -> Inf;
to_int(unlimited, Inf) -> Inf;
to_int(B, Inf) when is_binary(B) ->
    try binary_to_existing_atom(B, latin1) of
        A -> to_int(A, Inf)
    catch _:_ ->
            fail({bad_int, B})
    end;
to_int(Bad, _) ->
    fail({bad_int, Bad}).

-spec to_number(term()) -> number().
to_number(N) when is_number(N) ->
    N;
to_number(Bad) ->
    fail({bad_number, Bad}).

-spec to_timeout(term(), timeout_unit()) -> pos_integer() | infinity().
to_timeout(Term, Unit) ->
    to_timeout(Term, Unit, undefined).

-spec to_timeout(term(), timeout_unit(), infinity() | undefined) -> pos_integer() | infinity().
to_timeout(I, Unit, Inf) when is_integer(I) ->
    if I>0 -> to_ms(I, Unit);
       Inf == undefined -> fail({bad_pos_int, I});
       true -> fail({bad_pos_int, Inf, I})
    end;
to_timeout(A, Unit, Inf) when is_atom(A) ->
    to_timeout(atom_to_binary(A, latin1), Unit, Inf);
to_timeout(B, Unit, Inf) when is_binary(B) ->
    S = binary_to_list(B),
    case string:to_integer(S) of
        {error, _} when Inf /= undefined ->
            _ = (enum([infinite, infinity, unlimited]))(B),
            Inf;
        {error, _} ->
            fail({bad_int, B});
        {I, ""} when is_integer(I), I>0 ->
            to_ms(I, Unit);
        {I, [_|_] = Suffix} when is_integer(I), I>0 ->
            case timeout_unit(Suffix) of
                {ok, Unit1} -> to_ms(I, Unit1, Unit);
                error -> fail({bad_timeout_unit, Suffix})
            end;
        {I, _} when Inf == undefined ->
            fail({bad_pos_int, I});
        {I, _} ->
            fail({bad_pos_int, Inf, I})
    end;
to_timeout(Bad, _, Inf) when Inf == undefined ->
    fail({bad_timeout, Bad});
to_timeout(Bad, _, Inf) ->
    fail({bad_timeout, Inf, Bad}).

-spec to_ms(pos_integer(), timeout_unit()) -> pos_integer().
to_ms(I, Unit) ->
    case Unit of
        millisecond -> I;
        second -> timer:seconds(I);
        minute -> timer:minutes(I);
        hour -> timer:hours(I);
        day -> timer:hours(I*24)
    end.

-spec to_ms(pos_integer(), timeout_unit(), timeout_unit()) -> pos_integer().
to_ms(I, Unit, MinUnit) ->
    MSecs = to_ms(I, Unit),
    case MSecs >= to_ms(1, MinUnit) of
        true -> MSecs;
        false -> fail({bad_timeout_min, MinUnit})
    end.

-spec timeout_unit(string()) -> {ok, timeout_unit()} | error.
timeout_unit(S) ->
    U = string:strip(string:to_lower(S), both, $ ),
    if U == "ms"; U == "msec"; U == "msecs";
       U == "millisec"; U == "millisecs";
       U == "millisecond"; U == "milliseconds" ->
            {ok, millisecond};
       U == "s"; U == "sec"; U == "secs"; U == "second"; U == "seconds" ->
            {ok, second};
       U == "m"; U == "min"; U == "mins"; U == "minute"; U == "minutes" ->
            {ok, minute};
       U == "h"; U == "hour"; U == "hours" ->
            {ok, hour};
       U == "d"; U == "day"; U == "days" ->
            {ok, day};
       true ->
            error
    end.

-spec is_unicode(binary(), unicode:encoding()) -> boolean().
is_unicode(Bin, Encoding) ->
    try unicode:characters_to_list(Bin, Encoding) of
        L when is_list(L) -> true;
        _ -> false
    catch _:_ ->
            false
    end.

-spec unique(list(T), [proplists:property()]) -> list(T).
unique(L, Opts) ->
    case proplists:get_bool(unique, Opts) of
        true -> unique(L);
        false -> L
    end.

-spec unique(list(T)) -> list(T).
unique([{_, _}|_] = Map) ->
    lists:foldr(
      fun({K, V}, Acc) ->
              case lists:keymember(K, 1, Acc) of
                  true -> fail({duplicated_key, K});
                  false -> [{K, V}|Acc]
              end
      end, [], Map);
unique(L) ->
    lists:foldr(
      fun(X, Acc) ->
              case lists:member(X, Acc) of
                  true -> fail({duplicated_value, X});
                  false -> [X|Acc]
              end
      end, [], L).

-spec string_to_number(string()) -> number().
string_to_number(S) ->
    try erlang:list_to_integer(S) of
        Int -> Int
    catch _:badarg ->
            try erlang:list_to_float(S) of
                Float -> Float
            catch _:badarg ->
                    fail({bad_number, list_to_binary(S)})
            end
    end.

-spec parse_ip_netmask(string()) -> {ok, inet:ip4_address(), 0..32} |
                                    {ok, inet:ip6_address(), 0..128} |
                                    error.
parse_ip_netmask(S) ->
    case string:tokens(S, "/") of
        [IPStr] ->
            case inet:parse_address(IPStr) of
                {ok, {_, _, _, _} = IP} -> {ok, IP, 32};
                {ok, {_, _, _, _, _, _, _, _} = IP} -> {ok, IP, 128};
                _ -> error
            end;
        [IPStr, MaskStr] ->
            try list_to_integer(MaskStr) of
                Mask when Mask >= 0 ->
                    case inet:parse_address(IPStr) of
                        {ok, {_, _, _, _} = IP} when Mask =< 32 ->
                            {ok, IP, Mask};
                        {ok, {_, _, _, _, _, _, _, _} = IP} when Mask =< 128 ->
                            {ok, IP, Mask};
                        _ ->
                            error
                    end;
                _ ->
                    error
            catch _:_ ->
                    error
            end;
        _ ->
            error
    end.

-spec fail(error_reason()) -> no_return().
fail(Reason) ->
    erlang:nif_error({?MODULE, Reason, erase_ctx()}).

-spec prep_path(term()) -> binary().
prep_path(Path0) ->
    Path1 = (non_empty(binary()))(Path0),
    case filename:pathtype(Path1) of
        relative ->
            case file:get_cwd() of
                {ok, CWD} ->
                    filename:join(
                      unicode:characters_to_binary(CWD), Path1);
                {error, Reason} ->
                    fail({bad_cwd, Reason})
            end;
        _ ->
            Path1
    end.

-spec validate_options(list(), validators(), validator() | undefined,
                       [atom()], #{atom() => term()}, [atom()],
                       boolean(), return_type()) -> options().
validate_options(Opts, Validators, DefaultValidator,
                 Required, Defaults, Disallowed, CheckDups, Return) ->
    validate_options(Opts, Validators, DefaultValidator,
                     Required, Defaults, Disallowed, CheckDups, Return, []).

-spec validate_options(list(), validators(), validator() | undefined,
                       [atom()], #{atom() => term()}, [atom()], boolean(),
                       return_type(), options()) -> options().
validate_options([{O, Val}|Opts], Validators, DefaultValidator,
                 Required, Defaults, Disallowed, CheckDups, Return, Acc) ->
    Opt = to_existing_atom(O),
    case lists:member(Opt, Disallowed) of
        true -> fail({disallowed_option, Opt});
        false ->
            case maps:get(Opt, Validators, DefaultValidator) of
                undefined ->
                    Allowed = maps:keys(Validators) -- Disallowed,
                    fail({unknown_option, Allowed, Opt});
                Validator when is_atom(Opt) ->
                    case CheckDups andalso lists:keymember(Opt, 1, Acc) of
                        true -> fail({duplicated_option, Opt});
                        false ->
                            Required1 = proplists:delete(Opt, Required),
                            Acc1 = [{Opt, validate_option(Opt, Val, Validator)}|Acc],
                            validate_options(Opts, Validators, DefaultValidator,
                                             Required1, Defaults, Disallowed,
                                             CheckDups, Return, Acc1)
                    end;
                _ ->
                    validate_options(Opts, Validators, DefaultValidator,
                                     Required, Defaults, Disallowed,
                                     CheckDups, Return, Acc)
            end
    end;
validate_options([], _, _, [], Defaults, _, _, Return, Acc) ->
    case Return of
        list -> apply_defaults(lists:reverse(Acc), Defaults);
        map -> maps:merge(Defaults, maps:from_list(Acc));
        dict -> dict:from_list(apply_defaults(Acc, Defaults));
        orddict -> orddict:from_list(apply_defaults(Acc, Defaults))
    end;
validate_options([], _, _, [Required|_], _,  _, _, _, _) ->
    fail({missing_option, Required});
validate_options(Bad, _, _, _, _, _, _, _, _) ->
    fail({bad_map, Bad}).

-spec validate_option(atom(), yaml(), validator(T)) -> T.
validate_option(Opt, Val, Validator) ->
    Ctx = get_ctx(),
    put_ctx([Opt|Ctx]),
    Ret = Validator(Val),
    put_ctx(Ctx),
    Ret.

-spec apply_defaults([{atom(), T}], #{atom() => T}) -> [{atom(), T}].
apply_defaults(Opts, Defaults) ->
    case maps:size(Defaults) of
        0 -> Opts;
        _ ->
            Rest = lists:foldl(
                     fun({Opt, _}, Acc) ->
                             maps:remove(Opt, Acc)
                     end, Defaults, Opts),
            Opts ++ maps:to_list(Rest)
    end.

%%%===================================================================
%%% Mutable context processing
%%%===================================================================
-spec get_ctx() -> ctx().
get_ctx() ->
    case get(yval_ctx) of
        undefined -> [];
        Opts -> Opts
    end.

-spec put_ctx(ctx()) -> ok.
put_ctx(Opts) ->
    put(yval_ctx, Opts),
    ok.

-spec erase_ctx() -> ctx().
erase_ctx() ->
    case erase(yval_ctx) of
        Opts when is_list(Opts) -> lists:reverse(Opts);
        _ -> []
    end.

%%%===================================================================
%%% Copied from xmerl_regexp.erl to avoid xmerl dependency
%%%===================================================================
-spec sh_to_awk(string()) -> string().
sh_to_awk(Sh) -> "^(" ++ sh_to_awk_1(Sh).    %Fix the beginning

sh_to_awk_1([$*|Sh]) ->                %This matches any string
    ".*" ++ sh_to_awk_1(Sh);
sh_to_awk_1([$?|Sh]) ->                %This matches any character
    [$.|sh_to_awk_1(Sh)];
sh_to_awk_1([$[, $^, $]|Sh]) ->            %This takes careful handling
    "\\^" ++ sh_to_awk_1(Sh);
%% Must move '^' to end.
sh_to_awk_1("[^" ++ Sh) -> [$[|sh_to_awk_2(Sh, true)];
sh_to_awk_1("[!" ++ Sh) -> "[^" ++ sh_to_awk_2(Sh, false);
sh_to_awk_1([$[|Sh]) -> [$[|sh_to_awk_2(Sh, false)];
sh_to_awk_1([C|Sh]) ->
    %% Unspecialise everything else which is not an escape character.
    case sh_special_char(C) of
        true -> [$\\, C|sh_to_awk_1(Sh)];
        false -> [C|sh_to_awk_1(Sh)]
    end;
sh_to_awk_1([]) -> ")$".            %Fix the end

sh_to_awk_2([$]|Sh], UpArrow) -> [$]|sh_to_awk_3(Sh, UpArrow)];
                       sh_to_awk_2(Sh, UpArrow) -> sh_to_awk_3(Sh, UpArrow).

sh_to_awk_3([$]|Sh], true) -> "^]" ++ sh_to_awk_1(Sh);
sh_to_awk_3([$]|Sh], false) -> [$]|sh_to_awk_1(Sh)];
sh_to_awk_3([C|Sh], UpArrow) -> [C|sh_to_awk_3(Sh, UpArrow)];
sh_to_awk_3([], true) -> [$^|sh_to_awk_1([])];
sh_to_awk_3([], false) -> sh_to_awk_1([]).

%% Test if a character is a special character.
-spec sh_special_char(char()) -> boolean().
sh_special_char($|) -> true;
sh_special_char($*) -> true;
sh_special_char($+) -> true;
sh_special_char($?) -> true;
sh_special_char($() -> true;
sh_special_char($)) -> true;
sh_special_char($\\) -> true;
sh_special_char($^) -> true;
sh_special_char($$) -> true;
sh_special_char($.) -> true;
sh_special_char($[) -> true;
sh_special_char($]) -> true;
sh_special_char($") -> true;
sh_special_char(_C) -> false.