| /** |
| * @fileoverview Abstraction of JavaScript source code. |
| * @author Nicholas C. Zakas |
| */ |
| "use strict"; |
| |
| //------------------------------------------------------------------------------ |
| // Requirements |
| //------------------------------------------------------------------------------ |
| |
| const { isCommentToken } = require("@eslint-community/eslint-utils"), |
| TokenStore = require("./token-store"), |
| astUtils = require("../../../shared/ast-utils"), |
| Traverser = require("../../../shared/traverser"), |
| globals = require("../../../../conf/globals"), |
| { directivesPattern } = require("../../../shared/directives"), |
| CodePathAnalyzer = require("../../../linter/code-path-analysis/code-path-analyzer"), |
| { |
| ConfigCommentParser, |
| VisitNodeStep, |
| CallMethodStep, |
| Directive, |
| } = require("@eslint/plugin-kit"), |
| eslintScope = require("eslint-scope"); |
| |
| //------------------------------------------------------------------------------ |
| // Type Definitions |
| //------------------------------------------------------------------------------ |
| |
| /** @typedef {import("eslint-scope").Variable} Variable */ |
| /** @typedef {import("eslint-scope").Scope} Scope */ |
| /** @typedef {import("@eslint/core").SourceCode} ISourceCode */ |
| /** @typedef {import("@eslint/core").Directive} IDirective */ |
| /** @typedef {import("@eslint/core").TraversalStep} ITraversalStep */ |
| |
| //------------------------------------------------------------------------------ |
| // Private |
| //------------------------------------------------------------------------------ |
| |
| const commentParser = new ConfigCommentParser(); |
| |
| /** |
| * Validates that the given AST has the required information. |
| * @param {ASTNode} ast The Program node of the AST to check. |
| * @throws {TypeError} If the AST doesn't contain the correct information. |
| * @returns {void} |
| * @private |
| */ |
| function validate(ast) { |
| if (!ast) { |
| throw new TypeError(`Unexpected empty AST. (${ast})`); |
| } |
| |
| if (!ast.tokens) { |
| throw new TypeError("AST is missing the tokens array."); |
| } |
| |
| if (!ast.comments) { |
| throw new TypeError("AST is missing the comments array."); |
| } |
| |
| if (!ast.loc) { |
| throw new TypeError("AST is missing location information."); |
| } |
| |
| if (!ast.range) { |
| throw new TypeError("AST is missing range information"); |
| } |
| } |
| |
| /** |
| * Retrieves globals for the given ecmaVersion. |
| * @param {number} ecmaVersion The version to retrieve globals for. |
| * @returns {Object} The globals for the given ecmaVersion. |
| */ |
| function getGlobalsForEcmaVersion(ecmaVersion) { |
| switch (ecmaVersion) { |
| case 3: |
| return globals.es3; |
| |
| case 5: |
| return globals.es5; |
| |
| default: |
| if (ecmaVersion < 2015) { |
| return globals[`es${ecmaVersion + 2009}`]; |
| } |
| |
| return globals[`es${ecmaVersion}`]; |
| } |
| } |
| |
| /** |
| * Check to see if its a ES6 export declaration. |
| * @param {ASTNode} astNode An AST node. |
| * @returns {boolean} whether the given node represents an export declaration. |
| * @private |
| */ |
| function looksLikeExport(astNode) { |
| return ( |
| astNode.type === "ExportDefaultDeclaration" || |
| astNode.type === "ExportNamedDeclaration" || |
| astNode.type === "ExportAllDeclaration" || |
| astNode.type === "ExportSpecifier" |
| ); |
| } |
| |
| /** |
| * Merges two sorted lists into a larger sorted list in O(n) time. |
| * @param {Token[]} tokens The list of tokens. |
| * @param {Token[]} comments The list of comments. |
| * @returns {Token[]} A sorted list of tokens and comments. |
| * @private |
| */ |
| function sortedMerge(tokens, comments) { |
| const result = []; |
| let tokenIndex = 0; |
| let commentIndex = 0; |
| |
| while (tokenIndex < tokens.length || commentIndex < comments.length) { |
| if ( |
| commentIndex >= comments.length || |
| (tokenIndex < tokens.length && |
| tokens[tokenIndex].range[0] < comments[commentIndex].range[0]) |
| ) { |
| result.push(tokens[tokenIndex++]); |
| } else { |
| result.push(comments[commentIndex++]); |
| } |
| } |
| |
| return result; |
| } |
| |
| /** |
| * Normalizes a value for a global in a config |
| * @param {(boolean|string|null)} configuredValue The value given for a global in configuration or in |
| * a global directive comment |
| * @returns {("readonly"|"writable"|"off")} The value normalized as a string |
| * @throws {Error} if global value is invalid |
| */ |
| function normalizeConfigGlobal(configuredValue) { |
| switch (configuredValue) { |
| case "off": |
| return "off"; |
| |
| case true: |
| case "true": |
| case "writeable": |
| case "writable": |
| return "writable"; |
| |
| case null: |
| case false: |
| case "false": |
| case "readable": |
| case "readonly": |
| return "readonly"; |
| |
| default: |
| throw new Error( |
| `'${configuredValue}' is not a valid configuration for a global (use 'readonly', 'writable', or 'off')`, |
| ); |
| } |
| } |
| |
| /** |
| * Determines if two nodes or tokens overlap. |
| * @param {ASTNode|Token} first The first node or token to check. |
| * @param {ASTNode|Token} second The second node or token to check. |
| * @returns {boolean} True if the two nodes or tokens overlap. |
| * @private |
| */ |
| function nodesOrTokensOverlap(first, second) { |
| return ( |
| (first.range[0] <= second.range[0] && |
| first.range[1] >= second.range[0]) || |
| (second.range[0] <= first.range[0] && second.range[1] >= first.range[0]) |
| ); |
| } |
| |
| /** |
| * Determines if two nodes or tokens have at least one whitespace character |
| * between them. Order does not matter. Returns false if the given nodes or |
| * tokens overlap. |
| * @param {SourceCode} sourceCode The source code object. |
| * @param {ASTNode|Token} first The first node or token to check between. |
| * @param {ASTNode|Token} second The second node or token to check between. |
| * @param {boolean} checkInsideOfJSXText If `true` is present, check inside of JSXText tokens for backward compatibility. |
| * @returns {boolean} True if there is a whitespace character between |
| * any of the tokens found between the two given nodes or tokens. |
| * @public |
| */ |
| function isSpaceBetween(sourceCode, first, second, checkInsideOfJSXText) { |
| if (nodesOrTokensOverlap(first, second)) { |
| return false; |
| } |
| |
| const [startingNodeOrToken, endingNodeOrToken] = |
| first.range[1] <= second.range[0] ? [first, second] : [second, first]; |
| const firstToken = |
| sourceCode.getLastToken(startingNodeOrToken) || startingNodeOrToken; |
| const finalToken = |
| sourceCode.getFirstToken(endingNodeOrToken) || endingNodeOrToken; |
| let currentToken = firstToken; |
| |
| while (currentToken !== finalToken) { |
| const nextToken = sourceCode.getTokenAfter(currentToken, { |
| includeComments: true, |
| }); |
| |
| if ( |
| currentToken.range[1] !== nextToken.range[0] || |
| /* |
| * For backward compatibility, check spaces in JSXText. |
| * https://github.com/eslint/eslint/issues/12614 |
| */ |
| (checkInsideOfJSXText && |
| nextToken !== finalToken && |
| nextToken.type === "JSXText" && |
| /\s/u.test(nextToken.value)) |
| ) { |
| return true; |
| } |
| |
| currentToken = nextToken; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Performs binary search to find the line number containing a given character index. |
| * Returns the lower bound - the index of the first element greater than the target. |
| * **Please note that the `lineStartIndices` should be sorted in ascending order**. |
| * - Time Complexity: O(log n) - Significantly faster than linear search for large files. |
| * @param {number[]} lineStartIndices Sorted array of line start indices. |
| * @param {number} target The character index to find the line number for. |
| * @returns {number} The 1-based line number for the target index. |
| * @private |
| */ |
| function findLineNumberBinarySearch(lineStartIndices, target) { |
| let low = 0; |
| let high = lineStartIndices.length; |
| |
| while (low < high) { |
| const mid = ((low + high) / 2) | 0; // Use bitwise OR to floor the division |
| |
| if (target < lineStartIndices[mid]) { |
| high = mid; |
| } else { |
| low = mid + 1; |
| } |
| } |
| |
| return low; |
| } |
| |
| //----------------------------------------------------------------------------- |
| // Directive Comments |
| //----------------------------------------------------------------------------- |
| |
| /** |
| * Ensures that variables representing built-in properties of the Global Object, |
| * and any globals declared by special block comments, are present in the global |
| * scope. |
| * @param {Scope} globalScope The global scope. |
| * @param {Object|undefined} configGlobals The globals declared in configuration |
| * @param {Object|undefined} inlineGlobals The globals declared in the source code |
| * @returns {void} |
| */ |
| function addDeclaredGlobals( |
| globalScope, |
| configGlobals = {}, |
| inlineGlobals = {}, |
| ) { |
| // Define configured global variables. |
| for (const id of new Set([ |
| ...Object.keys(configGlobals), |
| ...Object.keys(inlineGlobals), |
| ])) { |
| /* |
| * `normalizeConfigGlobal` will throw an error if a configured global value is invalid. However, these errors would |
| * typically be caught when validating a config anyway (validity for inline global comments is checked separately). |
| */ |
| const configValue = |
| configGlobals[id] === void 0 |
| ? void 0 |
| : normalizeConfigGlobal(configGlobals[id]); |
| const commentValue = inlineGlobals[id] && inlineGlobals[id].value; |
| const value = commentValue || configValue; |
| const sourceComments = inlineGlobals[id] && inlineGlobals[id].comments; |
| |
| if (value === "off") { |
| continue; |
| } |
| |
| let variable = globalScope.set.get(id); |
| |
| if (!variable) { |
| variable = new eslintScope.Variable(id, globalScope); |
| |
| globalScope.variables.push(variable); |
| globalScope.set.set(id, variable); |
| } |
| |
| variable.eslintImplicitGlobalSetting = configValue; |
| variable.eslintExplicitGlobal = sourceComments !== void 0; |
| variable.eslintExplicitGlobalComments = sourceComments; |
| variable.writeable = value === "writable"; |
| } |
| |
| /* |
| * "through" contains all references which definitions cannot be found. |
| * Since we augment the global scope using configuration, we need to update |
| * references and remove the ones that were added by configuration. |
| */ |
| globalScope.through = globalScope.through.filter(reference => { |
| const name = reference.identifier.name; |
| const variable = globalScope.set.get(name); |
| |
| if (variable) { |
| /* |
| * Links the variable and the reference. |
| * And this reference is removed from `Scope#through`. |
| */ |
| reference.resolved = variable; |
| variable.references.push(reference); |
| |
| return false; |
| } |
| |
| return true; |
| }); |
| |
| /* |
| * "implicit" contains information about implicit global variables (those created |
| * implicitly by assigning values to undeclared variables in non-strict code). |
| * Since we augment the global scope using configuration, we need to remove |
| * the ones that were added by configuration, as they are either built-in |
| * or declared elsewhere, therefore not implicit. |
| * Since the "implicit" property was not documented, first we'll check if it exists |
| * because it's possible that not all custom scope managers create this property. |
| * If it exists, we assume it has properties `variables` and `set`. Property |
| * `left` is considered optional (for example, typescript-eslint's scope manage |
| * has this property named `leftToBeResolved`). |
| */ |
| const { implicit } = globalScope; |
| if (typeof implicit === "object" && implicit !== null) { |
| implicit.variables = implicit.variables.filter(variable => { |
| const name = variable.name; |
| if (globalScope.set.has(name)) { |
| implicit.set.delete(name); |
| return false; |
| } |
| return true; |
| }); |
| |
| if (implicit.left) { |
| implicit.left = implicit.left.filter( |
| reference => !globalScope.set.has(reference.identifier.name), |
| ); |
| } |
| } |
| } |
| |
| /** |
| * Sets the given variable names as exported so they won't be triggered by |
| * the `no-unused-vars` rule. |
| * @param {eslint.Scope} globalScope The global scope to define exports in. |
| * @param {Record<string,string>} variables An object whose keys are the variable |
| * names to export. |
| * @returns {void} |
| */ |
| function markExportedVariables(globalScope, variables) { |
| Object.keys(variables).forEach(name => { |
| const variable = globalScope.set.get(name); |
| |
| if (variable) { |
| variable.eslintUsed = true; |
| variable.eslintExported = true; |
| } |
| }); |
| } |
| |
| //------------------------------------------------------------------------------ |
| // Public Interface |
| //------------------------------------------------------------------------------ |
| |
| const caches = Symbol("caches"); |
| |
| /** |
| * Represents parsed source code. |
| * @implements {ISourceCode} |
| */ |
| class SourceCode extends TokenStore { |
| /** |
| * The cache of steps that were taken while traversing the source code. |
| * @type {Array<ITraversalStep>} |
| */ |
| #steps; |
| |
| /** |
| * Creates a new instance. |
| * @param {string|Object} textOrConfig The source code text or config object. |
| * @param {string} textOrConfig.text The source code text. |
| * @param {ASTNode} textOrConfig.ast The Program node of the AST representing the code. This AST should be created from the text that BOM was stripped. |
| * @param {boolean} textOrConfig.hasBOM Indicates if the text has a Unicode BOM. |
| * @param {Object|null} textOrConfig.parserServices The parser services. |
| * @param {ScopeManager|null} textOrConfig.scopeManager The scope of this source code. |
| * @param {Object|null} textOrConfig.visitorKeys The visitor keys to traverse AST. |
| * @param {ASTNode} [astIfNoConfig] The Program node of the AST representing the code. This AST should be created from the text that BOM was stripped. |
| */ |
| constructor(textOrConfig, astIfNoConfig) { |
| let text, hasBOM, ast, parserServices, scopeManager, visitorKeys; |
| |
| // Process overloading of arguments |
| if (typeof textOrConfig === "string") { |
| text = textOrConfig; |
| ast = astIfNoConfig; |
| hasBOM = false; |
| } else if (typeof textOrConfig === "object" && textOrConfig !== null) { |
| text = textOrConfig.text; |
| ast = textOrConfig.ast; |
| hasBOM = textOrConfig.hasBOM; |
| parserServices = textOrConfig.parserServices; |
| scopeManager = textOrConfig.scopeManager; |
| visitorKeys = textOrConfig.visitorKeys; |
| } |
| |
| validate(ast); |
| super(ast.tokens, ast.comments); |
| |
| /** |
| * General purpose caching for the class. |
| */ |
| this[caches] = new Map([ |
| ["scopes", new WeakMap()], |
| ["vars", new Map()], |
| ["configNodes", void 0], |
| ["isGlobalReference", new WeakMap()], |
| ]); |
| |
| /** |
| * Indicates if the AST is ESTree compatible. |
| * @type {boolean} |
| */ |
| this.isESTree = ast.type === "Program"; |
| |
| /* |
| * Backwards compatibility for BOM handling. |
| * |
| * The `hasBOM` property has been available on the `SourceCode` object |
| * for a long time and is used to indicate if the source contains a BOM. |
| * The linter strips the BOM and just passes the `hasBOM` property to the |
| * `SourceCode` constructor to make it easier for languages to not deal with |
| * the BOM. |
| * |
| * However, the text passed in to the `SourceCode` constructor might still |
| * have a BOM if the constructor is called outside of the linter, so we still |
| * need to check for the BOM in the text. |
| */ |
| const textHasBOM = text.charCodeAt(0) === 0xfeff; |
| |
| /** |
| * The flag to indicate that the source code has Unicode BOM. |
| * @type {boolean} |
| */ |
| this.hasBOM = textHasBOM || !!hasBOM; |
| |
| /** |
| * The original text source code. |
| * BOM was stripped from this text. |
| * @type {string} |
| */ |
| this.text = textHasBOM ? text.slice(1) : text; |
| |
| /** |
| * The parsed AST for the source code. |
| * @type {ASTNode} |
| */ |
| this.ast = ast; |
| |
| /** |
| * The parser services of this source code. |
| * @type {Object} |
| */ |
| this.parserServices = parserServices || {}; |
| |
| /** |
| * The scope of this source code. |
| * @type {ScopeManager|null} |
| */ |
| this.scopeManager = scopeManager || null; |
| |
| /** |
| * The visitor keys to traverse AST. |
| * @type {Object} |
| */ |
| this.visitorKeys = visitorKeys || Traverser.DEFAULT_VISITOR_KEYS; |
| |
| // Check the source text for the presence of a shebang since it is parsed as a standard line comment. |
| const shebangMatched = this.text.match(astUtils.shebangPattern); |
| const hasShebang = |
| shebangMatched && |
| ast.comments.length && |
| ast.comments[0].value === shebangMatched[1]; |
| |
| if (hasShebang) { |
| ast.comments[0].type = "Shebang"; |
| } |
| |
| this.tokensAndComments = sortedMerge(ast.tokens, ast.comments); |
| |
| /** |
| * The source code split into lines according to ECMA-262 specification. |
| * This is done to avoid each rule needing to do so separately. |
| * @type {string[]} |
| */ |
| this.lines = []; |
| |
| /** |
| * @type {number[]} |
| */ |
| this.lineStartIndices = [0]; |
| |
| const lineEndingPattern = astUtils.createGlobalLinebreakMatcher(); |
| let match; |
| |
| /* |
| * Previously, this was implemented using a regex that |
| * matched a sequence of non-linebreak characters followed by a |
| * linebreak, then adding the lengths of the matches. However, |
| * this caused a catastrophic backtracking issue when the end |
| * of a file contained a large number of non-newline characters. |
| * To avoid this, the current implementation just matches newlines |
| * and uses match.index to get the correct line start indices. |
| */ |
| while ((match = lineEndingPattern.exec(this.text))) { |
| this.lines.push( |
| this.text.slice(this.lineStartIndices.at(-1), match.index), |
| ); |
| this.lineStartIndices.push(match.index + match[0].length); |
| } |
| this.lines.push(this.text.slice(this.lineStartIndices.at(-1))); |
| |
| // don't allow further modification of this object |
| Object.freeze(this); |
| Object.freeze(this.lines); |
| } |
| |
| /** |
| * Split the source code into multiple lines based on the line delimiters. |
| * @param {string} text Source code as a string. |
| * @returns {string[]} Array of source code lines. |
| * @public |
| */ |
| static splitLines(text) { |
| return text.split(astUtils.createGlobalLinebreakMatcher()); |
| } |
| |
| /** |
| * Gets the source code for the given node. |
| * @param {ASTNode} [node] The AST node to get the text for. |
| * @param {number} [beforeCount] The number of characters before the node to retrieve. |
| * @param {number} [afterCount] The number of characters after the node to retrieve. |
| * @returns {string} The text representing the AST node. |
| * @public |
| */ |
| getText(node, beforeCount, afterCount) { |
| if (node) { |
| return this.text.slice( |
| Math.max(node.range[0] - (beforeCount || 0), 0), |
| node.range[1] + (afterCount || 0), |
| ); |
| } |
| return this.text; |
| } |
| |
| /** |
| * Gets the entire source text split into an array of lines. |
| * @returns {string[]} The source text as an array of lines. |
| * @public |
| */ |
| getLines() { |
| return this.lines; |
| } |
| |
| /** |
| * Retrieves an array containing all comments in the source code. |
| * @returns {ASTNode[]} An array of comment nodes. |
| * @public |
| */ |
| getAllComments() { |
| return this.ast.comments; |
| } |
| |
| /** |
| * Retrieves the JSDoc comment for a given node. |
| * @param {ASTNode} node The AST node to get the comment for. |
| * @returns {Token|null} The Block comment token containing the JSDoc comment |
| * for the given node or null if not found. |
| * @public |
| * @deprecated |
| */ |
| getJSDocComment(node) { |
| /** |
| * Checks for the presence of a JSDoc comment for the given node and returns it. |
| * @param {ASTNode} astNode The AST node to get the comment for. |
| * @returns {Token|null} The Block comment token containing the JSDoc comment |
| * for the given node or null if not found. |
| * @private |
| */ |
| const findJSDocComment = astNode => { |
| const tokenBefore = this.getTokenBefore(astNode, { |
| includeComments: true, |
| }); |
| |
| if ( |
| tokenBefore && |
| isCommentToken(tokenBefore) && |
| tokenBefore.type === "Block" && |
| tokenBefore.value.charAt(0) === "*" && |
| astNode.loc.start.line - tokenBefore.loc.end.line <= 1 |
| ) { |
| return tokenBefore; |
| } |
| |
| return null; |
| }; |
| let parent = node.parent; |
| |
| switch (node.type) { |
| case "ClassDeclaration": |
| case "FunctionDeclaration": |
| return findJSDocComment( |
| looksLikeExport(parent) ? parent : node, |
| ); |
| |
| case "ClassExpression": |
| return findJSDocComment(parent.parent); |
| |
| case "ArrowFunctionExpression": |
| case "FunctionExpression": |
| if ( |
| parent.type !== "CallExpression" && |
| parent.type !== "NewExpression" |
| ) { |
| while ( |
| !this.getCommentsBefore(parent).length && |
| !/Function/u.test(parent.type) && |
| parent.type !== "MethodDefinition" && |
| parent.type !== "Property" |
| ) { |
| parent = parent.parent; |
| |
| if (!parent) { |
| break; |
| } |
| } |
| |
| if ( |
| parent && |
| parent.type !== "FunctionDeclaration" && |
| parent.type !== "Program" |
| ) { |
| return findJSDocComment(parent); |
| } |
| } |
| |
| return findJSDocComment(node); |
| |
| // falls through |
| default: |
| return null; |
| } |
| } |
| |
| /** |
| * Gets the deepest node containing a range index. |
| * @param {number} index Range index of the desired node. |
| * @returns {ASTNode} The node if found or null if not found. |
| * @public |
| */ |
| getNodeByRangeIndex(index) { |
| let result = null; |
| |
| Traverser.traverse(this.ast, { |
| visitorKeys: this.visitorKeys, |
| enter(node) { |
| if (node.range[0] <= index && index < node.range[1]) { |
| result = node; |
| } else { |
| this.skip(); |
| } |
| }, |
| leave(node) { |
| if (node === result) { |
| this.break(); |
| } |
| }, |
| }); |
| |
| return result; |
| } |
| |
| /** |
| * Determines if two nodes or tokens have at least one whitespace character |
| * between them. Order does not matter. Returns false if the given nodes or |
| * tokens overlap. |
| * @param {ASTNode|Token} first The first node or token to check between. |
| * @param {ASTNode|Token} second The second node or token to check between. |
| * @returns {boolean} True if there is a whitespace character between |
| * any of the tokens found between the two given nodes or tokens. |
| * @public |
| */ |
| isSpaceBetween(first, second) { |
| return isSpaceBetween(this, first, second, false); |
| } |
| |
| /** |
| * Determines if two nodes or tokens have at least one whitespace character |
| * between them. Order does not matter. Returns false if the given nodes or |
| * tokens overlap. |
| * For backward compatibility, this method returns true if there are |
| * `JSXText` tokens that contain whitespaces between the two. |
| * @param {ASTNode|Token} first The first node or token to check between. |
| * @param {ASTNode|Token} second The second node or token to check between. |
| * @returns {boolean} True if there is a whitespace character between |
| * any of the tokens found between the two given nodes or tokens. |
| * @deprecated in favor of isSpaceBetween(). |
| * @public |
| */ |
| isSpaceBetweenTokens(first, second) { |
| return isSpaceBetween(this, first, second, true); |
| } |
| |
| /** |
| * Converts a source text index into a (line, column) pair. |
| * @param {number} index The index of a character in a file. |
| * @throws {TypeError|RangeError} If non-numeric index or index out of range. |
| * @returns {{line: number, column: number}} A {line, column} location object with 1-indexed line and 0-indexed column. |
| * @public |
| */ |
| getLocFromIndex(index) { |
| if (typeof index !== "number") { |
| throw new TypeError("Expected `index` to be a number."); |
| } |
| |
| if (index < 0 || index > this.text.length) { |
| throw new RangeError( |
| `Index out of range (requested index ${index}, but source text has length ${this.text.length}).`, |
| ); |
| } |
| |
| /* |
| * For an argument of this.text.length, return the location one "spot" past the last character |
| * of the file. If the last character is a linebreak, the location will be column 0 of the next |
| * line; otherwise, the location will be in the next column on the same line. |
| * |
| * See getIndexFromLoc for the motivation for this special case. |
| */ |
| if (index === this.text.length) { |
| return { |
| line: this.lines.length, |
| column: this.lines.at(-1).length, |
| }; |
| } |
| |
| /* |
| * To figure out which line index is on, determine the last place at which index could |
| * be inserted into lineStartIndices to keep the list sorted. |
| */ |
| const lineNumber = |
| index >= this.lineStartIndices.at(-1) |
| ? this.lineStartIndices.length |
| : findLineNumberBinarySearch(this.lineStartIndices, index); |
| |
| return { |
| line: lineNumber, |
| column: index - this.lineStartIndices[lineNumber - 1], |
| }; |
| } |
| |
| /** |
| * Converts a (line, column) pair into a range index. |
| * @param {Object} loc A line/column location |
| * @param {number} loc.line The line number of the location (1-indexed) |
| * @param {number} loc.column The column number of the location (0-indexed) |
| * @throws {TypeError|RangeError} If `loc` is not an object with a numeric |
| * `line` and `column`, if the `line` is less than or equal to zero or |
| * the line or column is out of the expected range. |
| * @returns {number} The range index of the location in the file. |
| * @public |
| */ |
| getIndexFromLoc(loc) { |
| if ( |
| loc === null || |
| typeof loc !== "object" || |
| typeof loc.line !== "number" || |
| typeof loc.column !== "number" |
| ) { |
| throw new TypeError( |
| "Expected `loc` to be an object with numeric `line` and `column` properties.", |
| ); |
| } |
| |
| if (loc.line <= 0) { |
| throw new RangeError( |
| `Line number out of range (line ${loc.line} requested). Line numbers should be 1-based.`, |
| ); |
| } |
| |
| if (loc.line > this.lineStartIndices.length) { |
| throw new RangeError( |
| `Line number out of range (line ${loc.line} requested, but only ${this.lineStartIndices.length} lines present).`, |
| ); |
| } |
| |
| if (loc.column < 0) { |
| throw new RangeError( |
| `Invalid column number (column ${loc.column} requested).`, |
| ); |
| } |
| |
| const lineStartIndex = this.lineStartIndices[loc.line - 1]; |
| const lineEndIndex = |
| loc.line === this.lineStartIndices.length |
| ? this.text.length |
| : this.lineStartIndices[loc.line]; |
| const positionIndex = lineStartIndex + loc.column; |
| |
| /* |
| * By design, getIndexFromLoc({ line: lineNum, column: 0 }) should return the start index of |
| * the given line, provided that the line number is valid element of this.lines. Since the |
| * last element of this.lines is an empty string for files with trailing newlines, add a |
| * special case where getting the index for the first location after the end of the file |
| * will return the length of the file, rather than throwing an error. This allows rules to |
| * use getIndexFromLoc consistently without worrying about edge cases at the end of a file. |
| */ |
| if ( |
| (loc.line === this.lineStartIndices.length && |
| positionIndex > lineEndIndex) || |
| (loc.line < this.lineStartIndices.length && |
| positionIndex >= lineEndIndex) |
| ) { |
| throw new RangeError( |
| `Column number out of range (column ${loc.column} requested, but the length of line ${loc.line} is ${lineEndIndex - lineStartIndex}).`, |
| ); |
| } |
| |
| return positionIndex; |
| } |
| |
| /** |
| * Gets the scope for the given node |
| * @param {ASTNode} currentNode The node to get the scope of |
| * @returns {Scope} The scope information for this node |
| * @throws {TypeError} If the `currentNode` argument is missing. |
| */ |
| getScope(currentNode) { |
| if (!currentNode) { |
| throw new TypeError("Missing required argument: node."); |
| } |
| |
| // check cache first |
| const cache = this[caches].get("scopes"); |
| const cachedScope = cache.get(currentNode); |
| |
| if (cachedScope) { |
| return cachedScope; |
| } |
| |
| // On Program node, get the outermost scope to avoid return Node.js special function scope or ES modules scope. |
| const inner = currentNode.type !== "Program"; |
| |
| for (let node = currentNode; node; node = node.parent) { |
| const scope = this.scopeManager.acquire(node, inner); |
| |
| if (scope) { |
| if (scope.type === "function-expression-name") { |
| cache.set(currentNode, scope.childScopes[0]); |
| return scope.childScopes[0]; |
| } |
| |
| cache.set(currentNode, scope); |
| return scope; |
| } |
| } |
| |
| cache.set(currentNode, this.scopeManager.scopes[0]); |
| return this.scopeManager.scopes[0]; |
| } |
| |
| /** |
| * Get the variables that `node` defines. |
| * This is a convenience method that passes through |
| * to the same method on the `scopeManager`. |
| * @param {ASTNode} node The node for which the variables are obtained. |
| * @returns {Array<Variable>} An array of variable nodes representing |
| * the variables that `node` defines. |
| */ |
| getDeclaredVariables(node) { |
| return this.scopeManager.getDeclaredVariables(node); |
| } |
| |
| /* eslint-disable class-methods-use-this -- node is owned by SourceCode */ |
| /** |
| * Gets all the ancestors of a given node |
| * @param {ASTNode} node The node |
| * @returns {Array<ASTNode>} All the ancestor nodes in the AST, not including the provided node, starting |
| * from the root node at index 0 and going inwards to the parent node. |
| * @throws {TypeError} When `node` is missing. |
| */ |
| getAncestors(node) { |
| if (!node) { |
| throw new TypeError("Missing required argument: node."); |
| } |
| |
| const ancestorsStartingAtParent = []; |
| |
| for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) { |
| ancestorsStartingAtParent.push(ancestor); |
| } |
| |
| return ancestorsStartingAtParent.reverse(); |
| } |
| |
| /** |
| * Determines whether the given identifier node is a reference to a global variable. |
| * @param {ASTNode} node `Identifier` node to check. |
| * @returns {boolean} True if the identifier is a reference to a global variable. |
| */ |
| isGlobalReference(node) { |
| if (!node) { |
| throw new TypeError("Missing required argument: node."); |
| } |
| |
| const cache = this[caches].get("isGlobalReference"); |
| |
| if (cache.has(node)) { |
| return cache.get(node); |
| } |
| |
| if (node.type !== "Identifier") { |
| cache.set(node, false); |
| return false; |
| } |
| |
| const variable = this.scopeManager.scopes[0].set.get(node.name); |
| |
| if (!variable || variable.defs.length > 0) { |
| cache.set(node, false); |
| return false; |
| } |
| |
| const result = variable.references.some( |
| ({ identifier }) => identifier === node, |
| ); |
| cache.set(node, result); |
| return result; |
| } |
| |
| /** |
| * Returns the location of the given node or token. |
| * @param {ASTNode|Token} nodeOrToken The node or token to get the location of. |
| * @returns {SourceLocation} The location of the node or token. |
| */ |
| getLoc(nodeOrToken) { |
| return nodeOrToken.loc; |
| } |
| |
| /** |
| * Returns the range of the given node or token. |
| * @param {ASTNode|Token} nodeOrToken The node or token to get the range of. |
| * @returns {[number, number]} The range of the node or token. |
| */ |
| getRange(nodeOrToken) { |
| return nodeOrToken.range; |
| } |
| |
| /* eslint-enable class-methods-use-this -- node is owned by SourceCode */ |
| |
| /** |
| * Marks a variable as used in the current scope |
| * @param {string} name The name of the variable to mark as used. |
| * @param {ASTNode} [refNode] The closest node to the variable reference. |
| * @returns {boolean} True if the variable was found and marked as used, false if not. |
| */ |
| markVariableAsUsed(name, refNode = this.ast) { |
| const currentScope = this.getScope(refNode); |
| let initialScope = currentScope; |
| |
| /* |
| * When we are in an ESM or CommonJS module, we need to start searching |
| * from the top-level scope, not the global scope. For ESM the top-level |
| * scope is the module scope; for CommonJS the top-level scope is the |
| * outer function scope. |
| * |
| * Without this check, we might miss a variable declared with `var` at |
| * the top-level because it won't exist in the global scope. |
| */ |
| if ( |
| currentScope.type === "global" && |
| currentScope.childScopes.length > 0 && |
| // top-level scopes refer to a `Program` node |
| currentScope.childScopes[0].block === this.ast |
| ) { |
| initialScope = currentScope.childScopes[0]; |
| } |
| |
| for (let scope = initialScope; scope; scope = scope.upper) { |
| const variable = scope.variables.find( |
| scopeVar => scopeVar.name === name, |
| ); |
| |
| if (variable) { |
| variable.eslintUsed = true; |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Returns an array of all inline configuration nodes found in the |
| * source code. |
| * @returns {Array<Token>} An array of all inline configuration nodes. |
| */ |
| getInlineConfigNodes() { |
| // check the cache first |
| let configNodes = this[caches].get("configNodes"); |
| |
| if (configNodes) { |
| return configNodes; |
| } |
| |
| // calculate fresh config nodes |
| configNodes = this.ast.comments.filter(comment => { |
| // shebang comments are never directives |
| if (comment.type === "Shebang") { |
| return false; |
| } |
| |
| const directive = commentParser.parseDirective(comment.value); |
| |
| if (!directive) { |
| return false; |
| } |
| |
| if (!directivesPattern.test(directive.label)) { |
| return false; |
| } |
| |
| // only certain comment types are supported as line comments |
| return ( |
| comment.type !== "Line" || |
| !!/^eslint-disable-(?:next-)?line$/u.test(directive.label) |
| ); |
| }); |
| |
| this[caches].set("configNodes", configNodes); |
| |
| return configNodes; |
| } |
| |
| /** |
| * Returns an all directive nodes that enable or disable rules along with any problems |
| * encountered while parsing the directives. |
| * @returns {{problems:Array<Problem>,directives:Array<Directive>}} Information |
| * that ESLint needs to further process the directives. |
| */ |
| getDisableDirectives() { |
| // check the cache first |
| const cachedDirectives = this[caches].get("disableDirectives"); |
| |
| if (cachedDirectives) { |
| return cachedDirectives; |
| } |
| |
| const problems = []; |
| const directives = []; |
| |
| this.getInlineConfigNodes().forEach(comment => { |
| // Step 1: Parse the directive |
| const { |
| label, |
| value, |
| justification: justificationPart, |
| } = commentParser.parseDirective(comment.value); |
| |
| // Step 2: Extract the directive value |
| const lineCommentSupported = |
| /^eslint-disable-(?:next-)?line$/u.test(label); |
| |
| if (comment.type === "Line" && !lineCommentSupported) { |
| return; |
| } |
| |
| // Step 3: Validate the directive does not span multiple lines |
| if ( |
| label === "eslint-disable-line" && |
| comment.loc.start.line !== comment.loc.end.line |
| ) { |
| const message = `${label} comment should not span multiple lines.`; |
| |
| problems.push({ |
| ruleId: null, |
| message, |
| loc: comment.loc, |
| }); |
| return; |
| } |
| |
| // Step 4: Extract the directive value and create the Directive object |
| switch (label) { |
| case "eslint-disable": |
| case "eslint-enable": |
| case "eslint-disable-next-line": |
| case "eslint-disable-line": { |
| const directiveType = label.slice("eslint-".length); |
| |
| directives.push( |
| new Directive({ |
| type: directiveType, |
| node: comment, |
| value, |
| justification: justificationPart, |
| }), |
| ); |
| } |
| |
| // no default |
| } |
| }); |
| |
| const result = { problems, directives }; |
| |
| this[caches].set("disableDirectives", result); |
| |
| return result; |
| } |
| |
| /** |
| * Applies language options sent in from the core. |
| * @param {Object} languageOptions The language options for this run. |
| * @returns {void} |
| */ |
| applyLanguageOptions(languageOptions) { |
| /* |
| * Add configured globals and language globals |
| * |
| * Using Object.assign instead of object spread for performance reasons |
| * https://github.com/eslint/eslint/issues/16302 |
| */ |
| const configGlobals = Object.assign( |
| Object.create(null), // https://github.com/eslint/eslint/issues/18363 |
| getGlobalsForEcmaVersion(languageOptions.ecmaVersion), |
| languageOptions.sourceType === "commonjs" |
| ? globals.commonjs |
| : void 0, |
| languageOptions.globals, |
| ); |
| const varsCache = this[caches].get("vars"); |
| |
| varsCache.set("configGlobals", configGlobals); |
| } |
| |
| /** |
| * Applies configuration found inside of the source code. This method is only |
| * called when ESLint is running with inline configuration allowed. |
| * @returns {{problems:Array<Problem>,configs:{config:FlatConfigArray,loc:Location}}} Information |
| * that ESLint needs to further process the inline configuration. |
| */ |
| applyInlineConfig() { |
| const problems = []; |
| const configs = []; |
| const exportedVariables = {}; |
| const inlineGlobals = Object.create(null); |
| |
| this.getInlineConfigNodes().forEach(comment => { |
| const { label, value } = commentParser.parseDirective( |
| comment.value, |
| ); |
| |
| switch (label) { |
| case "exported": |
| Object.assign( |
| exportedVariables, |
| commentParser.parseListConfig(value), |
| ); |
| break; |
| |
| case "globals": |
| case "global": |
| for (const [id, idSetting] of Object.entries( |
| commentParser.parseStringConfig(value), |
| )) { |
| let normalizedValue; |
| |
| try { |
| normalizedValue = normalizeConfigGlobal(idSetting); |
| } catch (err) { |
| problems.push({ |
| ruleId: null, |
| loc: comment.loc, |
| message: err.message, |
| }); |
| continue; |
| } |
| |
| if (inlineGlobals[id]) { |
| inlineGlobals[id].comments.push(comment); |
| inlineGlobals[id].value = normalizedValue; |
| } else { |
| inlineGlobals[id] = { |
| comments: [comment], |
| value: normalizedValue, |
| }; |
| } |
| } |
| break; |
| |
| case "eslint": { |
| const parseResult = |
| commentParser.parseJSONLikeConfig(value); |
| |
| if (parseResult.ok) { |
| configs.push({ |
| config: { |
| rules: parseResult.config, |
| }, |
| loc: comment.loc, |
| }); |
| } else { |
| problems.push({ |
| ruleId: null, |
| loc: comment.loc, |
| message: parseResult.error.message, |
| }); |
| } |
| |
| break; |
| } |
| |
| // no default |
| } |
| }); |
| |
| // save all the new variables for later |
| const varsCache = this[caches].get("vars"); |
| |
| varsCache.set("inlineGlobals", inlineGlobals); |
| varsCache.set("exportedVariables", exportedVariables); |
| |
| return { |
| configs, |
| problems, |
| }; |
| } |
| |
| /** |
| * Called by ESLint core to indicate that it has finished providing |
| * information. We now add in all the missing variables and ensure that |
| * state-changing methods cannot be called by rules. |
| * @returns {void} |
| */ |
| finalize() { |
| const varsCache = this[caches].get("vars"); |
| const configGlobals = varsCache.get("configGlobals"); |
| const inlineGlobals = varsCache.get("inlineGlobals"); |
| const exportedVariables = varsCache.get("exportedVariables"); |
| const globalScope = this.scopeManager.scopes[0]; |
| |
| addDeclaredGlobals(globalScope, configGlobals, inlineGlobals); |
| |
| if (exportedVariables) { |
| markExportedVariables(globalScope, exportedVariables); |
| } |
| } |
| |
| /** |
| * Traverse the source code and return the steps that were taken. |
| * @returns {Array<TraversalStep>} The steps that were taken while traversing the source code. |
| */ |
| traverse() { |
| // Because the AST doesn't mutate, we can cache the steps |
| if (this.#steps) { |
| return this.#steps; |
| } |
| |
| const steps = (this.#steps = []); |
| |
| /* |
| * This logic works for any AST, not just ESTree. Because ESLint has allowed |
| * custom parsers to return any AST, we need to ensure that the traversal |
| * logic works for any AST. |
| */ |
| let analyzer = { |
| enterNode(node) { |
| steps.push( |
| new VisitNodeStep({ |
| target: node, |
| phase: 1, |
| args: [node], |
| }), |
| ); |
| }, |
| leaveNode(node) { |
| steps.push( |
| new VisitNodeStep({ |
| target: node, |
| phase: 2, |
| args: [node], |
| }), |
| ); |
| }, |
| emit(eventName, args) { |
| steps.push( |
| new CallMethodStep({ |
| target: eventName, |
| args, |
| }), |
| ); |
| }, |
| }; |
| |
| /* |
| * We do code path analysis for ESTree only. Code path analysis is not |
| * necessary for other ASTs, and it's also not possible to do for other |
| * ASTs because the necessary information is not available. |
| * |
| * Generally speaking, we can tell that the AST is an ESTree if it has a |
| * Program node at the top level. This is not a perfect heuristic, but it |
| * is good enough for now. |
| */ |
| if (this.isESTree) { |
| analyzer = new CodePathAnalyzer(analyzer); |
| } |
| |
| /* |
| * The actual AST traversal is done by the `Traverser` class. This class |
| * is responsible for walking the AST and calling the appropriate methods |
| * on the `analyzer` object, which is appropriate for the given AST. |
| */ |
| Traverser.traverse(this.ast, { |
| enter(node, parent) { |
| // save the parent node on a property for backwards compatibility |
| node.parent = parent; |
| |
| analyzer.enterNode(node); |
| }, |
| leave(node) { |
| analyzer.leaveNode(node); |
| }, |
| visitorKeys: this.visitorKeys, |
| }); |
| |
| return steps; |
| } |
| } |
| |
| module.exports = SourceCode; |