src/euneus_encoder.erl

%%%---------------------------------------------------------------------
%%% @copyright 2023 William Fank Thomé
%%% @author William Fank Thomé <willilamthome@hotmail.com>
%%% @doc JSON generator.
%%%
%%% Copyright 2023 William Fank Thomé
%%%
%%% 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.
%%%
%%% @end
%%%---------------------------------------------------------------------
-module(euneus_encoder).

-compile({ inline, plugins/3 }).
-compile({ inline, drop_nulls/2 }).
-compile({ inline, encode_binary/2 }).
-compile({ inline, encode_atom/2 }).
-compile({ inline, encode_integer/2 }).
-compile({ inline, encode_float/2 }).
-compile({ inline, encode_list/2 }).
-compile({ inline, encode_map/2 }).
-compile({ inline, encode_unhandled/2 }).
-compile({ inline, escape_json/4 }).
-compile({ inline, escape_json_chunk/5 }).
-compile({ inline, escape_html/4 }).
-compile({ inline, escape_html_chunk/5 }).
-compile({ inline, escape_js/4 }).
-compile({ inline, escape_js_chunk/5 }).
-compile({ inline, escape_unicode/4 }).
-compile({ inline, escape_unicode_chunk/5 }).
-compile({ inline, handle_error/3 }).
-compile({ inline, maps_get/3 }).
-compile({ inline, maps_to_list/1 }).

% By default, encode_unhandled/2 will raise unsupported_type exception,
% so it is a function without a local return. Note that parse_opts/1
% is included because unhandled_encoder option has no local return.
-dialyzer({ no_return, parse_opts/1 }).
-dialyzer({ no_return, encode_unhandled/2 }).
-dialyzer( no_improper_lists ).

%% API functions

-export([ encode/2 ]).
-export([ parse_opts/1 ]).
-export([ get_nulls_option/1 ]).
-export([ get_binary_encoder_option/1 ]).
-export([ get_atom_encoder_option/1 ]).
-export([ get_integer_encoder_option/1 ]).
-export([ get_float_encoder_option/1 ]).
-export([ get_list_encoder_option/1 ]).
-export([ get_map_encoder_option/1 ]).
-export([ get_unhandled_encoder_option/1 ]).
-export([ get_escaper_option/1 ]).
-export([ get_error_handler_option/1 ]).
-export([ get_plugins_option/1 ]).
-export([ encode_parsed/2 ]).
-export([ encode_binary/2 ]).
-export([ encode_atom/2 ]).
-export([ encode_integer/2 ]).
-export([ encode_float/2 ]).
-export([ encode_list/2 ]).
-export([ encode_map/2 ]).
-export([ encode_unhandled/2 ]).
-export([ escape/2 ]).
-export([ escape_byte/1 ]).
-export([ escape_json/1 ]).
-export([ escape_html/1 ]).
-export([ escape_js/1 ]).
-export([ escape_unicode/1 ]).
-export([ throw_unsupported_type_error/1 ]).
-export([ handle_error/3 ]).

%% Types

-export_type([ input/0 ]).
-export_type([ options/0 ]).
-export_type([ parsed_options/0 ]).
-export_type([ result/0 ]).
-export_type([ encoder/1 ]).
-export_type([ escaper/1 ]).
-export_type([ error_handler/0 ]).
-export_type([ error_reason/0 ]).

-record(opts, { nulls :: list()
              , binary_encoder :: encoder(binary())
              , atom_encoder :: encoder(atom())
              , integer_encoder :: encoder(integer())
              , float_encoder :: encoder(float())
              , list_encoder :: encoder(list())
              , map_encoder :: encoder(map())
              , unhandled_encoder :: encoder(term())
              , escaper :: json
                         | html
                         | javascript
                         | unicode
                         | escaper(binary())
              , error_handler :: error_handler()
              , plugins :: [plugin()]
              }).

-type input() :: term().
-type options() :: map().
-type parsed_options() :: #opts{}.
-type result() :: {ok, iolist()} | {error, error_reason()}.
-type encoder(Input) :: fun((Input, parsed_options()) -> iolist()).
-type escaper(Input) :: fun((Input, parsed_options()) -> iolist()).
-type error_class() :: error | exit | throw.
-type unsupported_type_error() :: {unsupported_type, Unsupported :: term()}.
-type invalid_byte_error() :: {invalid_byte, Byte :: byte(), Input :: binary()}.
-type error_reason() :: unsupported_type_error() | invalid_byte_error().
-type error_stacktrace() :: erlang:stacktrace().
-type error_handler() :: fun(( error_class()
                             , error_reason()
                             , error_stacktrace() ) -> error_stacktrace()).
-type plugin() :: datetime
                | inet
                | pid
                | port
                | proplist
                | reference
                | timestamp
                | drop_nulls
                | module()
                .

%% Macros

-define(min(X, Min), is_integer(X) andalso X >= Min).
-define(range(X, Min, Max), is_integer(X) andalso X >= Min andalso X =< Max).
-define(is_proplist_key(X), is_binary(X) orelse is_atom(X) orelse is_integer(X)).

-define(NON_PRINTABLE_LAST, 31).
-define(ONE_BYTE_LAST, 127).
-define(TWO_BYTE_LAST, 2_047).
-define(THREE_BYTE_LAST, 65_535).

%%%=====================================================================
%%% API functions
%%%=====================================================================

%%----------------------------------------------------------------------
%% @doc Generates a JSON from Erlang term.
%%
%% @param Term :: {@link euneus_encoder:input()}.
%% @param Opts :: {@link euneus_encoder:options()}.
%%
%% @returns {@link euneus_encoder:result()}.
%%
%% @end
%%----------------------------------------------------------------------
-spec encode(input(), options()) -> result().

encode(Term, Opts) ->
    encode_parsed(Term, parse_opts(Opts)).

%%----------------------------------------------------------------------
%% @doc Parses {@link euneus_encoder:options()} to {@link euneus_encoder:parsed_options()}.
%%
%% The parsed map can be expanded in compile time or stored to be
%% reused, avoiding parsing the options in every encoding.
%%
%% @end
%%
%% NOTE: The explicit call of functions wrapped in another function is
%% required for the inline optimization.
%%
%% @param Opts :: {@link euneus_encoder:options()}.
%%
%% @returns {@link euneus_encoder:parsed_options()}.
%%
%% @end
%%----------------------------------------------------------------------
-spec parse_opts(options()) -> parsed_options().

parse_opts(Opts) ->
    #opts{
        nulls = maps_get(nulls, Opts, [undefined]),
        binary_encoder = maps_get(binary_encoder, Opts, fun (X, O) ->
            escape(X, O)
        end),
        atom_encoder = maps_get(atom_encoder, Opts, fun (X, O) ->
            encode_atom(X, O)
        end),
        integer_encoder = maps_get(integer_encoder, Opts, fun (X, O) ->
            encode_integer(X, O)
        end),
        float_encoder = maps_get(float_encoder, Opts, fun (X, O) ->
            encode_float(X, O)
        end),
        list_encoder = maps_get(list_encoder, Opts, fun (X, O) ->
            encode_list(X, O)
        end),
        map_encoder = maps_get(map_encoder, Opts, fun (X, O) ->
            encode_map(X, O)
        end),
        unhandled_encoder = maps_get(unhandled_encoder, Opts, fun (X, O) ->
            encode_unhandled(X, O)
        end),
        escaper =
            case maps_get(escaper, Opts, json) of
                json ->
                    fun(X, _O) -> [$", escape_json(X, [], X, 0), $"] end;
                html ->
                    fun(X, _O) -> [$", escape_html(X, [], X, 0), $"] end;
                javascript ->
                    fun(X, _O) -> [$", escape_js(X, [], X, 0), $"] end;
                unicode ->
                    fun(X, _O) -> [$", escape_unicode(X, [], X, 0), $"] end;
                Fun when is_function(Fun, 2) ->
                    Fun
            end,
        error_handler = maps_get(error_handler, Opts, fun(C, R, S) ->
            handle_error(C, R, S)
        end),
        plugins = maps_get(plugins, Opts, [])
    }.

%%%---------------------------------------------------------------------
%%% Options
%%%---------------------------------------------------------------------

get_nulls_option(#opts{nulls = Nulls}) ->
    Nulls.

get_binary_encoder_option(#opts{binary_encoder = BinaryEncoder}) ->
    BinaryEncoder.

get_atom_encoder_option(#opts{atom_encoder = AtomEncoder}) ->
    AtomEncoder.

get_integer_encoder_option(#opts{integer_encoder = IntegerEncoder}) ->
    IntegerEncoder.

get_float_encoder_option(#opts{float_encoder = FloatEncoder}) ->
    FloatEncoder.

get_list_encoder_option(#opts{list_encoder = ListEncoder}) ->
    ListEncoder.

get_map_encoder_option(#opts{map_encoder = MapEncoder}) ->
    MapEncoder.

get_unhandled_encoder_option(#opts{unhandled_encoder = UnhandledEncoder}) ->
    UnhandledEncoder.

get_escaper_option(#opts{escaper = Escaper}) ->
    Escaper.

get_error_handler_option(#opts{error_handler = Handler}) ->
    Handler.

get_plugins_option(#opts{plugins = Plugins}) ->
    Plugins.

%%----------------------------------------------------------------------
%% @doc Generates a JSON from Erlang term.
%%
%% @param Term :: {@link euneus_encoder:input()}.
%% @param Opts :: {@link euneus_encoder:parsed_options()}.
%%
%% @returns {@link euneus_encoder:result()}.
%%
%% @see euneus_encoder:parse_opts/1
%%
%% @end
%%----------------------------------------------------------------------
-spec encode_parsed(input(), parsed_options()) -> result().

encode_parsed(Term, Opts) ->
    try
        {ok, value(Term, Opts)}
    catch
        Class:Reason:Stacktrace ->
            Handle = Opts#opts.error_handler,
            Handle(Class, Reason, Stacktrace)
    end.

encode_binary(Bin, Opts) ->
    escape(Bin, Opts).

encode_atom(true, _Opts) ->
    <<"true">>;
encode_atom(false, _Opts) ->
    <<"false">>;
encode_atom(Atom, #opts{nulls = Nulls} = Opts) ->
    case lists:member(Atom, Nulls) of
        true ->
            <<"null">>;
        false ->
            escape(atom_to_binary(Atom, utf8), Opts)
    end.

encode_integer(Int, _Opts) ->
    integer_to_binary(Int).

encode_float(Float, _Opts) ->
    float_to_binary(Float, [short]).

encode_list([H | T], Opts) ->
    [$[, value(H, Opts), do_encode_list_loop(T, Opts)];
encode_list([], _Opts) ->
    <<"[]">>.

do_encode_list_loop([], _Opts) ->
    [$]];
do_encode_list_loop([H | T], Opts) ->
    [$,, value(H, Opts) | do_encode_list_loop(T, Opts)].

encode_map(Map, Opts) ->
    do_encode_map(maps_to_list(Map), Opts).

do_encode_map([{K, V} | T], Opts) ->
    [${, key(K, Opts), $:, value(V, Opts) | do_encode_map_loop(T, Opts)];
do_encode_map([], _) ->
    <<"{}">>.

do_encode_map_loop([], _Opts) ->
    [$}];
do_encode_map_loop([{K, V} | T], Opts) ->
    [$,, key(K, Opts), $:, value(V, Opts) | do_encode_map_loop(T, Opts)].

encode_unhandled(Term, _Opts) ->
    throw_unsupported_type_error(Term).

escape(Bin, #opts{escaper = Escape} = Opts) ->
    Escape(Bin, Opts).

escape_json(Bin) ->
    escape_json(Bin, [], Bin, 0).

escape_json(Data, Acc, Input, Pos) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\"">>],
            escape_json(Rest, Acc1, Input, Pos+1);
        <<$\\/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\\">>],
            escape_json(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Acc1 = [Acc | escape_byte(Byte)],
            escape_json(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, 1);
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, 2);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, 3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_json_chunk(Rest, Acc, Input, Pos, 4);
        <<>> ->
            Acc;
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_json_chunk(Data, Acc, Input, Pos, Len) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\"">>]],
            escape_json(Rest, Acc1, Input, Pos+Len+1);
        <<$\\/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\\">>]],
            escape_json(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, escape_byte(Byte)]],
            escape_json(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, Len+1);
        <<>> ->
            case Acc =:= [] of
                true ->
                    binary_part(Input, Pos, Len);
                false ->
                    [Acc, binary_part(Input, Pos, Len)]
            end;
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, Len+2);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_json_chunk(Rest, Acc, Input, Pos, Len+3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_json_chunk(Rest, Acc, Input, Pos, Len+4);
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_html(Bin) ->
    escape_html(Bin, [], Bin, 0).

escape_html(Data, Acc, Input, Pos) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
          Acc1 = [Acc, <<"\\\"">>],
          escape_html(Rest, Acc1, Input, Pos+1);
        <<$\\/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\\">>],
            escape_html(Rest, Acc1, Input, Pos+1);
        <<$//integer, Rest/bitstring>> ->
          Acc1 = [Acc | <<"\\/">>],
          escape_html(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte < 33 ->
            Acc1 = [Acc, escape_byte(Byte)],
            escape_html(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, 1);
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, 2);
        <<8232/utf8, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\u2028">>],
            escape_html(Rest, Acc1, Input, Pos+3);
        <<8233/utf8, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\u2029">>],
            escape_html(Rest, Acc1, Input, Pos+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, 3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_html_chunk(Rest, Acc, Input, Pos, 4);
        <<>> ->
            Acc;
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_html_chunk(Data, Acc, Input, Pos, Len) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc | [Part, <<"\\\"">>]],
            escape_html(Rest, Acc2, Input, Pos+Len+1);
        <<$\\/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc | [Part, <<"\\\\">>]],
            escape_html(Rest, Acc2, Input, Pos+Len+1);
        <<$//integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc | [Part, <<"\\/">>]],
            escape_html(Rest, Acc2, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc, Part, escape_byte(Byte)],
            escape_html(Rest, Acc2, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, Len+1);
        <<>> ->
            case Acc =:= [] of
                true ->
                    binary_part(Input, Pos, Len);
                false ->
                    [Acc, binary_part(Input, Pos, Len)]
            end;
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, Len+2);
        <<8232/utf8, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc | [Part, <<"\\u2028">>]],
            escape_html(Rest, Acc2, Input, Pos+Len+3);
        <<8233/utf8, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc2 = [Acc | [Part, <<"\\u2029">>]],
            escape_html(Rest, Acc2, Input, Pos+Len+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_html_chunk(Rest, Acc, Input, Pos, Len+3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_html_chunk(Rest, Acc, Input, Pos, Len+4);
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_js(Bin) ->
    escape_js(Bin, [], Bin, 0).

escape_js(Data, Acc, Input, Pos) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\"">>],
            escape_js(Rest, Acc1, Input, Pos+1);
        <<$\\/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\\">>],
            escape_js(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Acc1 = [Acc | escape_byte(Byte)],
            escape_js(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, 1);
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, 2);
        <<8232/utf8, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\u2028">>],
            escape_js(Rest, Acc1, Input, Pos+3);
        <<8233/utf8, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\u2029">>],
            escape_js(Rest, Acc1, Input, Pos+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, 3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_js_chunk(Rest, Acc, Input, Pos, 4);
        <<>> ->
            Acc;
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_js_chunk(Data, Acc, Input, Pos, Len) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\"">>]],
            escape_js(Rest, Acc1, Input, Pos+Len+1);
        <<$\\/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\\">>]],
            escape_js(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, escape_byte(Byte)]],
            escape_js(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, Len+1);
        <<>> ->
            case Acc =:= [] of
                true ->
                    binary_part(Input, Pos, Len);
                false ->
                    [Acc, binary_part(Input, Pos, Len)]
            end;
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, Len+2);
        <<8232/utf8, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u2028">>]],
            escape_js(Rest, Acc1, Input, Pos+Len+3);
        <<8233/utf8, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u2029">>]],
            escape_js(Rest, Acc1, Input, Pos+Len+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            escape_js_chunk(Rest, Acc, Input, Pos, Len+3);
        <<_Char/utf8, Rest/bitstring>> ->
            escape_js_chunk(Rest, Acc, Input, Pos, Len+4);
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_unicode(Bin) ->
    escape_unicode(Bin, [], Bin, 0).

escape_unicode(Data, Acc, Input, Pos) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\"">>],
            escape_unicode(Rest, Acc1, Input, Pos+1);
        <<$\\/integer, Rest/bitstring>> ->
            Acc1 = [Acc | <<"\\\\">>],
            escape_unicode(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Acc1 = [Acc | escape_byte(Byte)],
            escape_unicode(Rest, Acc1, Input, Pos+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_unicode_chunk(Rest, Acc, Input, Pos, 1);
        <<Char/utf8, Rest/bitstring>> when Char < 256 ->
            Acc1 = [Acc | [<<"\\u00">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+2);
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            Acc1 = [Acc | [<<"\\u0">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+2);
        <<Char/utf8, Rest/bitstring>> when Char < 4096 ->
            Acc1 = [Acc | [<<"\\u0">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            Acc1 = [Acc | [<<"\\u">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+3);
        <<Char0/utf8, Rest/bitstring>> ->
            Char = Char0 - 65536,
            Acc1 = [ Acc
                   | [ <<"\\uD">>
                     , integer_to_binary(2048 bor (Char bsr 10), 16)
                     , <<"\\uD">>
                     , integer_to_binary(3072 bor Char band 1023, 16) ]
                   ],
            escape_unicode(Rest, Acc1, Input, Pos+4);
        <<>> ->
            Acc;
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_unicode_chunk(Data, Acc, Input, Pos, Len) ->
    case Data of
        <<$"/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\"">>]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+1);
        <<$\\/integer, Rest/bitstring>> ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\\\">>]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?NON_PRINTABLE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, escape_byte(Byte)]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+1);
        <<Byte/integer, Rest/bitstring>> when Byte =< ?ONE_BYTE_LAST ->
            escape_unicode_chunk(Rest, Acc, Input, Pos, Len+1);
        <<>> ->
            case Acc =:= [] of
                true ->
                    binary_part(Input, Pos, Len);
                false ->
                    [Acc | binary_part(Input, Pos, Len)]
            end;
        <<Char/utf8, Rest/bitstring>> when Char < 256 ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u00">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+2);
        <<Char/utf8, Rest/bitstring>> when Char =< ?TWO_BYTE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u0">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+2);
        <<Char/utf8, Rest/bitstring>> when Char < 4096 ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u0">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+3);
        <<Char/utf8, Rest/bitstring>> when Char =< ?THREE_BYTE_LAST ->
            Part = binary_part(Input, Pos, Len),
            Acc1 = [Acc | [Part, <<"\\u">>, integer_to_binary(Char, 16)]],
            escape_unicode(Rest, Acc1, Input, Pos+Len+3);
        <<Char0/utf8, Rest/bitstring>> ->
            Char = Char0 - 65536,
            Part = binary_part(Input, Pos, Len),
            Acc1 = [ Acc
                   | [ Part
                     , <<"\\uD">>
                     , integer_to_binary(2048 bor (Char bsr 10), 16)
                     , <<"\\uD">>
                     , integer_to_binary(3072 bor Char band 1023, 16) ]
                   ],
            escape_unicode(Rest, Acc1, Input, Pos+Len+4);
        <<Byte/integer, _Rest/bitstring>> ->
            throw_invalid_byte_error(Byte, Input)
    end.

escape_byte(0) -> <<"\\u0000">>;
escape_byte(1) -> <<"\\u0001">>;
escape_byte(2) -> <<"\\u0002">>;
escape_byte(3) -> <<"\\u0003">>;
escape_byte(4) -> <<"\\u0004">>;
escape_byte(5) -> <<"\\u0005">>;
escape_byte(6) -> <<"\\u0006">>;
escape_byte(7) -> <<"\\u0007">>;
escape_byte($\b) -> <<"\\b">>;
escape_byte($\t) -> <<"\\t">>;
escape_byte($\n) -> <<"\\n">>;
escape_byte($\v) -> <<"\\u000B">>;
escape_byte($\f) -> <<"\\f">>;
escape_byte($\r) -> <<"\\r">>;
escape_byte(14) -> <<"\\u000E">>;
escape_byte(15) -> <<"\\u000F">>;
escape_byte(16) -> <<"\\u0010">>;
escape_byte(17) -> <<"\\u0011">>;
escape_byte(18) -> <<"\\u0012">>;
escape_byte(19) -> <<"\\u0013">>;
escape_byte(20) -> <<"\\u0014">>;
escape_byte(21) -> <<"\\u0015">>;
escape_byte(22) -> <<"\\u0016">>;
escape_byte(23) -> <<"\\u0017">>;
escape_byte(24) -> <<"\\u0018">>;
escape_byte(25) -> <<"\\u0019">>;
escape_byte(26) -> <<"\\u001A">>;
escape_byte($\e) -> <<"\\u001B">>;
escape_byte(28) -> <<"\\u001C">>;
escape_byte(29) -> <<"\\u001D">>;
escape_byte(30) -> <<"\\u001E">>;
escape_byte(31) -> <<"\\u001F">>;
escape_byte($\") -> <<"\\\"">>;
escape_byte($/) -> <<"\\/">>;
escape_byte($\\) -> <<"\\\\">>;
escape_byte(Byte) -> throw_invalid_byte_error(Byte, Byte).

throw_unsupported_type_error(Term) ->
    throw({unsupported_type, Term}).

throw_invalid_byte_error(Byte, Input) ->
    throw({invalid_byte, Byte, Input}).

handle_error(throw, Reason, _Stacktrace) ->
    case Reason of
        {unsupported_type, Unsupported} ->
            {error, {unsupported_type, Unsupported}};
        {invalid_byte, Byte0, Input} ->
            Byte = <<"0x"/utf8, (integer_to_binary(Byte0, 16))/binary>>,
            {error, {invalid_byte, Byte, Input}};
        _ ->
            {error, Reason}
    end;
handle_error(Class, Reason, Stacktrace) ->
    erlang:raise(Class, Reason, Stacktrace).

%%%=====================================================================
%%% Internal functions
%%%=====================================================================

key(Atom, #opts{binary_encoder = Encode} = Opts) when is_atom(Atom) ->
    Encode(atom_to_binary(Atom, utf8), Opts);
key(Bin, #opts{binary_encoder = Encode} = Opts) when is_binary(Bin) ->
    Encode(Bin, Opts);
key(Int, #opts{binary_encoder = Encode} = Opts) when is_integer(Int) ->
    Encode(integer_to_binary(Int), Opts);
key(String, #opts{binary_encoder = Encode} = Opts) when is_list(String) ->
    Encode(list_to_binary(String), Opts).

value(Term, #opts{plugins = Plugins} = Opts) ->
    case plugins(Plugins, Term, Opts) of
        next ->
            encode_term(Term, Opts);
        {halt, IOData} ->
            IOData
    end.

plugins([], _Term, _Opts) ->
    next;
plugins([datetime | T], Term, Opts) ->
    case Term of
        {{YYYY,MM,DD},{H,M,S}}
          when ?min(YYYY, 0), ?range(MM, 1, 12), ?range(DD, 1, 31)
             , ?range(H, 0, 23), ?range(M, 0, 59), ?range(S, 0, 59) ->
            DateTime = iolist_to_binary(io_lib:format(
                "~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0BZ",
                [YYYY,MM,DD,H,M,S])
            ),
            {halt, escape(DateTime, Opts)};
        _ ->
            plugins(T, Term, Opts)
    end;
plugins([inet | T], Term, Opts) ->
    case Term of
        {_A,_B,_C,_D} ->
            case inet_parse:ntoa(Term) of
                {error, einval} ->
                    plugins(T, Term, Opts);
                Ipv4 ->
                    {halt, escape(list_to_binary(Ipv4), Opts)}
            end;
        {_A,_B,_C,_D,_E,_F,_G,_H} ->
            case inet_parse:ntoa(Term) of
                {error, einval} ->
                    plugins(T, Term, Opts);
                Ipv6 ->
                    {halt, escape(list_to_binary(Ipv6), Opts)}
            end;
        _ ->
            plugins(T, Term, Opts)
    end;
plugins([pid | T], Term, Opts) ->
    case is_pid(Term) of
        true ->
            Pid = iolist_to_binary(pid_to_list(Term)),
            {halt, escape(Pid, Opts)};
        false ->
            plugins(T, Term, Opts)
    end;
plugins([port | T], Term, Opts) ->
    case is_port(Term) of
        true ->
            Pid = iolist_to_binary(port_to_list(Term)),
            {halt, escape(Pid, Opts)};
        false ->
            plugins(T, Term, Opts)
    end;
plugins([proplist | T], Term, Opts) ->
    case Term of
        [{X, _} | _] = Proplist when ?is_proplist_key(X) ->
            Map = case lists:member(drop_nulls, Opts#opts.plugins) of
                true ->
                    drop_nulls(proplists:to_map(Proplist), Opts);
                false ->
                    proplists:to_map(Proplist)
            end,
            Encode = Opts#opts.map_encoder,
            {halt, Encode(Map, Opts)};
        _ ->
            plugins(T, Term, Opts)
    end;
plugins([reference | T], Term, Opts) ->
    case is_reference(Term) of
        true ->
            Ref = iolist_to_binary(ref_to_list(Term)),
            {halt, escape(Ref, Opts)};
        false ->
            plugins(T, Term, Opts)
    end;
plugins([timestamp | T], Term, Opts) ->
    case Term of
        {MegaSecs, Secs, MicroSecs} = Timestamp
          when ?min(MegaSecs, 0), ?min(Secs, 0), ?min(MicroSecs, 0) ->
            MilliSecs = MicroSecs div 1000,
            {{YYYY,MM,DD},{H,M,S}} = calendar:now_to_datetime(Timestamp),
            DateTime = iolist_to_binary(io_lib:format(
                "~4.10.0B-~2.10.0B-~2.10.0BT~2.10.0B:~2.10.0B:~2.10.0B.~3.10.0BZ",
                [YYYY,MM,DD,H,M,S,MilliSecs])
            ),
            {halt, escape(DateTime, Opts)};
        _ ->
            plugins(T, Term, Opts)
    end;
plugins([drop_nulls | T], Term, Opts) ->
    case is_map(Term) of
        true ->
            Map = drop_nulls(Term, Opts),
            Encode = Opts#opts.map_encoder,
            {halt, Encode(Map, Opts)};
        false ->
            plugins(T, Term, Opts)
    end;
plugins([Plugin | T], Term, Opts) when is_atom(Plugin) ->
    case Plugin:encode(Term, Opts) of
        next ->
            plugins(T, Term, Opts);
        {halt, IOData} when is_binary(IOData); is_list(IOData) ->
            {halt, IOData}
    end.

drop_nulls(Map0, Opts) ->
    Nulls = euneus_encoder:get_nulls_option(Opts),
    maps:filter(fun(_, V) -> not lists:member(V, Nulls) end, Map0).

encode_term(Bin, #opts{binary_encoder = Encode} = Opts) when is_binary(Bin) ->
    Encode(Bin, Opts);
encode_term(Atom, #opts{atom_encoder = Encode} = Opts) when is_atom(Atom) ->
    Encode(Atom, Opts);
encode_term(Int, #opts{integer_encoder = Encode} = Opts) when is_integer(Int) ->
    Encode(Int, Opts);
encode_term(Float, #opts{float_encoder = Encode} = Opts) when is_float(Float) ->
    Encode(Float, Opts);
encode_term(List, #opts{list_encoder = Encode} = Opts) when is_list(List) ->
    Encode(List, Opts);
encode_term(Map, #opts{map_encoder = Encode} = Opts) when is_map(Map) ->
    Encode(Map, Opts);
encode_term(Term, #opts{unhandled_encoder = Encode} = Opts) ->
    Encode(Term, Opts).

%%%=====================================================================
%%% Support functions
%%%=====================================================================

maps_get(Key, Map, Default) ->
    case Map of
        #{Key := Value} -> Value;
        #{} -> Default
    end.

maps_to_list(Map) ->
    do_maps_to_list(erts_internal:map_next(0, Map, [])).

do_maps_to_list([Iter, Map | Acc]) when is_integer(Iter) ->
    do_maps_to_list(erts_internal:map_next(Iter, Map, Acc));
do_maps_to_list(Acc) ->
    Acc.

%%%=====================================================================
%%% Eunit tests
%%%=====================================================================

-ifdef(TEST).

-include_lib("eunit/include/eunit.hrl").

encode_to_bin(Input, Opts) ->
    case encode(Input, Opts) of
        {ok, IOList} ->
            {ok, iolist_to_binary(IOList)};
        {error, Reason} ->
            {error, Reason}
    end.

encode_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
      || {Expect, Input, Opts} <- [
        {{ok, <<"true">>}, true, #{}},
        {{ok, <<"\"foo\"">>}, foo, #{}},
        {{ok, <<"\"foo\"">>}, <<"foo">>, #{}},
        {{ok, <<"0">>}, 0, #{}},
        {{ok, <<"123.456789">>}, 123.45678900, #{}},
        {{ok, <<"[true,0,null]">>}, [true,0,undefined], #{}},
        {{ok, <<"{\"foo\":\"bar\"}">>}, #{foo => bar}, #{}},
        {{ok, <<"{\"0\":0}">>}, #{0 => 0}, #{}}
    ]].

datetime_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {error, {unsupported_type, {{1970,1,1},{0,0,0}}}}
        , {{1970,1,1},{0,0,0}}
        , #{}
        },
        { {ok, <<"\"1970-01-01T00:00:00Z\"">>}
        , {{1970,1,1},{0,0,0}}
        , #{plugins => [datetime]}
        }
    ]].

inet_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
      || {Expect, Input, Opts} <- [
        % ipv4
        {{error, {unsupported_type, {0,0,0,0}}}, {0,0,0,0}, #{} },
        {{ok, <<"\"0.0.0.0\"">>}, {0,0,0,0}, #{plugins => [inet]}},
        % ipv6
        {{error, {unsupported_type, {0,0,0,0,0,0,0,0}}}, {0,0,0,0,0,0,0,0}, #{} },
        {{ok, <<"\"::\"">>}, {0,0,0,0,0,0,0,0}, #{plugins => [inet]} },
        {{ok, <<"\"::1\"">>}, {0,0,0,0,0,0,0,1}, #{plugins => [inet]}},
        { {ok, <<"\"::192.168.42.2\"">>}
        , {0,0,0,0,0,0,(192 bsl 8) bor 168,(42 bsl 8) bor 2}
        , #{plugins => [inet]}
        },
        { {ok, <<"\"::ffff:192.168.42.2\"">>}
        , {0,0,0,0,0,16#FFFF,(192 bsl 8) bor 168,(42 bsl 8) bor 2}
        , #{plugins => [inet]}
        },
        { {ok, <<"\"3ffe:b80:1f8d:2:204:acff:fe17:bf38\"">>}
        , {16#3ffe,16#b80,16#1f8d,16#2,16#204,16#acff,16#fe17,16#bf38}
        , #{plugins => [inet]}
        },
        { {ok, <<"\"fe80::204:acff:fe17:bf38\"">>}
        , {16#fe80,0,0,0,16#204,16#acff,16#fe17,16#bf38}
        , #{plugins => [inet]}
        }
    ]].

pid_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {error, {unsupported_type, list_to_pid("<0.92.0>")}}
        , list_to_pid("<0.92.0>")
        , #{}
        },
        { {ok, <<"\"<0.92.0>\"">>}
        , list_to_pid("<0.92.0>")
        , #{plugins => [pid]}
        }
    ]].

port_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {error, {unsupported_type, list_to_port("#Port<0.1>")}}
        , list_to_port("#Port<0.1>")
        , #{}
        },
        { {ok, <<"\"#Port<0.1>\"">>}
        , list_to_port("#Port<0.1>")
        , #{plugins => [port]}
        }
    ]].

proplist_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {error, {unsupported_type, {foo, bar}}}, [{foo, bar}], #{}},
        { {ok, <<"{\"foo\":\"bar\"}">>}, [{foo, bar}], #{plugins => [proplist]}}
    ]].

reference_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {error, {unsupported_type, list_to_ref("#Ref<0.314572725.1088159747.110918>")}}
        , list_to_ref("#Ref<0.314572725.1088159747.110918>")
        , #{}
        },
        { {ok, <<"\"#Ref<0.314572725.1088159747.110918>\"">>}
        , list_to_ref("#Ref<0.314572725.1088159747.110918>")
        , #{plugins => [reference]}
        }
    ]].

timestamp_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        {{error, {unsupported_type, {0,0,0}}}, {0,0,0}, #{}},
        { {ok, <<"\"1970-01-01T00:00:00.000Z\"">>}
        , {0,0,0}
        , #{plugins => [timestamp]}
        }
    ]].

drop_nulls_plugin_test() ->
    [ ?assertEqual(Expect, encode_to_bin(Input, Opts))
        || {Expect, Input, Opts} <- [
        { {ok, <<"{\"a\":1}">>}
        , #{a => 1, b => undefined}
        , #{plugins => [drop_nulls]}
        },
        { {ok, <<"{\"a\":1}">>}
        , #{a => 1, b => undefined, c => foo}
        , #{nulls => [undefined, foo], plugins => [drop_nulls]}
        },
        { {ok, <<"{\"a\":1}">>}
        , [{a, 1}, {b, undefined}, {c, foo}]
        , #{nulls => [undefined, foo], plugins => [proplist, drop_nulls]}
        }
    ]].

-endif.