UNPKG

zod-opts

Version:

node.js CLI option parser / validator using Zod

424 lines (423 loc) 14.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.isNumericValue = isNumericValue; exports.findOptionByPrefixedName = findOptionByPrefixedName; exports.pickPositionalArguments = pickPositionalArguments; exports.parsePositionalArguments = parsePositionalArguments; exports.likesOptionArg = likesOptionArg; exports.parseMultipleCommands = parseMultipleCommands; exports.parse = parse; const error_1 = require("./error"); const logger_1 = require("./logger"); function isNumericValue(str) { return !isNaN(str) && !isNaN(parseFloat(str)); } function optionRequiresValue(option) { return option.type !== "boolean"; } function findOptionByPrefixedName(options, prefixedName) { const option = options.find((opt) => `--${opt.name}` === prefixedName || (opt.alias !== undefined ? `-${opt.alias}` === prefixedName : false)); if (option != null) { return [option, false]; } const negativeMatch = prefixedName.match(/^--no-(?<name>.+)$/); if (negativeMatch == null) { return undefined; } const groups = negativeMatch.groups; const negativeName = groups.name; const negativeOption = options.find((opt) => opt.name === negativeName); if (negativeOption != null) { return [negativeOption, true]; } return undefined; } function validateOptionArguments(option, isNegative, optionArgCandidates, isForcedValue) { if (optionRequiresValue(option) && optionArgCandidates.length === 0) { // ex. --foo and foo is string return { ok: false, message: `Option '${option.name}' needs value` }; } if (isForcedValue && !optionRequiresValue(option)) { // ex. --foo=bar and foo is boolean return { ok: false, message: `Boolean option '${option.name}' does not need value`, }; } if (isNegative && option.type !== "boolean") { // ex. --no-foo=bar and foo is not boolean return { ok: false, message: `Non boolean option '${option.name}' does not accept --no- prefix`, }; } const [value, shift] = !optionRequiresValue(option) ? [undefined, 0] : option.isArray ? [optionArgCandidates, optionArgCandidates.length] : [optionArgCandidates[0], 1]; return { ok: true, value, shift, }; } function removeOptionPrefix(prefixedName) { return prefixedName.replace(/^-+/, ""); } function parseLongNameOptionArgument(options, arg, optionArgCandidates) { const match = arg.match(/^(?<prefixedName>[^=]+)(=(?<forcedValue>.*))?$/); // forcedValue may be empty string if (match == null) { throw new Error(`Invalid option: ${arg}`); } const { prefixedName, forcedValue } = match.groups; if (forcedValue !== undefined) { const result = findOptionByPrefixedName(options, prefixedName); if (result === undefined) { throw new error_1.ParseError(`Invalid option: ${removeOptionPrefix(prefixedName)}`); } const [option, isNegative] = result; const validateResult = validateOptionArguments(option, isNegative, [forcedValue], true); if (!validateResult.ok) { throw new error_1.ParseError(`${validateResult.message}: ${option.name}`); } return { candidate: { name: option.name, value: validateResult.value, isNegative, }, shift: 1, }; } else { const result = findOptionByPrefixedName(options, prefixedName); if (result === undefined) { throw new error_1.ParseError(`Invalid option: ${removeOptionPrefix(prefixedName)}`); } const [option, isNegative] = result; const validateResult = validateOptionArguments(option, isNegative, optionArgCandidates, false); if (!validateResult.ok) { throw new error_1.ParseError(`${validateResult.message}: ${option.name}`); } return { candidate: { name: option.name, value: validateResult.value, isNegative, }, shift: validateResult.shift + 1, }; } } // ex. -abc => -a, -b, -c: ok // ex. -abc 10 => -a, -b, -c=10: ok // ex. -abc 10 => -a, -b, -c, 10(next):ok // ex. -abc 10 11 => -a, -b, -c=[10, 11]: ok // ex. -abc 10 11 => -a, -b, -c, [10, 11](next):ok // ex. -abc10 => -abc=10: ng // ex. -a10 => -a=10: ok // ex. -ab10 => -a, -b=10: ng function parseShortNameMultipleOptionArgument(options, arg, optionArgCandidates) { let shift = 1; const candidates = []; (0, logger_1.debugLog)("parseShortNameMultipleOptionArgument", arg, optionArgCandidates); const text = arg.slice(1); for (let i = 0; i < text.length; i++) { const c = text[i]; const result = findOptionByPrefixedName(options, `-${c}`); if (result === undefined) { throw new error_1.ParseError(`Invalid option: ${c}`); } const [option] = result; if (optionRequiresValue(option)) { const isLast = text[i + 1] === undefined; if (isLast && optionArgCandidates.length !== 0) { if (option.isArray) { candidates.push({ name: option.name, value: optionArgCandidates, isNegative: false, }); shift = optionArgCandidates.length + 1; } else { candidates.push({ name: option.name, value: optionArgCandidates[0], isNegative: false, }); shift = 2; } break; } const isFirst = i === 0; if (isFirst) { const value = text.slice(1); candidates.push({ name: option.name, value, isNegative: false, }); shift = 1; break; } } else { candidates.push({ name: option.name, value: undefined, isNegative: false, }); continue; } } return { candidates, shift }; } function parseShortNameOptionArgument(options, arg, optionArgCandidates) { const match = arg.match(/^(?<prefixedName>[^=]+)$/); if (match == null) { // -a=10 is ng throw new error_1.ParseError(`Invalid option: ${arg}`); } const { prefixedName } = match.groups; // option may be multiple // case 1. '-abc' => '-a -b -c' // case 2. '-abc' => '-abc' // If findOptionByPrefixedName() returns matched option, it is treated as single option even if the validation fails. const result = findOptionByPrefixedName(options, prefixedName); if (result === undefined) { return parseShortNameMultipleOptionArgument(options, prefixedName, optionArgCandidates); } const [option] = result; const validateResult = validateOptionArguments(option, false, optionArgCandidates, false); if (!validateResult.ok) { throw new error_1.ParseError(`${validateResult.message}: ${option.name}`); } return { candidates: [ { name: option.name, value: validateResult.value, isNegative: false, }, ], shift: validateResult.shift + 1, }; } function parseOptionArgument(options, arg, optionArgCandidates) { if (arg.startsWith("--")) { const { candidate, shift } = parseLongNameOptionArgument(options, arg, optionArgCandidates); return { candidates: [candidate], shift, }; } return parseShortNameOptionArgument(options, arg, optionArgCandidates); } function pickPositionalArguments(targets, options, hasDoubleDash) { if (hasDoubleDash) { return { positionalArgs: targets, shift: targets.length }; } const foundIndex = targets.findIndex((arg) => likesOptionArg(arg, options)); if (foundIndex === -1) { return { positionalArgs: targets, shift: targets.length }; } return { positionalArgs: targets.slice(0, foundIndex), shift: foundIndex }; } function parsePositionalArguments(args, positionalArgs) { let i = 0; let candidates = []; while (i < args.length) { const arg = args[i]; const option = positionalArgs[i]; if (option === undefined) { throw new error_1.ParseError("Too many positional arguments"); } if (option.isArray) { candidates = candidates.concat({ name: option.name, value: args.slice(i), }); break; } candidates = candidates.concat({ name: option.name, value: arg }); i++; } return candidates; } function pickNextNonOptionArgumentCandidates(targets, options) { const foundIndex = targets.findIndex((arg) => likesOptionArg(arg, options)); if (foundIndex === -1) { return targets; } return targets.slice(0, foundIndex); } function likesOptionArg(arg, options) { if (arg === "--") { return false; } const normalizedArg = arg.split("=")[0]; if (normalizedArg.startsWith("--")) { return true; } if (options.some((option) => { return option.alias !== undefined ? `-${option.alias}` === normalizedArg : false; })) { return true; } if (!normalizedArg.startsWith("-")) { return false; } if (isNumericValue(normalizedArg.slice(1))) { return false; } return true; } function processDoubleDash(state) { return { ...state, index: state.index + 1, hasDoubleDash: true, }; } function processOption(state, args, options) { const arg = args[state.index]; const picked = pickNextNonOptionArgumentCandidates(args.slice(state.index + 1), options); const { candidates, shift } = parseOptionArgument(options, arg, picked); return { ...state, index: state.index + shift, candidates: state.candidates.concat(candidates), }; } function processPositionalArguments(state, args, options, positionalArgs) { if (state.positionalCandidates.length !== 0) { throw new error_1.ParseError("Positional arguments specified twice"); } const { positionalArgs: picked, shift } = pickPositionalArguments(args.slice(state.index), options, state.hasDoubleDash); const positionalCandidates = parsePositionalArguments(picked, positionalArgs); return { ...state, positionalCandidates, index: state.index + shift, }; } function isHelpOption(arg) { return arg === "-h" || arg === "--help"; } function isVersionOption(arg) { return arg === "-V" || arg === "--version"; } function parseToFindCommand(args, commandNames) { const state = { index: 0, isHelp: false, isVersion: false, commandName: undefined, }; (0, logger_1.debugLog)("state", JSON.stringify(state)); const arg = args[state.index]; if (isHelpOption(arg)) { const next = args[state.index + 1]; if (next === undefined) { // global help return { ...state, isHelp: true }; } else if (commandNames.includes(next)) { // command help return { ...state, isHelp: true, commandName: next }; } // e.g. "program --help nonExistingCommand" // e.g. "program --help --version" // e.g. "program --help --option_like" return { ...state, isHelp: true }; } else if (isVersionOption(arg)) { return { ...state, isVersion: true }; } else if (commandNames.includes(arg)) { return { ...state, commandName: arg, index: state.index + 1 }; } else { throw new error_1.ParseError(`Unknown command: ${arg}`); } } function parseMultipleCommands({ args, commands, }) { var _a; const searchResult = parseToFindCommand(args, commands.map((command) => command.name)); if (searchResult.isHelp || searchResult.isVersion) { return { candidates: [], positionalCandidates: [], isHelp: searchResult.isHelp, isVersion: searchResult.isVersion, commandName: searchResult.commandName, }; } const foundCommand = commands.find((command) => command.name === searchResult.commandName); if (foundCommand === undefined) { throw new error_1.ParseError(`Unknown command: ${(_a = searchResult.commandName) !== null && _a !== void 0 ? _a : ""}`); } let parsed; try { parsed = parse({ args: args.slice(searchResult.index), options: foundCommand.options, positionalArgs: foundCommand.positionalArgs, }); } catch (e) { if (e instanceof error_1.ParseError) { e.commandName = searchResult.commandName; } throw e; } return { ...parsed, commandName: searchResult.commandName }; } function parse({ args, options, positionalArgs, }) { let state = { index: 0, candidates: [], positionalCandidates: [], hasDoubleDash: false, isHelp: false, isVersion: false, }; while (state.index < args.length) { const arg = args[state.index]; (0, logger_1.debugLog)("state", JSON.stringify(state)); if (state.hasDoubleDash) { state = processPositionalArguments(state, args, options, positionalArgs); } else { if (arg === "--") { state = processDoubleDash(state); } else if (isHelpOption(arg)) { state = { ...state, isHelp: true }; break; } else if (isVersionOption(arg)) { state = { ...state, isVersion: true }; break; } else if (likesOptionArg(arg, options)) { state = processOption(state, args, options); } else { state = processPositionalArguments(state, args, options, positionalArgs); } } } (0, logger_1.debugLog)("state", JSON.stringify(state)); return { candidates: state.candidates, positionalCandidates: state.positionalCandidates, isHelp: state.isHelp, isVersion: state.isVersion, }; }