| import { parseArgs } from "node:util"; |
| import { |
| createInvalidArgumentTypeError, |
| createUnsupportedError, |
| } from "../errors.js"; |
| import { isNumeric } from "../utils.cjs"; |
| import { list } from "./run-helpers.cjs"; |
| import { |
| aliases, |
| expectedTypeForFlag, |
| isMochaFlag, |
| stripLeadingDashes, |
| types, |
| } from "./run-option-metadata.cjs"; |
| import { isNodeFlag } from "./node-flags.cjs"; |
| import debugModule from "debug"; |
| |
| const debug = debugModule("mocha:cli:parse-args"); |
| const globOptions = ["spec", "ignore"]; |
| const aliasToCanonical = Object.entries(aliases).reduce( |
| (acc, [canonical, optionAliases]) => { |
| optionAliases.forEach((alias) => { |
| acc[alias] = canonical; |
| }); |
| return acc; |
| }, |
| {}, |
| ); |
| |
| const canonicalOptionName = (name) => aliasToCanonical[name] || name; |
| |
| const optionTypeByName = Object.entries(types).reduce((acc, [type, names]) => { |
| names.forEach((name) => { |
| acc[name] = type; |
| }); |
| return acc; |
| }, {}); |
| |
| const arrayOptions = new Set(types.array); |
| const booleanOptions = new Set(types.boolean); |
| const numberOptions = new Set(types.number); |
| const scalarOptions = new Set(types.boolean.concat(types.string, types.number)); |
| |
| /** |
| * Directly-passed Node/V8 flags are not a fixed list known to this parser. When |
| * we see them, define them as boolean options for parseArgs(), then reapply any |
| * values from equals-form flags after parsing. |
| */ |
| const createParseArgsOptions = (nodeArgs) => { |
| const options = {}; |
| |
| Object.entries(optionTypeByName).forEach(([name, type]) => { |
| const short = (aliases[name] || []).find((alias) => alias.length === 1); |
| options[name] = { |
| type: type === "boolean" ? "boolean" : "string", |
| multiple: type !== "boolean", |
| }; |
| if (short) { |
| options[name].short = short; |
| } |
| }); |
| |
| nodeArgs.forEach(([name]) => { |
| options[name] = { type: "boolean" }; |
| }); |
| |
| return options; |
| }; |
| |
| const toArray = (value) => { |
| if (value === undefined) { |
| return []; |
| } |
| return Array.isArray(value) ? value : [value]; |
| }; |
| |
| // Array-type options should have unique values, with comma-delimited values |
| // split unless the option is a glob where commas can be part of the pattern. |
| const coerceArrayOption = (name, value) => { |
| if ( |
| name === "reporter-option" && |
| value && |
| typeof value === "object" && |
| !Array.isArray(value) |
| ) { |
| return value; |
| } |
| |
| if ( |
| name === "reporter-option" && |
| Array.isArray(value) && |
| value.every( |
| (item) => item && typeof item === "object" && !Array.isArray(item), |
| ) |
| ) { |
| return Object.assign({}, ...value); |
| } |
| const values = toArray(value); |
| return Array.from( |
| new Set(globOptions.includes(name) ? values : list(values)), |
| ); |
| }; |
| |
| // Boolean/string/number options use the last value when given multiple times. |
| const coerceScalarOption = (name, value) => { |
| const scalar = Array.isArray(value) ? value[value.length - 1] : value; |
| |
| if (booleanOptions.has(name)) { |
| if (scalar === "false") { |
| return false; |
| } |
| if (scalar === "true") { |
| return true; |
| } |
| } |
| |
| return scalar; |
| }; |
| |
| const coerceNumberOption = (value) => { |
| const scalar = Array.isArray(value) ? value[value.length - 1] : value; |
| return scalar === undefined ? scalar : Number(scalar); |
| }; |
| |
| const mergeValue = (existing, next) => { |
| if (existing === undefined) { |
| return next; |
| } |
| return toArray(existing).concat(toArray(next)); |
| }; |
| |
| const mergeConfigObjects = (defaultValues, configObjects) => { |
| const defaultArrayOptions = new Set(); |
| |
| return [defaultValues] |
| .concat(configObjects.slice().reverse()) |
| .reduce((merged, config) => { |
| if (!config) { |
| return merged; |
| } |
| |
| Object.entries(config).forEach(([rawName, value]) => { |
| const name = canonicalOptionName(rawName); |
| if (name === "_") { |
| merged._ = mergeValue(value, merged._); |
| } else if (arrayOptions.has(name)) { |
| if (config === defaultValues) { |
| defaultArrayOptions.add(name); |
| merged[name] = value; |
| } else if (defaultArrayOptions.has(name)) { |
| defaultArrayOptions.delete(name); |
| merged[name] = value; |
| } else { |
| merged[name] = mergeValue(value, merged[name]); |
| } |
| } else { |
| merged[name] = value; |
| } |
| }); |
| |
| return merged; |
| }, {}); |
| }; |
| |
| const preprocessArgs = (allArgs) => { |
| return allArgs.map((arg) => { |
| const noPrefix = stripLeadingDashes(arg); |
| const [name, ...rest] = noPrefix.split("="); |
| const canonical = canonicalOptionName(name.replace(/^no-/, "")); |
| const negated = name.startsWith("no-"); |
| const prefix = arg.startsWith("--") ? "--" : arg.startsWith("-") ? "-" : ""; |
| |
| if (!prefix || name === canonical || name.length === 1) { |
| return arg; |
| } |
| |
| const normalizedName = negated ? `no-${canonical}` : canonical; |
| return `${prefix}${normalizedName}${rest.length ? `=${rest.join("=")}` : ""}`; |
| }); |
| }; |
| |
| const requiresValue = (name) => { |
| const canonical = canonicalOptionName(name); |
| return ( |
| arrayOptions.has(canonical) || |
| types.string.includes(canonical) || |
| numberOptions.has(canonical) |
| ); |
| }; |
| |
| /** |
| * Splits for downstream handling. |
| * Does not apply Mocha-specific logic. |
| * If arg is already string array, returns arg. |
| * Respects escaped chars and quoted strings. |
| * @param {string|string[]} args Input to be split, e.g. `"--grep -1"` |
| * @returns {string[]} One entry per CLI arg-equivalent |
| * @sample |
| * ```js |
| * const x = splitArgs("--timeout -1 --grep 'foo \' bar' --color"); |
| * x === ["--timeout", "-1", "--grep", "foo ' bar", "--color"]; // true |
| * ``` |
| */ |
| const splitArgs = (args) => { |
| debug(`starting splitArgs(${args})`); |
| if (!args) { |
| debug("splitArgs took in falsy value, returning empty array"); |
| return []; |
| } |
| |
| if (Array.isArray(args)) { |
| debug("splitArgs took in array, returning argument unchanged"); |
| return args; |
| } |
| |
| const result = []; |
| let current = ""; |
| let tokenStarted = false; |
| let quote = null; |
| let escaped = false; |
| |
| for (const char of args) { |
| if (escaped) { |
| current += char; |
| tokenStarted = true; |
| escaped = false; |
| continue; |
| } |
| |
| if (char === "\\") { |
| tokenStarted = true; |
| escaped = true; |
| continue; |
| } |
| |
| if (quote) { |
| if (char === quote) { |
| quote = null; |
| } else { |
| current += char; |
| tokenStarted = true; |
| } |
| continue; |
| } |
| |
| if (char === '"' || char === "'") { |
| tokenStarted = true; |
| quote = char; |
| continue; |
| } |
| // At this point, we are not in quoted mode |
| |
| // Using newlines in CLI in this way is difficult. |
| // We do not handle it for now to keep tests simple. |
| if (/\t| /.test(char)) { |
| if (tokenStarted) { |
| result.push(current); |
| current = ""; |
| tokenStarted = false; |
| } |
| continue; |
| } |
| |
| current += char; |
| tokenStarted = true; |
| } |
| |
| if (escaped) { |
| current += "\\"; |
| } |
| |
| // Unterminated is OK, usually passed via MOCHA_OPTIONS |
| if (quote) { |
| debug("unterminated quoted token, prepending quote"); |
| current = quote + current; |
| debug(`updated token: ${current}`); |
| } |
| |
| if (tokenStarted) { |
| result.push(current); |
| } |
| |
| debug(`finishing splitArgs(${args})`); |
| debug(`returning ${result}`); |
| return result; |
| }; |
| |
| /** |
| * Disallows multiple arguments after a single option, such as `--grep abc def`. |
| */ |
| const validateArgsBeforeParse = (allArgs) => { |
| allArgs.forEach((arg, index) => { |
| if (!arg.startsWith("-") || arg === "--" || arg.includes("=")) { |
| return; |
| } |
| |
| const name = canonicalOptionName(stripLeadingDashes(arg)); |
| const next = allArgs[index + 1]; |
| |
| if ( |
| requiresValue(name) && |
| (next === undefined || (next.startsWith("-") && !isNumeric(next))) |
| ) { |
| throw new Error(`Not enough arguments following: ${name}`); |
| } |
| }); |
| }; |
| |
| const normalizeParsedValues = (values, positionals) => { |
| const normalized = Object.assign({ _: positionals }, values); |
| |
| Object.keys(normalized).forEach((rawName) => { |
| if (rawName === "_") { |
| return; |
| } |
| |
| const name = canonicalOptionName(rawName); |
| if (name !== rawName) { |
| normalized[name] = mergeValue(normalized[name], normalized[rawName]); |
| delete normalized[rawName]; |
| } |
| }); |
| |
| arrayOptions.forEach((name) => { |
| if (normalized[name] !== undefined) { |
| normalized[name] = coerceArrayOption(name, normalized[name]); |
| } |
| }); |
| |
| scalarOptions.forEach((name) => { |
| if (normalized[name] !== undefined) { |
| normalized[name] = coerceScalarOption(name, normalized[name]); |
| } |
| }); |
| |
| numberOptions.forEach((name) => { |
| if (normalized[name] !== undefined) { |
| normalized[name] = coerceNumberOption(normalized[name]); |
| } |
| }); |
| |
| return normalized; |
| }; |
| |
| const createErrorForNumericPositionalArg = ( |
| numericArg, |
| allArgs, |
| parsedResult, |
| ) => { |
| // A flag for `numericArg` exists if: |
| // 1. A Mocha flag immediately preceded the numericArg in `allArgs`, and |
| // 2. parseArgs() could not assign the numericArg to that flag because the |
| // option has an incompatible datatype. |
| const flag = allArgs.find((arg, index) => { |
| const normalizedArg = stripLeadingDashes(arg); |
| return ( |
| isMochaFlag(arg) && |
| allArgs[index + 1] === String(numericArg) && |
| parsedResult[normalizedArg] !== String(numericArg) |
| ); |
| }); |
| |
| if (flag) { |
| throw createInvalidArgumentTypeError( |
| `Mocha flag '${flag}' given invalid option: '${numericArg}'`, |
| numericArg, |
| expectedTypeForFlag(flag), |
| ); |
| } |
| |
| throw createUnsupportedError( |
| `Option ${numericArg} is unsupported by the mocha cli`, |
| ); |
| }; |
| |
| /** |
| * Parse the args into a config object. |
| * Splits, |
| * handles Node args, |
| * validates general args, |
| * parses into config object, |
| * normalizes, |
| * validates numerical args, |
| * and returns resulting config object. |
| * @param {string|string[]} args |
| * @param {object} defaultValues |
| * @param {...any} configObjects |
| */ |
| export const parseMochaArgs = ( |
| args = [], |
| defaultValues = {}, |
| ...configObjects |
| ) => { |
| const allArgs = splitArgs(args); |
| |
| // Save Node-specific args for special handling. Equals-form args have values; |
| // the rest are boolean flags. |
| const nodeArgs = allArgs.reduce((acc, arg) => { |
| const pair = arg.split("="); |
| let flag = pair[0]; |
| if (isNodeFlag(flag, false)) { |
| flag = stripLeadingDashes(flag); |
| return acc.concat([[flag, arg.includes("=") ? pair[1] : true]]); |
| } |
| return acc; |
| }, []); |
| |
| validateArgsBeforeParse(allArgs); |
| const parsed = parseArgs({ |
| args: preprocessArgs(allArgs), |
| options: createParseArgsOptions(nodeArgs), |
| allowNegative: true, |
| allowPositionals: true, |
| strict: false, |
| }); |
| |
| const result = normalizeParsedValues( |
| Object.assign( |
| mergeConfigObjects(defaultValues, configObjects), |
| parsed.values, |
| ), |
| parsed.positionals, |
| ); |
| |
| const numericPositionalArg = result._.find((arg) => isNumeric(arg)); |
| if (numericPositionalArg) { |
| createErrorForNumericPositionalArg( |
| Number(numericPositionalArg), |
| allArgs, |
| result, |
| ); |
| } |
| |
| // Reapply equals-form Node/V8 flag values saved above. |
| nodeArgs.forEach(([key, value]) => { |
| result[key] = value; |
| }); |
| |
| return result; |
| }; |