vfile-reporter
Version: 
vfile utility to create a report for a file
610 lines (531 loc) • 15 kB
JavaScript
/**
 * @typedef {import('vfile').VFile} VFile
 * @typedef {import('vfile-message').VFileMessage} VFileMessage
 * @typedef {import('vfile-statistics').Statistics} Statistics
 */
/**
 * @typedef Options
 *   Configuration (optional).
 * @property {boolean | null | undefined} [color]
 *   Use ANSI colors in report (default: `true` when in Node.js and
 *   [color is supported][supports-color], or `false`).
 *
 *   [supports-color]: https://github.com/chalk/supports-color
 * @property {string | null | undefined} [defaultName='<stdin>']
 *   Label to use for files without file path (default: `'<stdin>'`); if one
 *   file and no `defaultName` is given, no name will show up in the report.
 * @property {boolean | null | undefined} [verbose=false]
 *   Show message notes, URLs, and ancestor stack trace if available (default:
 *   `false`).
 * @property {boolean | null | undefined} [quiet=false]
 *   Do not show files without messages (default: `false`).
 * @property {boolean | null | undefined} [silent=false]
 *   Show errors only (default: `false`); this hides info and warning messages,
 *   and sets `quiet: true`.
 * @property {number | null | undefined} [traceLimit=10]
 *   Max number of nodes to show in ancestors trace (default: `10`); ancestors
 *   can be shown when `verbose: true`.
 */
/**
 * @typedef State
 *   Info passed around.
 * @property {string | undefined} defaultName
 *   Default name to use.
 * @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 {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} cyan
 *   Color.
 * @property {string} green
 *   Color.
 * @property {string} yellow
 *   Color.
 * @property {string} defaultColor
 *   Regular color.
 *
 * @typedef CodeSplit
 * @property {number} index
 * @property {number} size
 */
import stringWidth from 'string-width'
import {stringifyPosition} from 'unist-util-stringify-position'
import {compareFile, compareMessage} from 'vfile-sort'
import {statistics} from 'vfile-statistics'
import {color} from 'vfile-reporter/do-not-use-color'
const eol = /\r?\n|\r/
/**
 * Create a report from one or more files.
 *
 * @param {Array<VFile> | VFile} files
 *   Files or error to report.
 * @param {Options | null | undefined} [options]
 *   Configuration.
 * @returns {string}
 *   Report.
 */
// eslint-disable-next-line complexity
export function reporter(files, options) {
  if (
    // Nothing.
    !files ||
    // Error.
    ('name' in files && 'message' in files)
  ) {
    throw new TypeError(
      'Unexpected value for `files`, expected one or more `VFile`s'
    )
  }
  const settings = options || {}
  const colorEnabled =
    typeof settings.color === 'boolean' ? settings.color : color
  let oneFileMode = false
  if (Array.isArray(files)) {
    // Empty.
  } else {
    oneFileMode = true
    files = [files]
  }
  return serializeRows(
    createRows(
      {
        defaultName: settings.defaultName || undefined,
        oneFileMode,
        quiet: settings.quiet || false,
        silent: settings.silent || 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' : '',
        cyan: colorEnabled ? '\u001B[36m' : '',
        green: colorEnabled ? '\u001B[32m' : '',
        yellow: colorEnabled ? '\u001B[33m' : '',
        defaultColor: colorEnabled ? '\u001B[39m' : ''
      },
      files
    )
  )
}
/**
 * Create lines for ancestors.
 *
 * @param {State} state
 *   Info passed around.
 * @param {NonNullable<VFileMessage['ancestors']>} ancestors
 *   Ancestors.
 * @returns {Array<string>}
 *   Lines.
 */
function createAncestorsLines(state, ancestors) {
  const min =
    ancestors.length > state.traceLimit
      ? ancestors.length - state.traceLimit
      : 0
  let index = ancestors.length
  /** @type {Array<string>} */
  const lines = []
  if (index > min) {
    lines.unshift('  ' + state.bold + '[trace]' + state.normalIntensity + ':')
  }
  while (index-- > min) {
    const node = ancestors[index]
    /** @type {Record<string, unknown>} */
    // @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 total problems.
 *
 * @param {Readonly<State>} state
 *   Info passed around.
 * @param {Readonly<Statistics>} stats
 *   Statistics.
 * @returns {string}
 *   Line.
 */
function createByline(state, stats) {
  let result = ''
  if (stats.fatal) {
    result =
      state.red +
      '✖' +
      state.defaultColor +
      ' ' +
      stats.fatal +
      ' ' +
      (fatalToLabel(true) + (stats.fatal === 1 ? '' : 's'))
  }
  if (stats.warn) {
    result =
      (result ? result + ', ' : '') +
      (state.yellow + '⚠' + state.defaultColor) +
      ' ' +
      stats.warn +
      ' ' +
      (fatalToLabel(false) + (stats.warn === 1 ? '' : 's'))
  }
  if (stats.total !== stats.fatal && stats.total !== stats.warn) {
    result = stats.total + ' messages (' + result + ')'
  }
  return result
}
/**
 * Create lines for cause.
 *
 * @param {State} state
 *   Info passed around.
 * @param {unknown} cause
 *   Cause.
 * @returns {Array<Array<string> | string>}
 *   Lines.
 */
function createCauseLines(state, cause) {
  /** @type {Array<Array<string> | string>} */
  const lines = ['  ' + state.bold + '[cause]' + state.normalIntensity + ':']
  let foundReasonableCause = false
  if (cause !== null && typeof cause === 'object') {
    const stackValue =
      ('stack' in cause ? String(cause.stack) : undefined) ||
      ('message' in cause ? String(cause.message) : undefined)
    if (typeof stackValue === 'string') {
      foundReasonableCause = true
      /** @type {Array<Array<string> | string>} */
      let causeLines
      // Looks like a message.
      if ('file' in cause && 'fatal' in cause) {
        causeLines = createMessageLine(
          state,
          /** @type {VFileMessage} */ (cause)
        )
      }
      // Regular error.
      else {
        causeLines = stackValue.split(eol)
        // Recurse.
        if ('cause' in cause && cause.cause) {
          causeLines.push(...createCauseLines(state, cause.cause))
        }
      }
      const head = causeLines[0]
      if (typeof head === 'string') {
        causeLines[0] = '    ' + head
      } else {
        head[0] = '    ' + head[0]
      }
      lines.push(...causeLines)
    }
  }
  if (!foundReasonableCause) {
    lines.push('    ' + cause)
  }
  return lines
}
/**
 * Create a summary of problems for a file.
 *
 * @param {Readonly<State>} state
 *   Info passed around.
 * @param {Readonly<VFile>} 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 || '<stdin>'
    left =
      state.underline +
      (stats.fatal ? state.red : stats.total ? state.yellow : state.green) +
      name +
      state.defaultColor +
      state.noUnderline +
      (file.stored && name !== toPath ? ' > ' + toPath : '')
  }
  if (file.stored) {
    right = state.yellow + 'written' + state.defaultColor
  } else if (!stats.total) {
    right = 'no issues found'
  }
  return left && right ? left + ': ' + right : left + right
}
/**
 * Create lines for cause.
 *
 * @param {State} state
 *   Info passed around.
 * @param {NonNullable<VFileMessage['note']>} note
 *   Cause.
 * @returns {Array<string>}
 *   Lines.
 */
function createNoteLines(state, note) {
  const noteLines = note.split(eol)
  let index = -1
  while (++index < noteLines.length) {
    noteLines[index] = '    ' + noteLines[index]
  }
  return [
    '  ' + state.bold + '[note]' + state.normalIntensity + ':',
    ...noteLines
  ]
}
/**
 * Show a problem.
 *
 * @param {Readonly<State>} state
 *   Info passed around.
 * @param {Readonly<VFileMessage>} message
 *   Message.
 * @returns {Array<Array<string> | string>}
 *   Line.
 */
function createMessageLine(state, message) {
  const label = fatalToLabel(message.fatal)
  let reason = message.stack || message.message
  const match = eol.exec(reason)
  /** @type {Array<Array<string> | string>} */
  let rest = []
  if (match) {
    rest = reason.slice(match.index + 1).split(eol)
    reason = reason.slice(0, match.index)
  }
  /** @type {VFileMessage['place']} */
  // @ts-expect-error: `position` is the old value
  const place = message.place || message.position
  const row = [
    stringifyPosition(place),
    (label === 'error' ? state.red : state.yellow) + label + state.defaultColor,
    formatReason(state, reason),
    message.ruleId || '',
    message.source || ''
  ]
  if (message.cause) {
    rest.push(...createCauseLines(state, message.cause))
  }
  if (state.verbose && message.url) {
    rest.push(...createUrlLines(state, message.url))
  }
  if (state.verbose && message.note) {
    rest.push(...createNoteLines(state, message.note))
  }
  if (state.verbose && message.ancestors) {
    rest.push(...createAncestorsLines(state, message.ancestors))
  }
  return [row, ...rest]
}
/**
 * @param {State} state
 *   Info passed around.
 * @param {Readonly<Array<VFile>>} files
 *   Files.
 * @returns {Array<Array<string> | string>}
 *   Rows.
 */
function createRows(state, files) {
  // To do: when Node 18 is EOL, use `toSorted`.
  const sortedFiles = [...files].sort(compareFile)
  /** @type {Array<VFileMessage>} */
  const all = []
  let index = -1
  /** @type {Array<Array<string> | string>} */
  const rows = []
  let lastWasMessage = false
  while (++index < sortedFiles.length) {
    const file = sortedFiles[index]
    // To do: when Node 18 is EOL, use `toSorted`.
    const messages = [...file.messages].sort(compareMessage)
    /** @type {Array<Array<string> | string>} */
    const messageRows = []
    let offset = -1
    while (++offset < messages.length) {
      const message = messages[offset]
      if (!state.silent || message.fatal) {
        all.push(message)
        messageRows.push(...createMessageLine(state, message))
      }
    }
    if ((!state.quiet && !state.silent) || messageRows.length > 0) {
      const line = createFileLine(state, file)
      // EOL between message and a file header.
      if (lastWasMessage && line) rows.push('')
      if (line) rows.push(line)
      if (messageRows.length > 0) rows.push(...messageRows)
      lastWasMessage = messageRows.length > 0
    }
  }
  const stats = statistics(all)
  if (stats.fatal || stats.warn) {
    rows.push('', createByline(state, stats))
  }
  return rows
}
/**
 * Create lines for a URL.
 *
 * @param {State} state
 *   Info passed around.
 * @param {NonNullable<VFileMessage['url']>} url
 *   URL.
 * @returns {Array<string>}
 *   Lines.
 */
function createUrlLines(state, url) {
  return [
    '  ' + state.bold + '[url]' + state.normalIntensity + ':',
    '    ' + url
  ]
}
/**
 * Format a reason.
 *
 * @param {State} state
 *   Info passed around.
 * @param {string} reason
 *   Reason.
 * @returns {string}
 *   Result.
 */
function formatReason(state, reason) {
  /** @type {Array<string>} */
  const result = []
  /** @type {Array<CodeSplit>} */
  const splits = []
  let index = reason.indexOf('`')
  while (index !== -1) {
    const split = {index, size: 1}
    splits.push(split)
    while (reason.codePointAt(index + 1) === 96) {
      split.size++
      index++
    }
    index = reason.indexOf('`', index + 1)
  }
  index = -1
  let textStart = 0
  while (++index < splits.length) {
    let closeIndex = index
    /** @type {CodeSplit | undefined} */
    let close
    while (++closeIndex < splits.length) {
      if (splits[index].size === splits[closeIndex].size) {
        close = splits[closeIndex]
        break
      }
    }
    if (close) {
      const codeStart = splits[index].index
      const codeEnd = close.index + close.size
      result.push(
        reason.slice(textStart, codeStart) +
          state.cyan +
          reason.slice(codeStart, codeEnd) +
          state.defaultColor
      )
      textStart = codeEnd
      index = closeIndex
    }
  }
  result.push(reason.slice(textStart))
  return state.bold + result.join('') + state.normalIntensity
}
/**
 * Serialize `fatal` as a label.
 *
 * @param {boolean | null | undefined} value
 *   Fatal.
 * @returns {string}
 *   Label.
 */
function fatalToLabel(value) {
  return value ? 'error' : value === false ? 'warning' : 'info'
}
/**
 * @param {Readonly<Array<Readonly<Array<string>> | string>>} rows
 *   Rows.
 * @returns {string}
 *   Report.
 */
function serializeRows(rows) {
  /** @type {Array<number>} */
  const sizes = []
  let index = -1
  // Calculate sizes.
  while (++index < rows.length) {
    const row = rows[index]
    if (typeof row === 'string') {
      // Continue.
    } else {
      let cellIndex = -1
      while (++cellIndex < row.length) {
        const current = sizes[cellIndex] || 0
        const size = stringWidth(row[cellIndex])
        if (size > current) {
          sizes[cellIndex] = size
        }
      }
    }
  }
  /** @type {Array<string>} */
  const lines = []
  index = -1
  while (++index < rows.length) {
    const row = rows[index]
    let line = ''
    if (typeof row === 'string') {
      line = row
    } else {
      let cellIndex = -1
      while (++cellIndex < row.length) {
        const cell = row[cellIndex] || ''
        const max = (sizes[cellIndex] || 0) + 1
        line += cell + ' '.repeat(max - stringWidth(cell))
      }
    }
    lines.push(line.trimEnd())
  }
  return lines.join('\n')
}