prettier/src/cli-util.js

721 lines
20 KiB
JavaScript

"use strict";
const path = require("path");
const camelCase = require("camelcase");
const dashify = require("dashify");
const minimist = require("minimist");
const fs = require("fs");
const globby = require("globby");
const ignore = require("ignore");
const chalk = require("chalk");
const readline = require("readline");
const leven = require("leven");
const prettier = eval("require")("../index");
const cleanAST = require("./clean-ast").cleanAST;
const resolver = require("./resolve-config");
const constant = require("./cli-constant");
const validator = require("./cli-validator");
const apiDefaultOptions = require("./options").defaults;
const errors = require("./errors");
const logger = require("./cli-logger");
const thirdParty = require("./third-party");
const OPTION_USAGE_THRESHOLD = 25;
const CHOICE_USAGE_MARGIN = 3;
const CHOICE_USAGE_INDENTATION = 2;
function getOptions(argv) {
return constant.detailedOptions
.filter(option => option.forwardToApi)
.reduce(
(current, option) =>
Object.assign(current, { [option.forwardToApi]: argv[option.name] }),
{}
);
}
function dashifyObject(object) {
return Object.keys(object || {}).reduce((output, key) => {
output[dashify(key)] = object[key];
return output;
}, {});
}
function diff(a, b) {
return require("diff").createTwoFilesPatch("", "", a, b, "", "", {
context: 2
});
}
function handleError(filename, error) {
const isParseError = Boolean(error && error.loc);
const isValidationError = /Validation Error/.test(error && error.message);
// For parse errors and validation errors, we only want to show the error
// message formatted in a nice way. `String(error)` takes care of that. Other
// (unexpected) errors are passed as-is as a separate argument to
// `console.error`. That includes the stack trace (if any), and shows a nice
// `util.inspect` of throws things that aren't `Error` objects. (The Flow
// parser has mistakenly thrown arrays sometimes.)
if (isParseError) {
logger.error(`${filename}: ${String(error)}`);
} else if (isValidationError || error instanceof errors.ConfigError) {
logger.error(String(error));
// If validation fails for one file, it will fail for all of them.
process.exit(1);
} else if (error instanceof errors.DebugError) {
logger.error(`${filename}: ${error.message}`);
} else {
logger.error(filename + ": " + (error.stack || error));
}
// Don't exit the process if one file failed
process.exitCode = 2;
}
function logResolvedConfigPathOrDie(filePath) {
const configFile = resolver.resolveConfigFile.sync(filePath);
if (configFile) {
console.log(path.relative(process.cwd(), configFile));
} else {
process.exit(1);
}
}
function writeOutput(result, options) {
// Don't use `console.log` here since it adds an extra newline at the end.
process.stdout.write(result.formatted);
if (options.cursorOffset >= 0) {
process.stderr.write(result.cursorOffset + "\n");
}
}
function listDifferent(argv, input, options, filename) {
if (!argv["list-different"]) {
return;
}
options = Object.assign({}, options, { filepath: filename });
if (!prettier.check(input, options)) {
if (!argv["write"]) {
console.log(filename);
}
process.exitCode = 1;
}
return true;
}
function format(argv, input, opt) {
if (argv["debug-print-doc"]) {
const doc = prettier.__debug.printToDoc(input, opt);
return { formatted: prettier.__debug.formatDoc(doc) };
}
if (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 ast = cleanAST(prettier.__debug.parse(input, opt));
const past = cleanAST(prettier.__debug.parse(pp, opt));
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: opt.filepath || "(stdin)\n" };
}
return prettier.formatWithCursor(input, opt);
}
function getOptionsOrDie(argv, filePath) {
try {
if (argv["config"] === false) {
logger.debug("'--no-config' option found, skip loading config file.");
return null;
}
logger.debug(
argv["config"]
? `load config file from '${argv["config"]}'`
: `resolve config from '${filePath}'`
);
const options = resolver.resolveConfig.sync(filePath, {
editorconfig: argv.editorconfig,
config: argv["config"]
});
logger.debug("loaded options `" + JSON.stringify(options) + "`");
return options;
} catch (error) {
logger.error("Invalid configuration file: " + error.message);
process.exit(2);
}
}
function getOptionsForFile(argv, filepath) {
const options = getOptionsOrDie(argv, filepath);
const appliedOptions = Object.assign(
{ filepath },
applyConfigPrecedence(
argv,
options && normalizeConfig("api", options, constant.detailedOptionMap)
)
);
logger.debug(
`applied config-precedence (${argv["config-precedence"]}): ` +
`${JSON.stringify(appliedOptions)}`
);
return appliedOptions;
}
function parseArgsToOptions(argv, overrideDefaults) {
return getOptions(
normalizeConfig(
"cli",
minimist(
argv.__args,
Object.assign({
string: constant.minimistOptions.string,
boolean: constant.minimistOptions.boolean,
default: Object.assign(
{},
dashifyObject(apiDefaultOptions),
dashifyObject(overrideDefaults)
)
})
),
{ warning: false }
)
);
}
function applyConfigPrecedence(argv, options) {
try {
switch (argv["config-precedence"]) {
case "cli-override":
return parseArgsToOptions(argv, options);
case "file-override":
return Object.assign({}, parseArgsToOptions(argv), options);
case "prefer-file":
return options || parseArgsToOptions(argv);
}
} catch (error) {
logger.error(error.toString());
process.exit(2);
}
}
function formatStdin(argv) {
const filepath = argv["stdin-filepath"]
? path.resolve(process.cwd(), argv["stdin-filepath"])
: process.cwd();
const ignorer = createIgnorer(argv);
const relativeFilepath = path.relative(process.cwd(), filepath);
if (relativeFilepath && ignorer.filter([relativeFilepath]).length === 0) {
return;
}
thirdParty.getStream(process.stdin).then(input => {
const options = getOptionsForFile(argv, filepath);
if (listDifferent(argv, input, options, "(stdin)")) {
return;
}
try {
writeOutput(format(argv, input, options), options);
} catch (error) {
handleError("stdin", error);
}
});
}
function createIgnorer(argv) {
const ignoreFilePath = path.resolve(argv["ignore-path"]);
let ignoreText = "";
try {
ignoreText = fs.readFileSync(ignoreFilePath, "utf8");
} catch (readError) {
if (readError.code !== "ENOENT") {
logger.error(`Unable to read ${ignoreFilePath}: ` + readError.message);
process.exit(2);
}
}
return ignore().add(ignoreText);
}
function eachFilename(argv, patterns, callback) {
const ignoreNodeModules = argv["with-node-modules"] === false;
// The ignorer will be used to filter file paths after the glob is checked,
// before any files are actually read
const ignorer = createIgnorer(argv);
if (ignoreNodeModules) {
patterns = patterns.concat(["!**/node_modules/**", "!./node_modules/**"]);
}
try {
const filePaths = globby
.sync(patterns, { dot: true })
.map(filePath => path.relative(process.cwd(), filePath));
if (filePaths.length === 0) {
logger.error(`No matching files. Patterns tried: ${patterns.join(" ")}`);
process.exitCode = 2;
return;
}
ignorer
.filter(filePaths)
.forEach(filePath =>
callback(filePath, getOptionsForFile(argv, filePath))
);
} catch (error) {
logger.error(
`Unable to expand glob patterns: ${patterns.join(" ")}\n${error.message}`
);
// Don't exit the process if one pattern failed
process.exitCode = 2;
}
}
function formatFiles(argv) {
eachFilename(argv, argv.__filePatterns, (filename, options) => {
if (argv["write"] && process.stdout.isTTY) {
// Don't use `console.log` here since we need to replace this line.
process.stdout.write(filename);
}
let input;
try {
input = fs.readFileSync(filename, "utf8");
} catch (error) {
// Add newline to split errors from filename line.
process.stdout.write("\n");
logger.error(`Unable to read file: ${filename}\n${error.message}`);
// Don't exit the process if one file failed
process.exitCode = 2;
return;
}
listDifferent(argv, input, options, filename);
const start = Date.now();
let result;
let output;
try {
result = format(
argv,
input,
Object.assign({}, options, { filepath: filename })
);
output = result.formatted;
} catch (error) {
// Add newline to split errors from filename line.
process.stdout.write("\n");
handleError(filename, error);
return;
}
if (argv["write"]) {
if (process.stdout.isTTY) {
// Remove previously printed filename to log it with duration.
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0, null);
}
// Don't write the file if it won't change in order not to invalidate
// mtime based caches.
if (output === input) {
if (!argv["list-different"]) {
console.log(`${chalk.grey(filename)} ${Date.now() - start}ms`);
}
} else {
if (argv["list-different"]) {
console.log(filename);
} else {
console.log(`${filename} ${Date.now() - start}ms`);
}
try {
fs.writeFileSync(filename, output, "utf8");
} catch (error) {
logger.error(`Unable to write file: ${filename}\n${error.message}`);
// Don't exit the process if one file failed
process.exitCode = 2;
}
}
} else if (argv["debug-check"]) {
if (output) {
console.log(output);
} else {
process.exitCode = 2;
}
} else if (!argv["list-different"]) {
writeOutput(result, options);
}
});
}
function getOptionsWithOpposites(options) {
// Add --no-foo after --foo.
const optionsWithOpposites = options.map(option => [
option.description ? option : null,
option.oppositeDescription
? Object.assign({}, option, {
name: `no-${option.name}`,
type: "boolean",
description: option.oppositeDescription
})
: null
]);
return flattenArray(optionsWithOpposites).filter(Boolean);
}
function createUsage() {
const options = getOptionsWithOpposites(constant.detailedOptions).filter(
// remove unnecessary option (e.g. `semi`, `color`, etc.), which is only used for --help <flag>
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 =>
firstCategories.indexOf(category) === -1 &&
lastCategories.indexOf(category) === -1
);
const allCategories = firstCategories.concat(restCategories, lastCategories);
const optionsUsage = allCategories.map(category => {
const categoryOptions = groupedOptions[category]
.map(option => createOptionUsage(option, OPTION_USAGE_THRESHOLD))
.join("\n");
return `${category} options:\n\n${indent(categoryOptions, 2)}`;
});
return [constant.usageSummary].concat(optionsUsage, [""]).join("\n\n");
}
function createOptionUsage(option, threshold) {
const header = createOptionUsageHeader(option);
const optionDefaultValue = getOptionDefaultValue(option.name);
return createOptionUsageRow(
header,
`${option.description}${
optionDefaultValue === undefined
? ""
: `\nDefaults to ${optionDefaultValue}.`
}`,
threshold
);
}
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)
.map(choice => choice.value)
.join("|")}>`;
default:
return `<${option.type}>`;
}
}
function flattenArray(array) {
return [].concat.apply([], array);
}
function getOptionWithLevenSuggestion(options, optionName) {
// support aliases
const optionNameContainers = flattenArray(
options.map((option, index) => [
{ value: option.name, index },
option.alias ? { value: option.alias, index } : null
])
).filter(Boolean);
const optionNameContainer = optionNameContainers.find(
optionNameContainer => optionNameContainer.value === optionName
);
if (optionNameContainer !== undefined) {
return options[optionNameContainer.index];
}
const suggestedOptionNameContainer = optionNameContainers.find(
optionNameContainer => leven(optionNameContainer.value, optionName) < 3
);
if (suggestedOptionNameContainer !== undefined) {
const suggestedOptionName = suggestedOptionNameContainer.value;
logger.warn(
`Unknown option name "${optionName}", did you mean "${suggestedOptionName}"?`
);
return options[suggestedOptionNameContainer.index];
}
logger.warn(`Unknown option name "${optionName}"`);
return options.find(option => option.name === "help");
}
function createChoiceUsages(choices, margin, indentation) {
const activeChoices = choices.filter(choice => !choice.deprecated);
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(optionName) {
const option = getOptionWithLevenSuggestion(
getOptionsWithOpposites(constant.detailedOptions),
optionName
);
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(option.name);
const defaults =
optionDefaultValue !== undefined
? `\n\nDefault: ${optionDefaultValue}`
: "";
return `${header}${description}${choices}${defaults}`;
}
function getOptionDefaultValue(optionName) {
// --no-option
if (!(optionName in constant.detailedOptionMap)) {
return undefined;
}
const option = constant.detailedOptionMap[optionName];
if (option.default !== undefined) {
return option.default;
}
const optionCamelName = camelCase(optionName);
if (optionCamelName in apiDefaultOptions) {
return apiDefaultOptions[optionCamelName];
}
return undefined;
}
function indent(str, spaces) {
return str.replace(/^/gm, " ".repeat(spaces));
}
function groupBy(array, getKey) {
return array.reduce((obj, item) => {
const key = getKey(item);
const previousItems = key in obj ? obj[key] : [];
return Object.assign({}, obj, { [key]: previousItems.concat(item) });
}, Object.create(null));
}
/** @param {'api' | 'cli'} type */
function normalizeConfig(type, rawConfig, options) {
if (type === "api" && rawConfig === null) {
return null;
}
options = options || {};
const consoleWarn =
options.warning === false ? () => {} : logger.warn.bind(logger);
const normalized = {};
Object.keys(rawConfig).forEach(rawKey => {
const rawValue = rawConfig[rawKey];
const key = type === "cli" ? rawKey : dashify(rawKey);
if (type === "cli" && key === "_") {
normalized[rawKey] = rawValue;
return;
}
if (type === "cli" && key.length === 1) {
// do nothing with alias
return;
}
const option = constant.detailedOptionMap[key];
// unknown option
if (option === undefined) {
if (type === "api") {
consoleWarn(`Ignored unknown option: ${rawKey}`);
} else {
const optionName = rawValue === false ? `no-${rawKey}` : rawKey;
consoleWarn(`Ignored unknown option: --${optionName}`);
}
return;
}
const value = getValue(rawValue, option);
if (option.exception !== undefined) {
if (typeof option.exception === "function") {
if (option.exception(value)) {
normalized[rawKey] = value;
return;
}
} else {
if (value === option.exception) {
normalized[rawKey] = value;
return;
}
}
}
try {
switch (option.type) {
case "int":
validator.validateIntOption(type, value, option);
normalized[rawKey] = Number(value);
break;
case "choice":
validator.validateChoiceOption(type, value, option);
normalized[rawKey] = value;
break;
default:
normalized[rawKey] = value;
break;
}
} catch (error) {
logger.error(error.message);
process.exit(2);
}
});
return normalized;
function getOptionName(option) {
return type === "cli" ? `--${option.name}` : camelCase(option.name);
}
function getRedirectName(option, choice) {
return type === "cli"
? `--${option.name}=${choice.redirect}`
: `{ ${camelCase(option.name)}: ${JSON.stringify(choice.redirect)} }`;
}
function getValue(rawValue, option) {
const optionName = getOptionName(option);
if (rawValue && option.deprecated) {
let warning = `\`${optionName}\` is deprecated.`;
if (typeof option.deprecated === "string") {
warning += ` ${option.deprecated}`;
}
consoleWarn(warning);
}
const value = option.getter(rawValue, rawConfig);
if (option.type === "choice") {
const choice = option.choices.find(choice => choice.value === rawValue);
if (choice !== undefined && choice.deprecated) {
const warningDescription =
rawValue === ""
? "without an argument"
: `with value \`${rawValue}\``;
const redirectName = getRedirectName(option, choice);
consoleWarn(
`\`${optionName}\` ${warningDescription} is deprecated. Prettier now treats it as: \`${redirectName}\`.`
);
return choice.redirect;
}
}
return value;
}
}
module.exports = {
logResolvedConfigPathOrDie,
format,
formatStdin,
formatFiles,
createUsage,
createDetailedUsage,
normalizeConfig
};