Skip to content

Commit 7a98181

Browse files
committed
feat(utils): implement and test ascii table formatting
1 parent ecff79f commit 7a98181

File tree

9 files changed

+573
-8
lines changed

9 files changed

+573
-8
lines changed

packages/utils/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export {
118118
CODE_PUSHUP_UNICODE_LOGO,
119119
FOOTER_PREFIX,
120120
README_LINK,
121-
TERMINAL_WIDTH,
122121
} from './lib/reports/constants.js';
123122
export {
124123
listAuditsFromAllPlugins,

packages/utils/src/lib/logging.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import isaacs_cliui from '@isaacs/cliui';
22
import { cliui } from '@poppinss/cliui';
33
import ansis from 'ansis';
4-
import { TERMINAL_WIDTH } from './reports/constants.js';
4+
import { TERMINAL_WIDTH } from './text-formats/constants.js';
55

66
// TODO: remove once logger is used everywhere
77

packages/utils/src/lib/progress.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import ansis from 'ansis';
22
import { type CtorOptions, MultiProgressBars } from 'multi-progress-bars';
3-
import { TERMINAL_WIDTH } from './reports/constants.js';
3+
import { TERMINAL_WIDTH } from './text-formats/constants.js';
44

55
type BarStyles = 'active' | 'done' | 'idle';
66
type StatusStyles = Record<BarStyles, (s: string) => string>;

packages/utils/src/lib/reports/constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { HIERARCHY } from '../text-formats/index.js';
22

3-
// https://stackoverflow.com/questions/4651012/why-is-the-default-terminal-width-80-characters/4651037#4651037
4-
export const TERMINAL_WIDTH = 80;
5-
63
export const SCORE_COLOR_RANGE = {
74
/* eslint-disable @typescript-eslint/no-magic-numbers */
85
GREEN_MIN: 0.9,

packages/utils/src/lib/reports/log-stdout-summary.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import ansis from 'ansis';
33
import type { AuditReport } from '@code-pushup/models';
44
import { logger } from '../logger.js';
55
import { ui } from '../logging.js';
6+
import { TERMINAL_WIDTH } from '../text-formats/constants.js';
67
import {
78
CODE_PUSHUP_DOMAIN,
89
FOOTER_PREFIX,
910
REPORT_HEADLINE_TEXT,
1011
REPORT_RAW_OVERVIEW_TABLE_HEADERS,
11-
TERMINAL_WIDTH,
1212
} from './constants.js';
1313
import type { ScoredReport } from './types.js';
1414
import {
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import ansis from 'ansis';
2+
import type { TableCellAlignment } from 'build-md';
3+
import type {
4+
Table,
5+
TableAlignment,
6+
TableCellValue,
7+
TableColumnObject,
8+
} from '@code-pushup/models';
9+
import { TERMINAL_WIDTH } from '../constants.js';
10+
11+
type AsciiTableOptions = {
12+
borderless?: boolean;
13+
padding?: number;
14+
};
15+
16+
type NormalizedOptions = Required<AsciiTableOptions>;
17+
18+
type NormalizedTable = {
19+
rows: TableCell[][];
20+
columns?: TableCell[];
21+
};
22+
23+
type TableCell = { text: string; alignment: TableAlignment };
24+
25+
const DEFAULT_PADDING = 1;
26+
const DEFAULT_ALIGNMENT = 'left' satisfies TableAlignment;
27+
const MAX_WIDTH = TERMINAL_WIDTH; // TODO: use process.stdout.columns?
28+
const DEFAULT_OPTIONS: NormalizedOptions = {
29+
borderless: false,
30+
padding: DEFAULT_PADDING,
31+
};
32+
33+
const BORDERS = {
34+
single: {
35+
vertical: '│',
36+
horizontal: '─',
37+
},
38+
double: {
39+
top: { left: '┌', right: '┐' },
40+
middle: { left: '├', right: '┤' },
41+
bottom: { left: '└', right: '┘' },
42+
},
43+
triple: {
44+
top: '┬',
45+
middle: '┼',
46+
bottom: '┴',
47+
},
48+
};
49+
50+
export function formatAsciiTable(
51+
table: Table,
52+
options?: AsciiTableOptions,
53+
): string {
54+
const normalizedOptions = { ...DEFAULT_OPTIONS, ...options };
55+
const normalizedTable = normalizeTable(table);
56+
const formattedTable = formatTable(normalizedTable, normalizedOptions);
57+
58+
if (table.title) {
59+
return `${table.title}\n\n${formattedTable}`;
60+
}
61+
62+
return formattedTable;
63+
}
64+
65+
function formatTable(
66+
table: NormalizedTable,
67+
options: NormalizedOptions,
68+
): string {
69+
// TODO: enforce MAX_WIDTH
70+
const columnWidths = getColumnWidths(table);
71+
72+
return [
73+
formatBorderRow('top', columnWidths, options),
74+
...(table.columns
75+
? [
76+
formatContentRow(table.columns, columnWidths, options),
77+
formatBorderRow('middle', columnWidths, options),
78+
]
79+
: []),
80+
...table.rows.map(cells => formatContentRow(cells, columnWidths, options)),
81+
formatBorderRow('bottom', columnWidths, options),
82+
]
83+
.filter(Boolean)
84+
.join('\n');
85+
}
86+
87+
function formatBorderRow(
88+
position: 'top' | 'middle' | 'bottom',
89+
columnWidths: number[],
90+
options: NormalizedOptions,
91+
): string {
92+
if (options.borderless) {
93+
return '';
94+
}
95+
return ansis.dim(
96+
[
97+
BORDERS.double[position].left,
98+
columnWidths
99+
.map(width =>
100+
BORDERS.single.horizontal.repeat(width + 2 * options.padding),
101+
)
102+
.join(BORDERS.triple[position]),
103+
BORDERS.double[position].right,
104+
].join(''),
105+
);
106+
}
107+
108+
function formatContentRow(
109+
cells: TableCell[],
110+
columnWidths: number[],
111+
options: NormalizedOptions,
112+
): string {
113+
const aligned = cells.map(({ text, alignment }, index) =>
114+
alignText(text, alignment, columnWidths[index]),
115+
);
116+
const spaces = ' '.repeat(options.padding);
117+
const inner = aligned.join(
118+
options.borderless
119+
? spaces.repeat(2)
120+
: `${spaces}${ansis.dim(BORDERS.single.vertical)}${spaces}`,
121+
);
122+
if (options.borderless) {
123+
return inner.trimEnd();
124+
}
125+
return `${ansis.dim(BORDERS.single.vertical)}${spaces}${inner}${spaces}${ansis.dim(BORDERS.single.vertical)}`;
126+
}
127+
128+
function alignText(
129+
text: string,
130+
alignment: TableAlignment,
131+
width: number | undefined,
132+
): string {
133+
if (!width) {
134+
return text;
135+
}
136+
const missing = width - getTextWidth(text);
137+
switch (alignment) {
138+
case 'left':
139+
return `${text}${' '.repeat(missing)}`;
140+
case 'right':
141+
return `${' '.repeat(missing)}${text}`;
142+
case 'center':
143+
const missingLeft = Math.floor(missing / 2);
144+
const missingRight = missing - missingLeft;
145+
return `${' '.repeat(missingLeft)}${text}${' '.repeat(missingRight)}`;
146+
}
147+
}
148+
149+
function getColumnWidths(table: NormalizedTable): number[] {
150+
const columnCount = table.columns?.length ?? table.rows[0]?.length ?? 0;
151+
return Array.from({ length: columnCount }).map((_, index) => {
152+
const cells: TableCell[] = [
153+
table.columns?.[index],
154+
...table.rows.map(row => row[index]),
155+
].filter(cell => cell != null);
156+
const texts = cells.map(cell => cell.text);
157+
const widths = texts.map(getTextWidth);
158+
return Math.max(...widths);
159+
});
160+
}
161+
162+
function getTextWidth(text: string): number {
163+
return ansis.strip(text).length;
164+
}
165+
166+
function normalizeTable(table: Table): NormalizedTable {
167+
const rows = normalizeTableRows(table.rows, table.columns);
168+
const columns = normalizeTableColumns(table.columns);
169+
return { rows, ...(columns && { columns }) };
170+
}
171+
172+
function normalizeTableColumns(
173+
columns: Table['columns'],
174+
): TableCell[] | undefined {
175+
if (
176+
columns == null ||
177+
columns.length === 0 ||
178+
columns.every(column => typeof column === 'string') ||
179+
columns.every(column => !normalizeColumnTitle(column))
180+
) {
181+
return undefined;
182+
}
183+
return columns.map(column =>
184+
createCell(normalizeColumnTitle(column), column.align),
185+
);
186+
}
187+
188+
function normalizeColumnTitle(column: TableColumnObject): string {
189+
return column.label ?? column.key;
190+
}
191+
192+
function normalizeTableRows(
193+
rows: Table['rows'],
194+
columns: Table['columns'],
195+
): TableCell[][] {
196+
const columnCount =
197+
columns?.length ?? Math.max(...rows.map(row => Object.keys(row).length));
198+
199+
return rows.map((row): TableCell[] => {
200+
const rowEntries = new Map(Object.entries(row));
201+
202+
if (!columns) {
203+
return Array.from({ length: columnCount }).map((_, i): TableCell => {
204+
const value = rowEntries.get(i.toString());
205+
return createCell(value);
206+
});
207+
}
208+
209+
return columns.map((column, index): TableCell => {
210+
const align = typeof column === 'string' ? column : column.align;
211+
const key = typeof column === 'object' ? column.key : index.toString();
212+
const value = rowEntries.get(key);
213+
return createCell(value, align);
214+
});
215+
});
216+
}
217+
218+
function createCell(
219+
value: TableCellValue | undefined,
220+
alignment: TableCellAlignment = DEFAULT_ALIGNMENT,
221+
): TableCell {
222+
const text = value?.toString()?.trim() ?? '';
223+
return { text, alignment };
224+
}

0 commit comments

Comments
 (0)