UNPKG

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