diff --git a/common/changes/@microsoft/rush/terminal-table_2026-04-17.json b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json new file mode 100644 index 0000000000..c85f7e434b --- /dev/null +++ b/common/changes/@microsoft/rush/terminal-table_2026-04-17.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Replace `cli-table` dependency with `TerminalTable` from `@rushstack/terminal`.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json new file mode 100644 index 0000000000..8f45b59351 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table_2026-04-17.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/terminal", + "comment": "Add `TerminalTable` class for rendering fixed-column tables in terminal output, with correct handling of ANSI escape sequences when calculating column widths.", + "type": "minor" + } + ], + "packageName": "@rushstack/terminal" +} diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index a7cccee93a..87a7b4f48d 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -614,10 +614,6 @@ "name": "chokidar", "allowedCategories": [ "libraries" ] }, - { - "name": "cli-table", - "allowedCategories": [ "libraries" ] - }, { "name": "compression", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml index 01ef2b881c..e11eadee2b 100644 --- a/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml +++ b/common/config/subspaces/build-tests-subspace/pnpm-lock.yaml @@ -911,7 +911,7 @@ packages: '@rushstack/heft-api-extractor-plugin@file:../../../heft-plugins/heft-api-extractor-plugin': resolution: {directory: ../../../heft-plugins/heft-api-extractor-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft-config-file@file:../../../libraries/heft-config-file': resolution: {directory: ../../../libraries/heft-config-file, type: directory} @@ -920,7 +920,7 @@ packages: '@rushstack/heft-jest-plugin@file:../../../heft-plugins/heft-jest-plugin': resolution: {directory: ../../../heft-plugins/heft-jest-plugin, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.13 + '@rushstack/heft': ^1.2.14 '@types/jest': ^30.0.0 jest-environment-jsdom: ^30.3.0 jest-environment-node: ^30.3.0 @@ -935,17 +935,17 @@ packages: '@rushstack/heft-lint-plugin@file:../../../heft-plugins/heft-lint-plugin': resolution: {directory: ../../../heft-plugins/heft-lint-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft-node-rig@file:../../../rigs/heft-node-rig': resolution: {directory: ../../../rigs/heft-node-rig, type: directory} peerDependencies: - '@rushstack/heft': ^1.2.13 + '@rushstack/heft': ^1.2.14 '@rushstack/heft-typescript-plugin@file:../../../heft-plugins/heft-typescript-plugin': resolution: {directory: ../../../heft-plugins/heft-typescript-plugin, type: directory} peerDependencies: - '@rushstack/heft': 1.2.13 + '@rushstack/heft': 1.2.14 '@rushstack/heft@file:../../../apps/heft': resolution: {directory: ../../../apps/heft, type: directory} @@ -1622,10 +1622,6 @@ packages: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} engines: {node: '>= 10.0'} - cli-table@0.3.11: - resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} - engines: {node: '>= 0.2.0'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1656,10 +1652,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colors@1.0.3: - resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} - engines: {node: '>=0.1.90'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4344,7 +4336,6 @@ snapshots: '@rushstack/ts-command-line': file:../../../libraries/ts-command-line(@types/node@20.17.19) '@yarnpkg/lockfile': 1.0.2 builtin-modules: 3.1.0 - cli-table: 0.3.11 dependency-path: 9.2.8 dotenv: 16.4.7 fast-glob: 3.3.3 @@ -5763,10 +5754,6 @@ snapshots: dependencies: source-map: 0.6.1 - cli-table@0.3.11: - dependencies: - colors: 1.0.3 - cli-width@4.1.0: {} cmd-extension@1.0.2: {} @@ -5789,8 +5776,6 @@ snapshots: color-name@1.1.4: {} - colors@1.0.3: {} - commander@2.20.3: {} commander@8.3.0: {} diff --git a/common/config/subspaces/build-tests-subspace/repo-state.json b/common/config/subspaces/build-tests-subspace/repo-state.json index 9cdecb9853..05fdb80aa0 100644 --- a/common/config/subspaces/build-tests-subspace/repo-state.json +++ b/common/config/subspaces/build-tests-subspace/repo-state.json @@ -1,6 +1,6 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "67e1e1974c3a01231385fd6e8e3b892a4f3729dd", + "pnpmShrinkwrapHash": "72fae9b780cca1f45b7c807b24a587a13f1719e6", "preferredVersionsHash": "550b4cee0bef4e97db6c6aad726df5149d20e7d9", - "packageJsonInjectedDependenciesHash": "157a943794dbd83c14beb27a57db03705eb04aa6" + "packageJsonInjectedDependenciesHash": "258293487508f4a9172933cb6d0c90a02599bd8d" } diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 160aa31511..daaf5d4146 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4112,9 +4112,6 @@ importers: builtin-modules: specifier: ~3.1.0 version: 3.1.0 - cli-table: - specifier: ~0.3.1 - version: 0.3.11 dependency-path: specifier: ~9.2.8 version: 9.2.8 @@ -4191,9 +4188,6 @@ importers: '@rushstack/webpack-preserve-dynamic-require-plugin': specifier: workspace:* version: link:../../webpack/preserve-dynamic-require-plugin - '@types/cli-table': - specifier: 0.3.0 - version: 0.3.0 '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -10129,9 +10123,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/cli-table@0.3.0': - resolution: {integrity: sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==} - '@types/color-convert@2.0.4': resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==} @@ -11924,10 +11915,6 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} - cli-table@0.3.11: - resolution: {integrity: sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==} - engines: {node: '>= 0.2.0'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -12011,10 +11998,6 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} - colors@1.0.3: - resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} - engines: {node: '>=0.1.90'} - colors@1.2.5: resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==} engines: {node: '>=0.1.90'} @@ -26706,8 +26689,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/cli-table@0.3.0': {} - '@types/color-convert@2.0.4': dependencies: '@types/color-name': 1.1.5 @@ -29221,10 +29202,6 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 - cli-table@0.3.11: - dependencies: - colors: 1.0.3 - cli-width@4.1.0: {} cliui@6.0.0: @@ -29302,8 +29279,6 @@ snapshots: colorjs.io@0.5.2: {} - colors@1.0.3: {} - colors@1.2.5: {} combined-stream@1.0.8: diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 26574216c7..ac09829bf1 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "e329020e3621437b0039842e5440d019c5315c14", + "pnpmShrinkwrapHash": "f41b01db5e94d65ef640cb5e6eb9dce1780f93c6", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 97c5691622..0736636071 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -246,6 +246,33 @@ export interface ITerminalStreamWritableOptions { writableOptions?: WritableOptions; } +// @public +export interface ITerminalTableChars { + bottom: string; + bottomCenter: string; + bottomLeft: string; + bottomRight: string; + centerCenter: string; + horizontalCenter: string; + left: string; + leftCenter: string; + right: string; + rightCenter: string; + top: string; + topCenter: string; + topLeft: string; + topRight: string; + verticalCenter: string; +} + +// @public +export interface ITerminalTableOptions { + borderCharacters?: Partial; + borderless?: boolean; + colWidths?: number[]; + head?: string[]; +} + // @public export interface ITerminalTransformOptions extends ITerminalWritableOptions { destination: TerminalWritable; @@ -459,6 +486,15 @@ export class TerminalStreamWritable extends Writable { _write(chunk: string | Buffer | Uint8Array, encoding: string, callback: (error?: Error | null) => void): void; } +// @public +export class TerminalTable { + constructor(options?: ITerminalTableOptions); + // (undocumented) + getLines(): string[]; + push(...rows: string[][]): void; + toString(): string; +} + // @public export abstract class TerminalTransform extends TerminalWritable { constructor(options: ITerminalTransformOptions); diff --git a/libraries/rush-lib/package.json b/libraries/rush-lib/package.json index da8bcf9e7a..dda2f6d7f5 100644 --- a/libraries/rush-lib/package.json +++ b/libraries/rush-lib/package.json @@ -58,7 +58,6 @@ "@rushstack/ts-command-line": "workspace:*", "@yarnpkg/lockfile": "~1.0.2", "builtin-modules": "~3.1.0", - "cli-table": "~0.3.1", "dependency-path": "~9.2.8", "dotenv": "~16.4.7", "fast-glob": "~3.3.1", @@ -91,7 +90,6 @@ "@rushstack/operation-graph": "workspace:*", "@rushstack/webpack-deep-imports-plugin": "workspace:*", "@rushstack/webpack-preserve-dynamic-require-plugin": "workspace:*", - "@types/cli-table": "0.3.0", "@types/js-yaml": "4.0.9", "@types/npm-package-arg": "6.1.0", "@types/object-hash": "~3.0.6", diff --git a/libraries/rush-lib/src/cli/actions/ListAction.ts b/libraries/rush-lib/src/cli/actions/ListAction.ts index ec47625028..3793fb19b1 100644 --- a/libraries/rush-lib/src/cli/actions/ListAction.ts +++ b/libraries/rush-lib/src/cli/actions/ListAction.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import { Sort } from '@rushstack/node-core-library'; -import { ConsoleTerminalProvider, Terminal } from '@rushstack/terminal'; +import { ConsoleTerminalProvider, Terminal, TerminalTable } from '@rushstack/terminal'; import type { CommandLineFlagParameter } from '@rushstack/ts-command-line'; import { BaseRushAction } from './BaseRushAction'; @@ -220,8 +220,7 @@ export class ListAction extends BaseRushAction { tableHeader.push('Tags'); } - const { default: CliTable } = await import('cli-table'); - const table: import('cli-table') = new CliTable({ + const table: TerminalTable = new TerminalTable({ head: tableHeader }); diff --git a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts index 398443ac96..701e0a5b8e 100644 --- a/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts +++ b/libraries/rush-lib/src/utilities/InteractiveUpgradeUI.ts @@ -5,10 +5,9 @@ // https://github.com/dylang/npm-check/blob/master/lib/out/interactive-update.js // Extended to use one type of text table -import CliTable from 'cli-table'; import type { Separator } from '@inquirer/checkbox'; -import { AnsiEscape, Colorize } from '@rushstack/terminal'; +import { AnsiEscape, Colorize, TerminalTable } from '@rushstack/terminal'; import type { INpmCheckPackageSummary } from '@rushstack/npm-check-fork'; export interface IUIGroup { @@ -147,34 +146,20 @@ export const upgradeInteractive = async (pkgs: INpmCheckPackageSummary[]): Promi .map(getChoice) .filter(Boolean); - const cliTable: CliTable = new CliTable({ - chars: { - top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', - bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', - left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', - right: '', - 'right-mid': '', - middle: ' ' - }, + const cliTable: TerminalTable = new TerminalTable({ + borderless: true, colWidths: [50, 10, 3, 10, 100] }); for (const choice of choices) { if (typeof choice === 'object' && 'name' in choice) { - cliTable.push(choice.name); + // choice.name is string[] at this point (set by label()); it is only replaced + // with a string after the table is rendered below. + cliTable.push(choice.name as string[]); } } - const choicesAsATable: string[] = cliTable.toString().split('\n'); + const choicesAsATable: string[] = cliTable.getLines(); for (let i: number = 0; i < choices.length; i++) { const choice: IUpgradeInteractiveDepChoice | Separator | boolean | undefined = choices[i]; if (typeof choice === 'object' && 'name' in choice) { diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts new file mode 100644 index 0000000000..fd1100f647 --- /dev/null +++ b/libraries/terminal/src/TerminalTable.ts @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { AnsiEscape } from './AnsiEscape'; + +/** + * The set of characters used to draw table borders. + * + * Visual reference (default Unicode box-drawing characters): + * ``` + * ┌─────────┬─────────┐ ← topLeft, top (fill), topCenter, topRight + * │ header │ header │ ← left, verticalCenter, right + * ├─────────┼─────────┤ ← leftCenter, horizontalCenter (fill), centerCenter, rightCenter + * │ data │ data │ ← left, verticalCenter, right + * └─────────┴─────────┘ ← bottomLeft, bottom (fill), bottomCenter, bottomRight + * ``` + * + * @public + */ +export interface ITerminalTableChars { + /** Fill character for the top border row. Default: `─` */ + top: string; + /** Junction where a column divider meets the top border. Default: `┬` */ + topCenter: string; + /** Top-left corner. Default: `┌` */ + topLeft: string; + /** Top-right corner. Default: `┐` */ + topRight: string; + /** Fill character for the bottom border row. Default: `─` */ + bottom: string; + /** Junction where a column divider meets the bottom border. Default: `┴` */ + bottomCenter: string; + /** Bottom-left corner. Default: `└` */ + bottomLeft: string; + /** Bottom-right corner. Default: `┘` */ + bottomRight: string; + /** Left border character for data rows. Default: `│` */ + left: string; + /** Left end of the header/body separator row. Default: `├` */ + leftCenter: string; + /** Fill character for the header/body separator row. Default: `─` */ + horizontalCenter: string; + /** Junction where a column divider crosses the header/body separator. Default: `┼` */ + centerCenter: string; + /** Right border character for data rows. Default: `│` */ + right: string; + /** Right end of the header/body separator row. Default: `┤` */ + rightCenter: string; + /** Column separator character within data rows. Default: `│` */ + verticalCenter: string; +} + +/** + * Options for {@link TerminalTable}. + * + * @public + */ +export interface ITerminalTableOptions { + /** + * Column header labels. + */ + head?: string[]; + + /** + * Fixed column widths in characters, including one character of padding on each side. + * Columns not listed default to auto-sizing based on content. + */ + colWidths?: number[]; + + /** + * If `true`, all border and separator lines are suppressed. Columns are visually + * separated only by the built-in one-character left-padding of each cell. + * This is a convenient shorthand for setting every entry in `chars` to `''`. + */ + borderless?: boolean; + + /** + * Overrides for individual border characters. + * Pass an empty string for any character to suppress that part of the border. + * Applied after `borderless`, so individual characters can be restored even in + * borderless mode. + */ + borderCharacters?: Partial; +} + +const BORDERLESS_CHARS: ITerminalTableChars = { + top: '', + topCenter: '', + topLeft: '', + topRight: '', + bottom: '', + bottomCenter: '', + bottomLeft: '', + bottomRight: '', + left: '', + leftCenter: '', + horizontalCenter: '', + centerCenter: '', + right: '', + rightCenter: '', + verticalCenter: '' +}; + +const DEFAULT_CHARS: ITerminalTableChars = { + top: '─', + topCenter: '┬', + topLeft: '┌', + topRight: '┐', + bottom: '─', + bottomCenter: '┴', + bottomLeft: '└', + bottomRight: '┘', + left: '│', + leftCenter: '├', + horizontalCenter: '─', + centerCenter: '┼', + right: '│', + rightCenter: '┤', + verticalCenter: '│' +}; + +/** + * Renders text data as a fixed-column table suitable for terminal output. + * + * Designed as a drop-in replacement for the `cli-table` and `cli-table3` npm packages, + * with correct handling of ANSI escape sequences when calculating column widths. + * + * @example + * ```typescript + * const table = new TerminalTable({ head: ['Name', 'Version'] }); + * table.push(['@rushstack/terminal', '1.0.0']); + * table.push(['@rushstack/heft', '2.0.0']); + * console.log(table.toString()); + * ``` + * + * @public + */ +export class TerminalTable { + private readonly _head: string[]; + private readonly _specifiedColWidths: (number | undefined)[]; + private readonly _borderCharacters: ITerminalTableChars; + private readonly _rows: string[][]; + + public constructor(options: ITerminalTableOptions = {}) { + const { head, colWidths, borderless, borderCharacters } = options; + this._head = head ?? []; + this._specifiedColWidths = colWidths ?? []; + this._borderCharacters = { + ...(borderless ? BORDERLESS_CHARS : DEFAULT_CHARS), + ...borderCharacters + }; + this._rows = []; + } + + /** + * Appends one or more rows to the table. + */ + public push(...rows: string[][]): void { + for (const row of rows) { + this._rows.push(row); + } + } + + public getLines(): string[] { + const { + _head: head, + _rows: rows, + _specifiedColWidths: specifiedColWidths, + _borderCharacters: { + top: topSeparator, + topCenter: topCenterSeparator, + topLeft: topLeftSeparator, + topRight: topRightSeparator, + bottom: bottomSeparator, + bottomCenter: bottomCenterSeparator, + bottomLeft: bottomLeftSeparator, + bottomRight: bottomRightSeparator, + left: leftSeparator, + leftCenter: leftCenterSeparator, + horizontalCenter: horizontalCenterSeparator, + centerCenter: centerCenterSeparator, + right: rightSeparator, + rightCenter: rightCenterSeparator, + verticalCenter: verticalCenterSeparator + } + } = this; + + const allRows: string[][] = [head, ...rows]; + const columnCount: number = Math.max(0, ...allRows.map((r) => r.length)); + if (columnCount === 0) { + return []; + } + + // Resolve final column widths: use specified width if provided, otherwise auto-size from content. + const columnWidths: number[] = []; + for (let columnIndex: number = 0; columnIndex < columnCount; columnIndex++) { + const specified: number | undefined = specifiedColWidths[columnIndex]; + if (specified !== undefined) { + columnWidths.push(specified); + } else { + let maxContent: number = 0; + for (const row of allRows) { + if (columnIndex < row.length) { + const width: number = AnsiEscape.removeCodes(row[columnIndex]).length; + if (width > maxContent) { + maxContent = width; + } + } + } + + // +2 for one character of padding on each side + columnWidths.push(maxContent + 2); + } + } + + // Renders a horizontal separator line. Returns undefined if the result would be empty. + const renderSeparator = ( + leftChar: string, + fillChar: string, + midChar: string, + rightChar: string + ): string | undefined => { + const line: string = leftChar + columnWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; + return line.length > 0 ? line : undefined; + }; + + // Renders a single data row. + const renderRow = (row: string[]): string => { + const cells: string[] = []; + for (let col: number = 0; col < columnCount; col++) { + const content: string = col < row.length ? row[col] : ''; + const visualWidth: number = AnsiEscape.removeCodes(content).length; + // 1 char of left-padding; right-padding fills the remainder of the column width. + const padRight: number = Math.max(columnWidths[col] - 1 - visualWidth, 0); + cells.push(' ' + content + ' '.repeat(padRight)); + } + return leftSeparator + cells.join(verticalCenterSeparator) + rightSeparator; + }; + + const lines: string[] = []; + + // Top border + const topLine: string | undefined = renderSeparator( + topLeftSeparator, + topSeparator, + topCenterSeparator, + topRightSeparator + ); + if (topLine !== undefined) { + lines.push(topLine); + } + + // Header row + separator + if (head.length > 0) { + lines.push(renderRow(head)); + const headerSep: string | undefined = renderSeparator( + leftCenterSeparator, + horizontalCenterSeparator, + centerCenterSeparator, + rightCenterSeparator + ); + if (headerSep !== undefined) { + lines.push(headerSep); + } + } + + // Data rows (no separator between them) + for (const row of this._rows) { + lines.push(renderRow(row)); + } + + // Bottom border + const bottomLine: string | undefined = renderSeparator( + bottomLeftSeparator, + bottomSeparator, + bottomCenterSeparator, + bottomRightSeparator + ); + if (bottomLine !== undefined) { + lines.push(bottomLine); + } + + return lines; + } + + /** + * Renders the table to a string. + */ + public toString(): string { + const lines: string[] = this.getLines(); + return lines.join('\n'); + } +} diff --git a/libraries/terminal/src/index.ts b/libraries/terminal/src/index.ts index cbea552b49..b1d26c135c 100644 --- a/libraries/terminal/src/index.ts +++ b/libraries/terminal/src/index.ts @@ -56,3 +56,4 @@ export { NoOpTerminalProvider } from './NoOpTerminalProvider'; export { TerminalStreamWritable, type ITerminalStreamWritableOptions } from './TerminalStreamWritable'; export { ProblemCollector, type IProblemCollectorOptions } from './ProblemCollector'; export type { IProblemCollector } from './IProblemCollector'; +export { TerminalTable, type ITerminalTableOptions, type ITerminalTableChars } from './TerminalTable'; diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts new file mode 100644 index 0000000000..287e276eef --- /dev/null +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TerminalTable } from '../TerminalTable'; + +describe(TerminalTable.name, () => { + it('renders a table with a header and rows', () => { + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); + table.push(['@rushstack/terminal', '1.0.0']); + table.push(['@rushstack/heft', '2.0.0']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('renders a table without a header', () => { + const table: TerminalTable = new TerminalTable(); + table.push(['foo', 'bar']); + table.push(['baz', 'qux']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('auto-sizes columns to the widest content', () => { + const table: TerminalTable = new TerminalTable({ head: ['A', 'B'] }); + table.push(['short', 'a very long value here']); + const output: string = table.toString(); + // "a very long value here" is 22 chars; column width = 22 + 2 = 24 + const lines: string[] = output.split('\n'); + const dataRow: string = lines.find((l) => l.includes('short'))!; + expect(dataRow).toContain('a very long value here'); + expect(output).toMatchSnapshot(); + }); + + it('respects fixed colWidths', () => { + const table: TerminalTable = new TerminalTable({ colWidths: [10, 8] }); + table.push(['hi', 'there']); + const row: string = table.toString(); + // Cell 0 padded to 10, cell 1 padded to 8 + expect(row).toMatchSnapshot(); + }); + + it('borderless: true suppresses all borders', () => { + const table: TerminalTable = new TerminalTable({ + borderless: true, + colWidths: [10, 8, 6] + }); + table.push(['alpha', 'beta', 'g']); + table.push(['longer text', 'x', 'y']); + expect(table.toString()).toMatchSnapshot(); + }); + + it('produces one line per row when borderless (for inquirer-style usage)', () => { + const table: TerminalTable = new TerminalTable({ + borderless: true, + colWidths: [20, 10] + }); + table.push(['row one', 'v1']); + table.push(['row two', 'v2']); + table.push(['row three', 'v3']); + const lines: string[] = table.toString().split('\n'); + expect(lines.length).toBe(3); + }); + + it('chars overrides are applied on top of borderless', () => { + const table: TerminalTable = new TerminalTable({ + borderless: true, + borderCharacters: { verticalCenter: ' | ' }, + colWidths: [10, 8] + }); + table.push(['hello', 'world']); + const row: string = table.toString(); + expect(row).toContain(' | '); + expect(table.toString()).toMatchSnapshot(); + }); + + it('strips ANSI codes when calculating column widths', () => { + const table: TerminalTable = new TerminalTable({ head: ['Package'] }); + // Simulate a colored package name — ANSI codes should not inflate the column width + const colored: string = '\x1b[33mmy-package\x1b[0m'; // yellow "my-package" (10 chars visible) + table.push([colored]); + const output: string = table.toString(); + // Column width should be 10 + 2 = 12 (not inflated by escape codes) + const dataRow: string = output.split('\n').find((l) => l.includes('my-package'))!; + expect(dataRow).toBeDefined(); + expect(output).toMatchSnapshot(); + }); + + it('returns empty string for an empty table', () => { + const table: TerminalTable = new TerminalTable(); + expect(table.toString()).toBe(''); + }); +}); diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap new file mode 100644 index 0000000000..6046e64bac --- /dev/null +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`TerminalTable auto-sizes columns to the widest content 1`] = ` +"┌───────┬────────────────────────┐ +│ A │ B │ +├───────┼────────────────────────┤ +│ short │ a very long value here │ +└───────┴────────────────────────┘" +`; + +exports[`TerminalTable borderless: true suppresses all borders 1`] = ` +" alpha beta g + longer text x y " +`; + +exports[`TerminalTable chars overrides are applied on top of borderless 1`] = `" hello | world "`; + +exports[`TerminalTable renders a table with a header and rows 1`] = ` +"┌─────────────────────┬─────────┐ +│ Name │ Version │ +├─────────────────────┼─────────┤ +│ @rushstack/terminal │ 1.0.0 │ +│ @rushstack/heft │ 2.0.0 │ +└─────────────────────┴─────────┘" +`; + +exports[`TerminalTable renders a table without a header 1`] = ` +"┌─────┬─────┐ +│ foo │ bar │ +│ baz │ qux │ +└─────┴─────┘" +`; + +exports[`TerminalTable respects fixed colWidths 1`] = ` +"┌──────────┬────────┐ +│ hi │ there │ +└──────────┴────────┘" +`; + +exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` +"┌────────────┐ +│ Package │ +├────────────┤ +│ my-package │ +└────────────┘" +`;