lib/doex/io/table.ex

defmodule Doex.Io.Table do
  @moduledoc """
  Takes a list of rows (themselves a list of columns) and returns
  iodata containing an aligned ASCII table with `padding` spaces
  between each column.

  Thanks to
  https://gist.github.com/ivan/dc26e349de6f1693c1c77355ec85b643
  http://stackoverflow.com/questions/30749400/output-tabular-data-with-io-ansi

  ## Examples

        iex> Doex.Io.Table.format([[1, 2, 3], [4000, 6000, 9000]])
        [[[["1", "    "], ["2", "    "], "3"], 10],
         [[["4000", " "], ["6000", " "], "9000"], 10]]
  """
  def format(rows, opts \\ []) do
    padding = Keyword.get(opts, :padding, 1)
    rows = stringify(rows)
    widths = rows |> transpose |> column_widths

    rows
    |> pad_cells(widths, padding)
    |> Enum.map(&[&1, ?\n])
  end

  defp pad_cells(rows, widths, padding) do
    Enum.map(rows, fn row ->
      map_special(
        Enum.zip(row, widths),
        fn {val, width} ->
          pad_amount = width - (val |> byte_size) + padding
          [val, "" |> String.pad_leading(pad_amount)]
        end,
        fn {val, _width} -> val end
      )
    end)
  end

  defp stringify(rows) do
    Enum.map(rows, fn row ->
      Enum.map(row, &to_string/1)
    end)
  end

  defp column_widths(columns) do
    Enum.map(columns, fn column ->
      column |> Enum.map(&String.length/1) |> Enum.max()
    end)
  end

  defp transpose(rows) do
    rows
    |> List.zip()
    |> Enum.map(&Tuple.to_list(&1))
  end

  # Map elements in `enumerable` with `fun1` except for the last element
  # which is mapped with `fun2`.
  defp map_special(enumerable, fun1, fun2) do
    do_map_special(enumerable, [], fun1, fun2) |> :lists.reverse()
  end

  defp do_map_special([], _acc, _fun1, _fun2) do
    []
  end

  defp do_map_special([t], acc, _fun1, fun2) do
    [fun2.(t) | acc]
  end

  defp do_map_special([h | t], acc, fun1, fun2) do
    do_map_special(t, [fun1.(h) | acc], fun1, fun2)
  end
end