/** * Table renderer for CLI output. * * Renders structured data as aligned ASCII tables with optional color support. */ import chalk, { type ChalkInstance } from "chalk"; import type { AnyCommandResult, ColumnDef, OutputOptions, OutputSchema } from "./types.js"; // ANSI escape code regex for stripping colors when measuring width const ANSI_REGEX = // eslint-disable-next-line no-control-regex /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; /** Strip ANSI escape codes from a string */ function stripAnsi(str: string): string { return str.replace(ANSI_REGEX, ""); } /** Get visible string length (excluding ANSI codes) */ function visibleLength(str: string): number { return stripAnsi(str).length; } /** Pad a cell to the specified width with alignment */ function padCell(cell: string, width: number, align: "left" | "right" | "center"): string { const visible = visibleLength(cell); const padding = Math.max(0, width - visible); switch (align) { case "right": return " ".repeat(padding) + cell; case "center": { const left = Math.floor(padding / 2); const right = padding - left; return " ".repeat(left) + cell + " ".repeat(right); } case "left": default: return cell + " ".repeat(padding); } } /** Apply a chalk color to a string */ function applyColor(str: string, colorName: string): string { // Map color names to chalk methods const colorMap: Record = { red: chalk.red, green: chalk.green, blue: chalk.blue, yellow: chalk.yellow, cyan: chalk.cyan, magenta: chalk.magenta, white: chalk.white, gray: chalk.gray, grey: chalk.grey, dim: chalk.dim, bold: chalk.bold, }; const colorFn = colorMap[colorName]; return colorFn ? colorFn(str) : str; } /** Extract value from item using field definition */ function getValue(item: T, field: keyof T | ((item: T) => unknown)): unknown { return typeof field === "function" ? field(item) : item[field]; } /** Render a single table row */ function renderRow( item: T, columns: ColumnDef[], widths: number[], options: OutputOptions, ): string { return columns .map((col, colIndex) => { const value = getValue(item, col.field); let cell = String(value ?? ""); const width = widths[colIndex]; // Apply color if enabled if (col.color && !options.noColor) { const colorName = col.color(value, item); if (colorName) { cell = applyColor(cell, colorName); } } return padCell(cell, width ?? 0, col.align ?? "left"); }) .join(" "); } /** Render header row */ function renderHeader( columns: ColumnDef[], widths: number[], options: OutputOptions, ): string { const headerRow = columns .map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? "left")) .join(" "); return options.noColor ? headerRow : chalk.bold(headerRow); } /** Calculate column widths based on content and hints */ function calculateWidths(data: T[], columns: ColumnDef[], includeHeaders: boolean): number[] { return columns.map((col) => { // Start with header width if including headers let maxWidth = includeHeaders ? col.header.length : 0; // Check all data values for (const item of data) { const value = getValue(item, col.field); const str = String(value ?? ""); maxWidth = Math.max(maxWidth, visibleLength(str)); } // Apply width hint if specified (minimum width) if (col.width) { maxWidth = Math.max(maxWidth, col.width); } return maxWidth; }); } /** Render a list result as a table */ export function renderTable(result: AnyCommandResult, options: OutputOptions): string { const { schema } = result; const data = result.type === "list" ? result.data : [result.data]; if (data.length === 0) { return ""; } const columns = schema.columns; const includeHeaders = !options.noHeaders; const widths = calculateWidths(data, columns, includeHeaders); const lines: string[] = []; // Add header row if (includeHeaders) { lines.push(renderHeader(columns, widths, options)); } // Add data rows for (const item of data) { lines.push(renderRow(item, columns, widths, options)); } return lines.join("\n"); } /** Render just a table header (for streaming) */ export function renderTableHeader( schema: OutputSchema, options: OutputOptions, widths?: number[], ): string { const columns = schema.columns; const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length); return renderHeader(columns, actualWidths, options); } /** Render just a table row (for streaming) */ export function renderTableRow( item: T, schema: OutputSchema, options: OutputOptions, widths?: number[], ): string { const columns = schema.columns; const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length); return renderRow(item, columns, actualWidths, options); }