@maniascript/mslint
Version:
ManiaScript linter
511 lines (510 loc) • 24.2 kB
JavaScript
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 };