defmodule TermTable do def build(data, _options \\ :empty) do max_lengths_by_index = max_lengths_by_index(data) { table_base, last_row_cell_lengths, _ } = do_build(data, max_lengths_by_index) table_bottom = do_separator(last_row_cell_lengths) result = table_base <> table_bottom <> "\n" result end defp do_build(rows, max_lengths_by_index) do rows |> Enum.with_index |> Enum.reduce({"", [], false}, fn({cols, row_idx}, acc) -> { previous_row_output, previous_row_cell_lengths, sep_preceding } = acc if cols == [:separator] do { previous_row_output, previous_row_cell_lengths, true } else { row_output, row_cell_lengths } = cols |> Enum.with_index |> Enum.reduce({ "|", []}, fn({i, col_idx}, acc) -> { output, cell_lengths } = acc { i, opts } = if is_tuple(i), do: i, else: { i, [] } colspan = Keyword.get(opts, :colspan, 1) align = Keyword.get(opts, :align, :left) cell = adjust(max_lengths_by_index, i, col_idx, align, colspan) cell_length = String.length(cell) new_cell_lengths = cell_lengths ++ [ cell_length ] output = output <> cell <> "|" { output, new_cell_lengths } end) if sep_preceding || row_idx == 0 do separator = separator(previous_row_cell_lengths, row_cell_lengths) { previous_row_output <> separator <> "\n" <> row_output <> "\n", row_cell_lengths, false } else { previous_row_output <> row_output <> "\n", row_cell_lengths, false } end end end) end defp lengths_by_index(cols) do cols |> Enum.with_index |> Enum.reduce(%{}, &length_for_element/2) end defp length_for_element({{i, opts}, col_idx}, acc) do width = Keyword.get(opts, :width) unless width do width = String.length(to_string(i)) end Map.put acc, col_idx, width end defp length_for_element({i, col_idx}, acc) do Map.put acc, col_idx, String.length(to_string(i)) end defp separator(prev_lengths, curr_lengths) do { a, b } = { length(prev_lengths), length(curr_lengths) } lengths = if a > b, do: prev_lengths, else: curr_lengths do_separator(lengths) end defp do_separator(lengths) do fun = fn(x, acc) -> acc <> String.ljust("", x, ?-) <> "+" end lengths |> Enum.reduce("+", fun) end defp adjust(max_lengths_by_index, content, col_idx, align, colspan) do range = col_idx..(col_idx + colspan - 1) required_length = Enum.reduce range, 0, fn(idx, acc) -> acc + max_lengths_by_index[idx] end # correction by length of spaces between cols required_length = required_length + (colspan - 1) * 3 content = to_string(content) case align do :left -> " " <> String.ljust(content, required_length) <> " " :right -> " " <> String.rjust(content, required_length) <> " " :center -> right_adjust = div(required_length + String.length(content), 2) right_adjusted = String.rjust(content, right_adjust) " " <> String.ljust(right_adjusted, required_length) <> " " end end defp max_lengths_by_index(cols) do fun = fn(el, acc) -> lengths_by_index = lengths_by_index(el) Enum.reduce lengths_by_index, acc, fn({k, v}, acc) -> if acc[k] == nil || lengths_by_index[k] > acc[k] do Map.put(acc, k, v) else acc end end end cols |> Enum.reduce(%{}, fun) end def render(rows, options \\ :empty) do build(rows, options) end end