diff --git a/src/output.test.ts b/src/output.test.ts index 3486705f..3f4be0c3 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render } from './output.js'; -function stripAnsi(str: string): string { - return str.replace(/\u001B\[[0-9;]*m/g, ''); -} - describe('output TTY detection', () => { const originalIsTTY = process.stdout.isTTY; - const originalColumns = process.stdout.columns; let logSpy: ReturnType; beforeEach(() => { @@ -16,7 +11,6 @@ describe('output TTY detection', () => { afterEach(() => { Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); - Object.defineProperty(process.stdout, 'columns', { value: originalColumns, writable: true }); logSpy.mockRestore(); }); @@ -51,45 +45,4 @@ describe('output TTY detection', () => { expect(out).not.toContain('name: alice'); expect(out).toContain('alice'); }); - - it('renders single-row table output as key/value pairs', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - render( - [{ name: 'alice', score: 10, description: 'single row detail' }], - { fmt: 'table', columns: ['name', 'score', 'description'], title: 'Sample' }, - ); - const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); - expect(out).toContain('Sample'); - expect(out).toContain(' Name alice'); - expect(out).toContain(' Score 10'); - expect(out).toContain(' Description single row detail'); - expect(out).toContain('1 items'); - }); - - it('caps wide table columns to terminal width and truncates long values', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - Object.defineProperty(process.stdout, 'columns', { value: 40, writable: true }); - render( - [ - { - name: 'alpha', - status: 'ok', - description: 'This is a very long description that should wrap cleanly in a narrow terminal width.', - }, - { - name: 'beta', - status: 'warn', - description: 'Another long description that should also wrap instead of making the table extremely wide.', - }, - ], - { fmt: 'table', columns: ['name', 'status', 'description'] }, - ); - const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); - expect(out).toContain('This is a very l...'); - expect(out).toContain('Another long des...'); - expect(out).not.toContain('terminal width.'); - - const maxLineLength = out.split('\n').reduce((max: number, line: string) => Math.max(max, line.length), 0); - expect(maxLineLength).toBeLessThanOrEqual(40); - }); }); diff --git a/src/output.ts b/src/output.ts index 4d90a89d..746f8796 100644 --- a/src/output.ts +++ b/src/output.ts @@ -47,166 +47,31 @@ export function render(data: unknown, opts: RenderOptions = {}): void { } } -// ── CJK-aware string width ── - -function isWideCodePoint(cp: number): boolean { - return ( - (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs - (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A - (cp >= 0x20000 && cp <= 0x2A6DF) || // CJK Extension B - (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs - (cp >= 0xFF01 && cp <= 0xFF60) || // Fullwidth Forms - (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs - (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables - (cp >= 0x3000 && cp <= 0x303F) || // CJK Symbols - (cp >= 0x3040 && cp <= 0x309F) || // Hiragana - (cp >= 0x30A0 && cp <= 0x30FF) // Katakana - ); -} - -function displayWidth(str: string): number { - let w = 0; - for (const ch of str) { - w += isWideCodePoint(ch.codePointAt(0)!) ? 2 : 1; - } - return w; -} - -function truncateToWidth(str: string, maxWidth: number): string { - if (maxWidth <= 0 || displayWidth(str) <= maxWidth) return str; - - const ellipsis = '...'; - const ellipsisWidth = displayWidth(ellipsis); - if (maxWidth <= ellipsisWidth) return ellipsis.slice(0, maxWidth); - - let out = ''; - let width = 0; - for (const ch of str) { - const nextWidth = displayWidth(ch); - if (width + nextWidth + ellipsisWidth > maxWidth) break; - out += ch; - width += nextWidth; - } - - return out + ellipsis; -} - -// ── Table rendering ── - -// Fits typical date, status, and ID columns without truncation. -const SHORT_COL_THRESHOLD = 15; - -const NUMERIC_RE = /^-?[\d,]+\.?\d*$/; - function renderTable(data: unknown, opts: RenderOptions): void { const rows = normalizeRows(data); if (!rows.length) { console.log(styleText('dim', '(no data)')); return; } const columns = resolveColumns(rows, opts); - if (rows.length === 1) { - renderKeyValue(rows[0], columns, opts); - return; - } - - const cells: string[][] = rows.map(row => - columns.map(c => { - const v = (row as Record)[c]; - return v === null || v === undefined ? '' : String(v); - }), - ); - const header = columns.map(c => capitalize(c)); - const colCount = columns.length; - - // Single pass: measure column widths + detect numeric columns - const colContentWidths = header.map(h => displayWidth(h)); - const numericCounts = new Array(colCount).fill(0); - const totalCounts = new Array(colCount).fill(0); - - for (const row of cells) { - for (let ci = 0; ci < colCount; ci++) { - const w = displayWidth(row[ci]); - if (w > colContentWidths[ci]) colContentWidths[ci] = w; - const v = row[ci].trim(); - if (v) { - totalCounts[ci]++; - if (NUMERIC_RE.test(v)) numericCounts[ci]++; - } - } - } - - const colAligns: Array<'left' | 'right'> = columns.map((_, ci) => - totalCounts[ci] > 0 && numericCounts[ci] / totalCounts[ci] > 0.8 ? 'right' : 'left', - ); - - // Calculate column widths to fit terminal. - // cli-table3 colWidths includes cell padding (1 space each side). - const termWidth = process.stdout.columns || 120; - // Border chars: '│' between every column + edges = colCount + 1 - const borderOverhead = colCount + 1; - const availableWidth = Math.max(termWidth - borderOverhead, colCount * 5); - - let shortTotal = 0; - const longIndices: number[] = []; - - for (let i = 0; i < colCount; i++) { - // +2 for cell padding (1 space each side) - const padded = colContentWidths[i] + 2; - if (colContentWidths[i] <= SHORT_COL_THRESHOLD) { - colContentWidths[i] = padded; - shortTotal += padded; - } else { - longIndices.push(i); - } - } - - const remainingWidth = availableWidth - shortTotal; - if (longIndices.length > 0) { - const perLong = Math.max(Math.floor(remainingWidth / longIndices.length), 12); - for (const i of longIndices) { - colContentWidths[i] = Math.min(colContentWidths[i] + 2, perLong); - } - } - const table = new Table({ head: header.map(h => styleText('bold', h)), style: { head: [], border: [] }, - colWidths: colContentWidths, - colAligns, + wordWrap: true, + wrapOnWordBoundary: true, }); - for (const row of cells) { - table.push(row.map((cell, ci) => truncateToWidth(cell, colContentWidths[ci] - 2))); + for (const row of rows) { + table.push(columns.map(c => { + const v = (row as Record)[c]; + return v === null || v === undefined ? '' : String(v); + })); } console.log(); if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); console.log(table.toString()); - printFooter(rows.length, opts); -} - -function renderKeyValue(row: Record, columns: string[], opts: RenderOptions): void { - const entries = columns.map(c => ({ - key: capitalize(c), - value: row[c] === null || row[c] === undefined ? '' : String(row[c]), - })); - - const maxKeyWidth = Math.max(...entries.map(e => displayWidth(e.key))); - - console.log(); - if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); - console.log(); - for (const { key, value } of entries) { - const padding = ' '.repeat(maxKeyWidth - displayWidth(key)); - console.log(` ${styleText('bold', key)}${padding} ${value}`); - } - console.log(); - printFooter(1, opts); -} - -function printFooter(count: number, opts: RenderOptions): void { const footer: string[] = []; - footer.push(`${count} items`); + footer.push(`${rows.length} items`); if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`); if (opts.source) footer.push(opts.source); if (opts.footerExtra) footer.push(opts.footerExtra);