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