diff --git a/lib/index.js b/lib/index.js index a7816a7..7cf378d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12,6 +12,9 @@ * [color is supported][supports-color], or `false`). * * [supports-color]: https://github.com/chalk/supports-color + * @property {string | null | undefined} [defaultName=''] + * Label to use for files without file path (default: `''`); if one + * file and no `defaultName` is given, no name will show up in the report. * @property {boolean | null | undefined} [verbose=false] * Show message [`note`][message-note]s (default: `false`); notes are * optional, additional, long descriptions. @@ -22,17 +25,15 @@ * @property {boolean | null | undefined} [silent=false] * Show errors only (default: `false`); this hides info and warning messages, * and sets `quiet: true`. - * @property {string | null | undefined} [defaultName=''] - * Label to use for files without file path (default: `''`); if one - * file and no `defaultName` is given, no name will show up in the report. + * @property {number | null | undefined} [traceLimit=10] + * Max number of nodes to show in ancestors trace (default: `10`). */ /** * @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 {string | undefined} defaultName + * Default name to use. * @property {boolean} oneFileMode * Whether explicitly a single file is passed. * @property {boolean} verbose @@ -41,8 +42,24 @@ * Whether to hide files without messages. * @property {boolean} silent * Whether to hide warnings and info messages. - * @property {string | undefined} defaultName - * Default name to use. + * @property {number} traceLimit + * Max number of nodes to show in ancestors trace. + * @property {string} bold + * Bold style. + * @property {string} underline + * Underline style. + * @property {string} normalIntensity + * Regular style. + * @property {string} noUnderline + * Regular style. + * @property {string} red + * Color. + * @property {string} green + * Color. + * @property {string} yellow + * Color. + * @property {string} defaultColor + * Regular color. */ import stringWidth from 'string-width' @@ -76,6 +93,8 @@ export function reporter(files, options) { } const settings = options || {} + const colorEnabled = + typeof settings.color === 'boolean' ? settings.color : color let oneFileMode = false if (Array.isArray(files)) { @@ -88,13 +107,21 @@ export function reporter(files, options) { 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 + traceLimit: + typeof settings.traceLimit === 'number' ? settings.traceLimit : 10, + verbose: settings.verbose || false, + bold: colorEnabled ? '\u001B[1m' : '', + underline: colorEnabled ? '\u001B[4m' : '', + normalIntensity: colorEnabled ? '\u001B[22m' : '', + noUnderline: colorEnabled ? '\u001B[24m' : '', + red: colorEnabled ? '\u001B[31m' : '', + green: colorEnabled ? '\u001B[32m' : '', + yellow: colorEnabled ? '\u001B[33m' : '', + defaultColor: colorEnabled ? '\u001B[39m' : '' }, files ) @@ -240,21 +267,96 @@ function createMessageLine(state, message) { const row = [ '', stringifyPosition(message.place), - state.colorEnabled - ? (label === 'error' - ? '\u001B[31m' /* Red. */ - : '\u001B[33m') /* Yellow. */ + - label + - '\u001B[39m' - : label, + (label === 'error' ? state.red : state.yellow) + label + state.defaultColor, reason, message.ruleId || '', message.source || '' ] + if (message.cause) { + rest.push(...createCauseLines(state, message.cause)) + } + + if (message.ancestors) { + rest.push(...createAncestorsLines(state, message.ancestors)) + } + return [row, ...rest] } +/** + * Create lines for cause. + * + * @param {State} state + * Info passed around. + * @param {NonNullable} cause + * Cause. + * @returns {Array} + * Lines. + */ +function createCauseLines(state, cause) { + const lines = [' ' + state.bold + '[cause]' + state.normalIntensity + ':'] + /* c8 ignore next -- stacks can be missing for weird reasons or in weird places. */ + const stackLines = (cause.stack || cause.message).split(eol) + stackLines[0] = ' ' + stackLines[0] + lines.push(...stackLines) + + return lines +} + +/** + * Create lines for ancestors. + * + * @param {State} state + * Info passed around. + * @param {NonNullable} ancestors + * Ancestors. + * @returns {Array} + * Lines. + */ +function createAncestorsLines(state, ancestors) { + const min = + ancestors.length > state.traceLimit + ? ancestors.length - state.traceLimit + : 0 + let index = ancestors.length + + /** @type {Array} */ + const lines = [] + + if (index > min) { + lines.unshift(' ' + state.bold + '[trace]' + state.normalIntensity + ':') + } + + while (index-- > min) { + const node = ancestors[index] + /** @type {Record} */ + // @ts-expect-error: TypeScript is wrong: objects can be indexed. + const value = node + const name = + // `hast` + typeof value.tagName === 'string' + ? value.tagName + : // `xast` (and MDX JSX elements) + typeof value.name === 'string' + ? value.name + : undefined + + const position = stringifyPosition(node.position) + + lines.push( + ' at ' + + state.yellow + + node.type + + (name ? '<' + name + '>' : '') + + state.defaultColor + + (position ? ' (' + position + ')' : '') + ) + } + + return lines +} + /** * Create a summary of problems for a file. * @@ -276,24 +378,18 @@ function createFileLine(state, file) { 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 : '') + state.underline + + (stats.fatal ? state.red : stats.total ? state.yellow : state.green) + + name + + state.defaultColor + + state.noUnderline + + (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' + ? state.yellow + 'written' + state.defaultColor : 'no issues found' } @@ -315,7 +411,9 @@ function createByline(state, stats) { if (stats.fatal) { result = - (state.colorEnabled ? /* Red. */ '\u001B[31m✖\u001B[39m' : '✖') + + state.red + + '✖' + + state.defaultColor + ' ' + stats.fatal + ' ' + @@ -325,7 +423,7 @@ function createByline(state, stats) { if (stats.warn) { result = (result ? result + ', ' : '') + - (state.colorEnabled ? /* Yellow. */ '\u001B[33m⚠\u001B[39m' : '⚠') + + (state.yellow + '⚠' + state.defaultColor) + ' ' + stats.warn + ' ' + diff --git a/package.json b/package.json index 631223f..1210f71 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,11 @@ "vfile-statistics": "^3.0.0" }, "devDependencies": { + "@types/hast": "^2.0.0", "@types/node": "^20.0.0", "c8": "^7.0.0", "cross-env": "^7.0.0", + "mdast-util-mdx-jsx": "^2.0.0", "prettier": "^2.0.0", "remark-cli": "^11.0.0", "remark-preset-wooorm": "^9.0.0", diff --git a/test.js b/test.js index ffd3558..b0d9cb3 100644 --- a/test.js +++ b/test.js @@ -1,3 +1,11 @@ +/** + * @typedef {import('hast').Element} Element + * @typedef {import('hast').Root} Root + * @typedef {import('hast').Text} Text + * @typedef {import('mdast-util-mdx-jsx').MdxJsxTextElementHast} MdxJsxTextElementHast + * + */ + import assert from 'node:assert/strict' import test from 'node:test' import strip from 'strip-ansi' @@ -399,6 +407,84 @@ test('reporter', async function () { ': no issues found', 'should use `` for files w/o path if multiple are given' ) + + assert.equal( + strip(reporter([new VFile()])), + ': no issues found', + 'should use `` for files w/o path if multiple are given' + ) + + file = new VFile() + file.message('Something failed terribly', {cause: exception}) + + assert.equal( + strip(reporter(file)), + [ + ' warning Something failed terribly', + ' [cause]:', + ' ReferenceError: variable is not defined', + ' at test.js:1:1', + ' at ModuleJob.run (module_job:1:1)', + '', + '⚠ 1 warning' + ].join('\n'), + 'should support a `message.cause`' + ) + + /** @type {Text} */ + const text = {type: 'text', value: 'a'} + /** @type {MdxJsxTextElementHast} */ + const jsx = { + type: 'mdxJsxTextElement', + name: 'b', + attributes: [], + children: [text] + } + /** @type {Element} */ + const element = { + type: 'element', + tagName: 'p', + properties: {}, + children: [jsx], + position: {start: {line: 1, column: 1}, end: {line: 1, column: 9}} + } + /** @type {Root} */ + const root = { + type: 'root', + children: [element], + position: {start: {line: 1, column: 1}, end: {line: 1, column: 9}} + } + + file = new VFile() + file.message('x', {ancestors: [root, element, jsx, text]}) + + assert.equal( + strip(reporter(file)), + [ + ' warning x', + ' [trace]:', + ' at text', + ' at mdxJsxTextElement', + ' at element

(1:1-1:9)', + ' at root (1:1-1:9)', + '', + '⚠ 1 warning' + ].join('\n'), + 'should support `message.ancestors`' + ) + + assert.equal( + strip(reporter(file, {traceLimit: 2})), + [ + ' warning x', + ' [trace]:', + ' at text', + ' at mdxJsxTextElement', + '', + '⚠ 1 warning' + ].join('\n'), + 'should support `options.traceLimit`' + ) }) /**