UNPKG

@redocly/openapi-core

Version:

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

309 lines (267 loc) 8.78 kB
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 '&lt;'; case '>': return '&gt;'; case '&': return '&amp;'; case '"': return '&quot;'; case "'": return '&apos;'; default: return `&#${char.charCodeAt(0)};`; } }); }