Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 0 additions & 47 deletions src/output.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

beforeEach(() => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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);
});
});
151 changes: 8 additions & 143 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[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<number>(colCount).fill(0);
const totalCounts = new Array<number>(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<string, unknown>)[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<string, unknown>, 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);
Expand Down