lib/tabula.ex

defmodule Tabula do
  @moduledoc """
  Tabula can transform a list of maps (structs too, e.g. Ecto schemas) 
  or Keywords into an ASCII/GitHub Markdown table.
  """

  import Enum, only: [
    concat: 2,
    intersperse: 2,
    map: 2,
    max: 1,
    with_index: 1,
    zip: 2
  ]

  @index "#"
  @newline '\n'

  @sheets \
  org_mode: [
    heading:                    " | ",
    heading_border:             "-+-",
    heading_left_outer_border:  "|-",
    heading_right_outer_border: "-|",
    left_outer_border:          "| ",
    right_outer_border:         " |",
    row:                        " | ",
    spacer:                     "-"
  ],
  github_md: [
    heading:                    " | ",
    heading_border:             " | ",
    heading_left_outer_border:  "| ",
    heading_right_outer_border: " |",
    left_outer_border:          "| ",
    right_outer_border:         " |",
    row:                        " | ",
    spacer:                     "-"
  ]

  @default_sheet :org_mode

  defmacro __using__(opts) do
    quote do
      def print_table(rows) do
        unquote(__MODULE__).print_table(rows, unquote(opts))
      end
      def print_table(rows, override_opts) do
        unquote(__MODULE__).print_table(
          rows, Keyword.merge(unquote(opts), override_opts))
      end
      def render_table(rows) do
        unquote(__MODULE__).render_table(rows, unquote(opts))
      end
      def render_table(rows, override_opts) do
        unquote(__MODULE__).render_table(
          rows, Keyword.merge(unquote(opts), override_opts))
      end
    end
  end

  defprotocol Row do
    @fallback_to_any true
    def get(row, col, default \\ nil)
    def keys(row)
  end

  defimpl Row, for: Map do
    def get(row, col, default \\ nil), do: Map.get(row, col, default)
    def keys(row), do: Map.keys(row)
  end

  defimpl Row, for: List do
    def get(row, col, default \\ nil), do: Keyword.get(row, col, default)
    def keys(row), do: Keyword.keys(row)
  end

  defimpl Row, for: Any do
    def get(%{__struct__: _} = row, col, default \\ nil), do: Map.get(row, col, default)
    def keys(%{__struct__: _} = row) do
      row
      |> Map.from_struct
      |> Map.keys
    end
  end

  def print_table(rows, opts \\ []) do
    rows
    |> render_table(opts)
    |> IO.puts()
  end

  def render_table(rows, opts \\ []) do
    rows
    |> extract_cols(opts)
    |> render_table(rows, opts)
    |> :erlang.list_to_binary()
  end

  defp render_table([_ | _] = cols, rows, opts) do
    widths     = max_widths(cols, rows)
    formatters = formatters(widths, opts)
    spacers    = spacers(widths, opts)

    [
      render_row(cols, :heading, formatters, opts),
      render_row(spacers, :heading_border, formatters, opts),

      rows
      |> with_index()
      |> map(fn indexed_row ->
        cols
        |> values(indexed_row)
        |> render_row(:row, formatters, opts)
      end)
    ]
  end

  def max_widths(cols, rows) do
    max_index =
      rows
      |> length()
      |> strlen()

    map(cols, fn k ->
      max([
        strlen(k),
        max_index
        | map(rows, &strlen(Row.get(&1, k)))
      ])
    end)
  end

  defp extract_cols([first | _], opts) do
    case opts[:only] do
      cols when is_list(cols) -> cols
      nil                     -> Row.keys(first)
    end
  end

  defp render_row(cells, style_element, formatters, opts) do
    separator = style(style_element, opts)
    {left_outer_border, right_outer_border} =
      outer_border_style(style_element, opts)

    row =
      cells
      |> zip(formatters)
      |> map(fn {k, f} -> f.(k) end)
      |> intersperse(separator)

    concat([left_outer_border, row, right_outer_border], @newline)
  end

  defp render_cell(v) when is_binary(v), do: v
  defp render_cell(v) when is_number(v), do: inspect(v)
  defp render_cell(%{__struct__: _} = v) do
    if String.Chars.impl_for(v) do
      to_string(v)
    else
      inspect(v)
    end
  end
  defp render_cell(v), do: inspect(v)

  defp formatters(widths, _opts) do
    map(widths, fn w ->
      fn @index = cell ->
           # need to pad_leading '#' orelse github fails to render
           String.pad_leading(cell, w)
         cell when is_binary(cell) ->
           String.pad_trailing(cell, w)
         cell when is_number(cell) ->
           cell
           |> render_cell()
           |> String.pad_leading(w)
         cell ->
           cell
           |> render_cell()
           |> String.pad_trailing(w)
      end
    end)
  end

  defp spacers(widths, opts) do
    spacer = style(:spacer, opts)
    map(widths, &String.duplicate(spacer, &1))
  end

  defp strlen(x) do
    x
    |> render_cell
    |> String.length()
  end

  defp values(cols, {row, index}) do
    map(cols, fn (@index) -> index + 1
                 (col)    -> Row.get(row, col)
              end)
  end

  defp outer_border_style(:heading_border, opts) do
    {style(:heading_left_outer_border, opts),
     style(:heading_right_outer_border, opts)}
  end

  defp outer_border_style(style, opts) when style in [:heading, :row] do
    {style(:left_outer_border, opts),
     style(:right_outer_border, opts)}
  end

  defp style(style, opts) do
    sheet = Keyword.get(opts, :style, @default_sheet)
    @sheets[sheet][style]
  end
end