| 1 | package report |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // Table is a passive aligned-column renderer for terminal output. Cells may |
| 9 | // carry ANSI color escapes (e.g. a colored status); column widths are measured |
| 10 | // on visible runes so a colored cell aligns exactly as its plain twin. It is |
| 11 | // pure data-in, text-out: no I/O beyond the io.Writer passed to Render, and no |
| 12 | // dependency on the legatus core, transport, or sync. |
| 13 | type Table struct { |
| 14 | headers []string |
| 15 | rows [][]string |
| 16 | } |
| 17 | |
| 18 | // NewTable returns a Table with the given column headers. |
| 19 | func NewTable(headers ...string) *Table { |
| 20 | return &Table{headers: headers} |
| 21 | } |
| 22 | |
| 23 | // AddRow appends a row and returns the table for chaining. A row need not match |
| 24 | // the header count: shorter rows render the missing cells empty, longer rows add |
| 25 | // unheadered columns — Render derives the column count from the widest input. |
| 26 | func (t *Table) AddRow(cells ...string) *Table { |
| 27 | t.rows = append(t.rows, cells) |
| 28 | return t |
| 29 | } |
| 30 | |
| 31 | // Render writes the aligned table to w. Columns are left-aligned at the widest |
| 32 | // visible cell, separated by a two-space gutter, with no trailing whitespace and |
| 33 | // one newline per row. Output is deterministic for a given header+row set: the |
| 34 | // column order is construction order and no map iteration is involved. Write |
| 35 | // errors are ignored, matching TextReporter. |
| 36 | func (t *Table) Render(w io.Writer) { |
| 37 | cols := len(t.headers) |
| 38 | for _, r := range t.rows { |
| 39 | if len(r) > cols { |
| 40 | cols = len(r) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | widths := make([]int, cols) |
| 45 | for c := 0; c < cols; c++ { |
| 46 | widths[c] = visibleWidth(cell(t.headers, c)) |
| 47 | for _, r := range t.rows { |
| 48 | if v := visibleWidth(cell(r, c)); v > widths[c] { |
| 49 | widths[c] = v |
| 50 | } |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | writeRow(w, t.headers, widths) |
| 55 | for _, r := range t.rows { |
| 56 | writeRow(w, r, widths) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // writeRow renders one row, padding each cell (except the trailing run of empty |
| 61 | // space) to its column width and joining with a two-space gutter. |
| 62 | func writeRow(w io.Writer, cells []string, widths []int) { |
| 63 | var b strings.Builder |
| 64 | for c := 0; c < len(widths); c++ { |
| 65 | val := cell(cells, c) |
| 66 | if c > 0 { |
| 67 | b.WriteString(" ") |
| 68 | } |
| 69 | b.WriteString(val) |
| 70 | // Pad to the column width on visible length, except the last column where |
| 71 | // trailing padding would only add invisible trailing whitespace. |
| 72 | if c < len(widths)-1 { |
| 73 | b.WriteString(strings.Repeat(" ", widths[c]-visibleWidth(val))) |
| 74 | } |
| 75 | } |
| 76 | out := strings.TrimRight(b.String(), " ") |
| 77 | _, _ = io.WriteString(w, out+"\n") |
| 78 | } |
| 79 | |
| 80 | // cell returns the c-th cell of row, or "" when the row is shorter. |
| 81 | func cell(row []string, c int) string { |
| 82 | if c < len(row) { |
| 83 | return row[c] |
| 84 | } |
| 85 | return "" |
| 86 | } |
| 87 | |
| 88 | // visibleWidth counts the runes of s that are visible on a terminal, skipping |
| 89 | // ANSI CSI escape sequences (ESC '[' params final-byte). The final byte of a CSI |
| 90 | // sequence is in 0x40..0x7e; everything before it (parameters/intermediates) is |
| 91 | // skipped, so a colored cell measures the same width as its plain text. |
| 92 | func visibleWidth(s string) int { |
| 93 | n, rs := 0, []rune(s) |
| 94 | for i := 0; i < len(rs); i++ { |
| 95 | if rs[i] == '\x1b' && i+1 < len(rs) && rs[i+1] == '[' { |
| 96 | i += 2 |
| 97 | for i < len(rs) && (rs[i] < 0x40 || rs[i] > 0x7e) { |
| 98 | i++ // skip parameter/intermediate bytes |
| 99 | } |
| 100 | continue // i is at the final byte; the loop's i++ steps past it |
| 101 | } |
| 102 | n++ |
| 103 | } |
| 104 | return n |
| 105 | } |
| 106 | |