Skip to main content

lib/ex_check/json.ex

defmodule ExCheck.JSON do
  @moduledoc false

  # Minimal JSON encoder for the fully-controlled output of the JSON/agent reporters.
  # Kept dependency-free on purpose: ex_check targets Elixir ~> 1.17 (the built-in
  # JSON module only arrived in 1.18) and must not pull a runtime dep like Jason.

  @spec encode(term) :: iodata
  def encode(term), do: do_encode(term)

  defp do_encode(nil), do: "null"
  defp do_encode(true), do: "true"
  defp do_encode(false), do: "false"
  defp do_encode(int) when is_integer(int), do: Integer.to_string(int)
  defp do_encode(float) when is_float(float), do: Float.to_string(float)
  defp do_encode(atom) when is_atom(atom), do: encode_string(Atom.to_string(atom))
  defp do_encode(str) when is_binary(str), do: encode_string(str)

  defp do_encode(list) when is_list(list) do
    inner = list |> Enum.map(&do_encode/1) |> Enum.intersperse(",")
    ["[", inner, "]"]
  end

  defp do_encode(map) when is_map(map) do
    inner =
      map
      |> Enum.map(fn {key, value} -> [encode_key(key), ":", do_encode(value)] end)
      |> Enum.intersperse(",")

    ["{", inner, "}"]
  end

  defp encode_key(key) when is_atom(key), do: encode_string(Atom.to_string(key))
  defp encode_key(key) when is_binary(key), do: encode_string(key)

  defp encode_string(str) do
    [?", escape(str, ""), ?"]
  end

  defp escape(<<>>, acc), do: acc
  defp escape(<<?", rest::binary>>, acc), do: escape(rest, acc <> "\\\"")
  defp escape(<<?\\, rest::binary>>, acc), do: escape(rest, acc <> "\\\\")
  defp escape(<<?\n, rest::binary>>, acc), do: escape(rest, acc <> "\\n")
  defp escape(<<?\r, rest::binary>>, acc), do: escape(rest, acc <> "\\r")
  defp escape(<<?\t, rest::binary>>, acc), do: escape(rest, acc <> "\\t")
  defp escape(<<?\f, rest::binary>>, acc), do: escape(rest, acc <> "\\f")
  defp escape(<<?\b, rest::binary>>, acc), do: escape(rest, acc <> "\\b")

  defp escape(<<char::utf8, rest::binary>>, acc) when char < 0x20 do
    escaped = "\\u" <> (char |> Integer.to_string(16) |> String.pad_leading(4, "0"))
    escape(rest, acc <> escaped)
  end

  defp escape(<<char::utf8, rest::binary>>, acc) do
    escape(rest, acc <> <<char::utf8>>)
  end
end