UNPKG

18.6 kBJavaScriptView Raw
1/**
2 * @fileoverview Main CLI object.
3 * @author Nicholas C. Zakas
4 */
5
6"use strict";
7
8/*
9 * NOTE: The CLI object should *not* call process.exit() directly. It should only return
10 * exit codes. This allows other programs to use the CLI object and still control
11 * when the program exits.
12 */
13
14//------------------------------------------------------------------------------
15// Requirements
16//------------------------------------------------------------------------------
17
18const fs = require("fs"),
19 path = require("path"),
20 { promisify } = require("util"),
21 { LegacyESLint } = require("./eslint"),
22 { ESLint, shouldUseFlatConfig, locateConfigFileToUse } = require("./eslint/eslint"),
23 createCLIOptions = require("./options"),
24 log = require("./shared/logging"),
25 RuntimeInfo = require("./shared/runtime-info"),
26 { normalizeSeverityToString } = require("./shared/severity");
27const { Legacy: { naming } } = require("@eslint/eslintrc");
28const { ModuleImporter } = require("@humanwhocodes/module-importer");
29
30const debug = require("debug")("eslint:cli");
31
32//------------------------------------------------------------------------------
33// Types
34//------------------------------------------------------------------------------
35
36/** @typedef {import("./eslint/eslint").ESLintOptions} ESLintOptions */
37/** @typedef {import("./eslint/eslint").LintMessage} LintMessage */
38/** @typedef {import("./eslint/eslint").LintResult} LintResult */
39/** @typedef {import("./options").ParsedCLIOptions} ParsedCLIOptions */
40/** @typedef {import("./shared/types").Plugin} Plugin */
41/** @typedef {import("./shared/types").ResultsMeta} ResultsMeta */
42
43//------------------------------------------------------------------------------
44// Helpers
45//------------------------------------------------------------------------------
46
47const mkdir = promisify(fs.mkdir);
48const stat = promisify(fs.stat);
49const writeFile = promisify(fs.writeFile);
50
51/**
52 * Loads plugins with the specified names.
53 * @param {{ "import": (name: string) => Promise<any> }} importer An object with an `import` method called once for each plugin.
54 * @param {string[]} pluginNames The names of the plugins to be loaded, with or without the "eslint-plugin-" prefix.
55 * @returns {Promise<Record<string, Plugin>>} A mapping of plugin short names to implementations.
56 */
57async function loadPlugins(importer, pluginNames) {
58 const plugins = {};
59
60 await Promise.all(pluginNames.map(async pluginName => {
61
62 const longName = naming.normalizePackageName(pluginName, "eslint-plugin");
63 const module = await importer.import(longName);
64
65 if (!("default" in module)) {
66 throw new Error(`"${longName}" cannot be used with the \`--plugin\` option because its default module does not provide a \`default\` export`);
67 }
68
69 const shortName = naming.getShorthandName(pluginName, "eslint-plugin");
70
71 plugins[shortName] = module.default;
72 }));
73
74 return plugins;
75}
76
77/**
78 * Predicate function for whether or not to apply fixes in quiet mode.
79 * If a message is a warning, do not apply a fix.
80 * @param {LintMessage} message The lint result.
81 * @returns {boolean} True if the lint message is an error (and thus should be
82 * autofixed), false otherwise.
83 */
84function quietFixPredicate(message) {
85 return message.severity === 2;
86}
87
88/**
89 * Predicate function for whether or not to run a rule in quiet mode.
90 * If a rule is set to warning, do not run it.
91 * @param {{ ruleId: string; severity: number; }} rule The rule id and severity.
92 * @returns {boolean} True if the lint rule should run, false otherwise.
93 */
94function quietRuleFilter(rule) {
95 return rule.severity === 2;
96}
97
98/**
99 * Translates the CLI options into the options expected by the ESLint constructor.
100 * @param {ParsedCLIOptions} cliOptions The CLI options to translate.
101 * @param {"flat"|"eslintrc"} [configType="eslintrc"] The format of the
102 * config to generate.
103 * @returns {Promise<ESLintOptions>} The options object for the ESLint constructor.
104 * @private
105 */
106async function translateOptions({
107 cache,
108 cacheFile,
109 cacheLocation,
110 cacheStrategy,
111 config,
112 configLookup,
113 env,
114 errorOnUnmatchedPattern,
115 eslintrc,
116 ext,
117 fix,
118 fixDryRun,
119 fixType,
120 global,
121 ignore,
122 ignorePath,
123 ignorePattern,
124 inlineConfig,
125 parser,
126 parserOptions,
127 plugin,
128 quiet,
129 reportUnusedDisableDirectives,
130 reportUnusedDisableDirectivesSeverity,
131 resolvePluginsRelativeTo,
132 rule,
133 rulesdir,
134 stats,
135 warnIgnored,
136 passOnNoPatterns,
137 maxWarnings
138}, configType) {
139
140 let overrideConfig, overrideConfigFile;
141 const importer = new ModuleImporter();
142
143 if (configType === "flat") {
144 overrideConfigFile = (typeof config === "string") ? config : !configLookup;
145 if (overrideConfigFile === false) {
146 overrideConfigFile = void 0;
147 }
148
149 let globals = {};
150
151 if (global) {
152 globals = global.reduce((obj, name) => {
153 if (name.endsWith(":true")) {
154 obj[name.slice(0, -5)] = "writable";
155 } else {
156 obj[name] = "readonly";
157 }
158 return obj;
159 }, globals);
160 }
161
162 overrideConfig = [{
163 languageOptions: {
164 globals,
165 parserOptions: parserOptions || {}
166 },
167 rules: rule ? rule : {}
168 }];
169
170 if (reportUnusedDisableDirectives || reportUnusedDisableDirectivesSeverity !== void 0) {
171 overrideConfig[0].linterOptions = {
172 reportUnusedDisableDirectives: reportUnusedDisableDirectives
173 ? "error"
174 : normalizeSeverityToString(reportUnusedDisableDirectivesSeverity)
175 };
176 }
177
178 if (parser) {
179 overrideConfig[0].languageOptions.parser = await importer.import(parser);
180 }
181
182 if (plugin) {
183 overrideConfig[0].plugins = await loadPlugins(importer, plugin);
184 }
185
186 } else {
187 overrideConfigFile = config;
188
189 overrideConfig = {
190 env: env && env.reduce((obj, name) => {
191 obj[name] = true;
192 return obj;
193 }, {}),
194 globals: global && global.reduce((obj, name) => {
195 if (name.endsWith(":true")) {
196 obj[name.slice(0, -5)] = "writable";
197 } else {
198 obj[name] = "readonly";
199 }
200 return obj;
201 }, {}),
202 ignorePatterns: ignorePattern,
203 parser,
204 parserOptions,
205 plugins: plugin,
206 rules: rule
207 };
208 }
209
210 const options = {
211 allowInlineConfig: inlineConfig,
212 cache,
213 cacheLocation: cacheLocation || cacheFile,
214 cacheStrategy,
215 errorOnUnmatchedPattern,
216 fix: (fix || fixDryRun) && (quiet ? quietFixPredicate : true),
217 fixTypes: fixType,
218 ignore,
219 overrideConfig,
220 overrideConfigFile,
221 passOnNoPatterns
222 };
223
224 if (configType === "flat") {
225 options.ignorePatterns = ignorePattern;
226 options.stats = stats;
227 options.warnIgnored = warnIgnored;
228
229 /*
230 * For performance reasons rules not marked as 'error' are filtered out in quiet mode. As maxWarnings
231 * requires rules set to 'warn' to be run, we only filter out 'warn' rules if maxWarnings is not specified.
232 */
233 options.ruleFilter = quiet && maxWarnings === -1 ? quietRuleFilter : () => true;
234 } else {
235 options.resolvePluginsRelativeTo = resolvePluginsRelativeTo;
236 options.rulePaths = rulesdir;
237 options.useEslintrc = eslintrc;
238 options.extensions = ext;
239 options.ignorePath = ignorePath;
240 if (reportUnusedDisableDirectives || reportUnusedDisableDirectivesSeverity !== void 0) {
241 options.reportUnusedDisableDirectives = reportUnusedDisableDirectives
242 ? "error"
243 : normalizeSeverityToString(reportUnusedDisableDirectivesSeverity);
244 }
245 }
246
247 return options;
248}
249
250/**
251 * Count error messages.
252 * @param {LintResult[]} results The lint results.
253 * @returns {{errorCount:number;fatalErrorCount:number,warningCount:number}} The number of error messages.
254 */
255function countErrors(results) {
256 let errorCount = 0;
257 let fatalErrorCount = 0;
258 let warningCount = 0;
259
260 for (const result of results) {
261 errorCount += result.errorCount;
262 fatalErrorCount += result.fatalErrorCount;
263 warningCount += result.warningCount;
264 }
265
266 return { errorCount, fatalErrorCount, warningCount };
267}
268
269/**
270 * Check if a given file path is a directory or not.
271 * @param {string} filePath The path to a file to check.
272 * @returns {Promise<boolean>} `true` if the given path is a directory.
273 */
274async function isDirectory(filePath) {
275 try {
276 return (await stat(filePath)).isDirectory();
277 } catch (error) {
278 if (error.code === "ENOENT" || error.code === "ENOTDIR") {
279 return false;
280 }
281 throw error;
282 }
283}
284
285/**
286 * Outputs the results of the linting.
287 * @param {ESLint} engine The ESLint instance to use.
288 * @param {LintResult[]} results The results to print.
289 * @param {string} format The name of the formatter to use or the path to the formatter.
290 * @param {string} outputFile The path for the output file.
291 * @param {ResultsMeta} resultsMeta Warning count and max threshold.
292 * @returns {Promise<boolean>} True if the printing succeeds, false if not.
293 * @private
294 */
295async function printResults(engine, results, format, outputFile, resultsMeta) {
296 let formatter;
297
298 try {
299 formatter = await engine.loadFormatter(format);
300 } catch (e) {
301 log.error(e.message);
302 return false;
303 }
304
305 const output = await formatter.format(results, resultsMeta);
306
307 if (outputFile) {
308 const filePath = path.resolve(process.cwd(), outputFile);
309
310 if (await isDirectory(filePath)) {
311 log.error("Cannot write to output file path, it is a directory: %s", outputFile);
312 return false;
313 }
314
315 try {
316 await mkdir(path.dirname(filePath), { recursive: true });
317 await writeFile(filePath, output);
318 } catch (ex) {
319 log.error("There was a problem writing the output file:\n%s", ex);
320 return false;
321 }
322 } else if (output) {
323 log.info(output);
324 }
325
326 return true;
327}
328
329//------------------------------------------------------------------------------
330// Public Interface
331//------------------------------------------------------------------------------
332
333/**
334 * Encapsulates all CLI behavior for eslint. Makes it easier to test as well as
335 * for other Node.js programs to effectively run the CLI.
336 */
337const cli = {
338
339 /**
340 * Calculates the command string for the --inspect-config operation.
341 * @param {string} configFile The path to the config file to inspect.
342 * @returns {Promise<string>} The command string to execute.
343 */
344 async calculateInspectConfigFlags(configFile) {
345
346 // find the config file
347 const {
348 configFilePath,
349 basePath,
350 error
351 } = await locateConfigFileToUse({ cwd: process.cwd(), configFile });
352
353 if (error) {
354 throw error;
355 }
356
357 return ["--config", configFilePath, "--basePath", basePath];
358 },
359
360 /**
361 * Executes the CLI based on an array of arguments that is passed in.
362 * @param {string|Array|Object} args The arguments to process.
363 * @param {string} [text] The text to lint (used for TTY).
364 * @param {boolean} [allowFlatConfig=true] Whether or not to allow flat config.
365 * @returns {Promise<number>} The exit code for the operation.
366 */
367 async execute(args, text, allowFlatConfig = true) {
368 if (Array.isArray(args)) {
369 debug("CLI args: %o", args.slice(2));
370 }
371
372 /*
373 * Before doing anything, we need to see if we are using a
374 * flat config file. If so, then we need to change the way command
375 * line args are parsed. This is temporary, and when we fully
376 * switch to flat config we can remove this logic.
377 */
378
379 const usingFlatConfig = allowFlatConfig && await shouldUseFlatConfig();
380
381 debug("Using flat config?", usingFlatConfig);
382
383 if (allowFlatConfig && !usingFlatConfig) {
384 process.emitWarning("You are using an eslintrc configuration file, which is deprecated and support will be removed in v10.0.0. Please migrate to an eslint.config.js file. See https://eslint.org/docs/latest/use/configure/migration-guide for details.", "ESLintRCWarning");
385 }
386
387 const CLIOptions = createCLIOptions(usingFlatConfig);
388
389 /** @type {ParsedCLIOptions} */
390 let options;
391
392 try {
393 options = CLIOptions.parse(args);
394 } catch (error) {
395 debug("Error parsing CLI options:", error.message);
396
397 let errorMessage = error.message;
398
399 if (usingFlatConfig) {
400 errorMessage += "\nYou're using eslint.config.js, some command line flags are no longer available. Please see https://eslint.org/docs/latest/use/command-line-interface for details.";
401 }
402
403 log.error(errorMessage);
404 return 2;
405 }
406
407 const files = options._;
408 const useStdin = typeof text === "string";
409
410 if (options.help) {
411 log.info(CLIOptions.generateHelp());
412 return 0;
413 }
414 if (options.version) {
415 log.info(RuntimeInfo.version());
416 return 0;
417 }
418 if (options.envInfo) {
419 try {
420 log.info(RuntimeInfo.environment());
421 return 0;
422 } catch (err) {
423 debug("Error retrieving environment info");
424 log.error(err.message);
425 return 2;
426 }
427 }
428
429 if (options.printConfig) {
430 if (files.length) {
431 log.error("The --print-config option must be used with exactly one file name.");
432 return 2;
433 }
434 if (useStdin) {
435 log.error("The --print-config option is not available for piped-in code.");
436 return 2;
437 }
438
439 const engine = usingFlatConfig
440 ? new ESLint(await translateOptions(options, "flat"))
441 : new LegacyESLint(await translateOptions(options));
442 const fileConfig =
443 await engine.calculateConfigForFile(options.printConfig);
444
445 log.info(JSON.stringify(fileConfig, null, " "));
446 return 0;
447 }
448
449 if (options.inspectConfig) {
450
451 log.info("You can also run this command directly using 'npx @eslint/config-inspector' in the same directory as your configuration file.");
452
453 try {
454 const flatOptions = await translateOptions(options, "flat");
455 const spawn = require("cross-spawn");
456 const flags = await cli.calculateInspectConfigFlags(flatOptions.overrideConfigFile);
457
458 spawn.sync("npx", ["@eslint/config-inspector", ...flags], { encoding: "utf8", stdio: "inherit" });
459 } catch (error) {
460 log.error(error);
461 return 2;
462 }
463
464 return 0;
465 }
466
467 debug(`Running on ${useStdin ? "text" : "files"}`);
468
469 if (options.fix && options.fixDryRun) {
470 log.error("The --fix option and the --fix-dry-run option cannot be used together.");
471 return 2;
472 }
473 if (useStdin && options.fix) {
474 log.error("The --fix option is not available for piped-in code; use --fix-dry-run instead.");
475 return 2;
476 }
477 if (options.fixType && !options.fix && !options.fixDryRun) {
478 log.error("The --fix-type option requires either --fix or --fix-dry-run.");
479 return 2;
480 }
481
482 if (options.reportUnusedDisableDirectives && options.reportUnusedDisableDirectivesSeverity !== void 0) {
483 log.error("The --report-unused-disable-directives option and the --report-unused-disable-directives-severity option cannot be used together.");
484 return 2;
485 }
486
487 const ActiveESLint = usingFlatConfig ? ESLint : LegacyESLint;
488
489 const engine = new ActiveESLint(await translateOptions(options, usingFlatConfig ? "flat" : "eslintrc"));
490 let results;
491
492 if (useStdin) {
493 results = await engine.lintText(text, {
494 filePath: options.stdinFilename,
495
496 // flatConfig respects CLI flag and constructor warnIgnored, eslintrc forces true for backwards compatibility
497 warnIgnored: usingFlatConfig ? void 0 : true
498 });
499 } else {
500 results = await engine.lintFiles(files);
501 }
502
503 if (options.fix) {
504 debug("Fix mode enabled - applying fixes");
505 await ActiveESLint.outputFixes(results);
506 }
507
508 let resultsToPrint = results;
509
510 if (options.quiet) {
511 debug("Quiet mode enabled - filtering out warnings");
512 resultsToPrint = ActiveESLint.getErrorResults(resultsToPrint);
513 }
514
515 const resultCounts = countErrors(results);
516 const tooManyWarnings = options.maxWarnings >= 0 && resultCounts.warningCount > options.maxWarnings;
517 const resultsMeta = tooManyWarnings
518 ? {
519 maxWarningsExceeded: {
520 maxWarnings: options.maxWarnings,
521 foundWarnings: resultCounts.warningCount
522 }
523 }
524 : {};
525
526 if (await printResults(engine, resultsToPrint, options.format, options.outputFile, resultsMeta)) {
527
528 // Errors and warnings from the original unfiltered results should determine the exit code
529 const shouldExitForFatalErrors =
530 options.exitOnFatalError && resultCounts.fatalErrorCount > 0;
531
532 if (!resultCounts.errorCount && tooManyWarnings) {
533 log.error(
534 "ESLint found too many warnings (maximum: %s).",
535 options.maxWarnings
536 );
537 }
538
539 if (shouldExitForFatalErrors) {
540 return 2;
541 }
542
543 return (resultCounts.errorCount || tooManyWarnings) ? 1 : 0;
544 }
545
546 return 2;
547 }
548};
549
550module.exports = cli;