1/** 2 * @fileoverview Config initialization wizard. 3 * @author Ilya Volodin 4 */ 5 6 7"use strict"; 8 9//------------------------------------------------------------------------------ 10// Requirements 11//------------------------------------------------------------------------------ 12 13const util = require("util"), 14 path = require("path"), 15 inquirer = require("inquirer"), 16 ProgressBar = require("progress"), 17 semver = require("semver"), 18 espree = require("espree"), 19 recConfig = require("../../conf/eslint-recommended"), 20 ConfigOps = require("@eslint/eslintrc/lib/shared/config-ops"), 21 log = require("../shared/logging"), 22 naming = require("@eslint/eslintrc/lib/shared/naming"), 23 ModuleResolver = require("../shared/relative-module-resolver"), 24 autoconfig = require("./autoconfig.js"), 25 ConfigFile = require("./config-file"), 26 npmUtils = require("./npm-utils"), 27 { getSourceCodeOfFiles } = require("./source-code-utils"); 28 29const debug = require("debug")("eslint:config-initializer"); 30 31//------------------------------------------------------------------------------ 32// Private 33//------------------------------------------------------------------------------ 34 35/* istanbul ignore next: hard to test fs function */ 36/** 37 * Create .eslintrc file in the current working directory 38 * @param {Object} config object that contains user's answers 39 * @param {string} format The file format to write to. 40 * @returns {void} 41 */ 42function writeFile(config, format) { 43 44 // default is .js 45 let extname = ".js"; 46 47 if (format === "YAML") { 48 extname = ".yml"; 49 } else if (format === "JSON") { 50 extname = ".json"; 51 } 52 53 const installedESLint = config.installedESLint; 54 55 delete config.installedESLint; 56 57 ConfigFile.write(config, `./.eslintrc${extname}`); 58 log.info(`Successfully created .eslintrc${extname} file in ${process.cwd()}`); 59 60 if (installedESLint) { 61 log.info("ESLint was installed locally. We recommend using this local copy instead of your globally-installed copy."); 62 } 63} 64 65/** 66 * Get the peer dependencies of the given module. 67 * This adds the gotten value to cache at the first time, then reuses it. 68 * In a process, this function is called twice, but `npmUtils.fetchPeerDependencies` needs to access network which is relatively slow. 69 * @param {string} moduleName The module name to get. 70 * @returns {Object} The peer dependencies of the given module. 71 * This object is the object of `peerDependencies` field of `package.json`. 72 * Returns null if npm was not found. 73 */ 74function getPeerDependencies(moduleName) { 75 let result = getPeerDependencies.cache.get(moduleName); 76 77 if (!result) { 78 log.info(`Checking peerDependencies of ${moduleName}`); 79 80 result = npmUtils.fetchPeerDependencies(moduleName); 81 getPeerDependencies.cache.set(moduleName, result); 82 } 83 84 return result; 85} 86getPeerDependencies.cache = new Map(); 87 88/** 89 * Return necessary plugins, configs, parsers, etc. based on the config 90 * @param {Object} config config object 91 * @param {boolean} [installESLint=true] If `false` is given, it does not install eslint. 92 * @returns {string[]} An array of modules to be installed. 93 */ 94function getModulesList(config, installESLint) { 95 const modules = {}; 96 97 // Create a list of modules which should be installed based on config 98 if (config.plugins) { 99 for (const plugin of config.plugins) { 100 const moduleName = naming.normalizePackageName(plugin, "eslint-plugin"); 101 102 modules[moduleName] = "latest"; 103 } 104 } 105 if (config.extends) { 106 const extendList = Array.isArray(config.extends) ? config.extends : [config.extends]; 107 108 for (const extend of extendList) { 109 if (extend.startsWith("eslint:") || extend.startsWith("plugin:")) { 110 continue; 111 } 112 const moduleName = naming.normalizePackageName(extend, "eslint-config"); 113 114 modules[moduleName] = "latest"; 115 Object.assign( 116 modules, 117 getPeerDependencies(`${moduleName}@latest`) 118 ); 119 } 120 } 121 122 const parser = config.parser || (config.parserOptions && config.parserOptions.parser); 123 124 if (parser) { 125 modules[parser] = "latest"; 126 } 127 128 if (installESLint === false) { 129 delete modules.eslint; 130 } else { 131 const installStatus = npmUtils.checkDevDeps(["eslint"]); 132 133 // Mark to show messages if it's new installation of eslint. 134 if (installStatus.eslint === false) { 135 log.info("Local ESLint installation not found."); 136 modules.eslint = modules.eslint || "latest"; 137 config.installedESLint = true; 138 } 139 } 140 141 return Object.keys(modules).map(name => `${name}@${modules[name]}`); 142} 143 144/** 145 * Set the `rules` of a config by examining a user's source code 146 * 147 * Note: This clones the config object and returns a new config to avoid mutating 148 * the original config parameter. 149 * @param {Object} answers answers received from inquirer 150 * @param {Object} config config object 151 * @returns {Object} config object with configured rules 152 */ 153function configureRules(answers, config) { 154 const BAR_TOTAL = 20, 155 BAR_SOURCE_CODE_TOTAL = 4, 156 newConfig = Object.assign({}, config), 157 disabledConfigs = {}; 158 let sourceCodes, 159 registry; 160 161 // Set up a progress bar, as this process can take a long time 162 const bar = new ProgressBar("Determining Config: :percent [:bar] :elapseds elapsed, eta :etas ", { 163 width: 30, 164 total: BAR_TOTAL 165 }); 166 167 bar.tick(0); // Shows the progress bar 168 169 // Get the SourceCode of all chosen files 170 const patterns = answers.patterns.split(/[\s]+/u); 171 172 try { 173 sourceCodes = getSourceCodeOfFiles(patterns, { baseConfig: newConfig, useEslintrc: false }, total => { 174 bar.tick((BAR_SOURCE_CODE_TOTAL / total)); 175 }); 176 } catch (e) { 177 log.info("\n"); 178 throw e; 179 } 180 const fileQty = Object.keys(sourceCodes).length; 181 182 if (fileQty === 0) { 183 log.info("\n"); 184 throw new Error("Automatic Configuration failed. No files were able to be parsed."); 185 } 186 187 // Create a registry of rule configs 188 registry = new autoconfig.Registry(); 189 registry.populateFromCoreRules(); 190 191 // Lint all files with each rule config in the registry 192 registry = registry.lintSourceCode(sourceCodes, newConfig, total => { 193 bar.tick((BAR_TOTAL - BAR_SOURCE_CODE_TOTAL) / total); // Subtract out ticks used at beginning 194 }); 195 debug(`\nRegistry: ${util.inspect(registry.rules, { depth: null })}`); 196 197 // Create a list of recommended rules, because we don't want to disable them 198 const recRules = Object.keys(recConfig.rules).filter(ruleId => ConfigOps.isErrorSeverity(recConfig.rules[ruleId])); 199 200 // Find and disable rules which had no error-free configuration 201 const failingRegistry = registry.getFailingRulesRegistry(); 202 203 Object.keys(failingRegistry.rules).forEach(ruleId => { 204 205 // If the rule is recommended, set it to error, otherwise disable it 206 disabledConfigs[ruleId] = (recRules.indexOf(ruleId) !== -1) ? 2 : 0; 207 }); 208 209 // Now that we know which rules to disable, strip out configs with errors 210 registry = registry.stripFailingConfigs(); 211 212 /* 213 * If there is only one config that results in no errors for a rule, we should use it. 214 * createConfig will only add rules that have one configuration in the registry. 215 */ 216 const singleConfigs = registry.createConfig().rules; 217 218 /* 219 * The "sweet spot" for number of options in a config seems to be two (severity plus one option). 220 * Very often, a third option (usually an object) is available to address 221 * edge cases, exceptions, or unique situations. We will prefer to use a config with 222 * specificity of two. 223 */ 224 const specTwoConfigs = registry.filterBySpecificity(2).createConfig().rules; 225 226 // Maybe a specific combination using all three options works 227 const specThreeConfigs = registry.filterBySpecificity(3).createConfig().rules; 228 229 // If all else fails, try to use the default (severity only) 230 const defaultConfigs = registry.filterBySpecificity(1).createConfig().rules; 231 232 // Combine configs in reverse priority order (later take precedence) 233 newConfig.rules = Object.assign({}, disabledConfigs, defaultConfigs, specThreeConfigs, specTwoConfigs, singleConfigs); 234 235 // Make sure progress bar has finished (floating point rounding) 236 bar.update(BAR_TOTAL); 237 238 // Log out some stats to let the user know what happened 239 const finalRuleIds = Object.keys(newConfig.rules); 240 const totalRules = finalRuleIds.length; 241 const enabledRules = finalRuleIds.filter(ruleId => (newConfig.rules[ruleId] !== 0)).length; 242 const resultMessage = [ 243 `\nEnabled ${enabledRules} out of ${totalRules}`, 244 `rules based on ${fileQty}`, 245 `file${(fileQty === 1) ? "." : "s."}` 246 ].join(" "); 247 248 log.info(resultMessage); 249 250 ConfigOps.normalizeToStrings(newConfig); 251 return newConfig; 252} 253 254/** 255 * process user's answers and create config object 256 * @param {Object} answers answers received from inquirer 257 * @returns {Object} config object 258 */ 259function processAnswers(answers) { 260 let config = { 261 rules: {}, 262 env: {}, 263 parserOptions: {}, 264 extends: [] 265 }; 266 267 config.parserOptions.ecmaVersion = espree.latestEcmaVersion; 268 config.env.es2021 = true; 269 270 // set the module type 271 if (answers.moduleType === "esm") { 272 config.parserOptions.sourceType = "module"; 273 } else if (answers.moduleType === "commonjs") { 274 config.env.commonjs = true; 275 } 276 277 // add in browser and node environments if necessary 278 answers.env.forEach(env => { 279 config.env[env] = true; 280 }); 281 282 // add in library information 283 if (answers.framework === "react") { 284 config.parserOptions.ecmaFeatures = { 285 jsx: true 286 }; 287 config.plugins = ["react"]; 288 config.extends.push("plugin:react/recommended"); 289 } else if (answers.framework === "vue") { 290 config.plugins = ["vue"]; 291 config.extends.push("plugin:vue/essential"); 292 } 293 294 if (answers.typescript) { 295 if (answers.framework === "vue") { 296 config.parserOptions.parser = "@typescript-eslint/parser"; 297 } else { 298 config.parser = "@typescript-eslint/parser"; 299 } 300 301 if (Array.isArray(config.plugins)) { 302 config.plugins.push("@typescript-eslint"); 303 } else { 304 config.plugins = ["@typescript-eslint"]; 305 } 306 } 307 308 // setup rules based on problems/style enforcement preferences 309 if (answers.purpose === "problems") { 310 config.extends.unshift("eslint:recommended"); 311 } else if (answers.purpose === "style") { 312 if (answers.source === "prompt") { 313 config.extends.unshift("eslint:recommended"); 314 config.rules.indent = ["error", answers.indent]; 315 config.rules.quotes = ["error", answers.quotes]; 316 config.rules["linebreak-style"] = ["error", answers.linebreak]; 317 config.rules.semi = ["error", answers.semi ? "always" : "never"]; 318 } else if (answers.source === "auto") { 319 config = configureRules(answers, config); 320 config = autoconfig.extendFromRecommended(config); 321 } 322 } 323 if (answers.typescript && config.extends.includes("eslint:recommended")) { 324 config.extends.push("plugin:@typescript-eslint/eslint-recommended"); 325 config.extends.push("plugin:@typescript-eslint/recommended"); 326 } 327 328 // normalize extends 329 if (config.extends.length === 0) { 330 delete config.extends; 331 } else if (config.extends.length === 1) { 332 config.extends = config.extends[0]; 333 } 334 335 ConfigOps.normalizeToStrings(config); 336 return config; 337} 338 339/** 340 * Get the version of the local ESLint. 341 * @returns {string|null} The version. If the local ESLint was not found, returns null. 342 */ 343function getLocalESLintVersion() { 344 try { 345 const eslintPath = ModuleResolver.resolve("eslint", path.join(process.cwd(), "__placeholder__.js")); 346 const eslint = require(eslintPath); 347 348 return eslint.linter.version || null; 349 } catch { 350 return null; 351 } 352} 353 354/** 355 * Get the shareable config name of the chosen style guide. 356 * @param {Object} answers The answers object. 357 * @returns {string} The shareable config name. 358 */ 359function getStyleGuideName(answers) { 360 if (answers.styleguide === "airbnb" && answers.framework !== "react") { 361 return "airbnb-base"; 362 } 363 return answers.styleguide; 364} 365 366/** 367 * Check whether the local ESLint version conflicts with the required version of the chosen shareable config. 368 * @param {Object} answers The answers object. 369 * @returns {boolean} `true` if the local ESLint is found then it conflicts with the required version of the chosen shareable config. 370 */ 371function hasESLintVersionConflict(answers) { 372 373 // Get the local ESLint version. 374 const localESLintVersion = getLocalESLintVersion(); 375 376 if (!localESLintVersion) { 377 return false; 378 } 379 380 // Get the required range of ESLint version. 381 const configName = getStyleGuideName(answers); 382 const moduleName = `eslint-config-${configName}@latest`; 383 const peerDependencies = getPeerDependencies(moduleName) || {}; 384 const requiredESLintVersionRange = peerDependencies.eslint; 385 386 if (!requiredESLintVersionRange) { 387 return false; 388 } 389 390 answers.localESLintVersion = localESLintVersion; 391 answers.requiredESLintVersionRange = requiredESLintVersionRange; 392 393 // Check the version. 394 if (semver.satisfies(localESLintVersion, requiredESLintVersionRange)) { 395 answers.installESLint = false; 396 return false; 397 } 398 399 return true; 400} 401 402/** 403 * Install modules. 404 * @param {string[]} modules Modules to be installed. 405 * @returns {void} 406 */ 407function installModules(modules) { 408 log.info(`Installing ${modules.join(", ")}`); 409 npmUtils.installSyncSaveDev(modules); 410} 411 412/* istanbul ignore next: no need to test inquirer */ 413/** 414 * Ask user to install modules. 415 * @param {string[]} modules Array of modules to be installed. 416 * @param {boolean} packageJsonExists Indicates if package.json is existed. 417 * @returns {Promise} Answer that indicates if user wants to install. 418 */ 419function askInstallModules(modules, packageJsonExists) { 420 421 // If no modules, do nothing. 422 if (modules.length === 0) { 423 return Promise.resolve(); 424 } 425 426 log.info("The config that you've selected requires the following dependencies:\n"); 427 log.info(modules.join(" ")); 428 return inquirer.prompt([ 429 { 430 type: "confirm", 431 name: "executeInstallation", 432 message: "Would you like to install them now with npm?", 433 default: true, 434 when() { 435 return modules.length && packageJsonExists; 436 } 437 } 438 ]).then(({ executeInstallation }) => { 439 if (executeInstallation) { 440 installModules(modules); 441 } 442 }); 443} 444 445/* istanbul ignore next: no need to test inquirer */ 446/** 447 * Ask use a few questions on command prompt 448 * @returns {Promise} The promise with the result of the prompt 449 */ 450function promptUser() { 451 452 return inquirer.prompt([ 453 { 454 type: "list", 455 name: "purpose", 456 message: "How would you like to use ESLint?", 457 default: "problems", 458 choices: [ 459 { name: "To check syntax only", value: "syntax" }, 460 { name: "To check syntax and find problems", value: "problems" }, 461 { name: "To check syntax, find problems, and enforce code style", value: "style" } 462 ] 463 }, 464 { 465 type: "list", 466 name: "moduleType", 467 message: "What type of modules does your project use?", 468 default: "esm", 469 choices: [ 470 { name: "JavaScript modules (import/export)", value: "esm" }, 471 { name: "CommonJS (require/exports)", value: "commonjs" }, 472 { name: "None of these", value: "none" } 473 ] 474 }, 475 { 476 type: "list", 477 name: "framework", 478 message: "Which framework does your project use?", 479 default: "react", 480 choices: [ 481 { name: "React", value: "react" }, 482 { name: "Vue.js", value: "vue" }, 483 { name: "None of these", value: "none" } 484 ] 485 }, 486 { 487 type: "confirm", 488 name: "typescript", 489 message: "Does your project use TypeScript?", 490 default: false 491 }, 492 { 493 type: "checkbox", 494 name: "env", 495 message: "Where does your code run?", 496 default: ["browser"], 497 choices: [ 498 { name: "Browser", value: "browser" }, 499 { name: "Node", value: "node" } 500 ] 501 }, 502 { 503 type: "list", 504 name: "source", 505 message: "How would you like to define a style for your project?", 506 default: "guide", 507 choices: [ 508 { name: "Use a popular style guide", value: "guide" }, 509 { name: "Answer questions about your style", value: "prompt" }, 510 { name: "Inspect your JavaScript file(s)", value: "auto" } 511 ], 512 when(answers) { 513 return answers.purpose === "style"; 514 } 515 }, 516 { 517 type: "list", 518 name: "styleguide", 519 message: "Which style guide do you want to follow?", 520 choices: [ 521 { name: "Airbnb: https://github.com/airbnb/javascript", value: "airbnb" }, 522 { name: "Standard: https://github.com/standard/standard", value: "standard" }, 523 { name: "Google: https://github.com/google/eslint-config-google", value: "google" } 524 ], 525 when(answers) { 526 answers.packageJsonExists = npmUtils.checkPackageJson(); 527 return answers.source === "guide" && answers.packageJsonExists; 528 } 529 }, 530 { 531 type: "input", 532 name: "patterns", 533 message: "Which file(s), path(s), or glob(s) should be examined?", 534 when(answers) { 535 return (answers.source === "auto"); 536 }, 537 validate(input) { 538 if (input.trim().length === 0 && input.trim() !== ",") { 539 return "You must tell us what code to examine. Try again."; 540 } 541 return true; 542 } 543 }, 544 { 545 type: "list", 546 name: "format", 547 message: "What format do you want your config file to be in?", 548 default: "JavaScript", 549 choices: ["JavaScript", "YAML", "JSON"] 550 }, 551 { 552 type: "confirm", 553 name: "installESLint", 554 message(answers) { 555 const verb = semver.ltr(answers.localESLintVersion, answers.requiredESLintVersionRange) 556 ? "upgrade" 557 : "downgrade"; 558 559 return `The style guide "${answers.styleguide}" requires eslint@${answers.requiredESLintVersionRange}. You are currently using eslint@${answers.localESLintVersion}.\n Do you want to ${verb}?`; 560 }, 561 default: true, 562 when(answers) { 563 return answers.source === "guide" && answers.packageJsonExists && hasESLintVersionConflict(answers); 564 } 565 } 566 ]).then(earlyAnswers => { 567 568 // early exit if no style guide is necessary 569 if (earlyAnswers.purpose !== "style") { 570 const config = processAnswers(earlyAnswers); 571 const modules = getModulesList(config); 572 573 return askInstallModules(modules, earlyAnswers.packageJsonExists) 574 .then(() => writeFile(config, earlyAnswers.format)); 575 } 576 577 // early exit if you are using a style guide 578 if (earlyAnswers.source === "guide") { 579 if (!earlyAnswers.packageJsonExists) { 580 log.info("A package.json is necessary to install plugins such as style guides. Run `npm init` to create a package.json file and try again."); 581 return void 0; 582 } 583 if (earlyAnswers.installESLint === false && !semver.satisfies(earlyAnswers.localESLintVersion, earlyAnswers.requiredESLintVersionRange)) { 584 log.info(`Note: it might not work since ESLint's version is mismatched with the ${earlyAnswers.styleguide} config.`); 585 } 586 if (earlyAnswers.styleguide === "airbnb" && earlyAnswers.framework !== "react") { 587 earlyAnswers.styleguide = "airbnb-base"; 588 } 589 590 const config = processAnswers(earlyAnswers); 591 592 if (Array.isArray(config.extends)) { 593 config.extends.push(earlyAnswers.styleguide); 594 } else if (config.extends) { 595 config.extends = [config.extends, earlyAnswers.styleguide]; 596 } else { 597 config.extends = [earlyAnswers.styleguide]; 598 } 599 600 const modules = getModulesList(config); 601 602 return askInstallModules(modules, earlyAnswers.packageJsonExists) 603 .then(() => writeFile(config, earlyAnswers.format)); 604 605 } 606 607 if (earlyAnswers.source === "auto") { 608 const combinedAnswers = Object.assign({}, earlyAnswers); 609 const config = processAnswers(combinedAnswers); 610 const modules = getModulesList(config); 611 612 return askInstallModules(modules).then(() => writeFile(config, earlyAnswers.format)); 613 } 614 615 // continue with the style questions otherwise... 616 return inquirer.prompt([ 617 { 618 type: "list", 619 name: "indent", 620 message: "What style of indentation do you use?", 621 default: "tab", 622 choices: [{ name: "Tabs", value: "tab" }, { name: "Spaces", value: 4 }] 623 }, 624 { 625 type: "list", 626 name: "quotes", 627 message: "What quotes do you use for strings?", 628 default: "double", 629 choices: [{ name: "Double", value: "double" }, { name: "Single", value: "single" }] 630 }, 631 { 632 type: "list", 633 name: "linebreak", 634 message: "What line endings do you use?", 635 default: "unix", 636 choices: [{ name: "Unix", value: "unix" }, { name: "Windows", value: "windows" }] 637 }, 638 { 639 type: "confirm", 640 name: "semi", 641 message: "Do you require semicolons?", 642 default: true 643 } 644 ]).then(answers => { 645 const totalAnswers = Object.assign({}, earlyAnswers, answers); 646 647 const config = processAnswers(totalAnswers); 648 const modules = getModulesList(config); 649 650 return askInstallModules(modules).then(() => writeFile(config, earlyAnswers.format)); 651 }); 652 }); 653} 654 655//------------------------------------------------------------------------------ 656// Public Interface 657//------------------------------------------------------------------------------ 658 659const init = { 660 getModulesList, 661 hasESLintVersionConflict, 662 installModules, 663 processAnswers, 664 /* istanbul ignore next */initializeConfig() { 665 return promptUser(); 666 } 667}; 668 669module.exports = init; 670