zod-opts
Version:
node.js CLI option parser / validator using Zod
424 lines (423 loc) • 14.8 kB
JavaScript
;
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,
};
}