UNPKG

21.3 kBJavaScriptView Raw
1/**
2 * @fileoverview A collection of helper functions.
3 * This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6 */
7"use strict";
8
9const espree = require("espree");
10const estraverse = require("estraverse");
11const path = require("path");
12const fs = require("fs");
13const ini = require("ini-parser");
14
15var gModules = null;
16var gRootDir = null;
17var directoryManifests = new Map();
18
19const callExpressionDefinitions = [
20 /^loader\.lazyGetter\(this, "(\w+)"/,
21 /^loader\.lazyImporter\(this, "(\w+)"/,
22 /^loader\.lazyServiceGetter\(this, "(\w+)"/,
23 /^loader\.lazyRequireGetter\(this, "(\w+)"/,
24 /^XPCOMUtils\.defineLazyGetter\(this, "(\w+)"/,
25 /^XPCOMUtils\.defineLazyModuleGetter\(this, "(\w+)"/,
26 /^ChromeUtils\.defineModuleGetter\(this, "(\w+)"/,
27 /^XPCOMUtils\.defineLazyPreferenceGetter\(this, "(\w+)"/,
28 /^XPCOMUtils\.defineLazyScriptGetter\(this, "(\w+)"/,
29 /^XPCOMUtils\.defineLazyServiceGetter\(this, "(\w+)"/,
30 /^XPCOMUtils\.defineConstant\(this, "(\w+)"/,
31 /^DevToolsUtils\.defineLazyModuleGetter\(this, "(\w+)"/,
32 /^DevToolsUtils\.defineLazyGetter\(this, "(\w+)"/,
33 /^Object\.defineProperty\(this, "(\w+)"/,
34 /^Reflect\.defineProperty\(this, "(\w+)"/,
35 /^this\.__defineGetter__\("(\w+)"/
36];
37
38const callExpressionMultiDefinitions = [
39 "XPCOMUtils.defineLazyModuleGetters(this,",
40 "XPCOMUtils.defineLazyServiceGetters(this,"
41];
42
43const imports = [
44 /^(?:Cu|Components\.utils|ChromeUtils)\.import\(".*\/((.*?)\.jsm?)"(?:, this)?\)/
45];
46
47const workerImportFilenameMatch = /(.*\/)*(.*?\.jsm?)/;
48
49module.exports = {
50 get modulesGlobalData() {
51 if (!gModules) {
52 if (this.isMozillaCentralBased()) {
53 gModules = require(path.join(this.rootDir, "tools", "lint", "eslint", "modules.json"));
54 } else {
55 gModules = require("./modules.json");
56 }
57 }
58
59 return gModules;
60 },
61
62 /**
63 * Gets the abstract syntax tree (AST) of the JavaScript source code contained
64 * in sourceText.
65 *
66 * @param {String} sourceText
67 * Text containing valid JavaScript.
68 *
69 * @return {Object}
70 * The resulting AST.
71 */
72 getAST(sourceText) {
73 // Use a permissive config file to allow parsing of anything that Espree
74 // can parse.
75 var config = this.getPermissiveConfig();
76
77 return espree.parse(sourceText, config);
78 },
79
80 /**
81 * A simplistic conversion of some AST nodes to a standard string form.
82 *
83 * @param {Object} node
84 * The AST node to convert.
85 *
86 * @return {String}
87 * The JS source for the node.
88 */
89 getASTSource(node, context) {
90 switch (node.type) {
91 case "MemberExpression":
92 if (node.computed) {
93 let filename = context && context.getFilename();
94 throw new Error(`getASTSource unsupported computed MemberExpression in ${filename}`);
95 }
96 return this.getASTSource(node.object) + "." +
97 this.getASTSource(node.property);
98 case "ThisExpression":
99 return "this";
100 case "Identifier":
101 return node.name;
102 case "Literal":
103 return JSON.stringify(node.value);
104 case "CallExpression":
105 var args = node.arguments.map(a => this.getASTSource(a)).join(", ");
106 return this.getASTSource(node.callee) + "(" + args + ")";
107 case "ObjectExpression":
108 return "{}";
109 case "ExpressionStatement":
110 return this.getASTSource(node.expression) + ";";
111 case "FunctionExpression":
112 return "function() {}";
113 case "ArrayExpression":
114 return "[" + node.elements.map(this.getASTSource, this).join(",") + "]";
115 case "ArrowFunctionExpression":
116 return "() => {}";
117 case "AssignmentExpression":
118 return this.getASTSource(node.left) + " = " +
119 this.getASTSource(node.right);
120 default:
121 throw new Error("getASTSource unsupported node type: " + node.type);
122 }
123 },
124
125 /**
126 * This walks an AST in a manner similar to ESLint passing node events to the
127 * listener. The listener is expected to be a simple function
128 * which accepts node type, node and parents arguments.
129 *
130 * @param {Object} ast
131 * The AST to walk.
132 * @param {Function} listener
133 * A callback function to call for the nodes. Passed three arguments,
134 * event type, node and an array of parent nodes for the current node.
135 */
136 walkAST(ast, listener) {
137 let parents = [];
138
139 estraverse.traverse(ast, {
140 enter(node, parent) {
141 listener(node.type, node, parents);
142
143 parents.push(node);
144 },
145
146 leave(node, parent) {
147 if (parents.length == 0) {
148 throw new Error("Left more nodes than entered.");
149 }
150 parents.pop();
151 }
152 });
153 if (parents.length) {
154 throw new Error("Entered more nodes than left.");
155 }
156 },
157
158 /**
159 * Attempts to convert an ExpressionStatement to likely global variable
160 * definitions.
161 *
162 * @param {Object} node
163 * The AST node to convert.
164 * @param {boolean} isGlobal
165 * True if the current node is in the global scope.
166 *
167 * @return {Array}
168 * An array of objects that contain details about the globals:
169 * - {String} name
170 * The name of the global.
171 * - {Boolean} writable
172 * If the global is writeable or not.
173 */
174 convertWorkerExpressionToGlobals(node, isGlobal, dirname) {
175 var getGlobalsForFile = require("./globals").getGlobalsForFile;
176
177 let globalModules = this.modulesGlobalData;
178
179 let results = [];
180 let expr = node.expression;
181
182 if (node.expression.type === "CallExpression" &&
183 expr.callee &&
184 expr.callee.type === "Identifier" &&
185 expr.callee.name === "importScripts") {
186 for (var arg of expr.arguments) {
187 var match = arg.value && arg.value.match(workerImportFilenameMatch);
188 if (match) {
189 if (!match[1]) {
190 let filePath = path.resolve(dirname, match[2]);
191 if (fs.existsSync(filePath)) {
192 let additionalGlobals = getGlobalsForFile(filePath);
193 results = results.concat(additionalGlobals);
194 }
195 } else if (match[2] in globalModules) {
196 results = results.concat(globalModules[match[2]].map(name => {
197 return { name, writable: true };
198 }));
199 }
200 }
201 }
202 }
203
204 return results;
205 },
206
207 /**
208 * Attempts to convert an AssignmentExpression into a global variable
209 * definition if it applies to `this` in the global scope.
210 *
211 * @param {Object} node
212 * The AST node to convert.
213 * @param {boolean} isGlobal
214 * True if the current node is in the global scope.
215 *
216 * @return {Array}
217 * An array of objects that contain details about the globals:
218 * - {String} name
219 * The name of the global.
220 * - {Boolean} writable
221 * If the global is writeable or not.
222 */
223 convertThisAssignmentExpressionToGlobals(node, isGlobal) {
224 if (isGlobal &&
225 node.expression.left &&
226 node.expression.left.object &&
227 node.expression.left.object.type === "ThisExpression" &&
228 node.expression.left.property &&
229 node.expression.left.property.type === "Identifier") {
230 return [{ name: node.expression.left.property.name, writable: true }];
231 }
232 return [];
233 },
234
235 /**
236 * Attempts to convert an CallExpressions that look like module imports
237 * into global variable definitions, using modules.json data if appropriate.
238 *
239 * @param {Object} node
240 * The AST node to convert.
241 * @param {boolean} isGlobal
242 * True if the current node is in the global scope.
243 *
244 * @return {Array}
245 * An array of objects that contain details about the globals:
246 * - {String} name
247 * The name of the global.
248 * - {Boolean} writable
249 * If the global is writeable or not.
250 */
251 convertCallExpressionToGlobals(node, isGlobal) {
252 let express = node.expression;
253 if (express.type === "CallExpression" &&
254 express.callee.type === "MemberExpression" &&
255 express.callee.object &&
256 express.callee.object.type === "Identifier" &&
257 express.arguments.length === 1 &&
258 express.arguments[0].type === "ArrayExpression" &&
259 express.callee.property.type === "Identifier" &&
260 express.callee.property.name === "importGlobalProperties") {
261 return express.arguments[0].elements.map(literal => {
262 return {
263 name: literal.value,
264 writable: false
265 };
266 });
267 }
268
269 let source;
270 try {
271 source = this.getASTSource(node);
272 } catch (e) {
273 return [];
274 }
275
276 for (let reg of imports) {
277 let match = source.match(reg);
278 if (match) {
279 // The two argument form is only acceptable in the global scope
280 if (node.expression.arguments.length > 1 && !isGlobal) {
281 return [];
282 }
283
284 let globalModules = this.modulesGlobalData;
285
286 if (match[1] in globalModules) {
287 return globalModules[match[1]].map(name => ({ name, writable: true }));
288 }
289
290 return [{ name: match[2], writable: true }];
291 }
292 }
293
294 // The definition matches below must be in the global scope for us to define
295 // a global, so bail out early if we're not a global.
296 if (!isGlobal) {
297 return [];
298 }
299
300 for (let reg of callExpressionDefinitions) {
301 let match = source.match(reg);
302 if (match) {
303 return [{ name: match[1], writable: true }];
304 }
305 }
306
307 if (callExpressionMultiDefinitions.some(expr => source.startsWith(expr)) &&
308 node.expression.arguments[1] &&
309 node.expression.arguments[1].type === "ObjectExpression") {
310 return node.expression.arguments[1].properties
311 .map(p => ({ name: p.type === "Property" && p.key.name, writable: true }))
312 .filter(g => g.name);
313 }
314
315 if (node.expression.callee.type == "MemberExpression" &&
316 node.expression.callee.property.type == "Identifier" &&
317 node.expression.callee.property.name == "defineLazyScriptGetter") {
318 // The case where we have a single symbol as a string has already been
319 // handled by the regexp, so we have an array of symbols here.
320 return node.expression.arguments[1].elements.map(n => ({ name: n.value, writable: true }));
321 }
322
323 return [];
324 },
325
326 /**
327 * Add a variable to the current scope.
328 * HACK: This relies on eslint internals so it could break at any time.
329 *
330 * @param {String} name
331 * The variable name to add to the scope.
332 * @param {ASTScope} scope
333 * The scope to add to.
334 * @param {boolean} writable
335 * Whether the global can be overwritten.
336 */
337 addVarToScope(name, scope, writable) {
338 scope.__defineGeneric(name, scope.set, scope.variables, null, null);
339
340 let variable = scope.set.get(name);
341 variable.eslintExplicitGlobal = false;
342 variable.writeable = writable;
343
344 // Walk to the global scope which holds all undeclared variables.
345 while (scope.type != "global") {
346 scope = scope.upper;
347 }
348
349 // "through" contains all references with no found definition.
350 scope.through = scope.through.filter(function(reference) {
351 if (reference.identifier.name != name) {
352 return true;
353 }
354
355 // Links the variable and the reference.
356 // And this reference is removed from `Scope#through`.
357 reference.resolved = variable;
358 variable.references.push(reference);
359 return false;
360 });
361 },
362
363 /**
364 * Adds a set of globals to a scope.
365 *
366 * @param {Array} globalVars
367 * An array of global variable names.
368 * @param {ASTScope} scope
369 * The scope.
370 */
371 addGlobals(globalVars, scope) {
372 globalVars.forEach(v => this.addVarToScope(v.name, scope, v.writable));
373 },
374
375 /**
376 * To allow espree to parse almost any JavaScript we need as many features as
377 * possible turned on. This method returns that config.
378 *
379 * @return {Object}
380 * Espree compatible permissive config.
381 */
382 getPermissiveConfig() {
383 return {
384 range: true,
385 loc: true,
386 comment: true,
387 attachComment: true,
388 ecmaVersion: 9,
389 sourceType: "script"
390 };
391 },
392
393 /**
394 * Check whether a node is a function.
395 *
396 * @param {Object} node
397 * The AST node to check
398 *
399 * @return {Boolean}
400 * True or false
401 */
402 getIsFunctionNode(node) {
403 switch (node.type) {
404 case "ArrowFunctionExpression":
405 case "FunctionDeclaration":
406 case "FunctionExpression":
407 return true;
408 }
409 return false;
410 },
411
412 /**
413 * Check whether the context is the global scope.
414 *
415 * @param {Array} ancestors
416 * The parents of the current node.
417 *
418 * @return {Boolean}
419 * True or false
420 */
421 getIsGlobalScope(ancestors) {
422 for (let parent of ancestors) {
423 if (this.getIsFunctionNode(parent)) {
424 return false;
425 }
426 }
427 return true;
428 },
429
430 /**
431 * Check whether we might be in a test head file.
432 *
433 * @param {RuleContext} scope
434 * You should pass this from within a rule
435 * e.g. helpers.getIsHeadFile(context)
436 *
437 * @return {Boolean}
438 * True or false
439 */
440 getIsHeadFile(scope) {
441 var pathAndFilename = this.cleanUpPath(scope.getFilename());
442
443 return /.*[\\/]head(_.+)?\.js$/.test(pathAndFilename);
444 },
445
446 /**
447 * Gets the head files for a potential test file
448 *
449 * @param {RuleContext} scope
450 * You should pass this from within a rule
451 * e.g. helpers.getIsHeadFile(context)
452 *
453 * @return {String[]}
454 * Paths to head files to load for the test
455 */
456 getTestHeadFiles(scope) {
457 if (!this.getIsTest(scope)) {
458 return [];
459 }
460
461 let filepath = this.cleanUpPath(scope.getFilename());
462 let dir = path.dirname(filepath);
463
464 let names =
465 fs.readdirSync(dir)
466 .filter(name => (name.startsWith("head") ||
467 name.startsWith("xpcshell-head")) && name.endsWith(".js"))
468 .map(name => path.join(dir, name));
469 return names;
470 },
471
472 /**
473 * Gets all the test manifest data for a directory
474 *
475 * @param {String} dir
476 * The directory
477 *
478 * @return {Array}
479 * An array of objects with file and manifest properties
480 */
481 getManifestsForDirectory(dir) {
482 if (directoryManifests.has(dir)) {
483 return directoryManifests.get(dir);
484 }
485
486 let manifests = [];
487 let names = [];
488 try {
489 names = fs.readdirSync(dir);
490 } catch (err) {
491 // Ignore directory not found, it might be faked by a test
492 if (err.code !== "ENOENT") {
493 throw err;
494 }
495 }
496
497 for (let name of names) {
498 if (!name.endsWith(".ini")) {
499 continue;
500 }
501
502 try {
503 let manifest = ini.parse(fs.readFileSync(path.join(dir, name), "utf8"));
504
505 manifests.push({
506 file: path.join(dir, name),
507 manifest
508 });
509 } catch (e) {
510 }
511 }
512
513 directoryManifests.set(dir, manifests);
514 return manifests;
515 },
516
517 /**
518 * Gets the manifest file a test is listed in
519 *
520 * @param {RuleContext} scope
521 * You should pass this from within a rule
522 * e.g. helpers.getIsHeadFile(context)
523 *
524 * @return {String}
525 * The path to the test manifest file
526 */
527 getTestManifest(scope) {
528 let filepath = this.cleanUpPath(scope.getFilename());
529
530 let dir = path.dirname(filepath);
531 let filename = path.basename(filepath);
532
533 for (let manifest of this.getManifestsForDirectory(dir)) {
534 if (filename in manifest.manifest) {
535 return manifest.file;
536 }
537 }
538
539 return null;
540 },
541
542 /**
543 * Check whether we are in a test of some kind.
544 *
545 * @param {RuleContext} scope
546 * You should pass this from within a rule
547 * e.g. helpers.getIsTest(context)
548 *
549 * @return {Boolean}
550 * True or false
551 */
552 getIsTest(scope) {
553 // Regardless of the manifest name being in a manifest means we're a test.
554 let manifest = this.getTestManifest(scope);
555 if (manifest) {
556 return true;
557 }
558
559 return !!this.getTestType(scope);
560 },
561
562 /**
563 * Gets the type of test or null if this isn't a test.
564 *
565 * @param {RuleContext} scope
566 * You should pass this from within a rule
567 * e.g. helpers.getIsHeadFile(context)
568 *
569 * @return {String or null}
570 * Test type: xpcshell, browser, chrome, mochitest
571 */
572 getTestType(scope) {
573 let testTypes = ["browser", "xpcshell", "chrome", "mochitest", "a11y"];
574 let manifest = this.getTestManifest(scope);
575 if (manifest) {
576 let name = path.basename(manifest);
577 for (let testType of testTypes) {
578 if (name.startsWith(testType)) {
579 return testType;
580 }
581 }
582 }
583
584 let filepath = this.cleanUpPath(scope.getFilename());
585 let filename = path.basename(filepath);
586
587 if (filename.startsWith("browser_")) {
588 return "browser";
589 }
590
591 if (filename.startsWith("test_")) {
592 let parent = path.basename(path.dirname(filepath));
593 for (let testType of testTypes) {
594 if (parent.startsWith(testType)) {
595 return testType;
596 }
597 }
598
599 // It likely is a test, we're just not sure what kind.
600 return "unknown";
601 }
602
603 // Likely not a test
604 return null;
605 },
606
607 getIsWorker(filePath) {
608 let filename = path.basename(this.cleanUpPath(filePath)).toLowerCase();
609
610 return filename.includes("worker");
611 },
612
613 /**
614 * Gets the root directory of the repository by walking up directories from
615 * this file until a .eslintignore file is found. If this fails, the same
616 * procedure will be attempted from the current working dir.
617 * @return {String} The absolute path of the repository directory
618 */
619 get rootDir() {
620 if (!gRootDir) {
621 function searchUpForIgnore(dirName, filename) {
622 let parsed = path.parse(dirName);
623 while (parsed.root !== dirName) {
624 if (fs.existsSync(path.join(dirName, filename))) {
625 return dirName;
626 }
627 // Move up a level
628 dirName = parsed.dir;
629 parsed = path.parse(dirName);
630 }
631 return null;
632 }
633
634 let possibleRoot = searchUpForIgnore(path.dirname(module.filename), ".eslintignore");
635 if (!possibleRoot) {
636 possibleRoot = searchUpForIgnore(path.resolve(), ".eslintignore");
637 }
638 if (!possibleRoot) {
639 possibleRoot = searchUpForIgnore(path.resolve(), "package.json");
640 }
641 if (!possibleRoot) {
642 // We've couldn't find a root from the module or CWD, so lets just go
643 // for the CWD. We really don't want to throw if possible, as that
644 // tends to give confusing results when used with ESLint.
645 possibleRoot = process.cwd();
646 }
647
648 gRootDir = possibleRoot;
649 }
650
651 return gRootDir;
652 },
653
654 /**
655 * ESLint may be executed from various places: from mach, at the root of the
656 * repository, or from a directory in the repository when, for instance,
657 * executed by a text editor's plugin.
658 * The value returned by context.getFileName() varies because of this.
659 * This helper function makes sure to return an absolute file path for the
660 * current context, by looking at process.cwd().
661 * @param {Context} context
662 * @return {String} The absolute path
663 */
664 getAbsoluteFilePath(context) {
665 var fileName = this.cleanUpPath(context.getFilename());
666 var cwd = process.cwd();
667
668 if (path.isAbsolute(fileName)) {
669 // Case 2: executed from the repo's root with mach:
670 // fileName: /path/to/mozilla/repo/a/b/c/d.js
671 // cwd: /path/to/mozilla/repo
672 return fileName;
673 } else if (path.basename(fileName) == fileName) {
674 // Case 1b: executed from a nested directory, fileName is the base name
675 // without any path info (happens in Atom with linter-eslint)
676 return path.join(cwd, fileName);
677 }
678 // Case 1: executed form in a nested directory, e.g. from a text editor:
679 // fileName: a/b/c/d.js
680 // cwd: /path/to/mozilla/repo/a/b/c
681 var dirName = path.dirname(fileName);
682 return cwd.slice(0, cwd.length - dirName.length) + fileName;
683
684 },
685
686 /**
687 * When ESLint is run from SublimeText, paths retrieved from
688 * context.getFileName contain leading and trailing double-quote characters.
689 * These characters need to be removed.
690 */
691 cleanUpPath(pathName) {
692 return pathName.replace(/^"/, "").replace(/"$/, "");
693 },
694
695 get globalScriptsPath() {
696 return path.join(this.rootDir, "browser",
697 "base", "content", "global-scripts.inc");
698 },
699
700 isMozillaCentralBased() {
701 return fs.existsSync(this.globalScriptsPath);
702 },
703
704 getSavedEnvironmentItems(environment) {
705 return require("./environments/saved-globals.json").environments[environment];
706 },
707
708 getSavedRuleData(rule) {
709 return require("./rules/saved-rules-data.json").rulesData[rule];
710 }
711};