lib/io/ansi/table.ex

# ┌───────────────────────────────────────────────────────────┐
# │ Inspired by the book "Programming Elixir" by Dave Thomas. │
# └───────────────────────────────────────────────────────────┘
defmodule IO.ANSI.Table do
  @moduledoc """
  Writes data to stdout in a table with borders and colors.
  Can choose a table style to change the look of the table.

  ##### Inspired by the book [Programming Elixir](https://pragprog.com/book/elixir16/programming-elixir-1-6) by Dave Thomas.
  """

  use PersistConfig

  alias __MODULE__.{DynSpecSup, Header, Spec, SpecServer}

  @doc """
  Starts a new table spec server process and supervises it.
  Upon request (see `format/2`), the server will write data from
  `maps` to stdout in a table formatted per `headers` and `options`.

  The table columns are identified by `headers` (`map` keys).
  We calculate the width of each column to fit the longest element
  in that column, also considering the column heading.
  However, the `:max_width` option prevails.

  If the `:count` option is positive, we format the first _count_
  `maps` in the list, once sorted. If negative, the last _count_ ones.

  See `IO.ANSI.Table.Options` for a list of all options in this API.

  ## Parameters

    - `headers` - keys identifying each column (list)
    - `options` - up to `10` options (keyword)

  ## Options

    - `:align_specs`  - to align column elements (list)
    - `:bell`         - whether to ring the bell (boolean)
    - `:count`        - number of `maps` to format (integer)
    - `:header_fixes` - to alter the `headers` (map)
    - `:margins`      - to position the table (keyword)
    - `:max_width`    - to cap column widths (non neg integer)
    - `:sort_specs`   - to sort the `maps` (list)
    - `:sort_symbols` - to denote sort direction (keyword)
    - `:spec_name`    - to identify the table spec server (string)
    - `:style`        - table style (atom)

  ## Examples

      iex> alias IO.ANSI.Table
      iex> alias IO.ANSI.Table.SpecServer
      iex> {:ok, pid} = Table.start([:name, :dob, :likes])
      iex> pid == SpecServer.via("io_ansi_table") |> GenServer.whereis()
      true
  """
  @spec start([Header.t(), ...], Keyword.t()) :: Supervisor.on_start_child()
  def start([_ | _] = headers, options \\ []) when is_list(options) do
    spec = Spec.new(headers, options)
    DynamicSupervisor.start_child(DynSpecSup, {SpecServer, spec})
  end

  @doc """
  Stops a table spec server process normally. It won't be restarted.
  The table spec server to stop is identified by `option` `:spec_name`.

  See `IO.ANSI.Table.Options` for a list of all options in this API.

  ## Parameters

    - `options` - up to `1` option (keyword)

  ## Options

    - `:spec_name` - to identify the table spec server (string)

  ## Examples

      iex> alias IO.ANSI.Table
      iex> Table.start([:name, :dob, :likes])
      iex> Table.stop
      :ok
  """
  @spec stop(Keyword.t()) :: :ok
  def stop(options \\ []) when is_list(options) do
    spec_name = Spec.spec_name(options)
    SpecServer.via(spec_name) |> GenServer.stop(:shutdown)
  end

  @doc """
  Sends a request to the table spec server identified by `option` `:spec_name`.
  The server will write data from `maps` to stdout in a table formatted per its
  spec and `options`.

  Also supports:

    - keywords
    - structs implementing the [Access](https://hexdocs.pm/elixir/Access.html)
      behaviour.

  Does not support:

    - _nested_ maps, keywords or structs

  See `IO.ANSI.Table.Options` for a list of all options in this API.

  ## Parameters

    - `maps`    - _flat_ maps/keywords/structs (list)
    - `options` - up to `6` options (keyword)

  ## Options

    - `:async`     - whether to write the table asynchronously (boolean)
    - `:bell`      - whether to ring the bell (boolean)
    - `:count`     - number of `maps` to format (integer)
    - `:max_width` - to cap column widths (non neg integer)
    - `:spec_name` - to identify the table spec server (string)
    - `:style`     - table style (atom)

  ## Examples

      alias IO.ANSI.Table

      people = [
        %{name: "Mike", likes: "ski, arts", dob: "1992-04-15", bmi: 23.9},
        %{name: "Mary", likes: "travels"  , dob: "1992-04-15", bmi: 26.8},
        %{name: "Ann" , likes: "reading"  , dob: "1992-04-15", bmi: 24.7},
        %{name: "Ray" , likes: "cycling"  , dob: "1977-08-28", bmi: 19.1},
        %{name: "Bill", likes: "karate"   , dob: "1977-08-28", bmi: 18.1},
        %{name: "Joe" , likes: "boxing"   , dob: "1977-08-28", bmi: 20.8},
        %{name: "Jill", likes: "cooking"  , dob: "1976-09-28", bmi: 25.8}
      ]

      Table.start([:name, :dob, :likes],
        header_fixes: %{~r[dob]i => "Date of Birth"},
        sort_specs: [asc: :dob],
        align_specs: [center: :dob],
        margins: [top: 2, bottom: 2, left: 2]
      )

      Table.format(people, style: :light)
      Table.format(people, style: :light_alt)
      Table.format(people, style: :light_mult)
      Table.format(people, style: :cyan_alt)
      Table.format(people, style: :cyan_mult)

  ## ![light](images/light.png)
  ## ![light_alt](images/light_alt.png)
  ## ![light_mult](images/light_mult.png)
  ## ![cyan_alt](images/cyan_alt.png)
  ## ![cyan_mult](images/cyan_mult.png)
  """
  @spec format([Access.container()], Keyword.t()) :: :ok
  def format(maps, options \\ []) when is_list(maps) and is_list(options) do
    spec_name = Spec.spec_name(options)

    if options[:async],
      do: GenServer.cast(SpecServer.via(spec_name), {:format, maps, options}),
      else: GenServer.call(SpecServer.via(spec_name), {:format, maps, options})
  end

  @doc """
  Sends a request to the table spec server identified by `option` `:spec_name`.
  Returns the server's table spec.

  See `IO.ANSI.Table.Options` for a list of all options in this API.

  ## Parameters

    - `options` - up to `1` option (keyword)

  ## Options

    - `:spec_name` - to identify the table spec server (string)
  """
  def get(options \\ []) when is_list(options) do
    spec_name = Spec.spec_name(options)
    GenServer.call(SpecServer.via(spec_name), :get)
  end

  @doc """
  Writes data from `maps` to stdout in a table formatted per `spec` and
  `options`.

  Also supports:

    - keywords
    - structs implementing the [Access](https://hexdocs.pm/elixir/Access.html)
      behaviour

  Does not support:

    - _nested_ maps, keywords or structs

  See `IO.ANSI.Table.Options` for a list of all options in this API.

  ## Parameters

    - `spec`    - table spec (struct)
    - `maps`    - _flat_ maps/keywords/structs (list)
    - `options` - up to `4` options (keyword)

  ## Options

    - `:bell`      - whether to ring the bell (boolean)
    - `:count`     - number of `maps` to format (integer)
    - `:max_width` - to cap column widths (non neg integer)
    - `:style`     - table style (atom)

  ## Examples

      alias IO.ANSI.Table
      alias IO.ANSI.Table.Spec

      people = [
        %{name: "Mike", likes: "ski, arts", dob: "1992-04-15", bmi: 23.9},
        %{name: "Mary", likes: "travels"  , dob: "1992-04-15", bmi: 26.8},
        %{name: "Ann" , likes: "reading"  , dob: "1992-04-15", bmi: 24.7},
        %{name: "Ray" , likes: "cycling"  , dob: "1977-08-28", bmi: 19.1},
        %{name: "Bill", likes: "karate"   , dob: "1977-08-28", bmi: 18.1},
        %{name: "Joe" , likes: "boxing"   , dob: "1977-08-28", bmi: 20.8},
        %{name: "Jill", likes: "cooking"  , dob: "1976-09-28", bmi: 25.8}
      ]

      spec = Spec.new([:name, :dob, :likes],
        header_fixes: %{~r[dob]i => "Date of Birth"},
        sort_specs: [asc: :dob],
        align_specs: [center: :dob],
        margins: [top: 2, bottom: 2, left: 2]
      ) |> Spec.extend()

      Table.write(people, spec, style: :light)
      Table.write(people, spec, style: :light_alt)
      Table.write(people, spec, style: :light_mult)
      Table.write(people, spec, style: :cyan_alt)
      Table.write(people, spec, style: :cyan_mult)
  """
  @spec write([Access.container()], Spec.t(), Keyword.t()) :: :ok
  defdelegate write(maps, spec, options \\ []), to: Spec, as: :write_table
end