From c507a05086d3273e77aab73502855fdb09c40f5e Mon Sep 17 00:00:00 2001 From: sosukesuzuki Date: Thu, 3 Dec 2020 17:43:16 +0900 Subject: [PATCH] Extract files and rename util -> core WIP Extract create-minimist-options Remove warning Fix Extract format.js Fix Remove fix Fix Extract files for cli and rename util -> core Update tsconfig --- src/cli/context.js | 149 +++ src/cli/core.js | 58 ++ src/cli/create-minimist-options.js | 37 + src/cli/expand-patterns.js | 2 +- src/cli/format.js | 422 ++++++++ src/cli/index.js | 20 +- src/cli/logger.js | 51 + src/cli/option-map.js | 65 ++ src/cli/option.js | 145 +++ src/cli/usage.js | 192 ++++ src/cli/util.js | 1041 ------------------- tests_integration/__tests__/help-options.js | 6 +- tsconfig.json | 7 +- 13 files changed, 1139 insertions(+), 1056 deletions(-) create mode 100644 src/cli/context.js create mode 100644 src/cli/core.js create mode 100644 src/cli/create-minimist-options.js create mode 100644 src/cli/format.js create mode 100644 src/cli/logger.js create mode 100644 src/cli/option-map.js create mode 100644 src/cli/option.js create mode 100644 src/cli/usage.js delete mode 100644 src/cli/util.js diff --git a/src/cli/context.js b/src/cli/context.js new file mode 100644 index 000000000000..95da0f822d29 --- /dev/null +++ b/src/cli/context.js @@ -0,0 +1,149 @@ +"use strict"; +const fromPairs = require("lodash/fromPairs"); +const pick = require("lodash/pick"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +const { createLogger } = require("./logger"); +const { + optionsModule, + optionsNormalizer, + utils: { arrayify }, +} = require("./prettier-internal"); +const minimist = require("./minimist"); +const constant = require("./constant"); +const { + createDetailedOptionMap, + normalizeDetailedOptionMap, +} = require("./option-map"); +const createMinimistOptions = require("./create-minimist-options"); + +/** + * @typedef {Object} Context + * @property logger + * @property {string[]} args + * @property argv + * @property {string[]} filePatterns + * @property {any[]} supportOptions + * @property detailedOptions + * @property detailedOptionMap + * @property apiDefaultOptions + * @property languages + * @property {Partial[]} stack + */ + +/** @returns {Context} */ +function createContext(args) { + /** @type {Context} */ + const context = { args, stack: [] }; + + updateContextArgv(context); + normalizeContextArgv(context, ["loglevel", "plugin", "plugin-search-dir"]); + + context.logger = createLogger(context.argv.loglevel); + + updateContextArgv( + context, + context.argv.plugin, + context.argv["plugin-search-dir"] + ); + + return context; +} + +function initContext(context) { + // split into 2 step so that we could wrap this in a `try..catch` in cli/index.js + normalizeContextArgv(context); +} + +/** + * @param {Context} context + * @param {string[]} plugins + * @param {string[]=} pluginSearchDirs + */ +function updateContextOptions(context, plugins, pluginSearchDirs) { + const { options: supportOptions, languages } = prettier.getSupportInfo({ + showDeprecated: true, + showUnreleased: true, + showInternal: true, + plugins, + pluginSearchDirs, + }); + + const detailedOptionMap = normalizeDetailedOptionMap({ + ...createDetailedOptionMap(supportOptions), + ...constant.options, + }); + + const detailedOptions = arrayify(detailedOptionMap, "name"); + + const apiDefaultOptions = { + ...optionsModule.hiddenDefaults, + ...fromPairs( + supportOptions + .filter(({ deprecated }) => !deprecated) + .map((option) => [option.name, option.default]) + ), + }; + + Object.assign(context, { + supportOptions, + detailedOptions, + detailedOptionMap, + apiDefaultOptions, + languages, + }); +} + +/** + * @param {Context} context + * @param {string[]} plugins + * @param {string[]=} pluginSearchDirs + */ +function pushContextPlugins(context, plugins, pluginSearchDirs) { + context.stack.push( + pick(context, [ + "supportOptions", + "detailedOptions", + "detailedOptionMap", + "apiDefaultOptions", + "languages", + ]) + ); + updateContextOptions(context, plugins, pluginSearchDirs); +} + +/** + * @param {Context} context + */ +function popContextPlugins(context) { + Object.assign(context, context.stack.pop()); +} + +function updateContextArgv(context, plugins, pluginSearchDirs) { + pushContextPlugins(context, plugins, pluginSearchDirs); + + const minimistOptions = createMinimistOptions(context.detailedOptions); + const argv = minimist(context.args, minimistOptions); + + context.argv = argv; + context.filePatterns = argv._.map((file) => String(file)); +} + +function normalizeContextArgv(context, keys) { + const detailedOptions = !keys + ? context.detailedOptions + : context.detailedOptions.filter((option) => keys.includes(option.name)); + const argv = !keys ? context.argv : pick(context.argv, keys); + + context.argv = optionsNormalizer.normalizeCliOptions(argv, detailedOptions, { + logger: context.logger, + }); +} + +module.exports = { + createContext, + initContext, + popContextPlugins, + pushContextPlugins, +}; diff --git a/src/cli/core.js b/src/cli/core.js new file mode 100644 index 000000000000..fe6c7b0024d3 --- /dev/null +++ b/src/cli/core.js @@ -0,0 +1,58 @@ +"use strict"; + +const path = require("path"); + +const stringify = require("fast-json-stable-stringify"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); + +const { format, formatStdin, formatFiles } = require("./format"); +const { createContext, initContext } = require("./context"); +const { + normalizeDetailedOptionMap, + createDetailedOptionMap, +} = require("./option-map"); +const { createDetailedUsage, createUsage } = require("./usage"); + +function logResolvedConfigPathOrDie(context) { + const configFile = prettier.resolveConfigFile.sync( + context.argv["find-config-path"] + ); + if (configFile) { + context.logger.log(path.relative(process.cwd(), configFile)); + } else { + process.exit(1); + } +} + +function logFileInfoOrDie(context) { + const options = { + ignorePath: context.argv["ignore-path"], + withNodeModules: context.argv["with-node-modules"], + plugins: context.argv.plugin, + pluginSearchDirs: context.argv["plugin-search-dir"], + resolveConfig: context.argv.config !== false, + }; + + context.logger.log( + prettier.format( + stringify(prettier.getFileInfo.sync(context.argv["file-info"], options)), + { parser: "json" } + ) + ); +} + +module.exports = { + createContext, + createDetailedOptionMap, + createDetailedUsage, + createUsage, + format, + formatFiles, + formatStdin, + initContext, + logResolvedConfigPathOrDie, + logFileInfoOrDie, + normalizeDetailedOptionMap, +}; diff --git a/src/cli/create-minimist-options.js b/src/cli/create-minimist-options.js new file mode 100644 index 000000000000..91b8d1c3e426 --- /dev/null +++ b/src/cli/create-minimist-options.js @@ -0,0 +1,37 @@ +"use strict"; + +const partition = require("lodash/partition"); +const flat = require("lodash/flatten"); +const fromPairs = require("lodash/fromPairs"); + +module.exports = function createMinimistOptions(detailedOptions) { + const [boolean, string] = partition( + detailedOptions, + ({ type }) => type === "boolean" + ).map((detailedOptions) => + flat( + detailedOptions.map(({ name, alias }) => (alias ? [name, alias] : [name])) + ) + ); + + const defaults = fromPairs( + detailedOptions + .filter( + (option) => + !option.deprecated && + (!option.forwardToApi || + option.name === "plugin" || + option.name === "plugin-search-dir") && + option.default !== undefined + ) + .map((option) => [option.name, option.default]) + ); + + return { + // we use vnopts' AliasSchema to handle aliases for better error messages + alias: {}, + boolean, + string, + default: defaults, + }; +}; diff --git a/src/cli/expand-patterns.js b/src/cli/expand-patterns.js index 346301ad00e7..7dba07ddbf8b 100644 --- a/src/cli/expand-patterns.js +++ b/src/cli/expand-patterns.js @@ -5,7 +5,7 @@ const fs = require("fs"); const fastGlob = require("fast-glob"); const flat = require("lodash/flatten"); -/** @typedef {import('./util').Context} Context */ +/** @typedef {import('./context').Context} Context */ /** * @param {Context} context diff --git a/src/cli/format.js b/src/cli/format.js new file mode 100644 index 000000000000..99e6f59d6561 --- /dev/null +++ b/src/cli/format.js @@ -0,0 +1,422 @@ +"use strict"; + +const fs = require("fs"); +const readline = require("readline"); +const path = require("path"); + +const chalk = require("chalk"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); +// eslint-disable-next-line no-restricted-modules +const { getStdin } = require("../common/third-party"); + +const { createIgnorer, errors } = require("./prettier-internal"); +const { expandPatterns, fixWindowsSlashes } = require("./expand-patterns"); +const { getOptionsForFile } = require("./option"); +const isTTY = require("./is-tty"); + +function diff(a, b) { + return require("diff").createTwoFilesPatch("", "", a, b, "", "", { + context: 2, + }); +} + +function handleError(context, filename, error) { + if (error instanceof errors.UndefinedParserError) { + // Can't test on CI, `isTTY()` is always false, see ./is-tty.js + /* istanbul ignore next */ + if ((context.argv.write || context.argv["ignore-unknown"]) && isTTY()) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0, null); + } + if (context.argv["ignore-unknown"]) { + return; + } + if (!context.argv.check && !context.argv["list-different"]) { + process.exitCode = 2; + } + context.logger.error(error.message); + return; + } + + if (context.argv.write) { + // Add newline to split errors from filename line. + process.stdout.write("\n"); + } + + const isParseError = Boolean(error && error.loc); + const isValidationError = /^Invalid \S+ value\./.test(error && error.message); + + if (isParseError) { + // `invalid.js: SyntaxError: Unexpected token (1:1)`. + context.logger.error(`${filename}: ${String(error)}`); + } else if (isValidationError || error instanceof errors.ConfigError) { + // `Invalid printWidth value. Expected an integer, but received 0.5.` + context.logger.error(error.message); + // If validation fails for one file, it will fail for all of them. + process.exit(1); + } else if (error instanceof errors.DebugError) { + // `invalid.js: Some debug error message` + context.logger.error(`${filename}: ${error.message}`); + } else { + // `invalid.js: Error: Some unexpected error\n[stack trace]` + /* istanbul ignore next */ + context.logger.error(filename + ": " + (error.stack || error)); + } + + // Don't exit the process if one file failed + process.exitCode = 2; +} + +function writeOutput(context, result, options) { + // Don't use `console.log` here since it adds an extra newline at the end. + process.stdout.write( + context.argv["debug-check"] ? result.filepath : result.formatted + ); + + if (options && options.cursorOffset >= 0) { + process.stderr.write(result.cursorOffset + "\n"); + } +} + +function listDifferent(context, input, options, filename) { + if (!context.argv.check && !context.argv["list-different"]) { + return; + } + + try { + if (!options.filepath && !options.parser) { + throw new errors.UndefinedParserError( + "No parser and no file path given, couldn't infer a parser." + ); + } + if (!prettier.check(input, options)) { + if (!context.argv.write) { + context.logger.log(filename); + process.exitCode = 1; + } + } + } catch (error) { + context.logger.error(error.message); + } + + return true; +} + +function format(context, input, opt) { + if (!opt.parser && !opt.filepath) { + throw new errors.UndefinedParserError( + "No parser and no file path given, couldn't infer a parser." + ); + } + + if (context.argv["debug-print-doc"]) { + const doc = prettier.__debug.printToDoc(input, opt); + return { formatted: prettier.__debug.formatDoc(doc) }; + } + + if (context.argv["debug-check"]) { + const pp = prettier.format(input, opt); + const pppp = prettier.format(pp, opt); + if (pp !== pppp) { + throw new errors.DebugError( + "prettier(input) !== prettier(prettier(input))\n" + diff(pp, pppp) + ); + } else { + const stringify = (obj) => JSON.stringify(obj, null, 2); + const ast = stringify( + prettier.__debug.parse(input, opt, /* massage */ true).ast + ); + const past = stringify( + prettier.__debug.parse(pp, opt, /* massage */ true).ast + ); + + /* istanbul ignore next */ + if (ast !== past) { + const MAX_AST_SIZE = 2097152; // 2MB + const astDiff = + ast.length > MAX_AST_SIZE || past.length > MAX_AST_SIZE + ? "AST diff too large to render" + : diff(ast, past); + throw new errors.DebugError( + "ast(input) !== ast(prettier(input))\n" + + astDiff + + "\n" + + diff(input, pp) + ); + } + } + return { formatted: pp, filepath: opt.filepath || "(stdin)\n" }; + } + + /* istanbul ignore next */ + if (context.argv["debug-benchmark"]) { + let benchmark; + try { + benchmark = eval("require")("benchmark"); + } catch (err) { + context.logger.debug( + "'--debug-benchmark' requires the 'benchmark' package to be installed." + ); + process.exit(2); + } + context.logger.debug( + "'--debug-benchmark' option found, measuring formatWithCursor with 'benchmark' module." + ); + const suite = new benchmark.Suite(); + suite + .add("format", () => { + prettier.formatWithCursor(input, opt); + }) + .on("cycle", (event) => { + const results = { + benchmark: String(event.target), + hz: event.target.hz, + ms: event.target.times.cycle * 1000, + }; + context.logger.debug( + "'--debug-benchmark' measurements for formatWithCursor: " + + JSON.stringify(results, null, 2) + ); + }) + .run({ async: false }); + } else if (context.argv["debug-repeat"] > 0) { + const repeat = context.argv["debug-repeat"]; + context.logger.debug( + "'--debug-repeat' option found, running formatWithCursor " + + repeat + + " times." + ); + // should be using `performance.now()`, but only `Date` is cross-platform enough + const now = Date.now ? () => Date.now() : () => +new Date(); + let totalMs = 0; + for (let i = 0; i < repeat; ++i) { + const startMs = now(); + prettier.formatWithCursor(input, opt); + totalMs += now() - startMs; + } + const averageMs = totalMs / repeat; + const results = { + repeat, + hz: 1000 / averageMs, + ms: averageMs, + }; + context.logger.debug( + "'--debug-repeat' measurements for formatWithCursor: " + + JSON.stringify(results, null, 2) + ); + } + + return prettier.formatWithCursor(input, opt); +} + +function createIgnorerFromContextOrDie(context) { + try { + return createIgnorer.sync( + context.argv["ignore-path"], + context.argv["with-node-modules"] + ); + } catch (e) { + context.logger.error(e.message); + process.exit(2); + } +} + +function formatStdin(context) { + const filepath = context.argv["stdin-filepath"] + ? path.resolve(process.cwd(), context.argv["stdin-filepath"]) + : process.cwd(); + + const ignorer = createIgnorerFromContextOrDie(context); + // If there's an ignore-path set, the filename must be relative to the + // ignore path, not the current working directory. + const relativeFilepath = context.argv["ignore-path"] + ? path.relative(path.dirname(context.argv["ignore-path"]), filepath) + : path.relative(process.cwd(), filepath); + + getStdin() + .then((input) => { + if ( + relativeFilepath && + ignorer.ignores(fixWindowsSlashes(relativeFilepath)) + ) { + writeOutput(context, { formatted: input }); + return; + } + + const options = getOptionsForFile(context, filepath); + + if (listDifferent(context, input, options, "(stdin)")) { + return; + } + + writeOutput(context, format(context, input, options), options); + }) + .catch((error) => { + handleError(context, relativeFilepath || "stdin", error); + }); +} + +function formatFiles(context) { + // The ignorer will be used to filter file paths after the glob is checked, + // before any files are actually written + const ignorer = createIgnorerFromContextOrDie(context); + + let numberOfUnformattedFilesFound = 0; + + if (context.argv.check) { + context.logger.log("Checking formatting..."); + } + + for (const pathOrError of expandPatterns(context)) { + if (typeof pathOrError === "object") { + context.logger.error(pathOrError.error); + // Don't exit, but set the exit code to 2 + process.exitCode = 2; + continue; + } + + const filename = pathOrError; + // If there's an ignore-path set, the filename must be relative to the + // ignore path, not the current working directory. + const ignoreFilename = context.argv["ignore-path"] + ? path.relative(path.dirname(context.argv["ignore-path"]), filename) + : filename; + + const fileIgnored = ignorer.ignores(fixWindowsSlashes(ignoreFilename)); + if ( + fileIgnored && + (context.argv["debug-check"] || + context.argv.write || + context.argv.check || + context.argv["list-different"]) + ) { + continue; + } + + const options = { + ...getOptionsForFile(context, filename), + filepath: filename, + }; + + if (isTTY()) { + context.logger.log(filename, { newline: false }); + } + + let input; + try { + input = fs.readFileSync(filename, "utf8"); + } catch (error) { + // Add newline to split errors from filename line. + /* istanbul ignore next */ + context.logger.log(""); + + /* istanbul ignore next */ + context.logger.error( + `Unable to read file: ${filename}\n${error.message}` + ); + + // Don't exit the process if one file failed + /* istanbul ignore next */ + process.exitCode = 2; + + /* istanbul ignore next */ + continue; + } + + if (fileIgnored) { + writeOutput(context, { formatted: input }, options); + continue; + } + + const start = Date.now(); + + let result; + let output; + + try { + result = format(context, input, options); + output = result.formatted; + } catch (error) { + handleError(context, filename, error); + continue; + } + + const isDifferent = output !== input; + + if (isTTY()) { + // Remove previously printed filename to log it with duration. + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0, null); + } + + if (context.argv.write) { + // Don't write the file if it won't change in order not to invalidate + // mtime based caches. + if (isDifferent) { + if (!context.argv.check && !context.argv["list-different"]) { + context.logger.log(`${filename} ${Date.now() - start}ms`); + } + + try { + fs.writeFileSync(filename, output, "utf8"); + } catch (error) { + /* istanbul ignore next */ + context.logger.error( + `Unable to write file: ${filename}\n${error.message}` + ); + + // Don't exit the process if one file failed + /* istanbul ignore next */ + process.exitCode = 2; + } + } else if (!context.argv.check && !context.argv["list-different"]) { + context.logger.log(`${chalk.grey(filename)} ${Date.now() - start}ms`); + } + } else if (context.argv["debug-check"]) { + /* istanbul ignore else */ + if (result.filepath) { + context.logger.log(result.filepath); + } else { + process.exitCode = 2; + } + } else if (!context.argv.check && !context.argv["list-different"]) { + writeOutput(context, result, options); + } + + if (isDifferent) { + if (context.argv.check) { + context.logger.warn(filename); + } else if (context.argv["list-different"]) { + context.logger.log(filename); + } + numberOfUnformattedFilesFound += 1; + } + } + + // Print check summary based on expected exit code + if (context.argv.check) { + if (numberOfUnformattedFilesFound === 0) { + context.logger.log("All matched files use Prettier code style!"); + } else { + context.logger.warn( + context.argv.write + ? "Code style issues fixed in the above file(s)." + : "Code style issues found in the above file(s). Forgot to run Prettier?" + ); + } + } + + // Ensure non-zero exitCode when using --check/list-different is not combined with --write + if ( + (context.argv.check || context.argv["list-different"]) && + numberOfUnformattedFilesFound > 0 && + !process.exitCode && + !context.argv.write + ) { + process.exitCode = 1; + } +} + +module.exports = { format, formatStdin, formatFiles }; diff --git a/src/cli/index.js b/src/cli/index.js index 1d5c15edc8d1..27b059b4fbb3 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -6,13 +6,13 @@ require("please-upgrade-node")(require("../../package.json")); const stringify = require("fast-json-stable-stringify"); // eslint-disable-next-line no-restricted-modules const prettier = require("../index"); -const util = require("./util"); +const core = require("./core"); function run(args) { - const context = util.createContext(args); + const context = core.createContext(args); try { - util.initContext(context); + core.initContext(context); context.logger.debug(`normalized argv: ${JSON.stringify(context.argv)}`); @@ -44,8 +44,8 @@ function run(args) { if (context.argv.help !== undefined) { context.logger.log( typeof context.argv.help === "string" && context.argv.help !== "" - ? util.createDetailedUsage(context, context.argv.help) - : util.createUsage(context) + ? core.createDetailedUsage(context, context.argv.help) + : core.createUsage(context) ); process.exit(0); } @@ -65,15 +65,15 @@ function run(args) { (!process.stdin.isTTY || context.args["stdin-filepath"]); if (context.argv["find-config-path"]) { - util.logResolvedConfigPathOrDie(context); + core.logResolvedConfigPathOrDie(context); } else if (context.argv["file-info"]) { - util.logFileInfoOrDie(context); + core.logFileInfoOrDie(context); } else if (useStdin) { - util.formatStdin(context); + core.formatStdin(context); } else if (hasFilePatterns) { - util.formatFiles(context); + core.formatFiles(context); } else { - context.logger.log(util.createUsage(context)); + context.logger.log(core.createUsage(context)); process.exit(1); } } catch (error) { diff --git a/src/cli/logger.js b/src/cli/logger.js new file mode 100644 index 000000000000..c899381d548a --- /dev/null +++ b/src/cli/logger.js @@ -0,0 +1,51 @@ +"use strict"; + +const chalk = require("chalk"); + +function createLogger(logLevel) { + return { + warn: createLogFunc("warn", "yellow"), + error: createLogFunc("error", "red"), + debug: createLogFunc("debug", "blue"), + log: createLogFunc("log"), + }; + + function createLogFunc(loggerName, color) { + if (!shouldLog(loggerName)) { + return () => {}; + } + + const prefix = color ? `[${chalk[color](loggerName)}] ` : ""; + return function (message, opts) { + opts = { newline: true, ...opts }; + const stream = process[loggerName === "log" ? "stdout" : "stderr"]; + stream.write(message.replace(/^/gm, prefix) + (opts.newline ? "\n" : "")); + }; + } + + function shouldLog(loggerName) { + switch (logLevel) { + case "silent": + return false; + case "debug": + if (loggerName === "debug") { + return true; + } + // fall through + case "log": + if (loggerName === "log") { + return true; + } + // fall through + case "warn": + if (loggerName === "warn") { + return true; + } + // fall through + case "error": + return loggerName === "error"; + } + } +} + +module.exports = { createLogger }; diff --git a/src/cli/option-map.js b/src/cli/option-map.js new file mode 100644 index 000000000000..baf25598a161 --- /dev/null +++ b/src/cli/option-map.js @@ -0,0 +1,65 @@ +"use strict"; + +const dashify = require("dashify"); + +const fromPairs = require("lodash/fromPairs"); + +const { coreOptions } = require("./prettier-internal"); + +function normalizeDetailedOption(name, option) { + return { + category: coreOptions.CATEGORY_OTHER, + ...option, + choices: + option.choices && + option.choices.map((choice) => { + const newChoice = { + description: "", + deprecated: false, + ...(typeof choice === "object" ? choice : { value: choice }), + }; + /* istanbul ignore next */ + if (newChoice.value === true) { + newChoice.value = ""; // backward compatibility for original boolean option + } + return newChoice; + }), + }; +} + +function normalizeDetailedOptionMap(detailedOptionMap) { + return fromPairs( + Object.entries(detailedOptionMap) + .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) + .map(([name, option]) => [name, normalizeDetailedOption(name, option)]) + ); +} + +function createDetailedOptionMap(supportOptions) { + return fromPairs( + supportOptions.map((option) => { + const newOption = { + ...option, + name: option.cliName || dashify(option.name), + description: option.cliDescription || option.description, + category: option.cliCategory || coreOptions.CATEGORY_FORMAT, + forwardToApi: option.name, + }; + + /* istanbul ignore next */ + if (option.deprecated) { + delete newOption.forwardToApi; + delete newOption.description; + delete newOption.oppositeDescription; + newOption.deprecated = true; + } + + return [newOption.name, newOption]; + }) + ); +} + +module.exports = { + normalizeDetailedOptionMap, + createDetailedOptionMap, +}; diff --git a/src/cli/option.js b/src/cli/option.js new file mode 100644 index 000000000000..211fc4f8c4bc --- /dev/null +++ b/src/cli/option.js @@ -0,0 +1,145 @@ +"use strict"; + +const dashify = require("dashify"); + +const fromPairs = require("lodash/fromPairs"); + +// eslint-disable-next-line no-restricted-modules +const prettier = require("../index"); + +const minimist = require("./minimist"); +const { popContextPlugins, pushContextPlugins } = require("./context"); +const { optionsNormalizer } = require("./prettier-internal"); +const createMinimistOptions = require("./create-minimist-options"); + +function getOptions(argv, detailedOptions) { + return fromPairs( + detailedOptions + .filter(({ forwardToApi }) => forwardToApi) + .map(({ forwardToApi, name }) => [forwardToApi, argv[name]]) + ); +} + +function cliifyOptions(object, apiDetailedOptionMap) { + return Object.keys(object || {}).reduce((output, key) => { + const apiOption = apiDetailedOptionMap[key]; + const cliKey = apiOption ? apiOption.name : key; + + output[dashify(cliKey)] = object[key]; + return output; + }, {}); +} + +function createApiDetailedOptionMap(detailedOptions) { + return fromPairs( + detailedOptions + .filter( + (option) => option.forwardToApi && option.forwardToApi !== option.name + ) + .map((option) => [option.forwardToApi, option]) + ); +} + +function parseArgsToOptions(context, overrideDefaults) { + const minimistOptions = createMinimistOptions(context.detailedOptions); + const apiDetailedOptionMap = createApiDetailedOptionMap( + context.detailedOptions + ); + return getOptions( + optionsNormalizer.normalizeCliOptions( + minimist(context.args, { + string: minimistOptions.string, + boolean: minimistOptions.boolean, + default: cliifyOptions(overrideDefaults, apiDetailedOptionMap), + }), + context.detailedOptions, + { logger: false } + ), + context.detailedOptions + ); +} + +function getOptionsOrDie(context, filePath) { + try { + if (context.argv.config === false) { + context.logger.debug( + "'--no-config' option found, skip loading config file." + ); + return null; + } + + context.logger.debug( + context.argv.config + ? `load config file from '${context.argv.config}'` + : `resolve config from '${filePath}'` + ); + + const options = prettier.resolveConfig.sync(filePath, { + editorconfig: context.argv.editorconfig, + config: context.argv.config, + }); + + context.logger.debug("loaded options `" + JSON.stringify(options) + "`"); + return options; + } catch (error) { + context.logger.error( + `Invalid configuration file \`${filePath}\`: ` + error.message + ); + process.exit(2); + } +} + +function applyConfigPrecedence(context, options) { + try { + switch (context.argv["config-precedence"]) { + case "cli-override": + return parseArgsToOptions(context, options); + case "file-override": + return { ...parseArgsToOptions(context), ...options }; + case "prefer-file": + return options || parseArgsToOptions(context); + } + } catch (error) { + /* istanbul ignore next */ + context.logger.error(error.toString()); + + /* istanbul ignore next */ + process.exit(2); + } +} + +function getOptionsForFile(context, filepath) { + const options = getOptionsOrDie(context, filepath); + + const hasPlugins = options && options.plugins; + if (hasPlugins) { + pushContextPlugins(context, options.plugins); + } + + const appliedOptions = { + filepath, + ...applyConfigPrecedence( + context, + options && + optionsNormalizer.normalizeApiOptions(options, context.supportOptions, { + logger: context.logger, + }) + ), + }; + + context.logger.debug( + `applied config-precedence (${context.argv["config-precedence"]}): ` + + `${JSON.stringify(appliedOptions)}` + ); + + if (hasPlugins) { + popContextPlugins(context); + } + + return appliedOptions; +} + +module.exports = { + getOptionsForFile, + createMinimistOptions, +}; diff --git a/src/cli/usage.js b/src/cli/usage.js new file mode 100644 index 000000000000..222df21fc2f4 --- /dev/null +++ b/src/cli/usage.js @@ -0,0 +1,192 @@ +"use strict"; + +const groupBy = require("lodash/groupBy"); +const flat = require("lodash/flatten"); +const camelCase = require("camelcase"); +const constant = require("./constant"); + +const OPTION_USAGE_THRESHOLD = 25; +const CHOICE_USAGE_MARGIN = 3; +const CHOICE_USAGE_INDENTATION = 2; + +function indent(str, spaces) { + return str.replace(/^/gm, " ".repeat(spaces)); +} + +function createDefaultValueDisplay(value) { + return Array.isArray(value) + ? `[${value.map(createDefaultValueDisplay).join(", ")}]` + : value; +} + +function getOptionDefaultValue(context, optionName) { + // --no-option + if (!(optionName in context.detailedOptionMap)) { + return; + } + + const option = context.detailedOptionMap[optionName]; + + if (option.default !== undefined) { + return option.default; + } + + const optionCamelName = camelCase(optionName); + if (optionCamelName in context.apiDefaultOptions) { + return context.apiDefaultOptions[optionCamelName]; + } +} + +function createOptionUsageHeader(option) { + const name = `--${option.name}`; + const alias = option.alias ? `-${option.alias},` : null; + const type = createOptionUsageType(option); + return [alias, name, type].filter(Boolean).join(" "); +} + +function createOptionUsageRow(header, content, threshold) { + const separator = + header.length >= threshold + ? `\n${" ".repeat(threshold)}` + : " ".repeat(threshold - header.length); + + const description = content.replace(/\n/g, `\n${" ".repeat(threshold)}`); + + return `${header}${separator}${description}`; +} + +function createOptionUsageType(option) { + switch (option.type) { + case "boolean": + return null; + case "choice": + return `<${option.choices + .filter((choice) => !choice.deprecated && choice.since !== null) + .map((choice) => choice.value) + .join("|")}>`; + default: + return `<${option.type}>`; + } +} + +function createChoiceUsages(choices, margin, indentation) { + const activeChoices = choices.filter( + (choice) => !choice.deprecated && choice.since !== null + ); + const threshold = + activeChoices + .map((choice) => choice.value.length) + .reduce((current, length) => Math.max(current, length), 0) + margin; + return activeChoices.map((choice) => + indent( + createOptionUsageRow(choice.value, choice.description, threshold), + indentation + ) + ); +} + +function createOptionUsage(context, option, threshold) { + const header = createOptionUsageHeader(option); + const optionDefaultValue = getOptionDefaultValue(context, option.name); + return createOptionUsageRow( + header, + `${option.description}${ + optionDefaultValue === undefined + ? "" + : `\nDefaults to ${createDefaultValueDisplay(optionDefaultValue)}.` + }`, + threshold + ); +} + +function getOptionsWithOpposites(options) { + // Add --no-foo after --foo. + const optionsWithOpposites = options.map((option) => [ + option.description ? option : null, + option.oppositeDescription + ? { + ...option, + name: `no-${option.name}`, + type: "boolean", + description: option.oppositeDescription, + } + : null, + ]); + return flat(optionsWithOpposites).filter(Boolean); +} + +function createUsage(context) { + const options = getOptionsWithOpposites(context.detailedOptions).filter( + // remove unnecessary option (e.g. `semi`, `color`, etc.), which is only used for --help + (option) => + !( + option.type === "boolean" && + option.oppositeDescription && + !option.name.startsWith("no-") + ) + ); + + const groupedOptions = groupBy(options, (option) => option.category); + + const firstCategories = constant.categoryOrder.slice(0, -1); + const lastCategories = constant.categoryOrder.slice(-1); + const restCategories = Object.keys(groupedOptions).filter( + (category) => !constant.categoryOrder.includes(category) + ); + const allCategories = [ + ...firstCategories, + ...restCategories, + ...lastCategories, + ]; + + const optionsUsage = allCategories.map((category) => { + const categoryOptions = groupedOptions[category] + .map((option) => + createOptionUsage(context, option, OPTION_USAGE_THRESHOLD) + ) + .join("\n"); + return `${category} options:\n\n${indent(categoryOptions, 2)}`; + }); + + return [constant.usageSummary].concat(optionsUsage, [""]).join("\n\n"); +} + +function createDetailedUsage(context, flag) { + const option = getOptionsWithOpposites(context.detailedOptions).find( + (option) => option.name === flag || option.alias === flag + ); + + const header = createOptionUsageHeader(option); + const description = `\n\n${indent(option.description, 2)}`; + + const choices = + option.type !== "choice" + ? "" + : `\n\nValid options:\n\n${createChoiceUsages( + option.choices, + CHOICE_USAGE_MARGIN, + CHOICE_USAGE_INDENTATION + ).join("\n")}`; + + const optionDefaultValue = getOptionDefaultValue(context, option.name); + const defaults = + optionDefaultValue !== undefined + ? `\n\nDefault: ${createDefaultValueDisplay(optionDefaultValue)}` + : ""; + + const pluginDefaults = + option.pluginDefaults && Object.keys(option.pluginDefaults).length + ? `\nPlugin defaults:${Object.keys(option.pluginDefaults).map( + (key) => + `\n* ${key}: ${createDefaultValueDisplay( + option.pluginDefaults[key] + )}` + )}` + : ""; + return `${header}${description}${choices}${defaults}${pluginDefaults}`; +} + +module.exports = { + createUsage, + createDetailedUsage, +}; diff --git a/src/cli/util.js b/src/cli/util.js deleted file mode 100644 index a95a4b58043c..000000000000 --- a/src/cli/util.js +++ /dev/null @@ -1,1041 +0,0 @@ -"use strict"; - -const path = require("path"); -const fs = require("fs"); -const readline = require("readline"); -const camelCase = require("camelcase"); -const dashify = require("dashify"); - -const chalk = require("chalk"); -const stringify = require("fast-json-stable-stringify"); -const fromPairs = require("lodash/fromPairs"); -const pick = require("lodash/pick"); -const groupBy = require("lodash/groupBy"); -const flat = require("lodash/flatten"); -const partition = require("lodash/partition"); -// eslint-disable-next-line no-restricted-modules -const prettier = require("../index"); -// eslint-disable-next-line no-restricted-modules -const { getStdin } = require("../common/third-party"); -const { - createIgnorer, - errors, - coreOptions, - optionsModule, - optionsNormalizer, - utils: { arrayify }, -} = require("./prettier-internal"); - -const minimist = require("./minimist"); -const { expandPatterns, fixWindowsSlashes } = require("./expand-patterns"); -const constant = require("./constant"); -const isTTY = require("./is-tty"); - -const OPTION_USAGE_THRESHOLD = 25; -const CHOICE_USAGE_MARGIN = 3; -const CHOICE_USAGE_INDENTATION = 2; - -function getOptions(argv, detailedOptions) { - return fromPairs( - detailedOptions - .filter(({ forwardToApi }) => forwardToApi) - .map(({ forwardToApi, name }) => [forwardToApi, argv[name]]) - ); -} - -function cliifyOptions(object, apiDetailedOptionMap) { - return Object.keys(object || {}).reduce((output, key) => { - const apiOption = apiDetailedOptionMap[key]; - const cliKey = apiOption ? apiOption.name : key; - - output[dashify(cliKey)] = object[key]; - return output; - }, {}); -} - -function diff(a, b) { - return require("diff").createTwoFilesPatch("", "", a, b, "", "", { - context: 2, - }); -} - -function handleError(context, filename, error) { - if (error instanceof errors.UndefinedParserError) { - // Can't test on CI, `isTTY()` is always false, see ./is-tty.js - /* istanbul ignore next */ - if ((context.argv.write || context.argv["ignore-unknown"]) && isTTY()) { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0, null); - } - if (context.argv["ignore-unknown"]) { - return; - } - if (!context.argv.check && !context.argv["list-different"]) { - process.exitCode = 2; - } - context.logger.error(error.message); - return; - } - - if (context.argv.write) { - // Add newline to split errors from filename line. - process.stdout.write("\n"); - } - - const isParseError = Boolean(error && error.loc); - const isValidationError = /^Invalid \S+ value\./.test(error && error.message); - - if (isParseError) { - // `invalid.js: SyntaxError: Unexpected token (1:1)`. - context.logger.error(`${filename}: ${String(error)}`); - } else if (isValidationError || error instanceof errors.ConfigError) { - // `Invalid printWidth value. Expected an integer, but received 0.5.` - context.logger.error(error.message); - // If validation fails for one file, it will fail for all of them. - process.exit(1); - } else if (error instanceof errors.DebugError) { - // `invalid.js: Some debug error message` - context.logger.error(`${filename}: ${error.message}`); - } else { - // `invalid.js: Error: Some unexpected error\n[stack trace]` - /* istanbul ignore next */ - context.logger.error(filename + ": " + (error.stack || error)); - } - - // Don't exit the process if one file failed - process.exitCode = 2; -} - -function logResolvedConfigPathOrDie(context) { - const configFile = prettier.resolveConfigFile.sync( - context.argv["find-config-path"] - ); - if (configFile) { - context.logger.log(path.relative(process.cwd(), configFile)); - } else { - process.exit(1); - } -} - -function logFileInfoOrDie(context) { - const options = { - ignorePath: context.argv["ignore-path"], - withNodeModules: context.argv["with-node-modules"], - plugins: context.argv.plugin, - pluginSearchDirs: context.argv["plugin-search-dir"], - resolveConfig: context.argv.config !== false, - }; - - context.logger.log( - prettier.format( - stringify(prettier.getFileInfo.sync(context.argv["file-info"], options)), - { parser: "json" } - ) - ); -} - -function writeOutput(context, result, options) { - // Don't use `console.log` here since it adds an extra newline at the end. - process.stdout.write( - context.argv["debug-check"] ? result.filepath : result.formatted - ); - - if (options && options.cursorOffset >= 0) { - process.stderr.write(result.cursorOffset + "\n"); - } -} - -function listDifferent(context, input, options, filename) { - if (!context.argv.check && !context.argv["list-different"]) { - return; - } - - try { - if (!options.filepath && !options.parser) { - throw new errors.UndefinedParserError( - "No parser and no file path given, couldn't infer a parser." - ); - } - if (!prettier.check(input, options)) { - if (!context.argv.write) { - context.logger.log(filename); - process.exitCode = 1; - } - } - } catch (error) { - context.logger.error(error.message); - } - - return true; -} - -function format(context, input, opt) { - if (!opt.parser && !opt.filepath) { - throw new errors.UndefinedParserError( - "No parser and no file path given, couldn't infer a parser." - ); - } - - if (context.argv["debug-print-doc"]) { - const doc = prettier.__debug.printToDoc(input, opt); - return { formatted: prettier.__debug.formatDoc(doc) }; - } - - if (context.argv["debug-check"]) { - const pp = prettier.format(input, opt); - const pppp = prettier.format(pp, opt); - if (pp !== pppp) { - throw new errors.DebugError( - "prettier(input) !== prettier(prettier(input))\n" + diff(pp, pppp) - ); - } else { - const stringify = (obj) => JSON.stringify(obj, null, 2); - const ast = stringify( - prettier.__debug.parse(input, opt, /* massage */ true).ast - ); - const past = stringify( - prettier.__debug.parse(pp, opt, /* massage */ true).ast - ); - - /* istanbul ignore next */ - if (ast !== past) { - const MAX_AST_SIZE = 2097152; // 2MB - const astDiff = - ast.length > MAX_AST_SIZE || past.length > MAX_AST_SIZE - ? "AST diff too large to render" - : diff(ast, past); - throw new errors.DebugError( - "ast(input) !== ast(prettier(input))\n" + - astDiff + - "\n" + - diff(input, pp) - ); - } - } - return { formatted: pp, filepath: opt.filepath || "(stdin)\n" }; - } - - /* istanbul ignore next */ - if (context.argv["debug-benchmark"]) { - let benchmark; - try { - benchmark = eval("require")("benchmark"); - } catch (err) { - context.logger.debug( - "'--debug-benchmark' requires the 'benchmark' package to be installed." - ); - process.exit(2); - } - context.logger.debug( - "'--debug-benchmark' option found, measuring formatWithCursor with 'benchmark' module." - ); - const suite = new benchmark.Suite(); - suite - .add("format", () => { - prettier.formatWithCursor(input, opt); - }) - .on("cycle", (event) => { - const results = { - benchmark: String(event.target), - hz: event.target.hz, - ms: event.target.times.cycle * 1000, - }; - context.logger.debug( - "'--debug-benchmark' measurements for formatWithCursor: " + - JSON.stringify(results, null, 2) - ); - }) - .run({ async: false }); - } else if (context.argv["debug-repeat"] > 0) { - const repeat = context.argv["debug-repeat"]; - context.logger.debug( - "'--debug-repeat' option found, running formatWithCursor " + - repeat + - " times." - ); - // should be using `performance.now()`, but only `Date` is cross-platform enough - const now = Date.now ? () => Date.now() : () => +new Date(); - let totalMs = 0; - for (let i = 0; i < repeat; ++i) { - const startMs = now(); - prettier.formatWithCursor(input, opt); - totalMs += now() - startMs; - } - const averageMs = totalMs / repeat; - const results = { - repeat, - hz: 1000 / averageMs, - ms: averageMs, - }; - context.logger.debug( - "'--debug-repeat' measurements for formatWithCursor: " + - JSON.stringify(results, null, 2) - ); - } - - return prettier.formatWithCursor(input, opt); -} - -function getOptionsOrDie(context, filePath) { - try { - if (context.argv.config === false) { - context.logger.debug( - "'--no-config' option found, skip loading config file." - ); - return null; - } - - context.logger.debug( - context.argv.config - ? `load config file from '${context.argv.config}'` - : `resolve config from '${filePath}'` - ); - - const options = prettier.resolveConfig.sync(filePath, { - editorconfig: context.argv.editorconfig, - config: context.argv.config, - }); - - context.logger.debug("loaded options `" + JSON.stringify(options) + "`"); - return options; - } catch (error) { - context.logger.error( - `Invalid configuration file \`${filePath}\`: ` + error.message - ); - process.exit(2); - } -} - -function getOptionsForFile(context, filepath) { - const options = getOptionsOrDie(context, filepath); - - const hasPlugins = options && options.plugins; - if (hasPlugins) { - pushContextPlugins(context, options.plugins); - } - - const appliedOptions = { - filepath, - ...applyConfigPrecedence( - context, - options && - optionsNormalizer.normalizeApiOptions(options, context.supportOptions, { - logger: context.logger, - }) - ), - }; - - context.logger.debug( - `applied config-precedence (${context.argv["config-precedence"]}): ` + - `${JSON.stringify(appliedOptions)}` - ); - - if (hasPlugins) { - popContextPlugins(context); - } - - return appliedOptions; -} - -function parseArgsToOptions(context, overrideDefaults) { - const minimistOptions = createMinimistOptions(context.detailedOptions); - const apiDetailedOptionMap = createApiDetailedOptionMap( - context.detailedOptions - ); - return getOptions( - optionsNormalizer.normalizeCliOptions( - minimist(context.args, { - string: minimistOptions.string, - boolean: minimistOptions.boolean, - default: cliifyOptions(overrideDefaults, apiDetailedOptionMap), - }), - context.detailedOptions, - { logger: false } - ), - context.detailedOptions - ); -} - -function applyConfigPrecedence(context, options) { - try { - switch (context.argv["config-precedence"]) { - case "cli-override": - return parseArgsToOptions(context, options); - case "file-override": - return { ...parseArgsToOptions(context), ...options }; - case "prefer-file": - return options || parseArgsToOptions(context); - } - } catch (error) { - /* istanbul ignore next */ - context.logger.error(error.toString()); - - /* istanbul ignore next */ - process.exit(2); - } -} - -function formatStdin(context) { - const filepath = context.argv["stdin-filepath"] - ? path.resolve(process.cwd(), context.argv["stdin-filepath"]) - : process.cwd(); - - const ignorer = createIgnorerFromContextOrDie(context); - // If there's an ignore-path set, the filename must be relative to the - // ignore path, not the current working directory. - const relativeFilepath = context.argv["ignore-path"] - ? path.relative(path.dirname(context.argv["ignore-path"]), filepath) - : path.relative(process.cwd(), filepath); - - getStdin() - .then((input) => { - if ( - relativeFilepath && - ignorer.ignores(fixWindowsSlashes(relativeFilepath)) - ) { - writeOutput(context, { formatted: input }); - return; - } - - const options = getOptionsForFile(context, filepath); - - if (listDifferent(context, input, options, "(stdin)")) { - return; - } - - writeOutput(context, format(context, input, options), options); - }) - .catch((error) => { - handleError(context, relativeFilepath || "stdin", error); - }); -} - -function createIgnorerFromContextOrDie(context) { - try { - return createIgnorer.sync( - context.argv["ignore-path"], - context.argv["with-node-modules"] - ); - } catch (e) { - context.logger.error(e.message); - process.exit(2); - } -} - -function formatFiles(context) { - // The ignorer will be used to filter file paths after the glob is checked, - // before any files are actually written - const ignorer = createIgnorerFromContextOrDie(context); - - let numberOfUnformattedFilesFound = 0; - - if (context.argv.check) { - context.logger.log("Checking formatting..."); - } - - for (const pathOrError of expandPatterns(context)) { - if (typeof pathOrError === "object") { - context.logger.error(pathOrError.error); - // Don't exit, but set the exit code to 2 - process.exitCode = 2; - continue; - } - - const filename = pathOrError; - // If there's an ignore-path set, the filename must be relative to the - // ignore path, not the current working directory. - const ignoreFilename = context.argv["ignore-path"] - ? path.relative(path.dirname(context.argv["ignore-path"]), filename) - : filename; - - const fileIgnored = ignorer.ignores(fixWindowsSlashes(ignoreFilename)); - if ( - fileIgnored && - (context.argv["debug-check"] || - context.argv.write || - context.argv.check || - context.argv["list-different"]) - ) { - continue; - } - - const options = { - ...getOptionsForFile(context, filename), - filepath: filename, - }; - - if (isTTY()) { - context.logger.log(filename, { newline: false }); - } - - let input; - try { - input = fs.readFileSync(filename, "utf8"); - } catch (error) { - // Add newline to split errors from filename line. - /* istanbul ignore next */ - context.logger.log(""); - - /* istanbul ignore next */ - context.logger.error( - `Unable to read file: ${filename}\n${error.message}` - ); - - // Don't exit the process if one file failed - /* istanbul ignore next */ - process.exitCode = 2; - - /* istanbul ignore next */ - continue; - } - - if (fileIgnored) { - writeOutput(context, { formatted: input }, options); - continue; - } - - const start = Date.now(); - - let result; - let output; - - try { - result = format(context, input, options); - output = result.formatted; - } catch (error) { - handleError(context, filename, error); - continue; - } - - const isDifferent = output !== input; - - if (isTTY()) { - // Remove previously printed filename to log it with duration. - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0, null); - } - - if (context.argv.write) { - // Don't write the file if it won't change in order not to invalidate - // mtime based caches. - if (isDifferent) { - if (!context.argv.check && !context.argv["list-different"]) { - context.logger.log(`${filename} ${Date.now() - start}ms`); - } - - try { - fs.writeFileSync(filename, output, "utf8"); - } catch (error) { - /* istanbul ignore next */ - context.logger.error( - `Unable to write file: ${filename}\n${error.message}` - ); - - // Don't exit the process if one file failed - /* istanbul ignore next */ - process.exitCode = 2; - } - } else if (!context.argv.check && !context.argv["list-different"]) { - context.logger.log(`${chalk.grey(filename)} ${Date.now() - start}ms`); - } - } else if (context.argv["debug-check"]) { - /* istanbul ignore else */ - if (result.filepath) { - context.logger.log(result.filepath); - } else { - process.exitCode = 2; - } - } else if (!context.argv.check && !context.argv["list-different"]) { - writeOutput(context, result, options); - } - - if (isDifferent) { - if (context.argv.check) { - context.logger.warn(filename); - } else if (context.argv["list-different"]) { - context.logger.log(filename); - } - numberOfUnformattedFilesFound += 1; - } - } - - // Print check summary based on expected exit code - if (context.argv.check) { - if (numberOfUnformattedFilesFound === 0) { - context.logger.log("All matched files use Prettier code style!"); - } else { - context.logger.warn( - context.argv.write - ? "Code style issues fixed in the above file(s)." - : "Code style issues found in the above file(s). Forgot to run Prettier?" - ); - } - } - - // Ensure non-zero exitCode when using --check/list-different is not combined with --write - if ( - (context.argv.check || context.argv["list-different"]) && - numberOfUnformattedFilesFound > 0 && - !process.exitCode && - !context.argv.write - ) { - process.exitCode = 1; - } -} - -function getOptionsWithOpposites(options) { - // Add --no-foo after --foo. - const optionsWithOpposites = options.map((option) => [ - option.description ? option : null, - option.oppositeDescription - ? { - ...option, - name: `no-${option.name}`, - type: "boolean", - description: option.oppositeDescription, - } - : null, - ]); - return flat(optionsWithOpposites).filter(Boolean); -} - -function createUsage(context) { - const options = getOptionsWithOpposites(context.detailedOptions).filter( - // remove unnecessary option (e.g. `semi`, `color`, etc.), which is only used for --help - (option) => - !( - option.type === "boolean" && - option.oppositeDescription && - !option.name.startsWith("no-") - ) - ); - - const groupedOptions = groupBy(options, (option) => option.category); - - const firstCategories = constant.categoryOrder.slice(0, -1); - const lastCategories = constant.categoryOrder.slice(-1); - const restCategories = Object.keys(groupedOptions).filter( - (category) => !constant.categoryOrder.includes(category) - ); - const allCategories = [ - ...firstCategories, - ...restCategories, - ...lastCategories, - ]; - - const optionsUsage = allCategories.map((category) => { - const categoryOptions = groupedOptions[category] - .map((option) => - createOptionUsage(context, option, OPTION_USAGE_THRESHOLD) - ) - .join("\n"); - return `${category} options:\n\n${indent(categoryOptions, 2)}`; - }); - - return [constant.usageSummary].concat(optionsUsage, [""]).join("\n\n"); -} - -function createOptionUsage(context, option, threshold) { - const header = createOptionUsageHeader(option); - const optionDefaultValue = getOptionDefaultValue(context, option.name); - return createOptionUsageRow( - header, - `${option.description}${ - optionDefaultValue === undefined - ? "" - : `\nDefaults to ${createDefaultValueDisplay(optionDefaultValue)}.` - }`, - threshold - ); -} - -function createDefaultValueDisplay(value) { - return Array.isArray(value) - ? `[${value.map(createDefaultValueDisplay).join(", ")}]` - : value; -} - -function createOptionUsageHeader(option) { - const name = `--${option.name}`; - const alias = option.alias ? `-${option.alias},` : null; - const type = createOptionUsageType(option); - return [alias, name, type].filter(Boolean).join(" "); -} - -function createOptionUsageRow(header, content, threshold) { - const separator = - header.length >= threshold - ? `\n${" ".repeat(threshold)}` - : " ".repeat(threshold - header.length); - - const description = content.replace(/\n/g, `\n${" ".repeat(threshold)}`); - - return `${header}${separator}${description}`; -} - -function createOptionUsageType(option) { - switch (option.type) { - case "boolean": - return null; - case "choice": - return `<${option.choices - .filter((choice) => !choice.deprecated && choice.since !== null) - .map((choice) => choice.value) - .join("|")}>`; - default: - return `<${option.type}>`; - } -} - -function createChoiceUsages(choices, margin, indentation) { - const activeChoices = choices.filter( - (choice) => !choice.deprecated && choice.since !== null - ); - const threshold = - activeChoices - .map((choice) => choice.value.length) - .reduce((current, length) => Math.max(current, length), 0) + margin; - return activeChoices.map((choice) => - indent( - createOptionUsageRow(choice.value, choice.description, threshold), - indentation - ) - ); -} - -function createDetailedUsage(context, flag) { - const option = getOptionsWithOpposites(context.detailedOptions).find( - (option) => option.name === flag || option.alias === flag - ); - - const header = createOptionUsageHeader(option); - const description = `\n\n${indent(option.description, 2)}`; - - const choices = - option.type !== "choice" - ? "" - : `\n\nValid options:\n\n${createChoiceUsages( - option.choices, - CHOICE_USAGE_MARGIN, - CHOICE_USAGE_INDENTATION - ).join("\n")}`; - - const optionDefaultValue = getOptionDefaultValue(context, option.name); - const defaults = - optionDefaultValue !== undefined - ? `\n\nDefault: ${createDefaultValueDisplay(optionDefaultValue)}` - : ""; - - const pluginDefaults = - option.pluginDefaults && Object.keys(option.pluginDefaults).length - ? `\nPlugin defaults:${Object.keys(option.pluginDefaults).map( - (key) => - `\n* ${key}: ${createDefaultValueDisplay( - option.pluginDefaults[key] - )}` - )}` - : ""; - return `${header}${description}${choices}${defaults}${pluginDefaults}`; -} - -function getOptionDefaultValue(context, optionName) { - // --no-option - if (!(optionName in context.detailedOptionMap)) { - return; - } - - const option = context.detailedOptionMap[optionName]; - - if (option.default !== undefined) { - return option.default; - } - - const optionCamelName = camelCase(optionName); - if (optionCamelName in context.apiDefaultOptions) { - return context.apiDefaultOptions[optionCamelName]; - } -} - -function indent(str, spaces) { - return str.replace(/^/gm, " ".repeat(spaces)); -} - -function createLogger(logLevel) { - return { - warn: createLogFunc("warn", "yellow"), - error: createLogFunc("error", "red"), - debug: createLogFunc("debug", "blue"), - log: createLogFunc("log"), - }; - - function createLogFunc(loggerName, color) { - if (!shouldLog(loggerName)) { - return () => {}; - } - - const prefix = color ? `[${chalk[color](loggerName)}] ` : ""; - return function (message, opts) { - opts = { newline: true, ...opts }; - const stream = process[loggerName === "log" ? "stdout" : "stderr"]; - stream.write(message.replace(/^/gm, prefix) + (opts.newline ? "\n" : "")); - }; - } - - function shouldLog(loggerName) { - switch (logLevel) { - case "silent": - return false; - case "debug": - if (loggerName === "debug") { - return true; - } - // fall through - case "log": - if (loggerName === "log") { - return true; - } - // fall through - case "warn": - if (loggerName === "warn") { - return true; - } - // fall through - case "error": - return loggerName === "error"; - } - } -} - -function normalizeDetailedOption(name, option) { - return { - category: coreOptions.CATEGORY_OTHER, - ...option, - choices: - option.choices && - option.choices.map((choice) => { - const newChoice = { - description: "", - deprecated: false, - ...(typeof choice === "object" ? choice : { value: choice }), - }; - /* istanbul ignore next */ - if (newChoice.value === true) { - newChoice.value = ""; // backward compatibility for original boolean option - } - return newChoice; - }), - }; -} - -function normalizeDetailedOptionMap(detailedOptionMap) { - return fromPairs( - Object.entries(detailedOptionMap) - .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) - .map(([name, option]) => [name, normalizeDetailedOption(name, option)]) - ); -} - -function createMinimistOptions(detailedOptions) { - const [boolean, string] = partition( - detailedOptions, - ({ type }) => type === "boolean" - ).map((detailedOptions) => - flat( - detailedOptions.map(({ name, alias }) => (alias ? [name, alias] : [name])) - ) - ); - - const defaults = fromPairs( - detailedOptions - .filter( - (option) => - !option.deprecated && - (!option.forwardToApi || - option.name === "plugin" || - option.name === "plugin-search-dir") && - option.default !== undefined - ) - .map((option) => [option.name, option.default]) - ); - - return { - // we use vnopts' AliasSchema to handle aliases for better error messages - alias: {}, - boolean, - string, - default: defaults, - }; -} - -function createApiDetailedOptionMap(detailedOptions) { - return fromPairs( - detailedOptions - .filter( - (option) => option.forwardToApi && option.forwardToApi !== option.name - ) - .map((option) => [option.forwardToApi, option]) - ); -} - -function createDetailedOptionMap(supportOptions) { - return fromPairs( - supportOptions.map((option) => { - const newOption = { - ...option, - name: option.cliName || dashify(option.name), - description: option.cliDescription || option.description, - category: option.cliCategory || coreOptions.CATEGORY_FORMAT, - forwardToApi: option.name, - }; - - /* istanbul ignore next */ - if (option.deprecated) { - delete newOption.forwardToApi; - delete newOption.description; - delete newOption.oppositeDescription; - newOption.deprecated = true; - } - - return [newOption.name, newOption]; - }) - ); -} - -//-----------------------------context-util-start------------------------------- -/** - * @typedef {Object} Context - * @property logger - * @property {string[]} args - * @property argv - * @property {string[]} filePatterns - * @property {any[]} supportOptions - * @property detailedOptions - * @property detailedOptionMap - * @property apiDefaultOptions - * @property languages - * @property {Partial[]} stack - */ - -/** @returns {Context} */ -function createContext(args) { - /** @type {Context} */ - const context = { args, stack: [] }; - - updateContextArgv(context); - normalizeContextArgv(context, ["loglevel", "plugin", "plugin-search-dir"]); - - context.logger = createLogger(context.argv.loglevel); - - updateContextArgv( - context, - context.argv.plugin, - context.argv["plugin-search-dir"] - ); - - return context; -} - -function initContext(context) { - // split into 2 step so that we could wrap this in a `try..catch` in cli/index.js - normalizeContextArgv(context); -} - -/** - * @param {Context} context - * @param {string[]} plugins - * @param {string[]=} pluginSearchDirs - */ -function updateContextOptions(context, plugins, pluginSearchDirs) { - const { options: supportOptions, languages } = prettier.getSupportInfo({ - showDeprecated: true, - showUnreleased: true, - showInternal: true, - plugins, - pluginSearchDirs, - }); - - const detailedOptionMap = normalizeDetailedOptionMap({ - ...createDetailedOptionMap(supportOptions), - ...constant.options, - }); - - const detailedOptions = arrayify(detailedOptionMap, "name"); - - const apiDefaultOptions = { - ...optionsModule.hiddenDefaults, - ...fromPairs( - supportOptions - .filter(({ deprecated }) => !deprecated) - .map((option) => [option.name, option.default]) - ), - }; - - Object.assign(context, { - supportOptions, - detailedOptions, - detailedOptionMap, - apiDefaultOptions, - languages, - }); -} - -/** - * @param {Context} context - * @param {string[]} plugins - * @param {string[]=} pluginSearchDirs - */ -function pushContextPlugins(context, plugins, pluginSearchDirs) { - context.stack.push( - pick(context, [ - "supportOptions", - "detailedOptions", - "detailedOptionMap", - "apiDefaultOptions", - "languages", - ]) - ); - updateContextOptions(context, plugins, pluginSearchDirs); -} - -/** - * @param {Context} context - */ -function popContextPlugins(context) { - Object.assign(context, context.stack.pop()); -} - -function updateContextArgv(context, plugins, pluginSearchDirs) { - pushContextPlugins(context, plugins, pluginSearchDirs); - - const minimistOptions = createMinimistOptions(context.detailedOptions); - const argv = minimist(context.args, minimistOptions); - - context.argv = argv; - context.filePatterns = argv._.map((file) => String(file)); -} - -function normalizeContextArgv(context, keys) { - const detailedOptions = !keys - ? context.detailedOptions - : context.detailedOptions.filter((option) => keys.includes(option.name)); - const argv = !keys ? context.argv : pick(context.argv, keys); - - context.argv = optionsNormalizer.normalizeCliOptions(argv, detailedOptions, { - logger: context.logger, - }); -} -//------------------------------context-util-end-------------------------------- - -module.exports = { - createContext, - createDetailedOptionMap, - createDetailedUsage, - createUsage, - format, - formatFiles, - formatStdin, - initContext, - logResolvedConfigPathOrDie, - logFileInfoOrDie, - normalizeDetailedOptionMap, -}; diff --git a/tests_integration/__tests__/help-options.js b/tests_integration/__tests__/help-options.js index 2d2c06c5612c..a1dc6ae44114 100644 --- a/tests_integration/__tests__/help-options.js +++ b/tests_integration/__tests__/help-options.js @@ -3,19 +3,19 @@ const prettier = require("prettier-local"); const runPrettier = require("../runPrettier"); const constant = require("../../src/cli/constant"); -const util = require("../../src/cli/util"); +const core = require("../../src/cli/core"); const arrayify = require("../../src/utils/arrayify"); arrayify( { - ...util.createDetailedOptionMap( + ...core.createDetailedOptionMap( prettier.getSupportInfo({ showDeprecated: true, showUnreleased: true, showInternal: true, }).options ), - ...util.normalizeDetailedOptionMap(constant.options), + ...core.normalizeDetailedOptionMap(constant.options), }, "name" ).forEach((option) => { diff --git a/tsconfig.json b/tsconfig.json index 92b75bbccc60..b437d2f1972d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,12 @@ "src/common/load-plugins.js", "src/cli/index.js", "src/cli/expand-patterns.js", - "src/cli/util.js", + "src/cli/core.js", + "src/cli/context.js", + "src/cli/format.js", + "src/cli/option-map.js", + "src/cli/option.js", + "src/cli/usage.js", "src/cli/constant.js", "src/cli/prettier-internal.js", "src/languages.js",