Skip to main content

lib/alaja/components/json.ex

defmodule Alaja.Components.Json do
  @moduledoc """
  Static JSON pretty-printer with syntax highlighting for terminal output.

  Renders JSON with ANSI colors differentiated by value type.

  ## Usage

      iex> Alaja.Components.Json.print(%{name: "Alaja", version: "1.0.0"})
      # {
      #   "name": "Alaja",
      #   "version": "1.0.0"
      # }
  """

  @key_color {86, 182, 194}
  @string_color {152, 195, 121}
  @number_color {209, 154, 102}
  @boolean_color {198, 120, 221}
  @null_color {128, 128, 128}
  @punctuation_color {180, 180, 180}

  @doc """
  Prints JSON to stdout with syntax highlighting.

  ## Options

  - `:indent` - Indentation in spaces (default: 2)
  - `:key_color` - RGB for object keys
  - `:string_color` - RGB for string values
  - `:number_color` - RGB for numbers
  - `:boolean_color` - RGB for booleans
  - `:null_color` - RGB for null values
  """
  @spec print(term(), keyword()) :: :ok
  def print(data, opts \\ []) do
    data |> render(opts) |> IO.write()
    IO.puts("")
  end

  @doc """
  Renders JSON to iodata without printing.
  """
  @spec render(term(), keyword()) :: iodata()
  def render(data, opts \\ []) do
    indent = Keyword.get(opts, :indent, 2)
    colors = build_colors(opts)

    render_value(data, 0, indent, colors)
  end

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp build_colors(opts) do
    %{
      key: Keyword.get(opts, :key_color, @key_color),
      string: Keyword.get(opts, :string_color, @string_color),
      number: Keyword.get(opts, :number_color, @number_color),
      boolean: Keyword.get(opts, :boolean_color, @boolean_color),
      null: Keyword.get(opts, :null_color, @null_color),
      punctuation: Keyword.get(opts, :punctuation_color, @punctuation_color)
    }
  end

  defp color({r, g, b}), do: Pote.Orchestrator.to_ansi({r, g, b})
  defp reset, do: Alaja.ANSI.reset_attributes()

  defp indent_str(level, size), do: String.duplicate(" ", level * size)

  defp render_value(nil, _level, _indent, colors) do
    [color(colors.null), "null", reset()]
  end

  defp render_value(true, _level, _indent, colors) do
    [color(colors.boolean), "true", reset()]
  end

  defp render_value(false, _level, _indent, colors) do
    [color(colors.boolean), "false", reset()]
  end

  defp render_value(value, _level, _indent, colors) when is_integer(value) or is_float(value) do
    [color(colors.number), to_string(value), reset()]
  end

  defp render_value(value, _level, _indent, colors) when is_binary(value) do
    escaped = String.replace(value, "\"", "\\\"")
    [color(colors.string), "\"", escaped, "\"", reset()]
  end

  defp render_value(value, _level, _indent, colors) when is_atom(value) do
    escaped = to_string(value)
    [color(colors.string), "\"", escaped, "\"", reset()]
  end

  defp render_value([], _level, _indent, colors) do
    [color(colors.punctuation), "[]", reset()]
  end

  defp render_value(list, level, indent, colors) when is_list(list) do
    inner_indent = indent_str(level + 1, indent)
    outer_indent = indent_str(level, indent)

    items =
      list
      |> Enum.map(fn item ->
        [inner_indent, render_value(item, level + 1, indent, colors)]
      end)
      |> Enum.intersperse([color(colors.punctuation), ",", reset(), "\n"])

    [
      color(colors.punctuation),
      "[\n",
      reset(),
      items,
      "\n",
      outer_indent,
      color(colors.punctuation),
      "]",
      reset()
    ]
  end

  defp render_value(map, level, indent, colors) when is_map(map) do
    if map_size(map) == 0 do
      [color(colors.punctuation), "{}", reset()]
    else
      inner_indent = indent_str(level + 1, indent)
      outer_indent = indent_str(level, indent)

      pairs =
        map
        |> Enum.map(fn {key, value} ->
          key_str = to_string(key)

          [
            inner_indent,
            color(colors.key),
            "\"",
            key_str,
            "\"",
            reset(),
            color(colors.punctuation),
            ": ",
            reset(),
            render_value(value, level + 1, indent, colors)
          ]
        end)
        |> Enum.intersperse([color(colors.punctuation), ",", reset(), "\n"])

      [
        color(colors.punctuation),
        "{\n",
        reset(),
        pairs,
        "\n",
        outer_indent,
        color(colors.punctuation),
        "}",
        reset()
      ]
    end
  end

  defp render_value(value, _level, _indent, _colors) do
    inspect(value)
  end
end