UNPKG

eslint

Version:

An AST-based pattern checker for JavaScript.

609 lines (535 loc) 18.7 kB
/** * @fileoverview A class to track messages reported by the linter for a file. * @author Nicholas C. Zakas */ "use strict"; //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ const assert = require("../shared/assert"); const { RuleFixer } = require("./rule-fixer"); const { interpolate } = require("./interpolate"); const ruleReplacements = require("../../conf/replacements.json"); //------------------------------------------------------------------------------ // Typedefs //------------------------------------------------------------------------------ /** @typedef {import("../types").Linter.LintMessage} LintMessage */ /** @typedef {import("../types").Linter.LintSuggestion} SuggestionResult */ /** @typedef {import("@eslint/core").Language} Language */ /** @typedef {import("@eslint/core").SourceLocation} SourceLocation */ /** * An error message description * @typedef {Object} MessageDescriptor * @property {ASTNode} [node] The reported node * @property {Location} loc The location of the problem. * @property {string} message The problem message. * @property {Object} [data] Optional data to use to fill in placeholders in the * message. * @property {Function} [fix] The function to call that creates a fix command. * @property {Array<{desc?: string, messageId?: string, fix: Function}>} suggest Suggestion descriptions and functions to create a the associated fixes. */ /** * @typedef {Object} LintProblem * @property {string} ruleId The rule ID that reported the problem. * @property {string} message The problem message. * @property {SourceLocation} loc The location of the problem. */ //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ const DEFAULT_ERROR_LOC = { start: { line: 1, column: 0 }, end: { line: 1, column: 1 }, }; /** * Updates a given location based on the language offsets. This allows us to * change 0-based locations to 1-based locations. We always want ESLint * reporting lines and columns starting from 1. * @todo Potentially this should be moved into a shared utility file. * @param {Object} location The location to update. * @param {number} location.line The starting line number. * @param {number} location.column The starting column number. * @param {number} [location.endLine] The ending line number. * @param {number} [location.endColumn] The ending column number. * @param {Language} language The language to use to adjust the location information. * @returns {Object} The updated location. */ function updateLocationInformation( { line, column, endLine, endColumn }, language, ) { const columnOffset = language.columnStart === 1 ? 0 : 1; const lineOffset = language.lineStart === 1 ? 0 : 1; // calculate separately to account for undefined const finalEndLine = endLine === void 0 ? endLine : endLine + lineOffset; const finalEndColumn = endColumn === void 0 ? endColumn : endColumn + columnOffset; return { line: line + lineOffset, column: column + columnOffset, endLine: finalEndLine, endColumn: finalEndColumn, }; } /** * creates a missing-rule message. * @param {string} ruleId the ruleId to create * @returns {string} created error message * @private */ function createMissingRuleMessage(ruleId) { return Object.hasOwn(ruleReplacements.rules, ruleId) ? `Rule '${ruleId}' was removed and replaced by: ${ruleReplacements.rules[ruleId].join(", ")}` : `Definition for rule '${ruleId}' was not found.`; } /** * creates a linting problem * @param {LintProblem} options to create linting error * @param {RuleSeverity} severity the error message to report * @param {Language} language the language to use to adjust the location information. * @returns {LintMessage} created problem, returns a missing-rule problem if only provided ruleId. * @private */ function createLintingProblem(options, severity, language) { const { ruleId = null, loc = DEFAULT_ERROR_LOC, message = createMissingRuleMessage(options.ruleId), } = options; return { ruleId, message, ...updateLocationInformation( { line: loc.start.line, column: loc.start.column, endLine: loc.end.line, endColumn: loc.end.column, }, language, ), severity, nodeType: null, }; } /** * Translates a multi-argument context.report() call into a single object argument call * @param {...*} args A list of arguments passed to `context.report` * @returns {MessageDescriptor} A normalized object containing report information */ function normalizeMultiArgReportCall(...args) { // If there is one argument, it is considered to be a new-style call already. if (args.length === 1) { // Shallow clone the object to avoid surprises if reusing the descriptor return Object.assign({}, args[0]); } // If the second argument is a string, the arguments are interpreted as [node, message, data, fix]. if (typeof args[1] === "string") { return { node: args[0], message: args[1], data: args[2], fix: args[3], }; } // Otherwise, the arguments are interpreted as [node, loc, message, data, fix]. return { node: args[0], loc: args[1], message: args[2], data: args[3], fix: args[4], }; } /** * Asserts that either a loc or a node was provided, and the node is valid if it was provided. * @param {MessageDescriptor} descriptor A descriptor to validate * @returns {void} * @throws AssertionError if neither a node nor a loc was provided, or if the node is not an object */ function assertValidNodeInfo(descriptor) { if (descriptor.node) { assert(typeof descriptor.node === "object", "Node must be an object"); } else { assert( descriptor.loc, "Node must be provided when reporting error if location is not provided", ); } } /** * Normalizes a MessageDescriptor to always have a `loc` with `start` and `end` properties * @param {MessageDescriptor} descriptor A descriptor for the report from a rule. * @returns {{start: Location, end: (Location|null)}} An updated location that infers the `start` and `end` properties * from the `node` of the original descriptor, or infers the `start` from the `loc` of the original descriptor. */ function normalizeReportLoc(descriptor) { if (descriptor.loc.start) { return descriptor.loc; } return { start: descriptor.loc, end: null }; } /** * Clones the given fix object. * @param {Fix|null} fix The fix to clone. * @returns {Fix|null} Deep cloned fix object or `null` if `null` or `undefined` was passed in. */ function cloneFix(fix) { if (!fix) { return null; } return { range: [fix.range[0], fix.range[1]], text: fix.text, }; } /** * Check that a fix has a valid range. * @param {Fix|null} fix The fix to validate. * @returns {void} */ function assertValidFix(fix) { if (fix) { assert( fix.range && typeof fix.range[0] === "number" && typeof fix.range[1] === "number", `Fix has invalid range: ${JSON.stringify(fix, null, 2)}`, ); } } /** * Compares items in a fixes array by range. * @param {Fix} a The first message. * @param {Fix} b The second message. * @returns {number} -1 if a comes before b, 1 if a comes after b, 0 if equal. * @private */ function compareFixesByRange(a, b) { return a.range[0] - b.range[0] || a.range[1] - b.range[1]; } /** * Merges the given fixes array into one. * @param {Fix[]} fixes The fixes to merge. * @param {SourceCode} sourceCode The source code object to get the text between fixes. * @returns {{text: string, range: number[]}} The merged fixes */ function mergeFixes(fixes, sourceCode) { for (const fix of fixes) { assertValidFix(fix); } if (fixes.length === 0) { return null; } if (fixes.length === 1) { return cloneFix(fixes[0]); } fixes.sort(compareFixesByRange); const originalText = sourceCode.text; const start = fixes[0].range[0]; const end = fixes.at(-1).range[1]; let text = ""; let lastPos = Number.MIN_SAFE_INTEGER; for (const fix of fixes) { assert( fix.range[0] >= lastPos, "Fix objects must not be overlapped in a report.", ); if (fix.range[0] >= 0) { text += originalText.slice( Math.max(0, start, lastPos), fix.range[0], ); } text += fix.text; lastPos = fix.range[1]; } text += originalText.slice(Math.max(0, start, lastPos), end); return { range: [start, end], text }; } /** * Gets one fix object from the given descriptor. * If the descriptor retrieves multiple fixes, this merges those to one. * @param {MessageDescriptor} descriptor The report descriptor. * @param {SourceCode} sourceCode The source code object to get text between fixes. * @returns {({text: string, range: number[]}|null)} The fix for the descriptor */ function normalizeFixes(descriptor, sourceCode) { if (typeof descriptor.fix !== "function") { return null; } const ruleFixer = new RuleFixer({ sourceCode }); // @type {null | Fix | Fix[] | IterableIterator<Fix>} const fix = descriptor.fix(ruleFixer); // Merge to one. if (fix && Symbol.iterator in fix) { return mergeFixes(Array.from(fix), sourceCode); } assertValidFix(fix); return cloneFix(fix); } /** * Gets an array of suggestion objects from the given descriptor. * @param {MessageDescriptor} descriptor The report descriptor. * @param {SourceCode} sourceCode The source code object to get text between fixes. * @param {Object} messages Object of meta messages for the rule. * @returns {Array<SuggestionResult>} The suggestions for the descriptor */ function mapSuggestions(descriptor, sourceCode, messages) { if (!descriptor.suggest || !Array.isArray(descriptor.suggest)) { return []; } return ( descriptor.suggest .map(suggestInfo => { const computedDesc = suggestInfo.desc || messages[suggestInfo.messageId]; return { ...suggestInfo, desc: interpolate(computedDesc, suggestInfo.data), fix: normalizeFixes(suggestInfo, sourceCode), }; }) // Remove suggestions that didn't provide a fix .filter(({ fix }) => fix) ); } /** * Creates information about the report from a descriptor * @param {Object} options Information about the problem * @param {string} options.ruleId Rule ID * @param {(0|1|2)} options.severity Rule severity * @param {(ASTNode|null)} options.node Node * @param {string} options.message Error message * @param {string} [options.messageId] The error message ID. * @param {{start: SourceLocation, end: (SourceLocation|null)}} options.loc Start and end location * @param {{text: string, range: (number[]|null)}} options.fix The fix object * @param {Array<{text: string, range: (number[]|null)}>} options.suggestions The array of suggestions objects * @param {Language} [options.language] The language to use to adjust line and column offsets. * @returns {LintMessage} Information about the report */ function createProblem(options) { const { language } = options; // calculate offsets based on the language in use const columnOffset = language.columnStart === 1 ? 0 : 1; const lineOffset = language.lineStart === 1 ? 0 : 1; const problem = { ruleId: options.ruleId, severity: options.severity, message: options.message, line: options.loc.start.line + lineOffset, column: options.loc.start.column + columnOffset, nodeType: (options.node && options.node.type) || null, }; /* * If this isn’t in the conditional, some of the tests fail * because `messageId` is present in the problem object */ if (options.messageId) { problem.messageId = options.messageId; } if (options.loc.end) { problem.endLine = options.loc.end.line + lineOffset; problem.endColumn = options.loc.end.column + columnOffset; } if (options.fix) { problem.fix = options.fix; } if (options.suggestions && options.suggestions.length > 0) { problem.suggestions = options.suggestions; } return problem; } /** * Validates that suggestions are properly defined. Throws if an error is detected. * @param {Array<{ desc?: string, messageId?: string }>} suggest The incoming suggest data. * @param {Object} messages Object of meta messages for the rule. * @returns {void} */ function validateSuggestions(suggest, messages) { if (suggest && Array.isArray(suggest)) { suggest.forEach(suggestion => { if (suggestion.messageId) { const { messageId } = suggestion; if (!messages) { throw new TypeError( `context.report() called with a suggest option with a messageId '${messageId}', but no messages were present in the rule metadata.`, ); } if (!messages[messageId]) { throw new TypeError( `context.report() called with a suggest option with a messageId '${messageId}' which is not present in the 'messages' config: ${JSON.stringify(messages, null, 2)}`, ); } if (suggestion.desc) { throw new TypeError( "context.report() called with a suggest option that defines both a 'messageId' and an 'desc'. Please only pass one.", ); } } else if (!suggestion.desc) { throw new TypeError( "context.report() called with a suggest option that doesn't have either a `desc` or `messageId`", ); } if (typeof suggestion.fix !== "function") { throw new TypeError( `context.report() called with a suggest option without a fix function. See: ${suggestion}`, ); } }); } } /** * Computes the message from a report descriptor. * @param {MessageDescriptor} descriptor The report descriptor. * @param {Object} messages Object of meta messages for the rule. * @returns {string} The computed message. * @throws {TypeError} If messageId is not found or both message and messageId are provided. */ function computeMessageFromDescriptor(descriptor, messages) { if (descriptor.messageId) { if (!messages) { throw new TypeError( "context.report() called with a messageId, but no messages were present in the rule metadata.", ); } const id = descriptor.messageId; if (descriptor.message) { throw new TypeError( "context.report() called with a message and a messageId. Please only pass one.", ); } if (!messages || !Object.hasOwn(messages, id)) { throw new TypeError( `context.report() called with a messageId of '${id}' which is not present in the 'messages' config: ${JSON.stringify(messages, null, 2)}`, ); } return messages[id]; } if (descriptor.message) { return descriptor.message; } throw new TypeError( "Missing `message` property in report() call; add a message that describes the linting problem.", ); } /** * A report object that contains the messages reported the linter * for a file. */ class FileReport { /** * The messages reported by the linter for this file. * @type {LintMessage[]} */ messages = []; /** * A rule mapper that maps rule IDs to their metadata. * @type {(string) => RuleDefinition} */ #ruleMapper; /** * The source code object for the file. * @type {SourceCode} */ #sourceCode; /** * The language to use to adjust line and column offsets. * @type {Language} */ #language; /** * Whether to disable fixes for this report. * @type {boolean} */ #disableFixes; /** * Creates a new FileReport instance. * @param {Object} options The options for the file report * @param {(string) => RuleDefinition} options.ruleMapper A rule mapper that maps rule IDs to their metadata. * @param {SourceCode} options.sourceCode The source code object for the file. * @param {Language} options.language The language to use to adjust line and column offsets. * @param {boolean} [options.disableFixes=false] Whether to disable fixes for this report. */ constructor({ ruleMapper, sourceCode, language, disableFixes = false }) { this.#ruleMapper = ruleMapper; this.#sourceCode = sourceCode; this.#language = language; this.#disableFixes = disableFixes; } /** * Adds a rule-generated message to the report. * @param {string} ruleId The rule ID that reported the problem. * @param {0|1|2} severity The severity of the problem (0 = off, 1 = warning, 2 = error). * @param {...*} args The arguments passed to `context.report()`. * @returns {LintMessage} The created message object. * @throws {TypeError} If the messageId is not found or both message and messageId are provided. * @throws {AssertionError} If the node is not an object or neither a node nor a loc is provided. */ addRuleMessage(ruleId, severity, ...args) { const descriptor = normalizeMultiArgReportCall(...args); const ruleDefinition = this.#ruleMapper(ruleId); const messages = ruleDefinition?.meta?.messages; assertValidNodeInfo(descriptor); const computedMessage = computeMessageFromDescriptor( descriptor, messages, ); validateSuggestions(descriptor.suggest, messages); this.messages.push( createProblem({ ruleId, severity, node: descriptor.node, message: interpolate(computedMessage, descriptor.data), messageId: descriptor.messageId, loc: descriptor.loc ? normalizeReportLoc(descriptor) : this.#sourceCode.getLoc(descriptor.node), fix: this.#disableFixes ? null : normalizeFixes(descriptor, this.#sourceCode), suggestions: this.#disableFixes ? [] : mapSuggestions(descriptor, this.#sourceCode, messages), language: this.#language, }), ); return this.messages.at(-1); } /** * Adds an error message to the report. Meant to be called outside of rules. * @param {LintProblem} descriptor The descriptor for the error message. * @returns {LintMessage} The created message object. */ addError(descriptor) { const message = createLintingProblem(descriptor, 2, this.#language); this.messages.push(message); return message; } /** * Adds a fatal error message to the report. Meant to be called outside of rules. * @param {LintProblem} descriptor The descriptor for the fatal error message. * @returns {LintMessage} The created message object. */ addFatal(descriptor) { const message = createLintingProblem(descriptor, 2, this.#language); message.fatal = true; this.messages.push(message); return message; } /** * Adds a warning message to the report. Meant to be called outside of rules. * @param {LintProblem} descriptor The descriptor for the warning message. * @returns {LintMessage} The created message object. */ addWarning(descriptor) { const message = createLintingProblem(descriptor, 1, this.#language); this.messages.push(message); return message; } } module.exports = { FileReport, updateLocationInformation, };