Skip to content

Commit 7673051

Browse files
committed
feat(utils): wrap columns in ascii table
1 parent 7d4eb71 commit 7673051

File tree

5 files changed

+330
-84
lines changed

5 files changed

+330
-84
lines changed

package-lock.json

Lines changed: 51 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"ts-morph": "^24.0.0",
4646
"tslib": "^2.6.2",
4747
"vscode-material-icons": "^0.1.1",
48+
"wrap-ansi": "^9.0.2",
4849
"yaml": "^2.5.1",
4950
"yargs": "^17.7.2",
5051
"zod": "^4.0.5"

packages/utils/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
"simple-git": "^3.20.0",
4040
"string-width": "^8.1.0",
4141
"ora": "^9.0.0",
42-
"zod": "^4.0.5"
42+
"zod": "^4.0.5",
43+
"wrap-ansi": "^9.0.2"
4344
},
4445
"files": [
4546
"src",

packages/utils/src/lib/text-formats/ascii/table.ts

Lines changed: 146 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ansis from 'ansis';
22
import type { TableCellAlignment } from 'build-md';
33
import stringWidth from 'string-width';
4+
import wrapAnsi from 'wrap-ansi';
45
import type {
56
Table,
67
TableAlignment,
@@ -12,6 +13,7 @@ import { TERMINAL_WIDTH } from '../constants.js';
1213
type AsciiTableOptions = {
1314
borderless?: boolean;
1415
padding?: number;
16+
maxWidth?: number;
1517
};
1618

1719
type NormalizedOptions = Required<AsciiTableOptions>;
@@ -23,12 +25,14 @@ type NormalizedTable = {
2325

2426
type TableCell = { text: string; alignment: TableAlignment };
2527

28+
type ColumnStats = { maxWidth: number; maxWord: string };
29+
2630
const DEFAULT_PADDING = 1;
2731
const DEFAULT_ALIGNMENT = 'left' satisfies TableAlignment;
28-
const MAX_WIDTH = TERMINAL_WIDTH; // TODO: use process.stdout.columns?
2932
const DEFAULT_OPTIONS: NormalizedOptions = {
3033
borderless: false,
3134
padding: DEFAULT_PADDING,
35+
maxWidth: TERMINAL_WIDTH, // TODO: use process.stdout.columns?
3236
};
3337

3438
const BORDERS = {
@@ -67,18 +71,23 @@ function formatTable(
6771
table: NormalizedTable,
6872
options: NormalizedOptions,
6973
): string {
70-
// TODO: enforce MAX_WIDTH
71-
const columnWidths = getColumnWidths(table);
74+
const columnWidths = getColumnWidths(table, options);
7275

7376
return [
7477
formatBorderRow('top', columnWidths, options),
7578
...(table.columns
7679
? [
77-
formatContentRow(table.columns, columnWidths, options),
80+
...wrapRow(table.columns, columnWidths).map(row =>
81+
formatContentRow(row, columnWidths, options),
82+
),
7883
formatBorderRow('middle', columnWidths, options),
7984
]
8085
: []),
81-
...table.rows.map(cells => formatContentRow(cells, columnWidths, options)),
86+
...table.rows.flatMap(row =>
87+
wrapRow(row, columnWidths).map(cells =>
88+
formatContentRow(cells, columnWidths, options),
89+
),
90+
),
8291
formatBorderRow('bottom', columnWidths, options),
8392
]
8493
.filter(Boolean)
@@ -126,6 +135,62 @@ function formatContentRow(
126135
return `${ansis.dim(BORDERS.single.vertical)}${spaces}${inner}${spaces}${ansis.dim(BORDERS.single.vertical)}`;
127136
}
128137

138+
function wrapRow(cells: TableCell[], columnWidths: number[]): TableCell[][] {
139+
const emptyCell: TableCell = { text: '', alignment: DEFAULT_ALIGNMENT };
140+
141+
return cells.reduce<TableCell[][]>((acc, cell, colIndex) => {
142+
const wrapped: string = wrapText(cell.text, columnWidths[colIndex]);
143+
const lines = wrapped.split('\n').filter(Boolean);
144+
145+
const rowCount = Math.max(acc.length, lines.length);
146+
147+
return Array.from({ length: rowCount }).map((_, rowIndex) => {
148+
const prevCols =
149+
acc[rowIndex] ?? Array.from({ length: colIndex }).map(() => emptyCell);
150+
const currCol = { ...cell, text: lines[rowIndex] ?? '' };
151+
return [...prevCols, currCol];
152+
});
153+
}, []);
154+
}
155+
156+
function wrapText(text: string, width: number | undefined): string {
157+
if (!width || stringWidth(text) <= width) {
158+
return text;
159+
}
160+
const words = extractWords(text);
161+
const longWords = words.filter(word => word.length > width);
162+
const replacements = longWords.map(original => {
163+
const parts = original.includes('-')
164+
? original.split('-')
165+
: partitionString(original, width - 1);
166+
const replacement = parts.join('-\n');
167+
return { original, replacement };
168+
});
169+
const textWithSplitLongWords = replacements.reduce(
170+
(acc, { original, replacement }) => acc.replace(original, replacement),
171+
text,
172+
);
173+
return wrapAnsi(textWithSplitLongWords, width);
174+
}
175+
176+
function extractWords(text: string): string[] {
177+
return ansis
178+
.strip(text)
179+
.split(' ')
180+
.map(word => word.trim());
181+
}
182+
183+
function partitionString(text: string, maxChars: number): string[] {
184+
const groups = [...text].reduce<Record<number, string[]>>(
185+
(acc, char, index) => {
186+
const key = Math.floor(index / maxChars);
187+
return { ...acc, [key]: [...(acc[key] ?? []), char] };
188+
},
189+
{},
190+
);
191+
return Object.values(groups).map(chars => chars.join(''));
192+
}
193+
129194
function alignText(
130195
text: string,
131196
alignment: TableAlignment,
@@ -147,19 +212,92 @@ function alignText(
147212
}
148213
}
149214

150-
function getColumnWidths(table: NormalizedTable): number[] {
215+
function getColumnWidths(
216+
table: NormalizedTable,
217+
options: NormalizedOptions,
218+
): number[] {
219+
const columnTexts = getColumnTexts(table);
220+
const columnStats = aggregateColumnsStats(columnTexts);
221+
return adjustColumnWidthsToMax(columnStats, options);
222+
}
223+
224+
function getColumnTexts(table: NormalizedTable): string[][] {
151225
const columnCount = table.columns?.length ?? table.rows[0]?.length ?? 0;
152226
return Array.from({ length: columnCount }).map((_, index) => {
153227
const cells: TableCell[] = [
154228
table.columns?.[index],
155229
...table.rows.map(row => row[index]),
156230
].filter(cell => cell != null);
157-
const texts = cells.map(cell => cell.text);
231+
return cells.map(cell => cell.text);
232+
});
233+
}
234+
235+
function aggregateColumnsStats(columnTexts: string[][]): ColumnStats[] {
236+
return columnTexts.map(texts => {
158237
const widths = texts.map(text => stringWidth(text));
159-
return Math.max(...widths);
238+
const longestWords = texts
239+
.flatMap(extractWords)
240+
.toSorted((a, b) => b.length - a.length);
241+
return {
242+
maxWidth: Math.max(...widths),
243+
maxWord: longestWords[0] ?? '',
244+
};
160245
});
161246
}
162247

248+
function adjustColumnWidthsToMax(
249+
columnStats: ColumnStats[],
250+
options: NormalizedOptions,
251+
): number[] {
252+
const tableWidth = getTableWidth(columnStats, options);
253+
if (tableWidth <= options.maxWidth) {
254+
return columnStats.map(({ maxWidth }) => maxWidth);
255+
}
256+
const overflow = tableWidth - options.maxWidth;
257+
258+
return truncateColumns(columnStats, overflow);
259+
}
260+
261+
function truncateColumns(
262+
columnStats: ColumnStats[],
263+
overflow: number,
264+
): number[] {
265+
const sortedColumns = columnStats
266+
.map((stats, index) => ({ ...stats, index }))
267+
.toSorted(
268+
(a, b) => b.maxWidth - a.maxWidth || b.maxWord.length - a.maxWord.length,
269+
);
270+
271+
let remaining = overflow;
272+
const newWidths = new Map<number, number>();
273+
for (const { index, maxWidth, maxWord } of sortedColumns) {
274+
const newWidth = Math.max(
275+
maxWidth - remaining,
276+
Math.ceil(maxWidth / 2),
277+
Math.ceil(maxWord.length / 2) + 1,
278+
);
279+
newWidths.set(index, newWidth);
280+
remaining -= maxWidth - newWidth;
281+
if (remaining <= 0) {
282+
break;
283+
}
284+
}
285+
return columnStats.map(
286+
({ maxWidth }, index) => newWidths.get(index) ?? maxWidth,
287+
);
288+
}
289+
290+
function getTableWidth(
291+
columnStats: ColumnStats[],
292+
options: NormalizedOptions,
293+
): number {
294+
const contents = columnStats.reduce((acc, { maxWidth }) => acc + maxWidth, 0);
295+
const paddings =
296+
options.padding * columnStats.length * 2 - (options.borderless ? 2 : 0);
297+
const borders = options.borderless ? 0 : columnStats.length + 1;
298+
return contents + paddings + borders;
299+
}
300+
163301
function normalizeTable(table: Table): NormalizedTable {
164302
const rows = normalizeTableRows(table.rows, table.columns);
165303
const columns = normalizeTableColumns(table.columns);

0 commit comments

Comments
 (0)