UNPKG

25.5 kBJavaScriptView Raw
1/**
2 * @fileoverview Main API Class
3 * @author Kai Cataldo
4 * @author Toru Nagashima
5 */
6
7"use strict";
8
9//------------------------------------------------------------------------------
10// Requirements
11//------------------------------------------------------------------------------
12
13const path = require("path");
14const fs = require("fs");
15const { promisify } = require("util");
16const { CLIEngine, getCLIEngineInternalSlots } = require("../cli-engine/cli-engine");
17const BuiltinRules = require("../rules");
18const {
19 Legacy: {
20 ConfigOps: {
21 getRuleSeverity
22 }
23 }
24} = require("@eslint/eslintrc");
25const { version } = require("../../package.json");
26
27//------------------------------------------------------------------------------
28// Typedefs
29//------------------------------------------------------------------------------
30
31/** @typedef {import("../cli-engine/cli-engine").LintReport} CLIEngineLintReport */
32/** @typedef {import("../shared/types").DeprecatedRuleInfo} DeprecatedRuleInfo */
33/** @typedef {import("../shared/types").ConfigData} ConfigData */
34/** @typedef {import("../shared/types").LintMessage} LintMessage */
35/** @typedef {import("../shared/types").Plugin} Plugin */
36/** @typedef {import("../shared/types").Rule} Rule */
37/** @typedef {import("./load-formatter").Formatter} Formatter */
38
39/**
40 * The options with which to configure the ESLint instance.
41 * @typedef {Object} ESLintOptions
42 * @property {boolean} [allowInlineConfig] Enable or disable inline configuration comments.
43 * @property {ConfigData} [baseConfig] Base config object, extended by all configs used with this instance
44 * @property {boolean} [cache] Enable result caching.
45 * @property {string} [cacheLocation] The cache file to use instead of .eslintcache.
46 * @property {"metadata" | "content"} [cacheStrategy] The strategy used to detect changed files.
47 * @property {string} [cwd] The value to use for the current working directory.
48 * @property {boolean} [errorOnUnmatchedPattern] If `false` then `ESLint#lintFiles()` doesn't throw even if no target files found. Defaults to `true`.
49 * @property {string[]} [extensions] An array of file extensions to check.
50 * @property {boolean|Function} [fix] Execute in autofix mode. If a function, should return a boolean.
51 * @property {string[]} [fixTypes] Array of rule types to apply fixes for.
52 * @property {boolean} [globInputPaths] Set to false to skip glob resolution of input file paths to lint (default: true). If false, each input file paths is assumed to be a non-glob path to an existing file.
53 * @property {boolean} [ignore] False disables use of .eslintignore.
54 * @property {string} [ignorePath] The ignore file to use instead of .eslintignore.
55 * @property {ConfigData} [overrideConfig] Override config object, overrides all configs used with this instance
56 * @property {string} [overrideConfigFile] The configuration file to use.
57 * @property {Record<string,Plugin>} [plugins] An array of plugin implementations.
58 * @property {"error" | "warn" | "off"} [reportUnusedDisableDirectives] the severity to report unused eslint-disable directives.
59 * @property {string} [resolvePluginsRelativeTo] The folder where plugins should be resolved from, defaulting to the CWD.
60 * @property {string[]} [rulePaths] An array of directories to load custom rules from.
61 * @property {boolean} [useEslintrc] False disables looking for .eslintrc.* files.
62 */
63
64/**
65 * A rules metadata object.
66 * @typedef {Object} RulesMeta
67 * @property {string} id The plugin ID.
68 * @property {Object} definition The plugin definition.
69 */
70
71/**
72 * A linting result.
73 * @typedef {Object} LintResult
74 * @property {string} filePath The path to the file that was linted.
75 * @property {LintMessage[]} messages All of the messages for the result.
76 * @property {number} errorCount Number of errors for the result.
77 * @property {number} warningCount Number of warnings for the result.
78 * @property {number} fixableErrorCount Number of fixable errors for the result.
79 * @property {number} fixableWarningCount Number of fixable warnings for the result.
80 * @property {string} [source] The source code of the file that was linted.
81 * @property {string} [output] The source code of the file that was linted, with as many fixes applied as possible.
82 * @property {DeprecatedRuleInfo[]} usedDeprecatedRules The list of used deprecated rules.
83 */
84
85/**
86 * Private members for the `ESLint` instance.
87 * @typedef {Object} ESLintPrivateMembers
88 * @property {CLIEngine} cliEngine The wrapped CLIEngine instance.
89 * @property {ESLintOptions} options The options used to instantiate the ESLint instance.
90 */
91
92//------------------------------------------------------------------------------
93// Helpers
94//------------------------------------------------------------------------------
95
96const writeFile = promisify(fs.writeFile);
97
98/**
99 * The map with which to store private class members.
100 * @type {WeakMap<ESLint, ESLintPrivateMembers>}
101 */
102const privateMembersMap = new WeakMap();
103
104/**
105 * Check if a given value is a non-empty string or not.
106 * @param {any} x The value to check.
107 * @returns {boolean} `true` if `x` is a non-empty string.
108 */
109function isNonEmptyString(x) {
110 return typeof x === "string" && x.trim() !== "";
111}
112
113/**
114 * Check if a given value is an array of non-empty stringss or not.
115 * @param {any} x The value to check.
116 * @returns {boolean} `true` if `x` is an array of non-empty stringss.
117 */
118function isArrayOfNonEmptyString(x) {
119 return Array.isArray(x) && x.every(isNonEmptyString);
120}
121
122/**
123 * Check if a given value is a valid fix type or not.
124 * @param {any} x The value to check.
125 * @returns {boolean} `true` if `x` is valid fix type.
126 */
127function isFixType(x) {
128 return x === "problem" || x === "suggestion" || x === "layout";
129}
130
131/**
132 * Check if a given value is an array of fix types or not.
133 * @param {any} x The value to check.
134 * @returns {boolean} `true` if `x` is an array of fix types.
135 */
136function isFixTypeArray(x) {
137 return Array.isArray(x) && x.every(isFixType);
138}
139
140/**
141 * The error for invalid options.
142 */
143class ESLintInvalidOptionsError extends Error {
144 constructor(messages) {
145 super(`Invalid Options:\n- ${messages.join("\n- ")}`);
146 this.code = "ESLINT_INVALID_OPTIONS";
147 Error.captureStackTrace(this, ESLintInvalidOptionsError);
148 }
149}
150
151/**
152 * Validates and normalizes options for the wrapped CLIEngine instance.
153 * @param {ESLintOptions} options The options to process.
154 * @returns {ESLintOptions} The normalized options.
155 */
156function processOptions({
157 allowInlineConfig = true, // ← we cannot use `overrideConfig.noInlineConfig` instead because `allowInlineConfig` has side-effect that suppress warnings that show inline configs are ignored.
158 baseConfig = null,
159 cache = false,
160 cacheLocation = ".eslintcache",
161 cacheStrategy = "metadata",
162 cwd = process.cwd(),
163 errorOnUnmatchedPattern = true,
164 extensions = null, // ← should be null by default because if it's an array then it suppresses RFC20 feature.
165 fix = false,
166 fixTypes = null, // ← should be null by default because if it's an array then it suppresses rules that don't have the `meta.type` property.
167 globInputPaths = true,
168 ignore = true,
169 ignorePath = null, // ← should be null by default because if it's a string then it may throw ENOENT.
170 overrideConfig = null,
171 overrideConfigFile = null,
172 plugins = {},
173 reportUnusedDisableDirectives = null, // ← should be null by default because if it's a string then it overrides the 'reportUnusedDisableDirectives' setting in config files. And we cannot use `overrideConfig.reportUnusedDisableDirectives` instead because we cannot configure the `error` severity with that.
174 resolvePluginsRelativeTo = null, // ← should be null by default because if it's a string then it suppresses RFC47 feature.
175 rulePaths = [],
176 useEslintrc = true,
177 ...unknownOptions
178}) {
179 const errors = [];
180 const unknownOptionKeys = Object.keys(unknownOptions);
181
182 if (unknownOptionKeys.length >= 1) {
183 errors.push(`Unknown options: ${unknownOptionKeys.join(", ")}`);
184 if (unknownOptionKeys.includes("cacheFile")) {
185 errors.push("'cacheFile' has been removed. Please use the 'cacheLocation' option instead.");
186 }
187 if (unknownOptionKeys.includes("configFile")) {
188 errors.push("'configFile' has been removed. Please use the 'overrideConfigFile' option instead.");
189 }
190 if (unknownOptionKeys.includes("envs")) {
191 errors.push("'envs' has been removed. Please use the 'overrideConfig.env' option instead.");
192 }
193 if (unknownOptionKeys.includes("globals")) {
194 errors.push("'globals' has been removed. Please use the 'overrideConfig.globals' option instead.");
195 }
196 if (unknownOptionKeys.includes("ignorePattern")) {
197 errors.push("'ignorePattern' has been removed. Please use the 'overrideConfig.ignorePatterns' option instead.");
198 }
199 if (unknownOptionKeys.includes("parser")) {
200 errors.push("'parser' has been removed. Please use the 'overrideConfig.parser' option instead.");
201 }
202 if (unknownOptionKeys.includes("parserOptions")) {
203 errors.push("'parserOptions' has been removed. Please use the 'overrideConfig.parserOptions' option instead.");
204 }
205 if (unknownOptionKeys.includes("rules")) {
206 errors.push("'rules' has been removed. Please use the 'overrideConfig.rules' option instead.");
207 }
208 }
209 if (typeof allowInlineConfig !== "boolean") {
210 errors.push("'allowInlineConfig' must be a boolean.");
211 }
212 if (typeof baseConfig !== "object") {
213 errors.push("'baseConfig' must be an object or null.");
214 }
215 if (typeof cache !== "boolean") {
216 errors.push("'cache' must be a boolean.");
217 }
218 if (!isNonEmptyString(cacheLocation)) {
219 errors.push("'cacheLocation' must be a non-empty string.");
220 }
221 if (
222 cacheStrategy !== "metadata" &&
223 cacheStrategy !== "content"
224 ) {
225 errors.push("'cacheStrategy' must be any of \"metadata\", \"content\".");
226 }
227 if (!isNonEmptyString(cwd) || !path.isAbsolute(cwd)) {
228 errors.push("'cwd' must be an absolute path.");
229 }
230 if (typeof errorOnUnmatchedPattern !== "boolean") {
231 errors.push("'errorOnUnmatchedPattern' must be a boolean.");
232 }
233 if (!isArrayOfNonEmptyString(extensions) && extensions !== null) {
234 errors.push("'extensions' must be an array of non-empty strings or null.");
235 }
236 if (typeof fix !== "boolean" && typeof fix !== "function") {
237 errors.push("'fix' must be a boolean or a function.");
238 }
239 if (fixTypes !== null && !isFixTypeArray(fixTypes)) {
240 errors.push("'fixTypes' must be an array of any of \"problem\", \"suggestion\", and \"layout\".");
241 }
242 if (typeof globInputPaths !== "boolean") {
243 errors.push("'globInputPaths' must be a boolean.");
244 }
245 if (typeof ignore !== "boolean") {
246 errors.push("'ignore' must be a boolean.");
247 }
248 if (!isNonEmptyString(ignorePath) && ignorePath !== null) {
249 errors.push("'ignorePath' must be a non-empty string or null.");
250 }
251 if (typeof overrideConfig !== "object") {
252 errors.push("'overrideConfig' must be an object or null.");
253 }
254 if (!isNonEmptyString(overrideConfigFile) && overrideConfigFile !== null) {
255 errors.push("'overrideConfigFile' must be a non-empty string or null.");
256 }
257 if (typeof plugins !== "object") {
258 errors.push("'plugins' must be an object or null.");
259 } else if (plugins !== null && Object.keys(plugins).includes("")) {
260 errors.push("'plugins' must not include an empty string.");
261 }
262 if (Array.isArray(plugins)) {
263 errors.push("'plugins' doesn't add plugins to configuration to load. Please use the 'overrideConfig.plugins' option instead.");
264 }
265 if (
266 reportUnusedDisableDirectives !== "error" &&
267 reportUnusedDisableDirectives !== "warn" &&
268 reportUnusedDisableDirectives !== "off" &&
269 reportUnusedDisableDirectives !== null
270 ) {
271 errors.push("'reportUnusedDisableDirectives' must be any of \"error\", \"warn\", \"off\", and null.");
272 }
273 if (
274 !isNonEmptyString(resolvePluginsRelativeTo) &&
275 resolvePluginsRelativeTo !== null
276 ) {
277 errors.push("'resolvePluginsRelativeTo' must be a non-empty string or null.");
278 }
279 if (!isArrayOfNonEmptyString(rulePaths)) {
280 errors.push("'rulePaths' must be an array of non-empty strings.");
281 }
282 if (typeof useEslintrc !== "boolean") {
283 errors.push("'useEslintrc' must be a boolean.");
284 }
285
286 if (errors.length > 0) {
287 throw new ESLintInvalidOptionsError(errors);
288 }
289
290 return {
291 allowInlineConfig,
292 baseConfig,
293 cache,
294 cacheLocation,
295 cacheStrategy,
296 configFile: overrideConfigFile,
297 cwd,
298 errorOnUnmatchedPattern,
299 extensions,
300 fix,
301 fixTypes,
302 globInputPaths,
303 ignore,
304 ignorePath,
305 reportUnusedDisableDirectives,
306 resolvePluginsRelativeTo,
307 rulePaths,
308 useEslintrc
309 };
310}
311
312/**
313 * Check if a value has one or more properties and that value is not undefined.
314 * @param {any} obj The value to check.
315 * @returns {boolean} `true` if `obj` has one or more properties that that value is not undefined.
316 */
317function hasDefinedProperty(obj) {
318 if (typeof obj === "object" && obj !== null) {
319 for (const key in obj) {
320 if (typeof obj[key] !== "undefined") {
321 return true;
322 }
323 }
324 }
325 return false;
326}
327
328/**
329 * Create rulesMeta object.
330 * @param {Map<string,Rule>} rules a map of rules from which to generate the object.
331 * @returns {Object} metadata for all enabled rules.
332 */
333function createRulesMeta(rules) {
334 return Array.from(rules).reduce((retVal, [id, rule]) => {
335 retVal[id] = rule.meta;
336 return retVal;
337 }, {});
338}
339
340/** @type {WeakMap<ExtractedConfig, DeprecatedRuleInfo[]>} */
341const usedDeprecatedRulesCache = new WeakMap();
342
343/**
344 * Create used deprecated rule list.
345 * @param {CLIEngine} cliEngine The CLIEngine instance.
346 * @param {string} maybeFilePath The absolute path to a lint target file or `"<text>"`.
347 * @returns {DeprecatedRuleInfo[]} The used deprecated rule list.
348 */
349function getOrFindUsedDeprecatedRules(cliEngine, maybeFilePath) {
350 const {
351 configArrayFactory,
352 options: { cwd }
353 } = getCLIEngineInternalSlots(cliEngine);
354 const filePath = path.isAbsolute(maybeFilePath)
355 ? maybeFilePath
356 : path.join(cwd, "__placeholder__.js");
357 const configArray = configArrayFactory.getConfigArrayForFile(filePath);
358 const config = configArray.extractConfig(filePath);
359
360 // Most files use the same config, so cache it.
361 if (!usedDeprecatedRulesCache.has(config)) {
362 const pluginRules = configArray.pluginRules;
363 const retv = [];
364
365 for (const [ruleId, ruleConf] of Object.entries(config.rules)) {
366 if (getRuleSeverity(ruleConf) === 0) {
367 continue;
368 }
369 const rule = pluginRules.get(ruleId) || BuiltinRules.get(ruleId);
370 const meta = rule && rule.meta;
371
372 if (meta && meta.deprecated) {
373 retv.push({ ruleId, replacedBy: meta.replacedBy || [] });
374 }
375 }
376
377 usedDeprecatedRulesCache.set(config, Object.freeze(retv));
378 }
379
380 return usedDeprecatedRulesCache.get(config);
381}
382
383/**
384 * Processes the linting results generated by a CLIEngine linting report to
385 * match the ESLint class's API.
386 * @param {CLIEngine} cliEngine The CLIEngine instance.
387 * @param {CLIEngineLintReport} report The CLIEngine linting report to process.
388 * @returns {LintResult[]} The processed linting results.
389 */
390function processCLIEngineLintReport(cliEngine, { results }) {
391 const descriptor = {
392 configurable: true,
393 enumerable: true,
394 get() {
395 return getOrFindUsedDeprecatedRules(cliEngine, this.filePath);
396 }
397 };
398
399 for (const result of results) {
400 Object.defineProperty(result, "usedDeprecatedRules", descriptor);
401 }
402
403 return results;
404}
405
406/**
407 * An Array.prototype.sort() compatible compare function to order results by their file path.
408 * @param {LintResult} a The first lint result.
409 * @param {LintResult} b The second lint result.
410 * @returns {number} An integer representing the order in which the two results should occur.
411 */
412function compareResultsByFilePath(a, b) {
413 if (a.filePath < b.filePath) {
414 return -1;
415 }
416
417 if (a.filePath > b.filePath) {
418 return 1;
419 }
420
421 return 0;
422}
423
424class ESLint {
425
426 /**
427 * Creates a new instance of the main ESLint API.
428 * @param {ESLintOptions} options The options for this instance.
429 */
430 constructor(options = {}) {
431 const processedOptions = processOptions(options);
432 const cliEngine = new CLIEngine(processedOptions);
433 const {
434 additionalPluginPool,
435 configArrayFactory,
436 lastConfigArrays
437 } = getCLIEngineInternalSlots(cliEngine);
438 let updated = false;
439
440 /*
441 * Address `plugins` to add plugin implementations.
442 * Operate the `additionalPluginPool` internal slot directly to avoid
443 * using `addPlugin(id, plugin)` method that resets cache everytime.
444 */
445 if (options.plugins) {
446 for (const [id, plugin] of Object.entries(options.plugins)) {
447 additionalPluginPool.set(id, plugin);
448 updated = true;
449 }
450 }
451
452 /*
453 * Address `overrideConfig` to set override config.
454 * Operate the `configArrayFactory` internal slot directly because this
455 * functionality doesn't exist as the public API of CLIEngine.
456 */
457 if (hasDefinedProperty(options.overrideConfig)) {
458 configArrayFactory.setOverrideConfig(options.overrideConfig);
459 updated = true;
460 }
461
462 // Update caches.
463 if (updated) {
464 configArrayFactory.clearCache();
465 lastConfigArrays[0] = configArrayFactory.getConfigArrayForFile();
466 }
467
468 // Initialize private properties.
469 privateMembersMap.set(this, {
470 cliEngine,
471 options: processedOptions
472 });
473 }
474
475 /**
476 * The version text.
477 * @type {string}
478 */
479 static get version() {
480 return version;
481 }
482
483 /**
484 * Outputs fixes from the given results to files.
485 * @param {LintResult[]} results The lint results.
486 * @returns {Promise<void>} Returns a promise that is used to track side effects.
487 */
488 static async outputFixes(results) {
489 if (!Array.isArray(results)) {
490 throw new Error("'results' must be an array");
491 }
492
493 await Promise.all(
494 results
495 .filter(result => {
496 if (typeof result !== "object" || result === null) {
497 throw new Error("'results' must include only objects");
498 }
499 return (
500 typeof result.output === "string" &&
501 path.isAbsolute(result.filePath)
502 );
503 })
504 .map(r => writeFile(r.filePath, r.output))
505 );
506 }
507
508 /**
509 * Returns results that only contains errors.
510 * @param {LintResult[]} results The results to filter.
511 * @returns {LintResult[]} The filtered results.
512 */
513 static getErrorResults(results) {
514 return CLIEngine.getErrorResults(results);
515 }
516
517 /**
518 * Executes the current configuration on an array of file and directory names.
519 * @param {string[]} patterns An array of file and directory names.
520 * @returns {Promise<LintResult[]>} The results of linting the file patterns given.
521 */
522 async lintFiles(patterns) {
523 if (!isNonEmptyString(patterns) && !isArrayOfNonEmptyString(patterns)) {
524 throw new Error("'patterns' must be a non-empty string or an array of non-empty strings");
525 }
526 const { cliEngine } = privateMembersMap.get(this);
527
528 return processCLIEngineLintReport(
529 cliEngine,
530 cliEngine.executeOnFiles(patterns)
531 );
532 }
533
534 /**
535 * Executes the current configuration on text.
536 * @param {string} code A string of JavaScript code to lint.
537 * @param {Object} [options] The options.
538 * @param {string} [options.filePath] The path to the file of the source code.
539 * @param {boolean} [options.warnIgnored] When set to true, warn if given filePath is an ignored path.
540 * @returns {Promise<LintResult[]>} The results of linting the string of code given.
541 */
542 async lintText(code, options = {}) {
543 if (typeof code !== "string") {
544 throw new Error("'code' must be a string");
545 }
546 if (typeof options !== "object") {
547 throw new Error("'options' must be an object, null, or undefined");
548 }
549 const {
550 filePath,
551 warnIgnored = false,
552 ...unknownOptions
553 } = options || {};
554
555 for (const key of Object.keys(unknownOptions)) {
556 throw new Error(`'options' must not include the unknown option '${key}'`);
557 }
558 if (filePath !== void 0 && !isNonEmptyString(filePath)) {
559 throw new Error("'options.filePath' must be a non-empty string or undefined");
560 }
561 if (typeof warnIgnored !== "boolean") {
562 throw new Error("'options.warnIgnored' must be a boolean or undefined");
563 }
564
565 const { cliEngine } = privateMembersMap.get(this);
566
567 return processCLIEngineLintReport(
568 cliEngine,
569 cliEngine.executeOnText(code, filePath, warnIgnored)
570 );
571 }
572
573 /**
574 * Returns the formatter representing the given formatter name.
575 * @param {string} [name] The name of the formatter to load.
576 * The following values are allowed:
577 * - `undefined` ... Load `stylish` builtin formatter.
578 * - A builtin formatter name ... Load the builtin formatter.
579 * - A thirdparty formatter name:
580 * - `foo` → `eslint-formatter-foo`
581 * - `@foo` → `@foo/eslint-formatter`
582 * - `@foo/bar` → `@foo/eslint-formatter-bar`
583 * - A file path ... Load the file.
584 * @returns {Promise<Formatter>} A promise resolving to the formatter object.
585 * This promise will be rejected if the given formatter was not found or not
586 * a function.
587 */
588 async loadFormatter(name = "stylish") {
589 if (typeof name !== "string") {
590 throw new Error("'name' must be a string");
591 }
592
593 const { cliEngine } = privateMembersMap.get(this);
594 const formatter = cliEngine.getFormatter(name);
595
596 if (typeof formatter !== "function") {
597 throw new Error(`Formatter must be a function, but got a ${typeof formatter}.`);
598 }
599
600 return {
601
602 /**
603 * The main formatter method.
604 * @param {LintResults[]} results The lint results to format.
605 * @returns {string} The formatted lint results.
606 */
607 format(results) {
608 let rulesMeta = null;
609
610 results.sort(compareResultsByFilePath);
611
612 return formatter(results, {
613 get rulesMeta() {
614 if (!rulesMeta) {
615 rulesMeta = createRulesMeta(cliEngine.getRules());
616 }
617
618 return rulesMeta;
619 }
620 });
621 }
622 };
623 }
624
625 /**
626 * Returns a configuration object for the given file based on the CLI options.
627 * This is the same logic used by the ESLint CLI executable to determine
628 * configuration for each file it processes.
629 * @param {string} filePath The path of the file to retrieve a config object for.
630 * @returns {Promise<ConfigData>} A configuration object for the file.
631 */
632 async calculateConfigForFile(filePath) {
633 if (!isNonEmptyString(filePath)) {
634 throw new Error("'filePath' must be a non-empty string");
635 }
636 const { cliEngine } = privateMembersMap.get(this);
637
638 return cliEngine.getConfigForFile(filePath);
639 }
640
641 /**
642 * Checks if a given path is ignored by ESLint.
643 * @param {string} filePath The path of the file to check.
644 * @returns {Promise<boolean>} Whether or not the given path is ignored.
645 */
646 async isPathIgnored(filePath) {
647 if (!isNonEmptyString(filePath)) {
648 throw new Error("'filePath' must be a non-empty string");
649 }
650 const { cliEngine } = privateMembersMap.get(this);
651
652 return cliEngine.isPathIgnored(filePath);
653 }
654}
655
656//------------------------------------------------------------------------------
657// Public Interface
658//------------------------------------------------------------------------------
659
660module.exports = {
661 ESLint,
662
663 /**
664 * Get the private class members of a given ESLint instance for tests.
665 * @param {ESLint} instance The ESLint instance to get.
666 * @returns {ESLintPrivateMembers} The instance's private class members.
667 */
668 getESLintPrivateMembers(instance) {
669 return privateMembersMap.get(instance);
670 }
671};