diff --git a/lib/index.js b/lib/index.js index fee8b71..a7816a7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -28,41 +28,21 @@ */ /** - * @typedef MessageRow - * Message. - * @property {string} place - * Serialized positional info. - * @property {string} label - * Kind of message. - * @property {string} reason - * Reason. - * @property {string} ruleId - * Rule. - * @property {string} source - * Source. - * - * @typedef {keyof MessageRow} MessageColumn - * - * @typedef FileRow - * File header row. - * @property {'file'} type - * Kind. - * @property {VFile} file - * Virtual file. - * @property {Statistics} stats - * Statistics. - * - * @typedef {Record} Sizes - * Sizes for message columns. - * - * @typedef Info - * Result. - * @property {Array} rows - * Rows. - * @property {Statistics} stats - * Total statistics. - * @property {Sizes} sizes - * Sizes for message columns. + * @typedef State + * Info passed around. + * @property {boolean} colorEnabled + * Whether color is enabled; can be turned on explicitly or implicitly in + * Node.js based on whether stderr supports color. + * @property {boolean} oneFileMode + * Whether explicitly a single file is passed. + * @property {boolean} verbose + * Whether notes should be shown. + * @property {boolean} quiet + * Whether to hide files without messages. + * @property {boolean} silent + * Whether to hide warnings and info messages. + * @property {string | undefined} defaultName + * Default name to use. */ import stringWidth from 'string-width' @@ -71,14 +51,7 @@ import {compareFile, compareMessage} from 'vfile-sort' import {statistics} from 'vfile-statistics' import {color} from './color.js' -const own = {}.hasOwnProperty - -const labels = { - true: 'error', - false: 'warning', - null: 'info', - undefined: 'info' -} +const eol = /\r?\n|\r/ /** * Create a report from one or more files. @@ -91,9 +64,6 @@ const labels = { * Report. */ export function reporter(files, options) { - const settings = options || {} - let one = false - if ( // Nothing. !files || @@ -105,229 +75,278 @@ export function reporter(files, options) { ) } + const settings = options || {} + let oneFileMode = false + if (Array.isArray(files)) { // Empty. } else { - one = true + oneFileMode = true files = [files] } - return format(transform(files, settings), one, settings) + return serializeRows( + createRows( + { + colorEnabled: + typeof settings.color === 'boolean' ? settings.color : color, + defaultName: settings.defaultName || undefined, + oneFileMode, + quiet: settings.quiet || false, + silent: settings.silent || false, + verbose: settings.verbose || false + }, + files + ) + ) } /** - * Parse a list of messages. - * - * @param {Array} rawFiles - * List of files. - * @param {Options} options - * Options. - * @returns {Info} + * @param {State} state + * Info passed around. + * @param {Readonly>} files + * Files. + * @returns {Array | string>} * Rows. */ -function transform(rawFiles, options) { +function createRows(state, files) { // To do: when Node 18 is EOL, use `toSorted`. - const files = rawFiles.sort(compareFile) - /** @type {Array} */ - const rows = [] + const sortedFiles = [...files].sort(compareFile) /** @type {Array} */ const all = [] - /** @type {Sizes} */ - const sizes = {place: 0, label: 0, reason: 0, ruleId: 0, source: 0} let index = -1 + /** @type {Array | string>} */ + const rows = [] + let lastWasMessage = false - while (++index < files.length) { + while (++index < sortedFiles.length) { + const file = sortedFiles[index] // To do: when Node 18 is EOL, use `toSorted`. - const messages = [...files[index].messages].sort(compareMessage) - /** @type {Array} */ + const messages = [...file.messages].sort(compareMessage) + /** @type {Array | string>} */ const messageRows = [] let offset = -1 while (++offset < messages.length) { const message = messages[offset] - if (!options.silent || message.fatal) { + if (!state.silent || message.fatal) { all.push(message) + messageRows.push(...createMessageLine(state, message)) + } + } - const row = { - place: stringifyPosition(message.place), - label: labels[/** @type {keyof labels} */ (String(message.fatal))], - reason: - (message.stack || message.message) + - (options.verbose && message.note ? '\n' + message.note : ''), - ruleId: message.ruleId || '', - source: message.source || '' - } - - /** @type {MessageColumn} */ - let key + if ((!state.quiet && !state.silent) || messageRows.length > 0) { + const line = createFileLine(state, file) - for (key in row) { - // eslint-disable-next-line max-depth - if (own.call(row, key)) { - sizes[key] = Math.max(size(row[key]), sizes[key] || 0) - } - } + // EOL between message and a file header. + if (lastWasMessage && line) rows.push('') + if (line) rows.push(line) + if (messageRows.length > 0) rows.push(...messageRows) - messageRows.push(row) - } + lastWasMessage = messageRows.length > 0 } + } - if ((!options.quiet && !options.silent) || messageRows.length > 0) { - rows.push( - {type: 'file', file: files[index], stats: statistics(messages)}, - ...messageRows - ) - } + const stats = statistics(all) + + if (stats.fatal || stats.warn) { + rows.push('', createByline(state, stats)) } - return {rows, stats: statistics(all), sizes} + return rows } /** - * @param {Info} map + * @param {Readonly> | string>>} rows * Rows. - * @param {boolean} one - * Whether the input was explicitly one file (not an array). - * @param {Options} options - * Configuration. * @returns {string} * Report. */ -// eslint-disable-next-line complexity -function format(map, one, options) { - const enabled = - options.color === undefined || options.color === null - ? color - : options.color - /** @type {Array} */ - const lines = [] +function serializeRows(rows) { + /** @type {Array} */ + const sizes = [] let index = -1 - while (++index < map.rows.length) { - const row = map.rows[index] - - if ('type' in row) { - const stats = row.stats - let line = row.file.history[0] || options.defaultName || '' - - line = - one && !options.defaultName && !row.file.history[0] - ? '' - : (enabled - ? '\u001B[4m' /* Underline. */ + - (stats.fatal - ? '\u001B[31m' /* Red. */ - : stats.total - ? '\u001B[33m' /* Yellow. */ - : '\u001B[32m') /* Green. */ + - line + - '\u001B[39m\u001B[24m' - : line) + - (row.file.stored && row.file.path !== row.file.history[0] - ? ' > ' + row.file.path - : '') - - if (!stats.total) { - line = - (line ? line + ': ' : '') + - (row.file.stored - ? enabled - ? '\u001B[33mwritten\u001B[39m' /* Yellow. */ - : 'written' - : 'no issues found') - } - - if (line) { - if (index && !('type' in map.rows[index - 1])) { - lines.push('') - } + // Calculate sizes. + while (++index < rows.length) { + const row = rows[index] - lines.push(line) - } + if (typeof row === 'string') { + // Continue. } else { - let reason = row.reason - const match = /\r?\n|\r/.exec(reason) - /** @type {string} */ - let rest - - if (match) { - rest = reason.slice(match.index) - reason = reason.slice(0, match.index) - } else { - rest = '' + let cellIndex = -1 + while (++cellIndex < row.length) { + const current = sizes[cellIndex] || 0 + const size = stringWidth(row[cellIndex]) + if (size > current) { + sizes[cellIndex] = size + } } - - lines.push( - ( - ' ' + - ' '.repeat(map.sizes.place - size(row.place)) + - row.place + - ' ' + - (enabled - ? (row.label === 'error' - ? '\u001B[31m' /* Red. */ - : '\u001B[33m') /* Yellow. */ + - row.label + - '\u001B[39m' - : row.label) + - ' '.repeat(map.sizes.label - size(row.label)) + - ' ' + - reason + - ' '.repeat(map.sizes.reason - size(reason)) + - ' ' + - row.ruleId + - ' '.repeat(map.sizes.ruleId - size(row.ruleId)) + - ' ' + - (row.source || '') - ).replace(/ +$/, '') + rest - ) } } - const stats = map.stats + /** @type {Array} */ + const lines = [] + index = -1 - if (stats.fatal || stats.warn) { + while (++index < rows.length) { + const row = rows[index] let line = '' - if (stats.fatal) { - line = - (enabled ? /* Red. */ '\u001B[31m✖\u001B[39m' : '✖') + - ' ' + - stats.fatal + - ' ' + - (labels.true + (stats.fatal === 1 ? '' : 's')) - } - - if (stats.warn) { - line = - (line ? line + ', ' : '') + - (enabled ? /* Yellow. */ '\u001B[33m⚠\u001B[39m' : '⚠') + - ' ' + - stats.warn + - ' ' + - (labels.false + (stats.warn === 1 ? '' : 's')) - } + if (typeof row === 'string') { + line = row + } else { + let cellIndex = -1 - if (stats.total !== stats.fatal && stats.total !== stats.warn) { - line = stats.total + ' messages (' + line + ')' + while (++cellIndex < row.length) { + const cell = row[cellIndex] || '' + const max = (sizes[cellIndex] || 0) + 2 + line += cell + ' '.repeat(max - stringWidth(cell)) + } } - lines.push('', line) + lines.push(line.trimEnd()) } return lines.join('\n') } /** - * Get the length of the first line of `value`, ignoring ANSI sequences. + * Show a problem. * - * @param {string} value + * @param {Readonly} state + * Info passed around. + * @param {Readonly} message * Message. - * @returns {number} - * Width. + * @returns {Array | string>} + * Line. + */ +function createMessageLine(state, message) { + const label = createLabel(message.fatal) + let reason = + (message.stack || message.message) + + (state.verbose && message.note ? '\n' + message.note : '') + + const match = eol.exec(reason) + /** @type {Array} */ + let rest = [] + + if (match) { + rest = reason.slice(match.index + 1).split(eol) + reason = reason.slice(0, match.index) + } + + const row = [ + '', + stringifyPosition(message.place), + state.colorEnabled + ? (label === 'error' + ? '\u001B[31m' /* Red. */ + : '\u001B[33m') /* Yellow. */ + + label + + '\u001B[39m' + : label, + reason, + message.ruleId || '', + message.source || '' + ] + + return [row, ...rest] +} + +/** + * Create a summary of problems for a file. + * + * @param {Readonly} state + * Info passed around. + * @param {Readonly} file + * File. + * @returns {string} + * Line. + */ +function createFileLine(state, file) { + const stats = statistics(file.messages) + const fromPath = file.history[0] + const toPath = file.path + let left = '' + let right = '' + + if (!state.oneFileMode || state.defaultName || fromPath) { + const name = fromPath || state.defaultName || '' + + left = + (state.colorEnabled + ? '\u001B[4m' /* Underline. */ + + (stats.fatal + ? '\u001B[31m' /* Red. */ + : stats.total + ? '\u001B[33m' /* Yellow. */ + : '\u001B[32m') /* Green. */ + + name + + '\u001B[39m\u001B[24m' + : name) + (file.stored && name !== toPath ? ' > ' + toPath : '') + } + + // To do: always expose `written` if stored? + if (!stats.total) { + right += file.stored + ? state.colorEnabled + ? '\u001B[33mwritten\u001B[39m' /* Yellow. */ + : 'written' + : 'no issues found' + } + + return left && right ? left + ': ' + right : left + right +} + +/** + * Create a summary of total problems. + * + * @param {Readonly} state + * Info passed around. + * @param {Readonly} stats + * Statistics. + * @returns {string} + * Line. + */ +function createByline(state, stats) { + let result = '' + + if (stats.fatal) { + result = + (state.colorEnabled ? /* Red. */ '\u001B[31m✖\u001B[39m' : '✖') + + ' ' + + stats.fatal + + ' ' + + (createLabel(true) + (stats.fatal === 1 ? '' : 's')) + } + + if (stats.warn) { + result = + (result ? result + ', ' : '') + + (state.colorEnabled ? /* Yellow. */ '\u001B[33m⚠\u001B[39m' : '⚠') + + ' ' + + stats.warn + + ' ' + + (createLabel(false) + (stats.warn === 1 ? '' : 's')) + } + + if (stats.total !== stats.fatal && stats.total !== stats.warn) { + result = stats.total + ' messages (' + result + ')' + } + + return result +} + +/** + * Serialize `fatal` as a label. + * + * @param {boolean | null | undefined} value + * Fatal. + * @returns {string} + * Label. */ -function size(value) { - const match = /\r?\n|\r/.exec(value) - return stringWidth(match ? value.slice(0, match.index) : value) +function createLabel(value) { + return value ? 'error' : value === false ? 'warning' : 'info' } diff --git a/test.js b/test.js index 9d11148..ffd3558 100644 --- a/test.js +++ b/test.js @@ -387,6 +387,18 @@ test('reporter', async function () { 'a.js: no issues found', 'should support `color: false`' ) + + assert.equal( + strip(reporter(new VFile(), {defaultName: ''})), + ': no issues found', + 'should support `defaultName`' + ) + + assert.equal( + strip(reporter([new VFile()])), + ': no issues found', + 'should use `` for files w/o path if multiple are given' + ) }) /**