UNPKG

@oclif/table

Version:

Display table in terminal

239 lines (238 loc) 10 kB
import { camelCase, capitalCase, constantCase, kebabCase, pascalCase, sentenceCase, snakeCase } from 'change-case'; import { orderBy } from 'natural-orderby'; import { env } from 'node:process'; import stringWidth from 'string-width'; import stripAnsi from 'strip-ansi'; /** * Intersperses a list of elements with another element. * * @example * ```js * intersperse(() => 'foo', [1, 2, 3]) // => [1, 'foo', 2, 'foo', 3] * ``` */ export function intersperse(intersperser, elements) { // Intersperse by reducing from left. const interspersed = elements.reduce((acc, element, index) => { // Only add element if it's the first one. if (acc.length === 0) return [element]; // Add the intersperser as well otherwise. return [...acc, intersperser(index), element]; }, []); return interspersed; } export function sortData(data, sort) { if (!sort) return data; const identifiers = Object.keys(sort); const orders = Object.values(sort); return orderBy(data, identifiers, orders); } export function allKeysInCollection(data) { const keys = new Set(); for (const row of data) { for (const key in row) { if (key in row) keys.add(key); } } return [...keys]; } export function determineWidthOfWrappedText(text) { const lines = text.split('\n'); return lines.reduce((max, line) => Math.max(max, stringWidth(line)), 0); } /** * Gets the width of the terminal column. * First checks for an override in the OCLIF_TABLE_COLUMN_OVERRIDE environment variable. * If no override is set or the override is 0, returns the actual terminal width from process.stdout.columns. * * It's possible that `process.stdout.columns` is undefined or 0, which is okay. We'll end up using the table's natural width * in that case. If that renders poorly for the user, they can set the OCLIF_TABLE_COLUMN_OVERRIDE environment variable to a * non-zero value. * * @returns {number} The width of the terminal column */ export function getColumnWidth() { return Number.parseInt(process.env.OCLIF_TABLE_COLUMN_OVERRIDE || '0', 10) || process.stdout.columns; } /** * Determines the configured width based on the provided width value. * If no width is provided, it returns the width of the current terminal. * - It's possible that `process.stdout.columns` is undefined, which is okay. We'll end up using the table's natural width. * If the provided width is a percentage, it calculates the width based on the percentage of the terminal width. * If the provided width is a number, it returns the provided width. * If the calculated width is greater than the terminal width, it returns the terminal width. * * @param providedWidth - The width value provided. * @returns The determined configured width. */ export function determineConfiguredWidth(providedWidth, columns = getColumnWidth()) { if (!providedWidth) return columns; if (providedWidth === 'none') return Infinity; const num = typeof providedWidth === 'string' && providedWidth.endsWith('%') ? Math.floor((Number.parseInt(providedWidth, 10) / 100) * columns) : typeof providedWidth === 'string' ? Number.parseInt(providedWidth, 10) : providedWidth; if (num > columns) { return columns; } return num; } export function getColumns(config, headings) { const { columns, horizontalAlignment, maxWidth, overflow, verticalAlignment, width } = config; const widths = columns.map((propsOrKey) => { const props = typeof propsOrKey === 'object' ? propsOrKey : { key: propsOrKey }; const { key } = props; const padding = props.padding ?? config.padding; // Get the width of each cell in the column const data = config.data.map((data) => { const value = data[key]; if (value === undefined || value === null) return 0; // Some terminals don't play nicely with zero-width characters, so we replace them with spaces. // https://github.com/sindresorhus/terminal-link/issues/18 // https://github.com/Shopify/cli/pull/995 return determineWidthOfWrappedText(stripAnsi(String(value).replaceAll('​', ' '))); }); const header = stringWidth(String(headings[key])); // If a column width is provided, use that. Otherwise, use the width of the largest cell in the column. const columnWidth = props.width ? determineConfiguredWidth(props.width, width ?? maxWidth) : Math.max(...data, header) + padding * 2; return { column: key, horizontalAlignment: props.horizontalAlignment ?? horizontalAlignment, key: String(key), overflow: props.overflow ?? overflow, padding, verticalAlignment: props.verticalAlignment ?? verticalAlignment, width: columnWidth, }; }); const numberOfBorders = widths.length + 1; const calculateTableWidth = (widths) => widths.map((w) => w.width).reduce((a, b) => a + b, 0) + numberOfBorders; let tableWidth = calculateTableWidth(widths); const seen = new Set(); const reduceColumnWidths = (calcMinWidth) => { // maxWidth === 0 is likely from a test environment where process.stdout.columns is undefined or 0 // In that case, we don't want to reduce the column widths and just use the table's natural width. if (maxWidth === 0) return; // The consumer has indicated they want an unbounded table if (maxWidth === Infinity) return; // If the table is too wide, reduce the width of the largest column as little as possible to fit the table. // If the table is still too wide, it will reduce the width of the next largest column and so on while (tableWidth > maxWidth) { const largestColumn = widths.filter((w) => !seen.has(w.key)).sort((a, b) => b.width - a.width)[0]; if (!largestColumn) break; if (seen.has(largestColumn.key)) break; const minWidth = calcMinWidth(largestColumn); const difference = tableWidth - maxWidth; const newWidth = largestColumn.width - difference < minWidth ? minWidth : largestColumn.width - difference; largestColumn.width = newWidth; tableWidth = calculateTableWidth(widths); seen.add(largestColumn.key); } }; // At most, reduce the width to the length of the column's header plus padding. reduceColumnWidths((col) => stringWidth(stripAnsi(String(headings[col.key]))) + col.padding * 2); seen.clear(); // At most, reduce the width to the padding + 3 reduceColumnWidths((col) => col.padding * 2 + 3); // If the table width was provided AND it's greater than the calculated table width, expand the columns to fill the width if (width && width > tableWidth) { const extraWidth = width - tableWidth; const extraWidthPerColumn = Math.floor(extraWidth / widths.length); for (const w of widths) { w.width += extraWidthPerColumn; // if it's the last column, add all the remaining width if (w === widths.at(-1)) { w.width += extraWidth - extraWidthPerColumn * widths.length; } } } return widths; } export function getHeadings(config) { const { columns, headerOptions: { formatter }, } = config; const format = (header) => { if (typeof header !== 'string') return header; if (!formatter) return header; if (typeof formatter === 'function') return formatter(header); switch (formatter) { case 'camelCase': { return camelCase(header); } case 'capitalCase': { return capitalCase(header); } case 'constantCase': { return constantCase(header); } case 'kebabCase': { return kebabCase(header); } case 'pascalCase': { return pascalCase(header); } case 'sentenceCase': { return sentenceCase(header); } case 'snakeCase': { return snakeCase(header); } default: { return header; } } }; return Object.fromEntries(columns.map((c) => { const key = typeof c === 'object' ? c.key : c; const name = typeof c === 'object' ? (c.name ?? format(key)) : format(c); return [key, name]; })); } export function maybeStripAnsi(data, noStyle) { if (!noStyle) return data; const newData = []; for (const row in data) { if (row in data) { const newRow = Object.fromEntries(Object.entries(data[row]).map(([key, value]) => [key, typeof value === 'string' ? stripAnsi(value) : value])); newData.push(newRow); } } return newData; } function isTruthy(value) { return value !== '0' && value !== 'false'; } /** * Determines whether the plain text table should be used. * * If the OCLIF_TABLE_SKIP_CI_CHECK environment variable is set to a truthy value, the CI check will be skipped. * * If the CI environment variable is set, the plain text table will be used. * * @returns {boolean} True if the plain text table should be used, false otherwise. */ export function shouldUsePlainTable() { if (env.OCLIF_TABLE_SKIP_CI_CHECK && isTruthy(env.OCLIF_TABLE_SKIP_CI_CHECK)) return false; // Inspired by https://github.com/sindresorhus/is-in-ci if (isTruthy(env.CI) && ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_')))) return true; return false; }