@redocly/openapi-core
Version:
See https://github.com/Redocly/openapi-cli
309 lines (267 loc) • 8.78 kB
text/typescript
import * as path from 'path';
import {
options as colorOptions,
gray,
blue,
bgRed,
bgYellow,
black,
yellow,
red,
} from 'colorette';
const coreVersion = require('../../package.json').version;
import { NormalizedProblem, ProblemSeverity, LineColLocationObject, LocationObject } from '../walk';
import { getCodeframe, getLineColLocation } from './codeframes';
export type Totals = {
errors: number;
warnings: number;
ignored: number;
}
const ERROR_MESSAGE = {
INVALID_SEVERITY_LEVEL: 'Invalid severity level; accepted values: error or warn',
};
const BG_COLORS = {
warn: (str: string) => bgYellow(black(str)),
error: bgRed,
};
const COLORS = {
warn: yellow,
error: red,
};
const SEVERITY_NAMES = {
warn: 'Warning',
error: 'Error',
};
const MAX_SUGGEST = 5;
function severityToNumber(severity: ProblemSeverity) {
return severity === 'error' ? 1 : 2;
}
export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle';
export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals {
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: (NormalizedProblem & { ignored?: boolean })[],
opts: {
maxProblems?: number;
cwd?: string;
format?: OutputFormat;
color?: boolean;
totals: Totals
version: string;
},
) {
const {
maxProblems = 100,
cwd = process.cwd(),
format = 'codeframe',
color = colorOptions.enabled,
totals = getTotals(problems),
version = coreVersion,
} = 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];
process.stderr.write(`${formatCodeframe(problem, i)}\n`);
}
break;
case 'stylish': {
const groupedByFile = groupByFiles(problems);
for (const [file, { ruleIdPad, locationPad: positionPad, fileProblems }] of Object.entries(
groupedByFile,
)) {
process.stderr.write(`${blue(path.relative(cwd, file))}:\n`);
for (let i = 0; i < fileProblems.length; i++) {
const problem = fileProblems[i];
process.stderr.write(`${formatStylish(problem, positionPad, ruleIdPad)}\n`);
}
process.stderr.write('\n');
}
break;
}
case 'checkstyle': {
const groupedByFile = groupByFiles(problems);
process.stdout.write('<?xml version="1.0" encoding="UTF-8"?>\n');
process.stdout.write('<checkstyle version="4.3">\n');
for (const [file, { fileProblems }] of Object.entries(groupedByFile)) {
process.stdout.write(`<file name="${xmlEscape(path.relative(cwd, file))}">\n`);
fileProblems.forEach(formatCheckstyle);
process.stdout.write(`</file>\n`);
}
process.stdout.write(`</checkstyle>\n`);
break;
}
}
if (totalProblems - ignoredProblems > maxProblems) {
process.stderr.write(
`< ... ${totalProblems - maxProblems} more problems hidden > ${gray(
'increase with `--max-problems N`',
)}\n`,
);
}
function outputJSON() {
const resultObject = {
totals,
version,
problems: problems.map((p) => {
let problem = {
...p,
location: p.location.map((location: any) => ({
...location,
source: {
ref: path.relative(cwd, location.source.absoluteRef),
},
})),
from: p.from
? {
...p.from,
source: {
ref: path.relative(cwd, p.from?.source.absoluteRef || cwd),
},
}
: undefined,
};
if (process.env.FORMAT_JSON_WITH_CODEFRAMES) {
const location = p.location[0]; // TODO: support multiple locations
const loc = getLineColLocation(location);
(problem as any).codeframe = getCodeframe(loc, color);
}
return problem;
}),
};
process.stdout.write(JSON.stringify(resultObject, null, 2));
}
function getBgColor(problem: NormalizedProblem) {
const { severity } = problem;
if (!BG_COLORS[severity]) {
throw new Error(ERROR_MESSAGE.INVALID_SEVERITY_LEVEL);
}
return BG_COLORS[severity];
}
function formatCodeframe(problem: NormalizedProblem, idx: number) {
const bgColor = getBgColor(problem);
const location = problem.location[0]; // TODO: support multiple locations
const relativePath = path.relative(cwd, location.source.absoluteRef);
const loc = getLineColLocation(location);
const atPointer = location.pointer ? 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 ${blue(problem.ruleId)} rule.\n\n`
);
}
function formatStylish(problem: OnlyLineColProblem, locationPad: number, ruleIdPad: number) {
const color = COLORS[problem.severity];
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 formatCheckstyle(problem: OnlyLineColProblem) {
const { line, col } = problem.location[0].start;
const severity = problem.severity == 'warn' ? 'warning' : 'error';
const message = xmlEscape(problem.message);
const source = xmlEscape(problem.ruleId);
process.stdout.write(
`<error line="${line}" column="${col}" severity="${severity}" message="${message}" source="${source}" />\n`,
);
}
}
function formatFrom(cwd: string, location?: LocationObject) {
if (!location) return '';
const relativePath = path.relative(cwd, location.source.absoluteRef);
const loc = getLineColLocation(location);
const fileWithLoc = `${relativePath}:${loc.start.line}:${loc.start.col}`;
return `referenced from ${blue(fileWithLoc)}\n\n`;
}
function formatDidYouMean(problem: NormalizedProblem) {
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`;
}
}
type OnlyLineColProblem = Omit<NormalizedProblem, 'location'> & {
location: LineColLocationObject[];
};
const groupByFiles = (problems: NormalizedProblem[]) => {
const fileGroups: Record<
string,
{
locationPad: number;
ruleIdPad: number;
fileProblems: OnlyLineColProblem[];
}
> = {};
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: string): string {
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)};`;
}
});
}