%%%--------------------------------------------------------------------- %%% @copyright 2023 William Fank Thomé %%% @author William Fank Thomé %%% @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; <> -> 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, <>, Buffer); <<$", Rest/binary>> -> pp_value(Rest, Opts, Prev, Depth, [<> | Buffer]); <> -> pp_string(Rest, Opts, Prev, Depth, <>, 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; <> -> pp_number(Rest, Opts, Prev, Depth, <>, 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.