@redocly/openapi-core
Version:
See https://github.com/Redocly/redocly-cli
360 lines • 15 kB
JavaScript
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 '<';
case '>':
return '>';
case '&':
return '&';
case '"':
return '"';
case "'":
return ''';
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