Skip to content

Commit 7820203

Browse files
committed
Add :pretty option to JSON encoding
Assisted-by: pi:deepseek-v4.1-flash
1 parent 669f848 commit 7820203

3 files changed

Lines changed: 292 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* [Code.Formatter] Add a `:migrate_atom_interpolations` option
2323
* [File] Accept an open IO device in `File.stat/2`, `File.stat!/2`, `File.regular?/2`, `File.dir?/2`, and `File.exists?/2`
2424
* [IO.ANSI] Add `bold/0`, `bold_off/0`, `dim/0`, `dim_off/0`, `blink/0`, `invisible/0`, `invisible_off/0`, `strikethrough/0`, `strikethrough_off/0`, `italic_off/0`, `underline_off/0`, `overline/0`, `overline_off/0`, `cursor_forward/0`, `cursor_backward/0`, and `cursor_home/0`, aligning names with Erlang/OTP
25+
* [JSON] Add the `:pretty` option to `JSON.encode!/2` and `JSON.encode_to_iodata!/2`
2526
* [Kernel] Improve performance of type constructors and complex intersections
2627
* [Kernel] Warn on aliases that are never used because a later alias shadows them
2728
* [Kernel] Warn on binary patterns with segments that are not byte-aligned

‎lib/elixir/lib/json.ex‎

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -499,50 +499,234 @@ defmodule JSON do
499499
end
500500
end
501501

502+
@max_indent 31
503+
504+
@indent_specials [?{, ?}, ?[, ?], ?,, ?:, ?", ?\s, ?\t, ?\r, ?\n]
505+
502506
@doc ~S"""
503507
Encodes the given term to JSON as a binary.
504508
505-
The second argument is a function that is recursively
506-
invoked to encode a term.
509+
The second argument is either a function that is recursively invoked to
510+
encode a term, or a keyword list of options, described below.
507511
508512
> #### IO and performance {: .tip}
509513
>
510514
> If you need to encode data to be sent over the network
511515
> or written to the filesystem, consider using the more
512516
> efficient `encode_to_iodata!/2`.
513517
518+
## Options
519+
520+
When the second argument is a keyword list, the only accepted option is:
521+
522+
* `:pretty` - either `true` to indent nested members by two spaces,
523+
or a binary of spaces and tabs to be used as the indent unit.
524+
Empty containers are kept on a single line. Defaults to `false`.
525+
526+
Unknown options raise. A custom encoder function cannot be combined
527+
with these options.
528+
514529
## Examples
515530
516531
iex> JSON.encode!([123, "string", %{key: "value"}])
517532
"[123,\"string\",{\"key\":\"value\"}]"
518533
534+
iex> JSON.encode!(%{tags: []}, pretty: true)
535+
"{\n \"tags\": []\n}"
536+
537+
iex> JSON.encode!(%{id: "abc"}, pretty: "\t")
538+
"{\n\t\"id\": \"abc\"\n}"
539+
519540
"""
520-
@spec encode!(term(), encoder()) :: binary()
521-
def encode!(term, encoder \\ &protocol_encode/2) do
541+
@spec encode!(term(), encoder() | [pretty: boolean() | binary()]) :: binary()
542+
def encode!(term, encoder_or_opts \\ &protocol_encode/2)
543+
544+
def encode!(term, encoder) when is_function(encoder, 2) do
522545
IO.iodata_to_binary(encoder.(term, encoder))
523546
end
524547

548+
def encode!(term, opts) when is_list(opts) do
549+
term |> encode_to_iodata!(opts) |> IO.iodata_to_binary()
550+
end
551+
525552
@doc ~S"""
526553
Encodes the given term to JSON as an iodata.
527554
528555
This is the most efficient format if the JSON is going to be
529556
used for IO purposes.
530557
531558
The second argument is a function that is recursively
532-
invoked to encode a term.
559+
invoked to encode a term. It may also be a keyword list of
560+
options, as accepted by `encode!/2`.
533561
534562
## Examples
535563
536564
iex> data = JSON.encode_to_iodata!([123, "string", %{key: "value"}])
537565
iex> IO.iodata_to_binary(data)
538566
"[123,\"string\",{\"key\":\"value\"}]"
539567
568+
iex> data = JSON.encode_to_iodata!(%{id: "abc"}, pretty: true)
569+
iex> IO.iodata_to_binary(data)
570+
"{\n \"id\": \"abc\"\n}"
571+
540572
"""
541-
@spec encode_to_iodata!(term(), encoder()) :: iodata()
542-
def encode_to_iodata!(term, encoder \\ &protocol_encode/2) do
573+
@spec encode_to_iodata!(term(), encoder() | [pretty: boolean() | binary()]) :: iodata()
574+
def encode_to_iodata!(term, encoder_or_opts \\ &protocol_encode/2)
575+
576+
def encode_to_iodata!(term, encoder) when is_function(encoder, 2) do
543577
encoder.(term, encoder)
544578
end
545579

580+
def encode_to_iodata!(term, opts) when is_list(opts) do
581+
iodata = protocol_encode(term, &protocol_encode/2)
582+
583+
case Keyword.fetch(validate_opts!(opts), :pretty) do
584+
{:ok, false} ->
585+
iodata
586+
587+
{:ok, true} ->
588+
format_iodata(iodata, " ")
589+
590+
{:ok, unit} when is_binary(unit) ->
591+
format_iodata(iodata, indent_unit!(unit))
592+
593+
{:ok, other} ->
594+
raise ArgumentError,
595+
"expected :pretty to be a boolean or a binary, got: #{inspect(other)}"
596+
597+
:error ->
598+
iodata
599+
end
600+
end
601+
602+
defp validate_opts!(opts) do
603+
if Keyword.keyword?(opts) do
604+
case Keyword.keys(opts) -- [:pretty] do
605+
[] ->
606+
opts
607+
608+
unknown ->
609+
raise ArgumentError,
610+
"unknown options #{inspect(unknown)}, expected only :pretty"
611+
end
612+
else
613+
raise ArgumentError,
614+
"expected the second argument to be a function or a keyword list, " <>
615+
"got: #{inspect(opts)}"
616+
end
617+
end
618+
619+
defp indent_unit!(unit) do
620+
if indent_unit?(unit) do
621+
unit
622+
else
623+
raise ArgumentError,
624+
"expected :pretty to be a binary of spaces and tabs, got: #{inspect(unit)}"
625+
end
626+
end
627+
628+
defp indent_unit?(<<char, rest::binary>>) when char in ~c" \t", do: indent_unit?(rest)
629+
defp indent_unit?(<<>>), do: true
630+
defp indent_unit?(_), do: false
631+
632+
defp format_iodata(iodata, unit) do
633+
# Flattening is required: the iodata mixes binaries with byte separators,
634+
# so string boundaries do not line up with iodata elements.
635+
indents = List.to_tuple(for depth <- 0..@max_indent, do: :binary.copy(unit, depth))
636+
format_value(IO.iodata_to_binary(iodata), 0, indents, [])
637+
end
638+
639+
defp format_value(<<>>, _depth, _pad, acc), do: acc
640+
641+
defp format_value(<<?", rest::binary>>, depth, indents, acc) do
642+
case format_string(rest, []) do
643+
{:ok, string, rest} ->
644+
format_value(rest, depth, indents, [acc, ?", string, ?"])
645+
646+
:eof ->
647+
raise ArgumentError,
648+
"expected the encoder to return valid JSON, got an unterminated string"
649+
end
650+
end
651+
652+
defp format_value(<<?{, rest::binary>>, depth, indents, acc) do
653+
case skip_whitespace(rest) do
654+
<<?}, rest::binary>> -> format_value(rest, depth, indents, [acc, "{}"])
655+
rest -> format_value(rest, depth + 1, indents, [acc, ?{, newline(depth + 1, indents)])
656+
end
657+
end
658+
659+
defp format_value(<<?[, rest::binary>>, depth, indents, acc) do
660+
case skip_whitespace(rest) do
661+
<<?], rest::binary>> -> format_value(rest, depth, indents, [acc, "[]"])
662+
rest -> format_value(rest, depth + 1, indents, [acc, ?[, newline(depth + 1, indents)])
663+
end
664+
end
665+
666+
defp format_value(<<?}, rest::binary>>, depth, indents, acc) when depth > 0 do
667+
format_value(rest, depth - 1, indents, [acc, newline(depth - 1, indents), ?}])
668+
end
669+
670+
defp format_value(<<?], rest::binary>>, depth, indents, acc) when depth > 0 do
671+
format_value(rest, depth - 1, indents, [acc, newline(depth - 1, indents), ?]])
672+
end
673+
674+
defp format_value(<<?,, rest::binary>>, depth, indents, acc) do
675+
format_value(rest, depth, indents, [acc, ?,, newline(depth, indents)])
676+
end
677+
678+
defp format_value(<<?:, rest::binary>>, depth, indents, acc) do
679+
format_value(rest, depth, indents, [acc, ": "])
680+
end
681+
682+
defp format_value(<<char, rest::binary>>, depth, indents, acc) when char in ~c" \t\r\n" do
683+
format_value(rest, depth, indents, acc)
684+
end
685+
686+
defp format_value(<<char, _::binary>> = binary, depth, indents, acc)
687+
when char not in @indent_specials do
688+
length = plain_length(binary, 0)
689+
rest = binary_part(binary, length, byte_size(binary) - length)
690+
format_value(rest, depth, indents, [acc, binary_part(binary, 0, length)])
691+
end
692+
693+
defp format_value(<<char, _::binary>>, 0, _pad, _acc) when char in [?}, ?]] do
694+
raise ArgumentError,
695+
"expected the encoder to return valid JSON, got unexpected #{<<char>>}"
696+
end
697+
698+
defp plain_length(<<char, rest::binary>>, count) when char not in @indent_specials,
699+
do: plain_length(rest, count + 1)
700+
701+
defp plain_length(<<_::binary>>, count), do: count
702+
703+
defp format_string(<<?\\, char, rest::binary>>, acc),
704+
do: format_string(rest, [acc, ?\\, char])
705+
706+
defp format_string(<<?", rest::binary>>, acc), do: {:ok, acc, rest}
707+
defp format_string(<<>>, _acc), do: :eof
708+
709+
defp format_string(binary, acc) do
710+
length = string_length(binary, 0)
711+
rest = binary_part(binary, length, byte_size(binary) - length)
712+
format_string(rest, [acc, binary_part(binary, 0, length)])
713+
end
714+
715+
defp string_length(<<char, rest::binary>>, count) when char not in [?", ?\\],
716+
do: string_length(rest, count + 1)
717+
718+
defp string_length(<<_::binary>>, count), do: count
719+
720+
defp skip_whitespace(<<char, rest::binary>>) when char in ~c" \t\r\n",
721+
do: skip_whitespace(rest)
722+
723+
defp skip_whitespace(binary), do: binary
724+
725+
defp newline(depth, indents) when depth < tuple_size(indents),
726+
do: [?\n, elem(indents, depth)]
727+
728+
defp newline(depth, indents), do: [?\n, :binary.copy(elem(indents, 1), depth)]
729+
546730
@doc """
547731
This is the default encode implementation passed to `encode!/1`.
548732

‎lib/elixir/test/elixir/json_test.exs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ defmodule JSONTest do
1818

1919
doctest JSON
2020

21+
defmodule Malformed do
22+
defstruct [:output]
23+
24+
defimpl JSON.Encoder do
25+
def encode(%{output: output}, _encoder), do: output
26+
end
27+
end
28+
2129
describe "encode" do
2230
test "atoms" do
2331
assert JSON.encode!([nil, false, true, :another]) == "[null,false,true,\"another\"]"
@@ -59,6 +67,98 @@ defmodule JSONTest do
5967
end
6068
end
6169

70+
describe "encode with pretty" do
71+
test "indents nested members" do
72+
assert JSON.encode!(%{a: [1, %{b: true}]}, pretty: true) ==
73+
"{\n \"a\": [\n 1,\n {\n \"b\": true\n }\n ]\n}"
74+
end
75+
76+
test "keeps empty containers on a single line" do
77+
assert JSON.encode!([[], %{}, [1]], pretty: true) == "[\n [],\n {},\n [\n 1\n ]\n]"
78+
end
79+
80+
test "accepts a custom indent unit" do
81+
assert JSON.encode!(%{a: 1}, pretty: "\t") == "{\n\t\"a\": 1\n}"
82+
assert JSON.encode!(%{a: 1}, pretty: " ") == "{\n \"a\": 1\n}"
83+
assert JSON.encode!(%{a: 1}, pretty: "") == "{\n\"a\": 1\n}"
84+
end
85+
86+
test "is compact by default" do
87+
assert JSON.encode!(%{a: [1]}) == "{\"a\":[1]}"
88+
assert JSON.encode!(%{a: [1]}, pretty: false) == "{\"a\":[1]}"
89+
end
90+
91+
test "preserves string contents" do
92+
term = %{a: "1,2:3 {\"}]"}
93+
assert term |> JSON.encode!(pretty: true) |> JSON.decode!() == %{"a" => "1,2:3 {\"}]"}
94+
assert JSON.encode!(%{a: "1,2:3"}, pretty: true) == "{\n \"a\": \"1,2:3\"\n}"
95+
end
96+
97+
test "supports structs and calendar types" do
98+
assert JSON.encode!(%{date: ~D[2024-01-01]}, pretty: true) ==
99+
"{\n \"date\": \"2024-01-01\"\n}"
100+
101+
assert JSON.encode!(%{nil: nil}, pretty: true) == "{\n \"nil\": null\n}"
102+
103+
assert JSON.encode!(%{duration: Duration.new!(second: 1)}, pretty: true) ==
104+
"{\n \"duration\": \"PT1S\"\n}"
105+
end
106+
107+
test "supports iodata" do
108+
assert IO.iodata_to_binary(JSON.encode_to_iodata!(%{a: [1]}, pretty: true)) ==
109+
"{\n \"a\": [\n 1\n ]\n}"
110+
end
111+
112+
test "validates the second argument" do
113+
assert_raise ArgumentError,
114+
"expected :pretty to be a boolean or a binary, got: 2",
115+
fn -> JSON.encode!(%{}, pretty: 2) end
116+
117+
assert_raise ArgumentError,
118+
"expected :pretty to be a binary of spaces and tabs, got: \"abc\"",
119+
fn -> JSON.encode!(%{}, pretty: "abc") end
120+
121+
assert_raise ArgumentError,
122+
"unknown options [:indent], expected only :pretty",
123+
fn -> JSON.encode!(%{}, indent: 4) end
124+
125+
assert_raise ArgumentError,
126+
"expected the second argument to be a function or a keyword list, got: [1, 2]",
127+
fn -> JSON.encode!(%{}, [1, 2]) end
128+
end
129+
130+
test "accepts any indent width" do
131+
assert JSON.encode!(%{a: 1}, pretty: String.duplicate(" ", 20)) ==
132+
"{\n#{String.duplicate(" ", 20)}\"a\": 1\n}"
133+
end
134+
135+
test "handles scalars and empty containers" do
136+
assert JSON.encode!(nil, pretty: true) == "null"
137+
assert JSON.encode!("abc", pretty: true) == "\"abc\""
138+
assert JSON.encode!(%{}, pretty: true) == "{}"
139+
assert JSON.encode!([], pretty: true) == "[]"
140+
end
141+
142+
test "keeps escaped characters intact" do
143+
assert JSON.encode!(%{a: "é"}, pretty: true) == "{\n \"a\": \"é\"\n}"
144+
assert JSON.encode!(%{a: "\u0000"}, pretty: true) == "{\n \"a\": \"\\u0000\"\n}"
145+
end
146+
147+
test "raises on malformed encoder output" do
148+
assert_raise ArgumentError,
149+
"expected the encoder to return valid JSON, got unexpected }",
150+
fn -> JSON.encode!(struct(Malformed, output: "}]"), pretty: true) end
151+
152+
assert_raise ArgumentError,
153+
"expected the encoder to return valid JSON, got an unterminated string",
154+
fn -> JSON.encode!(struct(Malformed, output: ~s("abc)), pretty: true) end
155+
end
156+
157+
test "keeps custom encoders working" do
158+
assert JSON.encode!(%Token{value: 1}) == "[1]"
159+
end
160+
end
161+
62162
describe "JSON.Encoder" do
63163
defp protocol_encode(term) do
64164
term

0 commit comments

Comments
 (0)