|
| 1 | +// Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands |
| 2 | +// (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its |
| 3 | +// CLI/MCP entrypoint on import, so importable helpers live here instead. |
| 4 | + |
| 5 | +export type TableHeader = { key: string; label?: string; align?: "left" | "right" }; |
| 6 | +export type TableRowObject = Record<string, unknown>; |
| 7 | +export type TableRow = TableRowObject | unknown[] | null | undefined; |
| 8 | +export type TableInput = TableRow[] | { headers?: (string | TableHeader)[]; rows?: TableRow[] }; |
| 9 | +export type FormatTableOptions = { align?: Record<string, "left" | "right">; gap?: number }; |
| 10 | + |
| 11 | +type NormalizedHeader = { key: string; label: string; align?: "left" | "right" | undefined }; |
| 12 | +type Normalized = { headers: NormalizedHeader[]; rows: TableRow[] }; |
| 13 | + |
| 14 | +// Normalize either an array of row objects or an explicit { headers, rows } shape into a common |
| 15 | +// { headers, rows } form. For an array of objects the column set is the union of keys in first-seen |
| 16 | +// order, and each key doubles as its own header label. |
| 17 | +function normalizeInput(input: TableInput | undefined | null): Normalized { |
| 18 | + if (Array.isArray(input)) { |
| 19 | + const keys: string[] = []; |
| 20 | + for (const row of input) { |
| 21 | + for (const key of Object.keys(row ?? {})) if (!keys.includes(key)) keys.push(key); |
| 22 | + } |
| 23 | + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; |
| 24 | + } |
| 25 | + const headers = (input?.headers ?? []).map((header) => |
| 26 | + typeof header === "string" ? { key: header, label: header } : { key: header.key, label: header.label ?? header.key, align: header.align }, |
| 27 | + ); |
| 28 | + return { headers, rows: input?.rows ?? [] }; |
| 29 | +} |
| 30 | + |
| 31 | +function stringifyCell(value: unknown): string { |
| 32 | + return value === undefined || value === null ? "" : String(value); |
| 33 | +} |
| 34 | + |
| 35 | +// A row is either an object keyed by column key or a positional array; read the matching cell. |
| 36 | +function readCell(row: TableRow, header: NormalizedHeader, columnIndex: number): unknown { |
| 37 | + if (Array.isArray(row)) return row[columnIndex]; |
| 38 | + return (row as TableRowObject | undefined)?.[header.key]; |
| 39 | +} |
| 40 | + |
| 41 | +function resolveAlign(header: NormalizedHeader, opts: FormatTableOptions): "left" | "right" { |
| 42 | + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); |
| 43 | + return header.align ?? fromOpts ?? "left"; |
| 44 | +} |
| 45 | + |
| 46 | +/** |
| 47 | + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). |
| 48 | + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` |
| 49 | + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; |
| 50 | + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. |
| 51 | + * Returns "" when there are no columns. |
| 52 | + */ |
| 53 | +export function formatTable(input?: TableInput | null, opts: FormatTableOptions = {}): string { |
| 54 | + const { headers, rows } = normalizeInput(input); |
| 55 | + if (headers.length === 0) return ""; |
| 56 | + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); |
| 57 | + const aligns = headers.map((header) => resolveAlign(header, opts)); |
| 58 | + // Precompute every cell's text so column widths and the rendered rows read the same strings. |
| 59 | + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); |
| 60 | + // Every `cells`/`widths` array here has exactly `headers.length` entries (built via headers.map), so |
| 61 | + // indexing by a column index drawn from that same range is always in bounds. |
| 62 | + const widths = headers.map((header, column) => |
| 63 | + Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]!.length), 0), |
| 64 | + ); |
| 65 | + const renderRow = (cells: string[]) => |
| 66 | + // Trim trailing padding so a left-aligned final column never emits dangling spaces. |
| 67 | + cells.map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column]!) : text.padEnd(widths[column]!))).join(gap).replace(/\s+$/, ""); |
| 68 | + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); |
| 69 | +} |
0 commit comments