2020-08-26 01:57:08 +02:00
/ * *
* @ fileoverview Config initialization wizard .
* @ author Ilya Volodin
* /
"use strict" ;
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
const util = require ( "util" ) ,
path = require ( "path" ) ,
enquirer = require ( "enquirer" ) ,
ProgressBar = require ( "progress" ) ,
semver = require ( "semver" ) ,
espree = require ( "espree" ) ,
recConfig = require ( "../../conf/eslint-recommended" ) ,
2021-02-26 04:58:33 +01:00
ConfigOps = require ( "@eslint/eslintrc/lib/shared/config-ops" ) ,
2020-08-26 01:57:08 +02:00
log = require ( "../shared/logging" ) ,
2021-02-26 04:58:33 +01:00
naming = require ( "@eslint/eslintrc/lib/shared/naming" ) ,
2020-08-26 01:57:08 +02:00
ModuleResolver = require ( "../shared/relative-module-resolver" ) ,
autoconfig = require ( "./autoconfig.js" ) ,
ConfigFile = require ( "./config-file" ) ,
npmUtils = require ( "./npm-utils" ) ,
{ getSourceCodeOfFiles } = require ( "./source-code-utils" ) ;
const debug = require ( "debug" ) ( "eslint:config-initializer" ) ;
//------------------------------------------------------------------------------
// Private
//------------------------------------------------------------------------------
/* istanbul ignore next: hard to test fs function */
/ * *
* Create . eslintrc file in the current working directory
* @ param { Object } config object that contains user ' s answers
* @ param { string } format The file format to write to .
* @ returns { void }
* /
function writeFile ( config , format ) {
// default is .js
let extname = ".js" ;
if ( format === "YAML" ) {
extname = ".yml" ;
} else if ( format === "JSON" ) {
extname = ".json" ;
}
const installedESLint = config . installedESLint ;
delete config . installedESLint ;
ConfigFile . write ( config , ` ./.eslintrc ${ extname } ` ) ;
log . info ( ` Successfully created .eslintrc ${ extname } file in ${ process . cwd ( ) } ` ) ;
if ( installedESLint ) {
log . info ( "ESLint was installed locally. We recommend using this local copy instead of your globally-installed copy." ) ;
}
}
/ * *
* Get the peer dependencies of the given module .
* This adds the gotten value to cache at the first time , then reuses it .
* In a process , this function is called twice , but ` npmUtils.fetchPeerDependencies ` needs to access network which is relatively slow .
* @ param { string } moduleName The module name to get .
* @ returns { Object } The peer dependencies of the given module .
* This object is the object of ` peerDependencies ` field of ` package.json ` .
* Returns null if npm was not found .
* /
function getPeerDependencies ( moduleName ) {
let result = getPeerDependencies . cache . get ( moduleName ) ;
if ( ! result ) {
log . info ( ` Checking peerDependencies of ${ moduleName } ` ) ;
result = npmUtils . fetchPeerDependencies ( moduleName ) ;
getPeerDependencies . cache . set ( moduleName , result ) ;
}
return result ;
}
getPeerDependencies . cache = new Map ( ) ;
/ * *
* Return necessary plugins , configs , parsers , etc . based on the config
* @ param { Object } config config object
* @ param { boolean } [ installESLint = true ] If ` false ` is given , it does not install eslint .
* @ returns { string [ ] } An array of modules to be installed .
* /
function getModulesList ( config , installESLint ) {
const modules = { } ;
// Create a list of modules which should be installed based on config
if ( config . plugins ) {
for ( const plugin of config . plugins ) {
const moduleName = naming . normalizePackageName ( plugin , "eslint-plugin" ) ;
modules [ moduleName ] = "latest" ;
}
}
if ( config . extends ) {
const extendList = Array . isArray ( config . extends ) ? config . extends : [ config . extends ] ;
for ( const extend of extendList ) {
if ( extend . startsWith ( "eslint:" ) || extend . startsWith ( "plugin:" ) ) {
continue ;
}
const moduleName = naming . normalizePackageName ( extend , "eslint-config" ) ;
modules [ moduleName ] = "latest" ;
Object . assign (
modules ,
getPeerDependencies ( ` ${ moduleName } @latest ` )
) ;
}
}
const parser = config . parser || ( config . parserOptions && config . parserOptions . parser ) ;
if ( parser ) {
modules [ parser ] = "latest" ;
}
if ( installESLint === false ) {
delete modules . eslint ;
} else {
const installStatus = npmUtils . checkDevDeps ( [ "eslint" ] ) ;
// Mark to show messages if it's new installation of eslint.
if ( installStatus . eslint === false ) {
log . info ( "Local ESLint installation not found." ) ;
modules . eslint = modules . eslint || "latest" ;
config . installedESLint = true ;
}
}
return Object . keys ( modules ) . map ( name => ` ${ name } @ ${ modules [ name ] } ` ) ;
}
/ * *
* Set the ` rules ` of a config by examining a user ' s source code
*
* Note : This clones the config object and returns a new config to avoid mutating
* the original config parameter .
* @ param { Object } answers answers received from enquirer
* @ param { Object } config config object
* @ returns { Object } config object with configured rules
* /
function configureRules ( answers , config ) {
const BAR _TOTAL = 20 ,
BAR _SOURCE _CODE _TOTAL = 4 ,
newConfig = Object . assign ( { } , config ) ,
disabledConfigs = { } ;
let sourceCodes ,
registry ;
// Set up a progress bar, as this process can take a long time
const bar = new ProgressBar ( "Determining Config: :percent [:bar] :elapseds elapsed, eta :etas " , {
width : 30 ,
total : BAR _TOTAL
} ) ;
bar . tick ( 0 ) ; // Shows the progress bar
// Get the SourceCode of all chosen files
const patterns = answers . patterns . split ( /[\s]+/u ) ;
try {
sourceCodes = getSourceCodeOfFiles ( patterns , { baseConfig : newConfig , useEslintrc : false } , total => {
bar . tick ( ( BAR _SOURCE _CODE _TOTAL / total ) ) ;
} ) ;
} catch ( e ) {
log . info ( "\n" ) ;
throw e ;
}
const fileQty = Object . keys ( sourceCodes ) . length ;
if ( fileQty === 0 ) {
log . info ( "\n" ) ;
throw new Error ( "Automatic Configuration failed. No files were able to be parsed." ) ;
}
// Create a registry of rule configs
registry = new autoconfig . Registry ( ) ;
registry . populateFromCoreRules ( ) ;
// Lint all files with each rule config in the registry
registry = registry . lintSourceCode ( sourceCodes , newConfig , total => {
bar . tick ( ( BAR _TOTAL - BAR _SOURCE _CODE _TOTAL ) / total ) ; // Subtract out ticks used at beginning
} ) ;
debug ( ` \n Registry: ${ util . inspect ( registry . rules , { depth : null } )} ` ) ;
// Create a list of recommended rules, because we don't want to disable them
const recRules = Object . keys ( recConfig . rules ) . filter ( ruleId => ConfigOps . isErrorSeverity ( recConfig . rules [ ruleId ] ) ) ;
// Find and disable rules which had no error-free configuration
const failingRegistry = registry . getFailingRulesRegistry ( ) ;
Object . keys ( failingRegistry . rules ) . forEach ( ruleId => {
// If the rule is recommended, set it to error, otherwise disable it
disabledConfigs [ ruleId ] = ( recRules . indexOf ( ruleId ) !== - 1 ) ? 2 : 0 ;
} ) ;
// Now that we know which rules to disable, strip out configs with errors
registry = registry . stripFailingConfigs ( ) ;
/ *
* If there is only one config that results in no errors for a rule , we should use it .
* createConfig will only add rules that have one configuration in the registry .
* /
const singleConfigs = registry . createConfig ( ) . rules ;
/ *
* The "sweet spot" for number of options in a config seems to be two ( severity plus one option ) .
* Very often , a third option ( usually an object ) is available to address
* edge cases , exceptions , or unique situations . We will prefer to use a config with
* specificity of two .
* /
const specTwoConfigs = registry . filterBySpecificity ( 2 ) . createConfig ( ) . rules ;
// Maybe a specific combination using all three options works
const specThreeConfigs = registry . filterBySpecificity ( 3 ) . createConfig ( ) . rules ;
// If all else fails, try to use the default (severity only)
const defaultConfigs = registry . filterBySpecificity ( 1 ) . createConfig ( ) . rules ;
// Combine configs in reverse priority order (later take precedence)
newConfig . rules = Object . assign ( { } , disabledConfigs , defaultConfigs , specThreeConfigs , specTwoConfigs , singleConfigs ) ;
// Make sure progress bar has finished (floating point rounding)
bar . update ( BAR _TOTAL ) ;
// Log out some stats to let the user know what happened
const finalRuleIds = Object . keys ( newConfig . rules ) ;
const totalRules = finalRuleIds . length ;
const enabledRules = finalRuleIds . filter ( ruleId => ( newConfig . rules [ ruleId ] !== 0 ) ) . length ;
const resultMessage = [
` \n Enabled ${ enabledRules } out of ${ totalRules } ` ,
` rules based on ${ fileQty } ` ,
` file ${ ( fileQty === 1 ) ? "." : "s." } `
] . join ( " " ) ;
log . info ( resultMessage ) ;
ConfigOps . normalizeToStrings ( newConfig ) ;
return newConfig ;
}
/ * *
* process user ' s answers and create config object
* @ param { Object } answers answers received from enquirer
* @ returns { Object } config object
* /
function processAnswers ( answers ) {
let config = {
rules : { } ,
env : { } ,
parserOptions : { } ,
extends : [ ]
} ;
config . parserOptions . ecmaVersion = espree . latestEcmaVersion ;
2021-02-26 04:58:33 +01:00
config . env . es2021 = true ;
2020-08-26 01:57:08 +02:00
// set the module type
if ( answers . moduleType === "esm" ) {
config . parserOptions . sourceType = "module" ;
} else if ( answers . moduleType === "commonjs" ) {
config . env . commonjs = true ;
}
// add in browser and node environments if necessary
answers . env . forEach ( env => {
config . env [ env ] = true ;
} ) ;
// add in library information
if ( answers . framework === "react" ) {
config . parserOptions . ecmaFeatures = {
jsx : true
} ;
config . plugins = [ "react" ] ;
config . extends . push ( "plugin:react/recommended" ) ;
} else if ( answers . framework === "vue" ) {
config . plugins = [ "vue" ] ;
config . extends . push ( "plugin:vue/essential" ) ;
}
if ( answers . typescript ) {
if ( answers . framework === "vue" ) {
config . parserOptions . parser = "@typescript-eslint/parser" ;
} else {
config . parser = "@typescript-eslint/parser" ;
}
if ( Array . isArray ( config . plugins ) ) {
config . plugins . push ( "@typescript-eslint" ) ;
} else {
config . plugins = [ "@typescript-eslint" ] ;
}
}
// setup rules based on problems/style enforcement preferences
if ( answers . purpose === "problems" ) {
config . extends . unshift ( "eslint:recommended" ) ;
} else if ( answers . purpose === "style" ) {
if ( answers . source === "prompt" ) {
config . extends . unshift ( "eslint:recommended" ) ;
config . rules . indent = [ "error" , answers . indent ] ;
config . rules . quotes = [ "error" , answers . quotes ] ;
config . rules [ "linebreak-style" ] = [ "error" , answers . linebreak ] ;
config . rules . semi = [ "error" , answers . semi ? "always" : "never" ] ;
} else if ( answers . source === "auto" ) {
config = configureRules ( answers , config ) ;
config = autoconfig . extendFromRecommended ( config ) ;
}
}
if ( answers . typescript && config . extends . includes ( "eslint:recommended" ) ) {
config . extends . push ( "plugin:@typescript-eslint/recommended" ) ;
}
// normalize extends
if ( config . extends . length === 0 ) {
delete config . extends ;
} else if ( config . extends . length === 1 ) {
config . extends = config . extends [ 0 ] ;
}
ConfigOps . normalizeToStrings ( config ) ;
return config ;
}
/ * *
* Get the version of the local ESLint .
* @ returns { string | null } The version . If the local ESLint was not found , returns null .
* /
function getLocalESLintVersion ( ) {
try {
const eslintPath = ModuleResolver . resolve ( "eslint" , path . join ( process . cwd ( ) , "__placeholder__.js" ) ) ;
const eslint = require ( eslintPath ) ;
return eslint . linter . version || null ;
} catch {
return null ;
}
}
/ * *
* Get the shareable config name of the chosen style guide .
* @ param { Object } answers The answers object .
* @ returns { string } The shareable config name .
* /
function getStyleGuideName ( answers ) {
if ( answers . styleguide === "airbnb" && answers . framework !== "react" ) {
return "airbnb-base" ;
}
return answers . styleguide ;
}
/ * *
* Check whether the local ESLint version conflicts with the required version of the chosen shareable config .
* @ param { Object } answers The answers object .
* @ returns { boolean } ` true ` if the local ESLint is found then it conflicts with the required version of the chosen shareable config .
* /
function hasESLintVersionConflict ( answers ) {
// Get the local ESLint version.
const localESLintVersion = getLocalESLintVersion ( ) ;
if ( ! localESLintVersion ) {
return false ;
}
// Get the required range of ESLint version.
const configName = getStyleGuideName ( answers ) ;
const moduleName = ` eslint-config- ${ configName } @latest ` ;
const peerDependencies = getPeerDependencies ( moduleName ) || { } ;
const requiredESLintVersionRange = peerDependencies . eslint ;
if ( ! requiredESLintVersionRange ) {
return false ;
}
answers . localESLintVersion = localESLintVersion ;
answers . requiredESLintVersionRange = requiredESLintVersionRange ;
// Check the version.
if ( semver . satisfies ( localESLintVersion , requiredESLintVersionRange ) ) {
answers . installESLint = false ;
return false ;
}
return true ;
}
/ * *
* Install modules .
* @ param { string [ ] } modules Modules to be installed .
* @ returns { void }
* /
function installModules ( modules ) {
log . info ( ` Installing ${ modules . join ( ", " ) } ` ) ;
npmUtils . installSyncSaveDev ( modules ) ;
}
/* istanbul ignore next: no need to test enquirer */
/ * *
* Ask user to install modules .
* @ param { string [ ] } modules Array of modules to be installed .
* @ param { boolean } packageJsonExists Indicates if package . json is existed .
* @ returns { Promise } Answer that indicates if user wants to install .
* /
function askInstallModules ( modules , packageJsonExists ) {
// If no modules, do nothing.
if ( modules . length === 0 ) {
return Promise . resolve ( ) ;
}
log . info ( "The config that you've selected requires the following dependencies:\n" ) ;
log . info ( modules . join ( " " ) ) ;
return enquirer . prompt ( [
{
type : "toggle" ,
name : "executeInstallation" ,
message : "Would you like to install them now with npm?" ,
enabled : "Yes" ,
disabled : "No" ,
initial : 1 ,
skip ( ) {
return ! ( modules . length && packageJsonExists ) ;
} ,
result ( input ) {
return this . skipped ? null : input ;
}
}
] ) . then ( ( { executeInstallation } ) => {
if ( executeInstallation ) {
installModules ( modules ) ;
}
} ) ;
}
/* istanbul ignore next: no need to test enquirer */
/ * *
* Ask use a few questions on command prompt
* @ returns { Promise } The promise with the result of the prompt
* /
function promptUser ( ) {
return enquirer . prompt ( [
{
type : "select" ,
name : "purpose" ,
message : "How would you like to use ESLint?" ,
// The returned number matches the name value of nth in the choices array.
initial : 1 ,
choices : [
{ message : "To check syntax only" , name : "syntax" } ,
{ message : "To check syntax and find problems" , name : "problems" } ,
{ message : "To check syntax, find problems, and enforce code style" , name : "style" }
]
} ,
{
type : "select" ,
name : "moduleType" ,
message : "What type of modules does your project use?" ,
initial : 0 ,
choices : [
{ message : "JavaScript modules (import/export)" , name : "esm" } ,
{ message : "CommonJS (require/exports)" , name : "commonjs" } ,
{ message : "None of these" , name : "none" }
]
} ,
{
type : "select" ,
name : "framework" ,
message : "Which framework does your project use?" ,
initial : 0 ,
choices : [
{ message : "React" , name : "react" } ,
{ message : "Vue.js" , name : "vue" } ,
{ message : "None of these" , name : "none" }
]
} ,
{
type : "toggle" ,
name : "typescript" ,
message : "Does your project use TypeScript?" ,
enabled : "Yes" ,
disabled : "No" ,
initial : 0
} ,
{
type : "multiselect" ,
name : "env" ,
message : "Where does your code run?" ,
hint : "(Press <space> to select, <a> to toggle all, <i> to invert selection)" ,
initial : 0 ,
choices : [
{ message : "Browser" , name : "browser" } ,
{ message : "Node" , name : "node" }
]
} ,
{
type : "select" ,
name : "source" ,
message : "How would you like to define a style for your project?" ,
choices : [
{ message : "Use a popular style guide" , name : "guide" } ,
{ message : "Answer questions about your style" , name : "prompt" } ,
{ message : "Inspect your JavaScript file(s)" , name : "auto" }
] ,
skip ( ) {
return this . state . answers . purpose !== "style" ;
} ,
result ( input ) {
return this . skipped ? null : input ;
}
} ,
{
type : "select" ,
name : "styleguide" ,
message : "Which style guide do you want to follow?" ,
choices : [
{ message : "Airbnb: https://github.com/airbnb/javascript" , name : "airbnb" } ,
{ message : "Standard: https://github.com/standard/standard" , name : "standard" } ,
{ message : "Google: https://github.com/google/eslint-config-google" , name : "google" }
] ,
skip ( ) {
this . state . answers . packageJsonExists = npmUtils . checkPackageJson ( ) ;
return ! ( this . state . answers . source === "guide" && this . state . answers . packageJsonExists ) ;
} ,
result ( input ) {
return this . skipped ? null : input ;
}
} ,
{
type : "input" ,
name : "patterns" ,
message : "Which file(s), path(s), or glob(s) should be examined?" ,
skip ( ) {
return this . state . answers . source !== "auto" ;
} ,
validate ( input ) {
if ( ! this . skipped && input . trim ( ) . length === 0 && input . trim ( ) !== "," ) {
return "You must tell us what code to examine. Try again." ;
}
return true ;
}
} ,
{
type : "select" ,
name : "format" ,
message : "What format do you want your config file to be in?" ,
initial : 0 ,
choices : [ "JavaScript" , "YAML" , "JSON" ]
} ,
{
type : "toggle" ,
name : "installESLint" ,
2021-02-26 04:58:33 +01:00
message ( ) {
const { answers } = this . state ;
2020-08-26 01:57:08 +02:00
const verb = semver . ltr ( answers . localESLintVersion , answers . requiredESLintVersionRange )
? "upgrade"
: "downgrade" ;
return ` The style guide " ${ answers . styleguide } " requires eslint@ ${ answers . requiredESLintVersionRange } . You are currently using eslint@ ${ answers . localESLintVersion } . \n Do you want to ${ verb } ? ` ;
} ,
enabled : "Yes" ,
disabled : "No" ,
initial : 1 ,
skip ( ) {
return ! ( this . state . answers . source === "guide" && this . state . answers . packageJsonExists && hasESLintVersionConflict ( this . state . answers ) ) ;
} ,
result ( input ) {
return this . skipped ? null : input ;
}
}
] ) . then ( earlyAnswers => {
// early exit if no style guide is necessary
if ( earlyAnswers . purpose !== "style" ) {
const config = processAnswers ( earlyAnswers ) ;
const modules = getModulesList ( config ) ;
return askInstallModules ( modules , earlyAnswers . packageJsonExists )
. then ( ( ) => writeFile ( config , earlyAnswers . format ) ) ;
}
// early exit if you are using a style guide
if ( earlyAnswers . source === "guide" ) {
if ( ! earlyAnswers . packageJsonExists ) {
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." ) ;
return void 0 ;
}
if ( earlyAnswers . installESLint === false && ! semver . satisfies ( earlyAnswers . localESLintVersion , earlyAnswers . requiredESLintVersionRange ) ) {
log . info ( ` Note: it might not work since ESLint's version is mismatched with the ${ earlyAnswers . styleguide } config. ` ) ;
}
if ( earlyAnswers . styleguide === "airbnb" && earlyAnswers . framework !== "react" ) {
earlyAnswers . styleguide = "airbnb-base" ;
}
const config = processAnswers ( earlyAnswers ) ;
if ( Array . isArray ( config . extends ) ) {
config . extends . push ( earlyAnswers . styleguide ) ;
} else if ( config . extends ) {
config . extends = [ config . extends , earlyAnswers . styleguide ] ;
} else {
config . extends = [ earlyAnswers . styleguide ] ;
}
const modules = getModulesList ( config ) ;
return askInstallModules ( modules , earlyAnswers . packageJsonExists )
. then ( ( ) => writeFile ( config , earlyAnswers . format ) ) ;
}
if ( earlyAnswers . source === "auto" ) {
const combinedAnswers = Object . assign ( { } , earlyAnswers ) ;
const config = processAnswers ( combinedAnswers ) ;
const modules = getModulesList ( config ) ;
return askInstallModules ( modules ) . then ( ( ) => writeFile ( config , earlyAnswers . format ) ) ;
}
// continue with the style questions otherwise...
return enquirer . prompt ( [
{
type : "select" ,
name : "indent" ,
message : "What style of indentation do you use?" ,
initial : 0 ,
choices : [ { message : "Tabs" , name : "tab" } , { message : "Spaces" , name : 4 } ]
} ,
{
type : "select" ,
name : "quotes" ,
message : "What quotes do you use for strings?" ,
initial : 0 ,
choices : [ { message : "Double" , name : "double" } , { message : "Single" , name : "single" } ]
} ,
{
type : "select" ,
name : "linebreak" ,
message : "What line endings do you use?" ,
initial : 0 ,
choices : [ { message : "Unix" , name : "unix" } , { message : "Windows" , name : "windows" } ]
} ,
{
type : "toggle" ,
name : "semi" ,
message : "Do you require semicolons?" ,
enabled : "Yes" ,
disabled : "No" ,
initial : 1
}
] ) . then ( answers => {
const totalAnswers = Object . assign ( { } , earlyAnswers , answers ) ;
const config = processAnswers ( totalAnswers ) ;
const modules = getModulesList ( config ) ;
return askInstallModules ( modules ) . then ( ( ) => writeFile ( config , earlyAnswers . format ) ) ;
} ) ;
} ) ;
}
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
const init = {
getModulesList ,
hasESLintVersionConflict ,
installModules ,
processAnswers ,
/* istanbul ignore next */ initializeConfig ( ) {
return promptUser ( ) ;
}
} ;
module . exports = init ;