lib/escape.ex

defmodule Escape do
  @moduledoc """
  Functionality to render ANSI escape sequences.

  This module is quite similar to the Elixir module `IO.ANSI`. For more info
  about [ANSI escape sequences](https://en.wikipedia.org/wiki/ANSI_escape_code),
  see the `IO.ANSI` documentation.

  For example, the function `IO.ANSI.format/1` and `Escape.format/1` working in
  the same way.

      iex> iodata = IO.ANSI.format([:green, "hello"])
      [[[[] | "\e[32m"], "hello"] | "\e[0m"]
      iex> iodata == Escape.format([:green, "hello"])
      true

  The `Escape` module adds the option `:theme` to `Escape.format/2`.

      iex> Escape.format([:say, "hello"], theme: %{say: :green})
      [[[[] | "\e[32m"], "hello"] | "\e[0m"]

  In the theme are ANSI escape sequeneces allowed.

      iex> Escape.format([:say, "hello"], theme: %{
      ...>   orange: IO.ANSI.color(178),
      ...>   say: :orange
      ...> })
      [[[[] | "\e[38;5;178m"], "hello"] | "\e[0m"]

  The theme can also contain further formats.

      iex> theme = %{
      ...>   orange: IO.ANSI.color(5, 3, 0),
      ...>   gray_background: IO.ANSI.color_background(59),
      ...>   say: [:orange, :gray_background],
      ...>   blank: " "
      ...> }
      iex> Escape.format([:say, :blank, "hello", :blank], theme: theme)
      [[[[[[], [[] | "\e[38;5;214m"] | "\e[48;5;59m"] | " "], "hello"] | " "] | "\e[0m"]
      iex> Escape.format([:say, :blank, "hello", :blank], theme: theme, emit: false)
      [[], "hello"]

  See `Escape.format/2` for more info.
  """

  import Escape.Sequence
  import Inspect.Algebra, only: [is_doc: 1]
  import Kernel, except: [length: 1]

  alias Inspect.Algebra
  alias IO.ANSI

  @type ansicode :: atom
  @type ansidata :: ansilist | ansicode | binary
  @type ansilist ::
          maybe_improper_list(
            char | ansicode | binary | ansilist,
            binary | ansicode | []
          )

  @sequence_regex ~r/\x1B\[[0-9;]*m/

  named_colors = [
    :black,
    :red,
    :green,
    :yellow,
    :blue,
    :magenta,
    :cyan,
    :white
  ]

  colors =
    named_colors
    |> Enum.with_index()
    |> Enum.flat_map(fn {name, index} ->
      [
        {name, index + 30},
        {:"#{name}_background", index + 40},
        {:"light_#{name}", index + 90},
        {:"light_#{name}_background", index + 100}
      ]
    end)

  fonts =
    for font_n <- 1..9 do
      {:"font_#{font_n}", font_n + 10}
    end

  sequences =
    colors ++
      fonts ++
      [
        {:default_color, 39},
        {:default_background, 49},
        {:reset, 0},
        {:bright, 1},
        {:faint, 2},
        {:italic, 3},
        {:underline, 4},
        {:blink_slow, 5},
        {:blink_rapid, 6},
        {:inverse, 7},
        {:reverse, 7},
        {:conceal, 8},
        {:crossed_out, 9},
        {:primary_font, 10},
        {:normal, 22},
        {:not_italic, 23},
        {:no_underline, 24},
        {:blink_off, 25},
        {:inverse_off, 27},
        {:reverse_off, 27},
        {:framed, 51},
        {:encircled, 52},
        {:overlined, 53},
        {:not_framed_encircled, 54},
        {:not_overlined, 55},
        {:home, "", "H"}
      ]

  @sequences sequences |> Enum.map(fn seq -> elem(seq, 0) end) |> Enum.sort()

  if Version.match?(System.version(), ">= 1.19.0") do
    @doc_nil []
  else
    @doc_nil :doc_nil
  end

  @doc """
  Returns a list of all available named ANSI sequences.

  ## Examples

      iex> Escape.sequences()
      [:black, :black_background, :blink_off, :blink_rapid, :blink_slow, :blue,
      :blue_background, :bright, :conceal, :crossed_out, :cyan, :cyan_background,
      :default_background, :default_color, :encircled, :faint, :font_1, :font_2,
      :font_3, :font_4, :font_5, :font_6, :font_7, :font_8, :font_9, :framed, :green,
      :green_background, :home, :inverse, :inverse_off, :italic, :light_black,
      :light_black_background, :light_blue, :light_blue_background, :light_cyan,
      :light_cyan_background, :light_green, :light_green_background, :light_magenta,
      :light_magenta_background, :light_red, :light_red_background, :light_white,
      :light_white_background, :light_yellow, :light_yellow_background, :magenta,
      :magenta_background, :no_underline, :normal, :not_framed_encircled,
      :not_italic, :not_overlined, :overlined, :primary_font, :red, :red_background,
      :reset, :reverse, :reverse_off, :underline, :white, :white_background, :yellow,
      :yellow_background]
  """
  @spec sequences :: [ansicode]
  def sequences, do: @sequences

  @doc """
  Formats a named ANSI sequences into an ANSI sequence.

  The named sequences are represented by atoms.

  ## Examples

      iex> Escape.sequence(:reverse)
      "\e[7m"
  """
  @spec sequence(ansicode) :: String.t()
  def sequence(ansicode)

  Enum.map(sequences, fn
    {name, code} -> defsequence(name, code, "m")
    {name, code, terminator} -> defsequence(name, code, terminator)
  end)

  def sequence(ansicode) do
    raise ArgumentError, "invalid sequence specification: #{inspect(ansicode)}"
  end

  @doc """
  Writes `ansidata` to a `device`, similar to `write/2`, but adds a newline at
  the end.

  The device is passed to the function with the option `:device` in the opts and
  defaults to standard output.

  The function also accepts the same options as `Escape.format/2`.
  """
  @spec puts(ansidata, keyword) :: :ok
  def puts(ansidata, opts \\ [device: :stdio])

  def puts(ansidata, opts) when is_list(ansidata) do
    {device, opts} = Keyword.pop(opts, :device, :stdio)
    chardata = format(ansidata, opts)
    IO.puts(device, chardata)
  end

  def puts(ansidata, opts) do
    opts |> Keyword.get(:device, :stdio) |> IO.puts(ansidata)
  end

  @doc """
  Writes `ansidata` to a device.

  The device is passed to the function with the option `:device` in the opts and
  defaults to standard output.

  The function also accepts the same options as `Escape.format/2`.
  """
  @spec write(ansidata, keyword) :: :ok
  def write(ansidata, opts \\ [device: :stdio])

  def write(ansidata, opts) when is_list(ansidata) do
    {device, opts} = Keyword.pop(opts, :device, :stdio)
    chardata = format(ansidata, opts)
    IO.write(device, chardata)
  end

  def write(ansidata, opts) do
    opts |> Keyword.get(:device, :stdio) |> IO.write(ansidata)
  end

  @doc """
  Returns a function that accepts a string and a named sequence and returns
  iodata with the applied format.

  Accepts the same options as `format/2`.

  ## Examples

      iex> colorizer = Escape.colorizer(theme: %{say: :green})
      iex> colorizer.("hello", :say)
      [[[[] | "\e[32m"], "hello"] | "\e[0m"]
      iex> colorizer.("hello", :green)
      [[[[] | "\e[32m"], "hello"] | "\e[0m"]
  """
  @spec colorizer(keyword) :: (String.t(), ansicode -> String.t())
  def colorizer(opts) do
    fn str, color ->
      format([color, str], opts)
    end
  end

  @doc """
  Returns a function that accepts a chardata-like argument and applies
  `Escape.format/2` with the argument and the given `opts`.

  ## Examples

      iex> formatter = Escape.formatter(theme: %{say: :green})
      iex> formatter.([:say, "hello"])
      [[[[] | "\e[32m"], "hello"] | "\e[0m"]
  """
  @spec formatter(keyword) :: (ansidata -> String.t())
  def formatter(opts) do
    fn ansidata ->
      format(ansidata, opts)
    end
  end

  @doc """
  Formats a chardata-like argument by converting named sequences into ANSI
  sequences.

  The named sequences are represented by atoms. The named sequences can be
  extended by a map for the option `:theme`.

  It will also append an `IO.ANSI.reset/0` to the chardata when a conversion is
  performed. If you don't want this behaviour, use the option `reset?: false`.

  The option `:emit` can be passed to enable or disable emitting ANSI codes.
  When false, no ANSI codes will be emitted. This option defaults to the return
  value of `IO.ANSI.enabled?/0`.

  ## Options

    * `:theme` a map that adds ANSI codes usable in the chardata-like argument.
      The searching in the theme performs a deep search.

    * `:reset` appends an `IO.ANSI.reset/0` when true.
      Defaults to `true`.

    * `:emit` enables or disables emitting ANSI codes.
      Defaults to `IO.ANSI.enabled?/0`.

  ## Examples

      iex> theme = %{
      ...>   gainsboro: ANSI.color(4, 4, 4),
      ...>   orange: ANSI.color(5, 3, 0),
      ...>   aquamarine: ANSI.color(2, 5, 4),
      ...>   error: :red,
      ...>   debug: :orange,
      ...>   info: :gainsboro
      ...> }
      iex> Escape.format([:error, "error"], theme: theme)
      [[[[] | "\e[31m"], "error"] | "\e[0m"]
      iex> Escape.format([:info, "info"], theme: theme)
      [[[[] | "\e[38;5;188m"], "info"] | "\e[0m"]
      iex> Escape.format([:info, "info"], theme: theme, reset: false)
      [[[] | "\e[38;5;188m"], "info"]
      iex> Escape.format([:info, "info"], theme: theme, emit: false)
      [[], "info"]
  """
  @spec format(ansidata(), keyword()) :: IO.chardata()
  def format(ansidata, opts \\ [emit: ANSI.enabled?(), reset: true]) do
    emit? = Keyword.get(opts, :emit, ANSI.enabled?())
    reset = Keyword.get(opts, :reset, if(emit?, do: :maybe, else: false))
    theme = Keyword.get(opts, :theme)

    do_format(ansidata, [], [], emit?, reset, theme)
  end

  defp do_format([term | rest], rem, acc, emit?, reset, theme) do
    do_format(term, [rest | rem], acc, emit?, reset, theme)
  end

  defp do_format([], [next | rest], acc, emit?, reset, theme) do
    do_format(next, rest, acc, emit?, reset, theme)
  end

  defp do_format([], [], acc, _emit? = true, _reset = true, _theme) do
    [acc | sequence(:reset)]
  end

  defp do_format([], [], acc, _emit?, _reset, _theme) do
    acc
  end

  defp do_format(term, rem, acc, _emit? = true, reset, theme) when is_atom(term) do
    do_format([], rem, [acc | format_sequence(term, theme, [])], true, !!reset, theme)
  end

  defp do_format(term, rem, acc, _emit? = false, reset, theme) when is_atom(term) do
    do_format([], rem, acc, false, reset, theme)
  end

  defp do_format(term, rem, acc, _emit? = true, reset, theme) do
    do_format([], rem, [acc, term], true, reset?(term, reset), theme)
  end

  defp do_format(term, rem, acc, _emit? = false, _reset, theme) do
    acc = if sequence?(term), do: acc, else: [acc, term]
    do_format([], rem, acc, false, false, theme)
  end

  defp format_sequence(term, nil, _seen) when is_atom(term) do
    sequence(term)
  end

  defp format_sequence(term, theme, seen) when is_atom(term) and is_map(theme) do
    case Map.fetch(theme, term) do
      {:ok, seq} when is_binary(seq) ->
        seq

      {:ok, seq} when is_list(seq) ->
        do_format(seq, [], [], true, false, theme)

      {:ok, seq} when is_atom(seq) ->
        if seq in seen do
          raise ArgumentError, "cyclic sequence specification: #{inspect(term)}"
        else
          format_sequence(seq, theme, [seq | seen])
        end

      :error ->
        sequence(term)
    end
  end

  defp reset?(<<"\e[", _rest::binary>>, :maybe), do: true
  defp reset?(_term, reset), do: reset

  defp sequence?(<<"\e[", _rest::binary>>), do: true
  defp sequence?(_term), do: false

  defmacrop doc_color(doc, color) do
    quote do: {:doc_color, unquote(doc), unquote(color)}
  end

  @doc """
  Colors a `Inspect.Algebra` document if the `color_key` has a color in the
  `theme`.

  This function is similar to `Inspect.Algebra.color/3` but has a different
  options argument.

  ## Options

    * `:theme` a map of ANSI codes. The searching in the theme performs a deep
      search.

    * `:emit` enables or disables emitting ANSI codes.
      Defaults to `IO.ANSI.enabled?/0`.
  """
  @spec color_doc(Algebra.t(), ansicode(), keyword()) :: Algebra.t()
  def color_doc(doc, color_key, opts \\ []) when is_doc(doc) do
    emit? = Keyword.get(opts, :emit, ANSI.enabled?())
    theme = Keyword.get(opts, :theme)

    if emit? && theme && Map.has_key?(theme, color_key) do
      precolor = format_sequence(color_key, theme, [])
      postcolor = format_sequence(:reset, theme, [])
      Algebra.concat(doc_color(doc, precolor), doc_color(@doc_nil, postcolor))
    else
      doc
    end
  end

  # Algebra.concat(doc_color(doc, precolor), doc_color("", postcolor))

  @doc """
  Returns the length of a string or ansidata without ANSI escape sequences.

  ## Examples

      iex> String.length("Hello, world!")
      13
      iex> [:green, "Hello", :reset, ", ", :blue, "world!"]
      ...> |> Escape.format()
      ...> |> Escape.length()
      13
      iex> [:green, "Hello", :reset, ", ", :blue, "world!"]
      ...> |> Escape.format()
      ...> |> IO.iodata_to_binary()
      ...> |> Escape.length()
      13
  """
  @spec length(String.t() | ansidata()) :: non_neg_integer
  def length(string_or_ansidata)

  def length(string) when is_binary(string) do
    string |> strip_sequences() |> String.length()
  end

  def length(ansidata) do
    ansidata |> IO.iodata_to_binary() |> length()
  end

  @doc ~S"""
  Returns the given `string` stripped of all escape sequences.

  ## Examples

      iex> [:green, "Hello, ", :green, "world!"]
      ...> |> Escape.format(reset: false)
      ...> |> IO.iodata_to_binary()
      ...> |> Escape.strip_sequences()
      "Hello, world!"

  """
  @spec strip_sequences(String.t()) :: String.t()
  def strip_sequences(string) when is_binary(string) do
    String.replace(string, @sequence_regex, "")
  end

  @doc ~S"""
  Splits a string into two parts at the specified offset, respecting ANSI escape
  sequences.

  ## Examples

      iex> string = [:red, "red", :green, "green"]
      ...>   |> Escape.format(reset: false)
      ...>   |> IO.iodata_to_binary()
      iex> Escape.split_at(string, 3)
      {"\e[31mred", "\e[32mgreen"}
      iex> Escape.split_at(string, 1)
      {"\e[31mr", "ed\e[32mgreen"}
  """
  @spec split_at(String.t(), non_neg_integer()) :: {String.t(), String.t()}
  def split_at(string, 0), do: {"", string}
  def split_at("", _position), do: {"", ""}

  def split_at(string, position) when is_binary(string) and position > 0 do
    string
    |> String.split(@sequence_regex, include_captures: true)
    |> do_split_at(position, [])
  end

  defp do_split_at([string], position, []), do: String.split_at(string, position)

  defp do_split_at([string], position, acc) do
    {left, right} = String.split_at(string, position)
    {IO.iodata_to_binary([acc, left]), right}
  end

  defp do_split_at([string, sequence | rest], position, acc) do
    case String.length(string) do
      length when length < position ->
        do_split_at(rest, position - length, [acc | [string, sequence]])

      length when length > position ->
        {left, right} = String.split_at(string, position)
        {IO.iodata_to_binary([acc, left]), IO.iodata_to_binary([right, sequence, rest])}

      _length ->
        {IO.iodata_to_binary([acc, string]), IO.iodata_to_binary([sequence, rest])}
    end
  end
end