UNPKG

@redocly/openapi-core

Version:

See https://github.com/Redocly/redocly-cli

360 lines 15 kB
import * as path from 'node:path'; import { colorOptions, colorize, logger } from '../logger.js'; import { getCodeframe, getLineColLocation } from './codeframes.js'; import { env, isBrowser } from '../env.js'; import { isAbsoluteUrl } from '../ref-utils.js'; const ERROR_MESSAGE = { INVALID_SEVERITY_LEVEL: 'Invalid severity level; accepted values: error or warn', }; const BG_COLORS = { warn: (str) => colorize.bgYellow(colorize.black(str)), error: colorize.bgRed, }; const COLORS = { warn: colorize.yellow, error: colorize.red, }; const SEVERITY_NAMES = { warn: 'Warning', error: 'Error', }; const CODECLIMATE_SEVERITY_MAPPING = { error: 'critical', warn: 'minor', }; const MAX_SUGGEST = 5; function severityToNumber(severity) { return severity === 'error' ? 1 : 2; } export function getTotals(problems) { let errors = 0; let warnings = 0; let ignored = 0; for (const m of problems) { if (m.ignored) { ignored++; continue; } if (m.severity === 'error') errors++; if (m.severity === 'warn') warnings++; } return { errors, warnings, ignored, }; } export function formatProblems(problems, opts) { const { maxProblems = 100, cwd = isBrowser ? '' : process.cwd(), format = 'codeframe', color = colorOptions.enabled, totals = getTotals(problems), version = '2.0', } = opts; colorOptions.enabled = color; // force colors if specified const totalProblems = problems.length; problems = problems.filter((m) => !m.ignored); const ignoredProblems = totalProblems - problems.length; problems = problems .sort((a, b) => severityToNumber(a.severity) - severityToNumber(b.severity)) .slice(0, maxProblems); if (!totalProblems && format !== 'json') return; switch (format) { case 'json': outputJSON(); break; case 'codeframe': for (let i = 0; i < problems.length; i++) { const problem = problems[i]; logger.output(`${formatCodeframe(problem, i)}\n`); } break; case 'stylish': { const groupedByFile = groupByFiles(problems); for (const [file, { ruleIdPad, locationPad: positionPad, fileProblems }] of Object.entries(groupedByFile)) { logger.output(`${colorize.blue(isAbsoluteUrl(file) ? file : path.relative(cwd, file))}:\n`); for (let i = 0; i < fileProblems.length; i++) { const problem = fileProblems[i]; logger.output(`${formatStylish(problem, positionPad, ruleIdPad)}\n`); } logger.output('\n'); } break; } case 'markdown': { const groupedByFile = groupByFiles(problems); for (const [file, { fileProblems }] of Object.entries(groupedByFile)) { logger.output(`## Lint: ${isAbsoluteUrl(file) ? file : path.relative(cwd, file)}\n\n`); logger.output(`| Severity | Location | Problem | Message |\n`); logger.output(`|---|---|---|---|\n`); for (let i = 0; i < fileProblems.length; i++) { const problem = fileProblems[i]; logger.output(`${formatMarkdown(problem)}\n`); } logger.output('\n'); if (totals.errors > 0) { logger.output(`Validation failed\nErrors: ${totals.errors}\n`); } else { logger.output('Validation successful\n'); } if (totals.warnings > 0) { logger.output(`Warnings: ${totals.warnings}\n`); } logger.output('\n'); } break; } case 'checkstyle': { const groupedByFile = groupByFiles(problems); logger.output('<?xml version="1.0" encoding="UTF-8"?>\n'); logger.output('<checkstyle version="4.3">\n'); for (const [file, { fileProblems }] of Object.entries(groupedByFile)) { logger.output(`<file name="${xmlEscape(isAbsoluteUrl(file) ? file : path.relative(cwd, file))}">\n`); fileProblems.forEach(formatCheckstyle); logger.output(`</file>\n`); } logger.output(`</checkstyle>\n`); break; } case 'codeclimate': outputForCodeClimate(); break; case 'summary': formatSummary(problems); break; case 'github-actions': outputForGithubActions(problems, cwd); } if (totalProblems - ignoredProblems > maxProblems) { logger.info(`< ... ${totalProblems - maxProblems} more problems hidden > ${colorize.gray('increase with `--max-problems N`')}\n`); } function outputForCodeClimate() { const issues = problems.map((p) => { const location = p.location[0]; // TODO: support multiple location const lineCol = getLineColLocation(location); return { description: p.message, location: { path: isAbsoluteUrl(location.source.absoluteRef) ? location.source.absoluteRef : path.relative(cwd, location.source.absoluteRef), lines: { begin: lineCol.start.line, }, }, severity: CODECLIMATE_SEVERITY_MAPPING[p.severity], fingerprint: `${p.ruleId}${p.location.length > 0 ? '-' + p.location[0].pointer : ''}`, }; }); logger.output(JSON.stringify(issues, null, 2)); } function outputJSON() { const resultObject = { totals, version, problems: problems.map((p) => { const problem = { ...p, location: p.location.map((location) => ({ ...location, source: { ref: isAbsoluteUrl(location.source.absoluteRef) ? location.source.absoluteRef : path.relative(cwd, location.source.absoluteRef), }, })), from: p.from ? { ...p.from, source: { ref: isAbsoluteUrl(p.from?.source.absoluteRef) ? p.from?.source.absoluteRef : path.relative(cwd, p.from?.source.absoluteRef || cwd), }, } : undefined, }; if (env.FORMAT_JSON_WITH_CODEFRAMES) { const location = p.location[0]; // TODO: support multiple locations const loc = getLineColLocation(location); problem.codeframe = getCodeframe(loc, color); } return problem; }), }; logger.output(JSON.stringify(resultObject, null, 2)); } function getBgColor(problem) { const { severity } = problem; if (!BG_COLORS[severity]) { throw new Error(ERROR_MESSAGE.INVALID_SEVERITY_LEVEL); } return BG_COLORS[severity]; } function formatCodeframe(problem, idx) { const bgColor = getBgColor(problem); const location = problem.location[0]; // TODO: support multiple locations const relativePath = isAbsoluteUrl(location.source.absoluteRef) ? location.source.absoluteRef : path.relative(cwd, location.source.absoluteRef); const loc = getLineColLocation(location); const atPointer = location.pointer ? colorize.gray(`at ${location.pointer}`) : ''; const fileWithLoc = `${relativePath}:${loc.start.line}:${loc.start.col}`; return (`[${idx + 1}] ${bgColor(fileWithLoc)} ${atPointer}\n\n` + `${problem.message}\n\n` + formatDidYouMean(problem) + getCodeframe(loc, color) + '\n\n' + formatFrom(cwd, problem.from) + `${SEVERITY_NAMES[problem.severity]} was generated by the ${colorize.blue(problem.ruleId)} rule.\n\n`); } function formatStylish(problem, locationPad, ruleIdPad) { const color = COLORS[problem.severity]; if (!SEVERITY_NAMES[problem.severity]) { return 'Error not found severity. Please check your config file. Allowed values: `warn,error,off`'; } const severityName = color(SEVERITY_NAMES[problem.severity].toLowerCase().padEnd(7)); const { start } = problem.location[0]; return ` ${`${start.line}:${start.col}`.padEnd(locationPad)} ${severityName} ${problem.ruleId.padEnd(ruleIdPad)} ${problem.message}`; } function formatMarkdown(problem) { if (!SEVERITY_NAMES[problem.severity]) { return 'Error not found severity. Please check your config file. Allowed values: `warn,error,off`'; } const severityName = SEVERITY_NAMES[problem.severity].toLowerCase(); const { start } = problem.location[0]; return `| ${severityName} | line ${`${start.line}:${start.col}`} | [${problem.ruleId}](https://redocly.com/docs/cli/rules/${problem.ruleId}/) | ${problem.message} |`; } function formatCheckstyle(problem) { const { line, col } = problem.location[0].start; const severity = problem.severity == 'warn' ? 'warning' : 'error'; const message = xmlEscape(problem.message); const source = xmlEscape(problem.ruleId); logger.output(`<error line="${line}" column="${col}" severity="${severity}" message="${message}" source="${source}" />\n`); } } function formatSummary(problems) { const counts = {}; for (const problem of problems) { counts[problem.ruleId] = counts[problem.ruleId] || { count: 0, severity: problem.severity }; counts[problem.ruleId].count++; } const sorted = Object.entries(counts).sort(([, a], [, b]) => { const severityDiff = severityToNumber(a.severity) - severityToNumber(b.severity); return severityDiff || b.count - a.count; }); for (const [ruleId, info] of sorted) { const color = COLORS[info.severity]; const severityName = color(SEVERITY_NAMES[info.severity].toLowerCase().padEnd(7)); logger.output(`${severityName} ${ruleId}: ${info.count}\n`); } logger.output('\n'); } function formatFrom(cwd, location) { if (!location) return ''; const relativePath = path.relative(cwd, location.source.absoluteRef); const loc = getLineColLocation(location); const fileWithLoc = `${relativePath}:${loc.start.line}:${loc.start.col}`; const atPointer = location.pointer ? colorize.gray(`at ${location.pointer}`) : ''; return `referenced from ${colorize.blue(fileWithLoc)} ${atPointer} \n\n`; } function formatDidYouMean(problem) { if (problem.suggest.length === 0) return ''; if (problem.suggest.length === 1) { return `Did you mean: ${problem.suggest[0]} ?\n\n`; } else { return `Did you mean:\n - ${problem.suggest.slice(0, MAX_SUGGEST).join('\n - ')}\n\n`; } } const groupByFiles = (problems) => { const fileGroups = {}; for (const problem of problems) { const absoluteRef = problem.location[0].source.absoluteRef; // TODO: multiple errors fileGroups[absoluteRef] = fileGroups[absoluteRef] || { fileProblems: [], ruleIdPad: 0, locationPad: 0, }; const mappedProblem = { ...problem, location: problem.location.map(getLineColLocation) }; fileGroups[absoluteRef].fileProblems.push(mappedProblem); fileGroups[absoluteRef].ruleIdPad = Math.max(problem.ruleId.length, fileGroups[absoluteRef].ruleIdPad); fileGroups[absoluteRef].locationPad = Math.max(Math.max(...mappedProblem.location.map((loc) => `${loc.start.line}:${loc.start.col}`.length)), fileGroups[absoluteRef].locationPad); } return fileGroups; }; function xmlEscape(s) { // eslint-disable-next-line no-control-regex return s.replace(/[<>&"'\x00-\x1F\x7F\u0080-\uFFFF]/gu, (char) => { switch (char) { case '<': return '&lt;'; case '>': return '&gt;'; case '&': return '&amp;'; case '"': return '&quot;'; case "'": return '&apos;'; default: return `&#${char.charCodeAt(0)};`; } }); } function outputForGithubActions(problems, cwd) { for (const problem of problems) { for (const location of problem.location.map(getLineColLocation)) { let command; switch (problem.severity) { case 'error': command = 'error'; break; case 'warn': command = 'warning'; break; } const suggest = formatDidYouMean(problem); const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message; const properties = { title: problem.ruleId, file: isAbsoluteUrl(location.source.absoluteRef) ? location.source.absoluteRef : path.relative(cwd, location.source.absoluteRef), line: location.start.line, col: location.start.col, endLine: location.end?.line, endColumn: location.end?.col, }; logger.output(`::${command} ${formatProperties(properties)}::${escapeMessage(message)}\n`); } } function formatProperties(props) { return Object.entries(props) .filter(([, v]) => v !== null && v !== undefined) .map(([k, v]) => `${k}=${escapeProperty(v)}`) .join(','); } function toString(v) { if (v === null || v === undefined) { return ''; } else if (typeof v === 'string' || v instanceof String) { return v; } return JSON.stringify(v); } function escapeMessage(v) { return toString(v).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); } function escapeProperty(v) { return toString(v) .replace(/%/g, '%25') .replace(/\r/g, '%0D') .replace(/\n/g, '%0A') .replace(/:/g, '%3A') .replace(/,/g, '%2C'); } } //# sourceMappingURL=format.js.map