UNPKG

@maniascript/mslint

Version:
417 lines (416 loc) 19.9 kB
import path from 'node:path'; import { EventEmitter } from 'node:events'; import fg from 'fast-glob'; import { parse, SourceLocationRange, Node, ManiaScriptLexer } from '@maniascript/parser'; import { generateFromFile } from '@maniascript/api'; import { loadConfig, getRuleSeverity, getRuleSettings, createConfig } from './config.js'; import { MSLintError } from './error.js'; import { readFile } from './files.js'; import { Severity } from './rule.js'; import * as output from './output.js'; import { allRules } from '../rules/index.js'; var Emitter; (function (Emitter) { Emitter["Parser"] = "Parser"; Emitter["Linter"] = "Linter"; })(Emitter || (Emitter = {})); var DisableDirectiveType; (function (DisableDirectiveType) { DisableDirectiveType[DisableDirectiveType["CurrentLine"] = 0] = "CurrentLine"; DisableDirectiveType[DisableDirectiveType["NextLine"] = 1] = "NextLine"; })(DisableDirectiveType || (DisableDirectiveType = {})); const DEFAULT_SOURCE_LOCATION_RANGE = { loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, range: { start: 0, end: 0 }, token: { start: 0, end: 0 } }; function displaySlowestFiles(reports, amount) { if (amount <= 0) return; const filesProcessDuration = reports.map((report) => { return { path: report.path, duration: report.stats.fileOpeningDuration + report.stats.parsingDuration + report.stats.lintingDuration }; }); filesProcessDuration.sort((a, b) => { if (a.duration > b.duration) { return -1; } else if (a.duration < b.duration) { return 1; } else { return 0; } }); if (amount === 1) { output.info('\nSlowest file to process:'); } else { output.info(`\nTop ${amount.toString()} slowest files to process:`); } for (const fileProcessDuration of filesProcessDuration.slice(0, amount)) { output.info(` - ${output.formatNanoseconds(fileProcessDuration.duration)} > ${fileProcessDuration.path}`); } } function getDisabledRuleComments(parseResult) { const commentTokens = parseResult.tokens.getTokens().filter(token => (token.type === ManiaScriptLexer.SINGLE_LINE_COMMENT || token.type === ManiaScriptLexer.MULTI_LINES_COMMENT)); const disabledRuleComments = []; for (const commentToken of commentTokens) { const found = commentToken.text?.match(/(?<directive>@mslint-disable(?:-next)?-line)(?<ruleList>.*)/u); if (found !== null && found !== undefined) { const disabledRuleComment = { line: 0, ruleIds: [], description: '', directiveType: DisableDirectiveType.CurrentLine, directiveSource: DEFAULT_SOURCE_LOCATION_RANGE }; if (found.groups?.['directive'] === '@mslint-disable-line') { disabledRuleComment.line = commentToken.line; disabledRuleComment.directiveSource = new SourceLocationRange(commentToken); } else if (found.groups?.['directive'] === '@mslint-disable-next-line') { const nextToken = parseResult.tokens.getTokens(commentToken.tokenIndex + 1, commentToken.tokenIndex + 1).at(0); if (nextToken === undefined) { disabledRuleComment.line = commentToken.line + 1; } else if (nextToken.column === 0) { disabledRuleComment.line = nextToken.line; } else { disabledRuleComment.line = nextToken.line + 1; } disabledRuleComment.directiveType = DisableDirectiveType.NextLine; disabledRuleComment.directiveSource = new SourceLocationRange(commentToken); } if (found.groups !== undefined && found.groups['ruleList'] !== '') { const ruleListParts = found.groups['ruleList'].replace(/\*\/$/u, '').split(/\s-{2,}\s/u); const ruleList = ruleListParts.shift() ?? ''; disabledRuleComment.ruleIds = ruleList.split(/,|\s+/u).map(ruleId => ruleId.trim()).filter(ruleId => ruleId.length > 0 && !ruleId.endsWith('*/')); disabledRuleComment.description = ruleListParts.join().trim(); } disabledRuleComments.push(disabledRuleComment); } } return disabledRuleComments; } class Linter { config; msApiCache; constructor(config) { if (config === undefined) { this.config = createConfig({}); } else if (typeof config === 'string') { this.config = loadConfig(config); } else { this.config = config; } this.config.linter.normalizeSync(); this.msApiCache = new Map(); } async lintCode(code, linterConfig) { const report = { success: true, messages: [], disabledRuleComments: [], stats: { fileOpeningDuration: 0n, msApiGenerationDuration: 0n, parsingDuration: 0n, lintingDuration: 0n } }; const parseOptions = { twoStepsParsing: true, buildAst: true, buildScopes: true }; if (linterConfig.msApiPath !== undefined && linterConfig.msApiPath !== '') { const absoluteMsApiPath = path.resolve(this.config.cwd, linterConfig.msApiPath); const classes = this.msApiCache.get(absoluteMsApiPath); if (classes === undefined) { try { const generatingMsApiStartTime = process.hrtime.bigint(); const api = await generateFromFile(absoluteMsApiPath); report.stats.msApiGenerationDuration = process.hrtime.bigint() - generatingMsApiStartTime; parseOptions.lexerClasses = new Set(api.classNames); this.msApiCache.set(absoluteMsApiPath, parseOptions.lexerClasses); } catch { throw new MSLintError(`MSLint failed to read ManiaScript API file '${absoluteMsApiPath}'`); } } else { parseOptions.lexerClasses = classes; } } const parsingStartTime = process.hrtime.bigint(); const parseResult = await parse(code, parseOptions); report.stats.parsingDuration = process.hrtime.bigint() - parsingStartTime; if (parseResult.success) { if (linterConfig.rules !== undefined) { const lintingStartTime = process.hrtime.bigint(); const ruleEmitter = new EventEmitter(); for (const ruleId of Object.keys(linterConfig.rules)) { const ruleConfig = linterConfig.rules[ruleId]; const severity = getRuleSeverity(ruleConfig); // Skip disabled rules if (severity === undefined || severity === Severity.Off) { continue; } const rule = allRules.get(ruleId); if (rule === undefined) { report.messages.push({ emitter: Emitter.Linter, ruleId, severity: Severity.Error, message: `Rule '${ruleId}' does not exist`, source: DEFAULT_SOURCE_LOCATION_RANGE }); continue; } const ruleContext = { id: ruleId, settings: Object.assign({}, rule.meta.settings, getRuleSettings(ruleConfig)), tokens: parseResult.tokens, getScope: (node) => { if (node === undefined) { return parseResult.scopeManager.scopes[0] ?? null; } else { return parseResult.scopeManager.getScope(node); } }, report(node, message) { // Columns are 0 based in the parser node.source.loc.start.column += 1; node.source.loc.end.column += 1; report.messages.push({ emitter: Emitter.Linter, ruleId, severity, message, source: node.source }); } }; const ruleInstance = rule.create(ruleContext); for (const eventName of Object.keys(ruleInstance)) { ruleEmitter.on(eventName, ruleInstance[eventName]); } } parseResult.ast.program?.visit(node => ruleEmitter.emit(`${node.kind}:enter`, node), node => ruleEmitter.emit(`${node.kind}:exit`, node)); report.stats.lintingDuration = process.hrtime.bigint() - lintingStartTime; } } else { for (const error of parseResult.errors) { // Columns are 0 based in the parser error.source.loc.start.column += 1; error.source.loc.end.column += 1; report.messages.push({ emitter: Emitter.Parser, ruleId: '', severity: Severity.Error, message: error.message, source: error.source }); } } // Do not report problems for rules disabled in comment if (report.messages.length > 0 || this.config.reportUnusedDisableDirective || this.config.reportDisableDirectiveWithoutDescription) { report.disabledRuleComments = getDisabledRuleComments(parseResult); const disabledRuleCommentsMap = new Map(); for (const disabledRuleComment of report.disabledRuleComments) { if (disabledRuleComment.ruleIds.length === 0) { disabledRuleCommentsMap.set('', (disabledRuleCommentsMap.get('') ?? new Set()).add(disabledRuleComment.line)); } else { for (const ruleId of disabledRuleComment.ruleIds) { disabledRuleCommentsMap.set(ruleId, (disabledRuleCommentsMap.get(ruleId) ?? new Set()).add(disabledRuleComment.line)); } } } // Find report to disable const usedDisabledRuleCommentsMap = new Map(); if (disabledRuleCommentsMap.size > 0) { for (const message of report.messages) { if (disabledRuleCommentsMap.get('')?.has(message.source.loc.start.line) === true) { message.severity = Severity.Off; if (this.config.reportUnusedDisableDirective) { usedDisabledRuleCommentsMap.set('', (usedDisabledRuleCommentsMap.get('') ?? new Set()).add(message.source.loc.start.line)); } } else if (disabledRuleCommentsMap.get(message.ruleId ?? '')?.has(message.source.loc.start.line) === true) { message.severity = Severity.Off; if (this.config.reportUnusedDisableDirective) { usedDisabledRuleCommentsMap.set(message.ruleId ?? '', (usedDisabledRuleCommentsMap.get(message.ruleId ?? '') ?? new Set()).add(message.source.loc.start.line)); } } } } // Find unused disable directives if (this.config.reportUnusedDisableDirective) { for (const disabledRuleComment of report.disabledRuleComments) { if (disabledRuleComment.ruleIds.length === 0) { if (usedDisabledRuleCommentsMap.get('')?.has(disabledRuleComment.line) !== true) { report.messages.push({ emitter: Emitter.Linter, ruleId: '', severity: Severity.Error, message: disabledRuleComment.directiveType === DisableDirectiveType.CurrentLine ? 'Unused @mslint-disable-line directive (no problems were reported)' : 'Unused @mslint-disable-next-line directive (no problems were reported)', source: disabledRuleComment.directiveSource }); } } else { for (const ruleId of disabledRuleComment.ruleIds) { if (usedDisabledRuleCommentsMap.get(ruleId)?.has(disabledRuleComment.line) !== true) { report.messages.push({ emitter: Emitter.Linter, ruleId: '', severity: Severity.Error, message: disabledRuleComment.directiveType === DisableDirectiveType.CurrentLine ? `Unused @mslint-disable-line directive (no problems were reported from ${ruleId})` : `Unused @mslint-disable-next-line directive (no problems were reported from ${ruleId})`, source: disabledRuleComment.directiveSource }); } } } } } // Find disable directive without description if (this.config.reportDisableDirectiveWithoutDescription) { for (const disabledRuleComment of report.disabledRuleComments) { if (disabledRuleComment.description === '') { report.messages.push({ emitter: Emitter.Linter, ruleId: '', severity: Severity.Error, message: disabledRuleComment.directiveType === DisableDirectiveType.CurrentLine ? 'You must add a description to the @mslint-disable-line directives' : 'You must add a description to the @mslint-disable-next-line directives', source: disabledRuleComment.directiveSource }); } } } } report.messages.sort((a, b) => a.source.range.start - b.source.range.start); report.success = !report.messages.some(message => message.severity === Severity.Error); return report; } async lintFile(filePath, fileContent) { const resolvedPath = path.resolve(this.config.cwd, filePath); const fileOpeningStartTime = process.hrtime.bigint(); const code = fileContent ?? readFile(resolvedPath); const fileOpeningDuration = process.hrtime.bigint() - fileOpeningStartTime; const linterConfig = this.config.linter.getConfig(resolvedPath); if (this.config.verbose) { output.info(`\nLint file '${filePath}'`); } // No matching config found if (linterConfig === undefined) { return { success: false, path: resolvedPath, messages: [{ emitter: Emitter.Linter, ruleId: '', severity: Severity.Warn, message: 'No matching configuration found', source: DEFAULT_SOURCE_LOCATION_RANGE }], stats: { fileOpeningDuration, msApiGenerationDuration: 0n, parsingDuration: 0n, lintingDuration: 0n } }; } const { success, messages, stats } = await this.lintCode(code, linterConfig); stats.fileOpeningDuration = fileOpeningDuration; if (this.config.verbose && this.config.displayStats) { output.info(` - File opening duration: ${output.formatNanoseconds(stats.fileOpeningDuration)}`); output.info(` - MSAPI generation duration: ${output.formatNanoseconds(stats.msApiGenerationDuration)}`); output.info(` - Parsing duration: ${output.formatNanoseconds(stats.parsingDuration)}`); output.info(` - Linting duration: ${output.formatNanoseconds(stats.lintingDuration)}`); } return { success, path: resolvedPath, messages, stats }; } async lint(patterns) { if ((typeof patterns === 'string' && patterns.trim() === '') || (Array.isArray(patterns) && patterns.length === 0)) { throw new MSLintError('You must provide a glob pattern to lint'); } else if (Array.isArray(patterns) && patterns.some(element => element.trim() === '')) { throw new MSLintError('Some of the glob patterns you provided are empty'); } const lintStartTime = process.hrtime.bigint(); const report = { success: true, files: [], stats: { fileOpeningDuration: 0n, msApiGenerationDuration: 0n, parsingDuration: 0n, lintingDuration: 0n } }; for await (const filePath of fg.stream(patterns, { cwd: this.config.cwd, absolute: true })) { if (typeof filePath === 'string') { const lintFileReport = await this.lintFile(filePath); report.files.push(lintFileReport); report.stats.fileOpeningDuration += lintFileReport.stats.fileOpeningDuration; report.stats.msApiGenerationDuration += lintFileReport.stats.msApiGenerationDuration; report.stats.parsingDuration += lintFileReport.stats.parsingDuration; report.stats.lintingDuration += lintFileReport.stats.lintingDuration; if (report.success && !lintFileReport.success) { report.success = false; } } } if (this.config.verbose || this.config.displayStats) { output.info(`\n${report.files.length.toString()} files processed in ${output.formatNanoseconds(process.hrtime.bigint() - lintStartTime)}`); } if (this.config.displayStats) { output.info(` - Files opening duration: ${output.formatNanoseconds(report.stats.fileOpeningDuration)}`); output.info(` - MSAPI generations duration: ${output.formatNanoseconds(report.stats.msApiGenerationDuration)}`); output.info(` - Parsing duration: ${output.formatNanoseconds(report.stats.parsingDuration)}`); output.info(` - Linting duration: ${output.formatNanoseconds(report.stats.lintingDuration)}`); displaySlowestFiles(report.files, 10); } return report; } } export { Linter, Emitter, DisableDirectiveType };