lib/io/ansi/table/spec.ex

defmodule IO.ANSI.Table.Spec do
  @moduledoc """
  Creates a table `spec` struct from `headers` and `options`.
  Also writes data (from `maps`) to stdout per a table `spec`.
  """

  use PersistConfig

  alias __MODULE__
  alias IO.ANSI.Plus, as: ANSI
  alias IO.ANSI.Table.{Column, Header, LineType, Row, Style}

  @default_bell get_env(:default_bell)
  @default_count get_env(:default_count)
  @default_margins get_env(:default_margins)
  @default_max_width get_env(:default_max_width)
  @default_sort_symbols get_env(:default_sort_symbols)
  @default_style get_env(:default_style)

  @enforce_keys [
    # initial fields
    :spec_name,
    :headers,
    :align_specs,
    :bell,
    :count,
    :header_fixes,
    :margins,
    :max_width,
    :sort_specs,
    :sort_symbols,
    :style
  ]
  defstruct [
    # initial fields
    :spec_name,
    :headers,
    :align_specs,
    :bell,
    :count,
    :header_fixes,
    :margins,
    :max_width,
    :sort_specs,
    :sort_symbols,
    :style,
    # derived fields
    align_attrs: [],
    column_widths: [],
    headings: [],
    left_margin: "",
    rows: [],
    sort_attrs: []
  ]

  @type t :: %Spec{
          # initial fields
          spec_name: String.t(),
          headers: [Header.t(), ...],
          align_specs: [Header.align_spec()],
          bell: boolean,
          count: integer,
          header_fixes: %{optional(String.t() | Regex.t()) => String.t()},
          margins: Keyword.t(),
          max_width: pos_integer,
          sort_specs: [Header.sort_spec()],
          sort_symbols: [Header.sort_symbol()],
          style: Style.t(),
          # derived fields
          align_attrs: [Header.align_attr()],
          column_widths: [Column.width()],
          headings: [String.t()],
          left_margin: String.t(),
          rows: [Row.t()],
          sort_attrs: [Header.sort_attr()]
        }

  @doc """
  Creates a table `spec` struct from `headers` and `options`.
  """
  @spec new([Header.t(), ...], Keyword.t()) :: t
  def new([_ | _] = headers, options \\ []) when is_list(options) do
    %Spec{
      spec_name: spec_name(options),
      headers: headers,
      align_specs: Keyword.get(options, :align_specs, []),
      bell: Keyword.get(options, :bell, @default_bell),
      count: Keyword.get(options, :count, @default_count),
      header_fixes: Keyword.get(options, :header_fixes, %{}),
      margins: Keyword.merge(@default_margins, options[:margins] || []),
      max_width: Keyword.get(options, :max_width, @default_max_width),
      sort_specs: Keyword.get(options, :sort_specs, []),
      sort_symbols:
        Keyword.merge(@default_sort_symbols, options[:sort_symbols] || []),
      style: Keyword.get(options, :style, @default_style)
    }
  end

  @spec extend(t) :: t
  def extend(%Spec{} = spec) do
    spec
    |> Spec.AlignAttrs.derive()
    |> Spec.Headings.derive()
    |> Spec.LeftMargin.derive()
    |> Spec.SortAttrs.derive()
  end

  @doc """
  Identifies a table spec server.
  Defaults to the current application name expressed as a string.

  ## Examples

      iex> alias IO.ANSI.Table.Spec
      iex> Spec.spec_name(style: :light, count: 9, spec_name: "github_issues")
      "github_issues"

      iex> alias IO.ANSI.Table.Spec
      iex> Spec.spec_name([])
      "io_ansi_table"
  """
  @spec spec_name(Keyword.t()) :: String.t()
  def spec_name(options) do
    case options[:spec_name] do
      nil ->
        case :application.get_application() do
          {:ok, app} -> app
          :undefined -> Mix.Project.config()[:app]
        end
        |> to_string()

      spec_name when is_binary(spec_name) ->
        spec_name
    end
  end

  @doc """
  Writes data from `maps` to stdout per table `spec` and `options`.
  """
  @spec write_table([Access.container()], t, Keyword.t()) :: :ok
  def write_table(maps, %Spec{} = spec, options \\ [])
      when is_list(maps) and is_list(options) do
    spec
    |> put(:bell, options[:bell])
    |> put(:count, options[:count])
    |> put(:max_width, options[:max_width])
    |> put(:style, options[:style])
    |> Spec.Rows.derive(maps)
    |> Spec.ColumnWidths.derive()
    |> write_table()
  end

  ## Private functions

  @spec put(t, atom, Map.value()) :: t
  defp put(spec, _key, nil), do: spec
  defp put(spec, key, value), do: Map.put(spec, key, value)

  @spec top_margin(t) :: String.t()
  defp top_margin(%Spec{margins: margins} = _spec) do
    case margins[:top] do
      # Move the cursor up N lines: \e[<N>A...
      n when n <= -1 -> ANSI.cursor_up(-n)
      n -> String.duplicate("\n", n)
    end
  end

  @spec bottom_margin(t) :: String.t()
  defp bottom_margin(%Spec{margins: margins} = _spec) do
    case margins[:bottom] do
      n when n >= 0 -> String.duplicate("\n", margins[:bottom])
      _ -> ""
    end
  end

  @spec write_table(t) :: :ok
  defp write_table(spec) do
    top_margin(spec) |> IO.write()
    Style.line_types(spec.style) |> Enum.each(&LineType.write_lines(&1, spec))
    bottom_margin(spec) |> IO.write()
    IO.write(if spec.bell, do: "\a", else: "")
  end
end