UNPKG

@maniascript/mslint

Version:
511 lines (510 loc) 24.2 kB
import path from 'node:path'; import { EventEmitter } from 'node:events'; import fg from 'fast-glob'; import { parse, SourceLocationRange, 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["Block"] = 2] = "Block"; })(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 getDirectiveName(directiveType) { switch (directiveType) { case DisableDirectiveType.Block: { return '@mslint-disable'; } case DisableDirectiveType.CurrentLine: { return '@mslint-disable-line'; } case DisableDirectiveType.NextLine: { return '@mslint-disable-next-line'; } } } function getDisabledRuleComments(parseResult) { const commentTokens = parseResult.tokens.getTokens().filter(token => (token.type === ManiaScriptLexer.SINGLE_LINE_COMMENT || token.type === ManiaScriptLexer.MULTI_LINES_COMMENT)); const disabledRuleCommentList = []; const disabledRuleRangeStartList = new Map(); for (const commentToken of commentTokens) { const foundLineDirective = commentToken.text?.match(/(?<directive>@mslint-disable(?:-next)?-line)(?<ruleList>.*)/u); if (foundLineDirective !== null && foundLineDirective !== undefined) { const disabledRuleComment = { line: 0, rangeStart: 0, rangeEnd: -1, ruleIds: [], description: '', directiveType: DisableDirectiveType.CurrentLine, directiveSource: new SourceLocationRange(commentToken), isUsed: false, usedRuleIds: [] }; if (foundLineDirective.groups?.['directive'] === '@mslint-disable-line') { disabledRuleComment.line = commentToken.line; } else if (foundLineDirective.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; } if (foundLineDirective.groups !== undefined && foundLineDirective.groups['ruleList'] !== '') { const ruleListParts = foundLineDirective.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(); } disabledRuleCommentList.push(disabledRuleComment); } else { const foundBlockDirective = commentToken.text?.match(/(?<directive>@mslint-(?:disable|enable))(?<ruleList>.*)/u); if (foundBlockDirective !== null && foundBlockDirective !== undefined) { let ruleIdList = []; let description = ''; if (foundBlockDirective.groups !== undefined && foundBlockDirective.groups['ruleList'] !== '') { const ruleListParts = foundBlockDirective.groups['ruleList'].replace(/\*\/$/u, '').split(/\s-{2,}\s/u); const ruleList = ruleListParts.shift() ?? ''; ruleIdList = ruleList.split(/,|\s+/u).map(ruleId => ruleId.trim()).filter(ruleId => ruleId.length > 0 && !ruleId.endsWith('*/')); description = ruleListParts.join().trim(); } // Disable directive if (foundBlockDirective.groups?.['directive'] === '@mslint-disable') { if (ruleIdList.length > 0) { for (const ruleId of ruleIdList) { if (!disabledRuleRangeStartList.has(ruleId)) { disabledRuleRangeStartList.set(ruleId, { line: 0, rangeStart: commentToken.start, rangeEnd: -1, ruleIds: [ruleId], description, directiveType: DisableDirectiveType.Block, directiveSource: new SourceLocationRange(commentToken), isUsed: false, usedRuleIds: [] }); } } } else if (!disabledRuleRangeStartList.has('')) { disabledRuleRangeStartList.set('', { line: 0, rangeStart: commentToken.start, rangeEnd: -1, ruleIds: [], description, directiveType: DisableDirectiveType.Block, directiveSource: new SourceLocationRange(commentToken), isUsed: false, usedRuleIds: [] }); } } // Enable directive else { if (ruleIdList.length > 0) { for (const ruleId of ruleIdList) { const disabledRuleRangeStart = disabledRuleRangeStartList.get(ruleId); if (disabledRuleRangeStart !== undefined) { disabledRuleRangeStart.rangeEnd = commentToken.stop; disabledRuleCommentList.push(disabledRuleRangeStart); disabledRuleRangeStartList.delete(ruleId); } } } else { for (const [ruleId, disabledRuleRangeStart] of disabledRuleRangeStartList) { disabledRuleRangeStart.rangeEnd = commentToken.stop; disabledRuleCommentList.push(disabledRuleRangeStart); disabledRuleRangeStartList.delete(ruleId); } } } } } } for (const disabledRuleRangeStart of disabledRuleRangeStartList) { disabledRuleRangeStart[1].rangeEnd = parseResult.chars.size - 1; disabledRuleCommentList.push(disabledRuleRangeStart[1]); } disabledRuleRangeStartList.clear(); return disabledRuleCommentList; } 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; } } else if (linterConfig.msApiGame !== undefined && linterConfig.msApiGame !== '') { parseOptions.lexerGame = linterConfig.msApiGame; } 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); // Find reports to disable for (const disabledRuleComment of report.disabledRuleComments) { for (const message of report.messages) { if ((disabledRuleComment.directiveType === DisableDirectiveType.Block && message.source.range.start >= disabledRuleComment.rangeStart && message.source.range.end <= disabledRuleComment.rangeEnd) || ((disabledRuleComment.directiveType === DisableDirectiveType.CurrentLine || disabledRuleComment.directiveType === DisableDirectiveType.NextLine) && disabledRuleComment.line >= message.source.loc.start.line && disabledRuleComment.line <= message.source.loc.end.line)) { if (disabledRuleComment.ruleIds.length === 0) { message.severity = Severity.Off; disabledRuleComment.isUsed = true; } else if (message.ruleId !== null && disabledRuleComment.ruleIds.includes(message.ruleId)) { message.severity = Severity.Off; disabledRuleComment.isUsed = true; if (!disabledRuleComment.usedRuleIds.includes(message.ruleId)) { disabledRuleComment.usedRuleIds.push(message.ruleId); } } } } } // Find unused disable directives if (this.config.reportUnusedDisableDirective) { for (const disabledRuleComment of report.disabledRuleComments) { if (disabledRuleComment.ruleIds.length === 0) { if (!disabledRuleComment.isUsed) { report.messages.push({ emitter: Emitter.Linter, ruleId: '', severity: Severity.Error, message: `Unused ${getDirectiveName(disabledRuleComment.directiveType)} directive (no problems were reported)`, source: disabledRuleComment.directiveSource }); } } else { for (const ruleId of disabledRuleComment.ruleIds) { if (!disabledRuleComment.isUsed || !disabledRuleComment.usedRuleIds.includes(ruleId)) { report.messages.push({ emitter: Emitter.Linter, ruleId: '', severity: Severity.Error, message: `Unused ${getDirectiveName(disabledRuleComment.directiveType)} 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: `You must add a description to the ${getDirectiveName(disabledRuleComment.directiveType)} 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) { if (this.config.linter.isFileIgnored(resolvedPath)) { return { success: true, path: resolvedPath, messages: [], stats: { fileOpeningDuration, msApiGenerationDuration: 0n, parsingDuration: 0n, lintingDuration: 0n } }; } else { 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 };