%%%---------------------------------------------------------------------
%%% @copyright 2023 William Fank Thomé
%%% @author William Fank Thomé <willilamthome@hotmail.com>
%%% @doc JSON formatter.
%%%
%%% 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_formatter).
-compile({ inline, pp_object/5 }).
-compile({ inline, pp_array/5 }).
-compile({ inline, pp_newline/3 }).
%% API functions
-export([ minify/1 ]).
-export([ prettify/1 ]).
-export([ format/2 ]).
-export([ parse_opts/1 ]).
-export([ format_parsed/2 ]).
%% Types
-export_type([ input/0 ]).
-export_type([ options/0 ]).
-export_type([ parsed_options/0 ]).
-export_type([ result/0 ]).
-record(opts, { spaces :: binary()
, indent :: binary()
, crlf :: binary()
}).
-type input() :: binary() | iolist().
-type options() :: #{ spaces => binary() | non_neg_integer()
, indent => binary() | non_neg_integer()
, crlf => binary() | cr | lf | crlf
}.
-type parsed_options() :: #opts{}.
-type result() :: iolist().
%%%=====================================================================
%%% API functions
%%%=====================================================================
%%----------------------------------------------------------------------
%% @doc Remove extra spaces and line feeds from JSON.
%%
%% @param JSON :: {@link euneus_formatter:input()}.
%%
%% @returns {@link euneus_formatter:result()}.
%%
%% @see euneus_formatter:format_parsed/2
%%
%% @end
%%----------------------------------------------------------------------
-spec minify(input()) -> result().
minify(JSON) ->
Opts = #opts{
spaces = <<>>,
indent = <<>>,
crlf = <<>>
},
format_parsed(JSON, Opts).
%%----------------------------------------------------------------------
%% @doc Format JSON for printing.
%%
%% @param JSON :: {@link euneus_formatter:input()}.
%%
%% @returns {@link euneus_formatter:result()}.
%%
%% @see euneus_formatter:format_parsed/2
%%
%% @end
%%----------------------------------------------------------------------
-spec prettify(input()) -> result().
prettify(JSON) ->
Opts = #opts{
spaces = <<$\s>>,
indent = <<$\s, $\s>>,
crlf = <<$\n>>
},
format_parsed(JSON, Opts).
%%----------------------------------------------------------------------
%% @doc Format JSON.
%%
%% @param JSON :: {@link euneus_formatter:input()}.
%% @param Opts :: {@link euneus_formatter:options()}.
%%
%% @returns {@link euneus_formatter:result()}.
%%
%% @end
%%----------------------------------------------------------------------
-spec format(input(), options()) -> result().
format(JSON, Opts) ->
format_parsed(JSON, parse_opts(Opts)).
%%----------------------------------------------------------------------
%% @doc Parses {@link euneus_formatter:options()} to {@link euneus_formatter:parsed_options()}.
%%
%% The parsed map can be expanded in compile time or stored to be
%% reused, avoiding parsing the options in every encoding.
%%
%% @param Opts :: {@link euneus_formatter:options()}.
%%
%% @returns {@link euneus_formatter:parsed_options()}.
%%
%% @end
%%----------------------------------------------------------------------
-spec parse_opts(options()) -> parsed_options().
parse_opts(Opts) ->
#opts{
spaces = case maps:get(spaces, Opts, <<>>) of
N when is_integer(N), N >= 0 ->
binary:copy(<<$\s>>, N);
Spaces when is_binary(Spaces) ->
Spaces
end,
indent = case maps:get(indent, Opts, <<>>) of
N when is_integer(N), N >= 0 ->
binary:copy(<<$\t>>, N);
Indent when is_binary(Indent) ->
Indent
end,
crlf = case maps:get(crlf, Opts, <<>>) of
cr ->
<<$\r>>;
lf ->
<<$\n>>;
crlf ->
<<$\r, $\n>>;
Crlf when is_binary(Crlf) ->
Crlf
end
}.
%%----------------------------------------------------------------------
%% @doc Format JSON.
%%
%% @param JSON :: {@link euneus_formatter:input()}.
%% @param Opts :: {@link euneus_formatter:parsed_options()}.
%%
%% @returns {@link euneus_formatter:result()}.
%%
%% @see euneus_formatter:parse_opts/1
%%
%% @end
%%----------------------------------------------------------------------
-spec format_parsed(input(), parsed_options()) -> result().
format_parsed(JSON, Opts) when is_binary(JSON) ->
pp_value(JSON, Opts, 0, 0, []);
format_parsed(JSON, Opts) when is_list(JSON) ->
format_parsed(iolist_to_binary(JSON), Opts).
%%%=====================================================================
%%% Internal functions
%%%=====================================================================
pp_value(Data, Opts, Prev, Depth, Buffer) ->
case Data of
<<$\s, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, Buffer);
<<$\t, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, Buffer);
<<$\r, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, Buffer);
<<$\n, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, Buffer);
<<$", Rest/bitstring>> ->
case Prev =:= ${ orelse Prev =:= $[ of
true ->
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth),
pp_string(Rest, Opts, Prev, Depth, <<$">>, [Newline | Buffer]);
false ->
pp_string(Rest, Opts, Prev, Depth, <<$">>, Buffer)
end;
<<$,, Rest/bitstring>> ->
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth),
pp_value(Rest, Opts, $,, Depth, [Newline, $, | Buffer]);
<<${, Rest/bitstring>> ->
pp_object(Rest, Opts, Prev, Depth, Buffer);
<<$}, Rest/bitstring>> ->
case Prev =:= ${ of
true ->
pp_value(Rest, Opts, $}, Depth - 1, [$} | Buffer]);
false ->
Depth1 = Depth - 1,
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth1),
pp_value(Rest, Opts, $}, Depth1, [$}, Newline | Buffer])
end;
<<$[, Rest/bitstring>> ->
pp_array(Rest, Opts, Prev, Depth, Buffer);
<<$], Rest/bitstring>> ->
case Prev =:= $[ of
true ->
pp_value(Rest, Opts, $], Depth - 1, [$] | Buffer]);
false ->
Depth1 = Depth - 1,
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth1),
pp_value(Rest, Opts, $], Depth1, [$], Newline | Buffer])
end;
<<Rest/bitstring>> ->
pp_number(Rest, Opts, Prev, Depth, <<>>, Buffer)
end.
pp_string(Data, Opts, Prev, Depth, String, Buffer) ->
case Data of
<<$\\, $", Rest/binary>> ->
pp_string(Rest, Opts, Prev, Depth, <<String/bitstring, $\\, $">>, Buffer);
<<$", Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, [<<String/bitstring, $">> | Buffer]);
<<H, Rest/bitstring>> ->
pp_string(Rest, Opts, Prev, Depth, <<String/bitstring, H>>, Buffer)
end.
pp_number(Data, Opts, Prev, Depth, Value, Buffer) ->
case Data of
<<$\s, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, [Value | Buffer]);
<<$\t, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, [Value | Buffer]);
<<$\r, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, [Value | Buffer]);
<<$\n, Rest/binary>> ->
pp_value(Rest, Opts, Prev, Depth, [Value | Buffer]);
<<$:, Rest/binary>> ->
pp_value(Rest, Opts, $:, Depth, [Value, Opts#opts.spaces, $: | Buffer]);
<<$,, Rest/binary>> ->
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth),
pp_value(Rest, Opts, $,, Depth, [Newline, $,, Value | Buffer]);
<<$], Rest/bitstring>> ->
case Prev =:= $[ of
true ->
pp_value(Rest, Opts, $], Depth - 1, [$], Value | Buffer]);
false ->
Depth1 = Depth - 1,
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth1),
pp_value(Rest, Opts, $], Depth1, [$], Newline, Value | Buffer])
end;
<<$}, Rest/bitstring>> ->
case Prev =:= ${ of
true ->
pp_value(Rest, Opts, $}, Depth - 1, [$}, Value | Buffer]);
false ->
Depth1 = Depth - 1,
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth1),
pp_value(Rest, Opts, $}, Depth1, [$}, Newline, Value | Buffer])
end;
<<H, Rest/bitstring>> ->
pp_number(Rest, Opts, Prev, Depth, <<Value/bitstring, H>>, Buffer);
<<>> ->
lists:reverse([Value | Buffer])
end.
pp_object(Data, Opts, Prev, Depth, Buffer) ->
case Buffer =:= [] of
true ->
pp_value(Data, Opts, ${, Depth + 1, [${ | Buffer]);
false ->
case Prev =:= $: orelse Prev =:= $, of
true ->
pp_value(Data, Opts, ${, Depth + 1, [${ | Buffer]);
false ->
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth),
pp_value(Data, Opts, ${, Depth + 1, [${, Newline | Buffer])
end
end.
pp_array(Data, Opts, Prev, Depth, Buffer) ->
case Buffer =:= [] of
true ->
pp_value(Data, Opts, $[, Depth + 1, [$[ | Buffer]);
false ->
case Prev =:= $: orelse Prev =:= $, of
true ->
pp_value(Data, Opts, $[, Depth + 1, [$[ | Buffer]);
false ->
Newline = pp_newline(Opts#opts.crlf, Opts#opts.indent, Depth),
pp_value(Data, Opts, $[, Depth + 1, [$[, Newline | Buffer])
end
end.
pp_newline(Crlf, Indent, Depth) ->
[Crlf, binary:copy(Indent, Depth)].
%%%=====================================================================
%%% Eunit tests
%%%=====================================================================
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
newline_test() ->
[ ?assertEqual(Expect, iolist_to_binary(pp_newline(Crlf, Indent, Depth)))
|| {Expect, Crlf, Indent, Depth} <- [
{<<>>, <<>>, <<>>, 0},
{<<>>, <<>>, <<>>, 1},
{<<$\n>>, <<$\n>>, <<$\s>>, 0},
{<<$\n,$\s,$\s>>, <<$\n>>, <<$\s>>, 2}
]].
format_test() ->
[ ?assertEqual(Expect, iolist_to_binary(format(JSON, Opts)))
|| {Expect, JSON, Opts} <- [
{<<"\"foo\"">>, <<"\"foo\"">>, #{}},
{<<"\"foo\"">>, <<" \s \n \"foo\" \r \t ">>, #{}},
{<<"\"f\\\"o\\\"o\"">>, <<"\"f\\\"o\\\"o\"">>, #{}},
{<<"1">>, <<"1">>, #{}},
{<<"1.010">>, <<"1.010">>, #{}},
{<<"true">>, <<"true">>, #{}},
{<<"false">>, <<"false">>, #{}},
{<<"null">>, <<"null">>, #{}},
{<<"{}">>, <<"{}">>, #{}},
{<<"[]">>, <<"[]">>, #{}},
{<<"{\"foo\":\"bar\",\"0\":0}">>, <<"{\"foo\":\"bar\",\"0\":0}">>, #{}},
{<<"[\"foo\",0]">>, <<"[\"foo\",0]">>, #{}}
]].
minify_test() ->
[ ?assertEqual(Expect, iolist_to_binary(minify(JSON)))
|| {Expect, JSON} <- [
{<<"{\"foo\":\"bar\",\"0\":0}">>, <<"{\"foo\" : \"bar\",\"0\" : 0}">>},
{<<"[\"foo\",0]">>, <<" [ \"foo\" , 0 ]">>}
]].
prettify_test() ->
[ ?assertEqual(Expect, iolist_to_binary(prettify(JSON)))
|| {Expect, JSON} <- [
{<<"{}">>, <<"{}">>},
{<<"[]">>, <<"[]">>},
{ <<"{\n \"foo\": \"bar\",\n \"baz\": {\n \"foo\": \"bar\",\n \"0\": [\n \"foo\",\n 0\n ]\n }\n}">>
, <<"{\"foo\":\"bar\",\"baz\":{\"foo\":\"bar\",\"0\":[\"foo\",0]}}">> }
]].
-endif.