1/** 2 * @fileoverview Abstraction of JavaScript source code. 3 * @author Nicholas C. Zakas 4 */ 5"use strict"; 6 7//------------------------------------------------------------------------------ 8// Requirements 9//------------------------------------------------------------------------------ 10 11const 12 { isCommentToken } = require("eslint-utils"), 13 TokenStore = require("./token-store"), 14 astUtils = require("../shared/ast-utils"), 15 Traverser = require("../shared/traverser"), 16 lodash = require("lodash"); 17 18//------------------------------------------------------------------------------ 19// Private 20//------------------------------------------------------------------------------ 21 22/** 23 * Validates that the given AST has the required information. 24 * @param {ASTNode} ast The Program node of the AST to check. 25 * @throws {Error} If the AST doesn't contain the correct information. 26 * @returns {void} 27 * @private 28 */ 29function validate(ast) { 30 if (!ast.tokens) { 31 throw new Error("AST is missing the tokens array."); 32 } 33 34 if (!ast.comments) { 35 throw new Error("AST is missing the comments array."); 36 } 37 38 if (!ast.loc) { 39 throw new Error("AST is missing location information."); 40 } 41 42 if (!ast.range) { 43 throw new Error("AST is missing range information"); 44 } 45} 46 47/** 48 * Check to see if its a ES6 export declaration. 49 * @param {ASTNode} astNode An AST node. 50 * @returns {boolean} whether the given node represents an export declaration. 51 * @private 52 */ 53function looksLikeExport(astNode) { 54 return astNode.type === "ExportDefaultDeclaration" || astNode.type === "ExportNamedDeclaration" || 55 astNode.type === "ExportAllDeclaration" || astNode.type === "ExportSpecifier"; 56} 57 58/** 59 * Merges two sorted lists into a larger sorted list in O(n) time. 60 * @param {Token[]} tokens The list of tokens. 61 * @param {Token[]} comments The list of comments. 62 * @returns {Token[]} A sorted list of tokens and comments. 63 * @private 64 */ 65function sortedMerge(tokens, comments) { 66 const result = []; 67 let tokenIndex = 0; 68 let commentIndex = 0; 69 70 while (tokenIndex < tokens.length || commentIndex < comments.length) { 71 if (commentIndex >= comments.length || tokenIndex < tokens.length && tokens[tokenIndex].range[0] < comments[commentIndex].range[0]) { 72 result.push(tokens[tokenIndex++]); 73 } else { 74 result.push(comments[commentIndex++]); 75 } 76 } 77 78 return result; 79} 80 81/** 82 * Determines if two nodes or tokens overlap. 83 * @param {ASTNode|Token} first The first node or token to check. 84 * @param {ASTNode|Token} second The second node or token to check. 85 * @returns {boolean} True if the two nodes or tokens overlap. 86 * @private 87 */ 88function nodesOrTokensOverlap(first, second) { 89 return (first.range[0] <= second.range[0] && first.range[1] >= second.range[0]) || 90 (second.range[0] <= first.range[0] && second.range[1] >= first.range[0]); 91} 92 93/** 94 * Determines if two nodes or tokens have at least one whitespace character 95 * between them. Order does not matter. Returns false if the given nodes or 96 * tokens overlap. 97 * @param {SourceCode} sourceCode The source code object. 98 * @param {ASTNode|Token} first The first node or token to check between. 99 * @param {ASTNode|Token} second The second node or token to check between. 100 * @param {boolean} checkInsideOfJSXText If `true` is present, check inside of JSXText tokens for backward compatibility. 101 * @returns {boolean} True if there is a whitespace character between 102 * any of the tokens found between the two given nodes or tokens. 103 * @public 104 */ 105function isSpaceBetween(sourceCode, first, second, checkInsideOfJSXText) { 106 if (nodesOrTokensOverlap(first, second)) { 107 return false; 108 } 109 110 const [startingNodeOrToken, endingNodeOrToken] = first.range[1] <= second.range[0] 111 ? [first, second] 112 : [second, first]; 113 const firstToken = sourceCode.getLastToken(startingNodeOrToken) || startingNodeOrToken; 114 const finalToken = sourceCode.getFirstToken(endingNodeOrToken) || endingNodeOrToken; 115 let currentToken = firstToken; 116 117 while (currentToken !== finalToken) { 118 const nextToken = sourceCode.getTokenAfter(currentToken, { includeComments: true }); 119 120 if ( 121 currentToken.range[1] !== nextToken.range[0] || 122 123 /* 124 * For backward compatibility, check spaces in JSXText. 125 * https://github.com/eslint/eslint/issues/12614 126 */ 127 ( 128 checkInsideOfJSXText && 129 nextToken !== finalToken && 130 nextToken.type === "JSXText" && 131 /\s/u.test(nextToken.value) 132 ) 133 ) { 134 return true; 135 } 136 137 currentToken = nextToken; 138 } 139 140 return false; 141} 142 143//------------------------------------------------------------------------------ 144// Public Interface 145//------------------------------------------------------------------------------ 146 147class SourceCode extends TokenStore { 148 149 /** 150 * Represents parsed source code. 151 * @param {string|Object} textOrConfig The source code text or config object. 152 * @param {string} textOrConfig.text The source code text. 153 * @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. 154 * @param {Object|null} textOrConfig.parserServices The parser services. 155 * @param {ScopeManager|null} textOrConfig.scopeManager The scope of this source code. 156 * @param {Object|null} textOrConfig.visitorKeys The visitor keys to traverse AST. 157 * @param {ASTNode} [astIfNoConfig] The Program node of the AST representing the code. This AST should be created from the text that BOM was stripped. 158 */ 159 constructor(textOrConfig, astIfNoConfig) { 160 let text, ast, parserServices, scopeManager, visitorKeys; 161 162 // Process overloading. 163 if (typeof textOrConfig === "string") { 164 text = textOrConfig; 165 ast = astIfNoConfig; 166 } else if (typeof textOrConfig === "object" && textOrConfig !== null) { 167 text = textOrConfig.text; 168 ast = textOrConfig.ast; 169 parserServices = textOrConfig.parserServices; 170 scopeManager = textOrConfig.scopeManager; 171 visitorKeys = textOrConfig.visitorKeys; 172 } 173 174 validate(ast); 175 super(ast.tokens, ast.comments); 176 177 /** 178 * The flag to indicate that the source code has Unicode BOM. 179 * @type boolean 180 */ 181 this.hasBOM = (text.charCodeAt(0) === 0xFEFF); 182 183 /** 184 * The original text source code. 185 * BOM was stripped from this text. 186 * @type string 187 */ 188 this.text = (this.hasBOM ? text.slice(1) : text); 189 190 /** 191 * The parsed AST for the source code. 192 * @type ASTNode 193 */ 194 this.ast = ast; 195 196 /** 197 * The parser services of this source code. 198 * @type {Object} 199 */ 200 this.parserServices = parserServices || {}; 201 202 /** 203 * The scope of this source code. 204 * @type {ScopeManager|null} 205 */ 206 this.scopeManager = scopeManager || null; 207 208 /** 209 * The visitor keys to traverse AST. 210 * @type {Object} 211 */ 212 this.visitorKeys = visitorKeys || Traverser.DEFAULT_VISITOR_KEYS; 213 214 // Check the source text for the presence of a shebang since it is parsed as a standard line comment. 215 const shebangMatched = this.text.match(astUtils.shebangPattern); 216 const hasShebang = shebangMatched && ast.comments.length && ast.comments[0].value === shebangMatched[1]; 217 218 if (hasShebang) { 219 ast.comments[0].type = "Shebang"; 220 } 221 222 this.tokensAndComments = sortedMerge(ast.tokens, ast.comments); 223 224 /** 225 * The source code split into lines according to ECMA-262 specification. 226 * This is done to avoid each rule needing to do so separately. 227 * @type string[] 228 */ 229 this.lines = []; 230 this.lineStartIndices = [0]; 231 232 const lineEndingPattern = astUtils.createGlobalLinebreakMatcher(); 233 let match; 234 235 /* 236 * Previously, this was implemented using a regex that 237 * matched a sequence of non-linebreak characters followed by a 238 * linebreak, then adding the lengths of the matches. However, 239 * this caused a catastrophic backtracking issue when the end 240 * of a file contained a large number of non-newline characters. 241 * To avoid this, the current implementation just matches newlines 242 * and uses match.index to get the correct line start indices. 243 */ 244 while ((match = lineEndingPattern.exec(this.text))) { 245 this.lines.push(this.text.slice(this.lineStartIndices[this.lineStartIndices.length - 1], match.index)); 246 this.lineStartIndices.push(match.index + match[0].length); 247 } 248 this.lines.push(this.text.slice(this.lineStartIndices[this.lineStartIndices.length - 1])); 249 250 // Cache for comments found using getComments(). 251 this._commentCache = new WeakMap(); 252 253 // don't allow modification of this object 254 Object.freeze(this); 255 Object.freeze(this.lines); 256 } 257 258 /** 259 * Split the source code into multiple lines based on the line delimiters. 260 * @param {string} text Source code as a string. 261 * @returns {string[]} Array of source code lines. 262 * @public 263 */ 264 static splitLines(text) { 265 return text.split(astUtils.createGlobalLinebreakMatcher()); 266 } 267 268 /** 269 * Gets the source code for the given node. 270 * @param {ASTNode} [node] The AST node to get the text for. 271 * @param {int} [beforeCount] The number of characters before the node to retrieve. 272 * @param {int} [afterCount] The number of characters after the node to retrieve. 273 * @returns {string} The text representing the AST node. 274 * @public 275 */ 276 getText(node, beforeCount, afterCount) { 277 if (node) { 278 return this.text.slice(Math.max(node.range[0] - (beforeCount || 0), 0), 279 node.range[1] + (afterCount || 0)); 280 } 281 return this.text; 282 } 283 284 /** 285 * Gets the entire source text split into an array of lines. 286 * @returns {Array} The source text as an array of lines. 287 * @public 288 */ 289 getLines() { 290 return this.lines; 291 } 292 293 /** 294 * Retrieves an array containing all comments in the source code. 295 * @returns {ASTNode[]} An array of comment nodes. 296 * @public 297 */ 298 getAllComments() { 299 return this.ast.comments; 300 } 301 302 /** 303 * Gets all comments for the given node. 304 * @param {ASTNode} node The AST node to get the comments for. 305 * @returns {Object} An object containing a leading and trailing array 306 * of comments indexed by their position. 307 * @public 308 * @deprecated replaced by getCommentsBefore(), getCommentsAfter(), and getCommentsInside(). 309 */ 310 getComments(node) { 311 if (this._commentCache.has(node)) { 312 return this._commentCache.get(node); 313 } 314 315 const comments = { 316 leading: [], 317 trailing: [] 318 }; 319 320 /* 321 * Return all comments as leading comments of the Program node when 322 * there is no executable code. 323 */ 324 if (node.type === "Program") { 325 if (node.body.length === 0) { 326 comments.leading = node.comments; 327 } 328 } else { 329 330 /* 331 * Return comments as trailing comments of nodes that only contain 332 * comments (to mimic the comment attachment behavior present in Espree). 333 */ 334 if ((node.type === "BlockStatement" || node.type === "ClassBody") && node.body.length === 0 || 335 node.type === "ObjectExpression" && node.properties.length === 0 || 336 node.type === "ArrayExpression" && node.elements.length === 0 || 337 node.type === "SwitchStatement" && node.cases.length === 0 338 ) { 339 comments.trailing = this.getTokens(node, { 340 includeComments: true, 341 filter: isCommentToken 342 }); 343 } 344 345 /* 346 * Iterate over tokens before and after node and collect comment tokens. 347 * Do not include comments that exist outside of the parent node 348 * to avoid duplication. 349 */ 350 let currentToken = this.getTokenBefore(node, { includeComments: true }); 351 352 while (currentToken && isCommentToken(currentToken)) { 353 if (node.parent && (currentToken.start < node.parent.start)) { 354 break; 355 } 356 comments.leading.push(currentToken); 357 currentToken = this.getTokenBefore(currentToken, { includeComments: true }); 358 } 359 360 comments.leading.reverse(); 361 362 currentToken = this.getTokenAfter(node, { includeComments: true }); 363 364 while (currentToken && isCommentToken(currentToken)) { 365 if (node.parent && (currentToken.end > node.parent.end)) { 366 break; 367 } 368 comments.trailing.push(currentToken); 369 currentToken = this.getTokenAfter(currentToken, { includeComments: true }); 370 } 371 } 372 373 this._commentCache.set(node, comments); 374 return comments; 375 } 376 377 /** 378 * Retrieves the JSDoc comment for a given node. 379 * @param {ASTNode} node The AST node to get the comment for. 380 * @returns {Token|null} The Block comment token containing the JSDoc comment 381 * for the given node or null if not found. 382 * @public 383 * @deprecated 384 */ 385 getJSDocComment(node) { 386 387 /** 388 * Checks for the presence of a JSDoc comment for the given node and returns it. 389 * @param {ASTNode} astNode The AST node to get the comment for. 390 * @returns {Token|null} The Block comment token containing the JSDoc comment 391 * for the given node or null if not found. 392 * @private 393 */ 394 const findJSDocComment = astNode => { 395 const tokenBefore = this.getTokenBefore(astNode, { includeComments: true }); 396 397 if ( 398 tokenBefore && 399 isCommentToken(tokenBefore) && 400 tokenBefore.type === "Block" && 401 tokenBefore.value.charAt(0) === "*" && 402 astNode.loc.start.line - tokenBefore.loc.end.line <= 1 403 ) { 404 return tokenBefore; 405 } 406 407 return null; 408 }; 409 let parent = node.parent; 410 411 switch (node.type) { 412 case "ClassDeclaration": 413 case "FunctionDeclaration": 414 return findJSDocComment(looksLikeExport(parent) ? parent : node); 415 416 case "ClassExpression": 417 return findJSDocComment(parent.parent); 418 419 case "ArrowFunctionExpression": 420 case "FunctionExpression": 421 if (parent.type !== "CallExpression" && parent.type !== "NewExpression") { 422 while ( 423 !this.getCommentsBefore(parent).length && 424 !/Function/u.test(parent.type) && 425 parent.type !== "MethodDefinition" && 426 parent.type !== "Property" 427 ) { 428 parent = parent.parent; 429 430 if (!parent) { 431 break; 432 } 433 } 434 435 if (parent && parent.type !== "FunctionDeclaration" && parent.type !== "Program") { 436 return findJSDocComment(parent); 437 } 438 } 439 440 return findJSDocComment(node); 441 442 // falls through 443 default: 444 return null; 445 } 446 } 447 448 /** 449 * Gets the deepest node containing a range index. 450 * @param {int} index Range index of the desired node. 451 * @returns {ASTNode} The node if found or null if not found. 452 * @public 453 */ 454 getNodeByRangeIndex(index) { 455 let result = null; 456 457 Traverser.traverse(this.ast, { 458 visitorKeys: this.visitorKeys, 459 enter(node) { 460 if (node.range[0] <= index && index < node.range[1]) { 461 result = node; 462 } else { 463 this.skip(); 464 } 465 }, 466 leave(node) { 467 if (node === result) { 468 this.break(); 469 } 470 } 471 }); 472 473 return result; 474 } 475 476 /** 477 * Determines if two nodes or tokens have at least one whitespace character 478 * between them. Order does not matter. Returns false if the given nodes or 479 * tokens overlap. 480 * @param {ASTNode|Token} first The first node or token to check between. 481 * @param {ASTNode|Token} second The second node or token to check between. 482 * @returns {boolean} True if there is a whitespace character between 483 * any of the tokens found between the two given nodes or tokens. 484 * @public 485 */ 486 isSpaceBetween(first, second) { 487 return isSpaceBetween(this, first, second, false); 488 } 489 490 /** 491 * Determines if two nodes or tokens have at least one whitespace character 492 * between them. Order does not matter. Returns false if the given nodes or 493 * tokens overlap. 494 * For backward compatibility, this method returns true if there are 495 * `JSXText` tokens that contain whitespaces between the two. 496 * @param {ASTNode|Token} first The first node or token to check between. 497 * @param {ASTNode|Token} second The second node or token to check between. 498 * @returns {boolean} True if there is a whitespace character between 499 * any of the tokens found between the two given nodes or tokens. 500 * @deprecated in favor of isSpaceBetween(). 501 * @public 502 */ 503 isSpaceBetweenTokens(first, second) { 504 return isSpaceBetween(this, first, second, true); 505 } 506 507 /** 508 * Converts a source text index into a (line, column) pair. 509 * @param {number} index The index of a character in a file 510 * @returns {Object} A {line, column} location object with a 0-indexed column 511 * @public 512 */ 513 getLocFromIndex(index) { 514 if (typeof index !== "number") { 515 throw new TypeError("Expected `index` to be a number."); 516 } 517 518 if (index < 0 || index > this.text.length) { 519 throw new RangeError(`Index out of range (requested index ${index}, but source text has length ${this.text.length}).`); 520 } 521 522 /* 523 * For an argument of this.text.length, return the location one "spot" past the last character 524 * of the file. If the last character is a linebreak, the location will be column 0 of the next 525 * line; otherwise, the location will be in the next column on the same line. 526 * 527 * See getIndexFromLoc for the motivation for this special case. 528 */ 529 if (index === this.text.length) { 530 return { line: this.lines.length, column: this.lines[this.lines.length - 1].length }; 531 } 532 533 /* 534 * To figure out which line rangeIndex is on, determine the last index at which rangeIndex could 535 * be inserted into lineIndices to keep the list sorted. 536 */ 537 const lineNumber = lodash.sortedLastIndex(this.lineStartIndices, index); 538 539 return { line: lineNumber, column: index - this.lineStartIndices[lineNumber - 1] }; 540 } 541 542 /** 543 * Converts a (line, column) pair into a range index. 544 * @param {Object} loc A line/column location 545 * @param {number} loc.line The line number of the location (1-indexed) 546 * @param {number} loc.column The column number of the location (0-indexed) 547 * @returns {number} The range index of the location in the file. 548 * @public 549 */ 550 getIndexFromLoc(loc) { 551 if (typeof loc !== "object" || typeof loc.line !== "number" || typeof loc.column !== "number") { 552 throw new TypeError("Expected `loc` to be an object with numeric `line` and `column` properties."); 553 } 554 555 if (loc.line <= 0) { 556 throw new RangeError(`Line number out of range (line ${loc.line} requested). Line numbers should be 1-based.`); 557 } 558 559 if (loc.line > this.lineStartIndices.length) { 560 throw new RangeError(`Line number out of range (line ${loc.line} requested, but only ${this.lineStartIndices.length} lines present).`); 561 } 562 563 const lineStartIndex = this.lineStartIndices[loc.line - 1]; 564 const lineEndIndex = loc.line === this.lineStartIndices.length ? this.text.length : this.lineStartIndices[loc.line]; 565 const positionIndex = lineStartIndex + loc.column; 566 567 /* 568 * By design, getIndexFromLoc({ line: lineNum, column: 0 }) should return the start index of 569 * the given line, provided that the line number is valid element of this.lines. Since the 570 * last element of this.lines is an empty string for files with trailing newlines, add a 571 * special case where getting the index for the first location after the end of the file 572 * will return the length of the file, rather than throwing an error. This allows rules to 573 * use getIndexFromLoc consistently without worrying about edge cases at the end of a file. 574 */ 575 if ( 576 loc.line === this.lineStartIndices.length && positionIndex > lineEndIndex || 577 loc.line < this.lineStartIndices.length && positionIndex >= lineEndIndex 578 ) { 579 throw new RangeError(`Column number out of range (column ${loc.column} requested, but the length of line ${loc.line} is ${lineEndIndex - lineStartIndex}).`); 580 } 581 582 return positionIndex; 583 } 584} 585 586module.exports = SourceCode; 587