argparse-ts
Version:
Modern CLI arguments parser for node.js
715 lines (623 loc) • 20.6 kB
text/typescript
import {
ActionConfig,
ArgConfig,
ArgConfigExtended,
ArgParserConfig,
ArgsParserInterface,
ParsedArgumentsCollectionInterface, RouterAction, RouterInterface,
} from "./types";
import { buildArgExtraConfig, parseArgsArray } from "./utils/utils";
import { convertToTable, formatArgHelp, tabTableRows } from "./utils/help";
import {
checkAllOptionsRecognized,
checkAllPositionalValuesUsed,
checkEnoughPositionalValues,
createValueValidator,
validateArgConfig,
} from "./utils/validation";
import { createValueCaster } from "./utils/cast";
import { helpAction, versionAction } from "./utils/actions";
import {
ArgsParserError,
ArgsParserException,
StopException,
isExceptionInstanceOf,
} from "./exceptions";
/**
* A collection of parsed arguments.
*
* @category Classes
*/
export class ParsedArgumentsCollection implements ParsedArgumentsCollectionInterface {
/**
* A collection of parsed positional arguments.
*/
private readonly positionalArgs: Record<string, unknown> = {};
/**
* A collection of parsed optional arguments.
*/
private readonly optionArgs: Record<string, unknown> = {};
/**
* Returns positional arguments as a record.
*/
public get positional(): Record<string, unknown> {
return { ...this.positionalArgs };
}
/**
* Returns options as a record.
*/
public get options(): Record<string, unknown> {
return { ...this.optionArgs };
}
/**
* Retrieves an argument value.
* @param name - The name of the argument.
* @returns The value of the argument, or undefined if not found.
*/
public get<T = unknown>(name: string): T {
const formattedName = this.formatName(name);
if (this.isPositional(name)) {
return (this.positional[formattedName] ?? undefined) as T;
}
return (this.optionArgs[formattedName] ?? undefined) as T;
}
/**
* Checks if an argument exists in the collection.
* @param name - The name of the argument.
* @returns True if the argument exists, otherwise false.
*/
public has(name: string): boolean {
const formattedName = this.formatName(name);
if (this.isPositional(name)) {
return (this.positional[formattedName] ?? undefined) !== undefined;
}
return (this.optionArgs[formattedName] ?? undefined) !== undefined;
}
/**
* Adds a new argument to the collection.
* @param name - The name of the argument.
* @param value - The value of the argument.
* @returns The instance of ParsedArgumentsCollection for chaining.
*/
public add(name: string, value: unknown) {
const formattedName = this.formatName(name);
if (this.isPositional(name)) {
this.positionalArgs[formattedName] = value;
} else {
this.optionArgs[formattedName] = value;
}
return this;
}
/**
* Formats the argument name by removing leading dashes.
* @param name - The argument name.
* @returns The formatted name.
*/
private formatName(name: string): string {
return name.replace(/^--/, '');
}
/**
* Checks if the argument is positional.
* @param name - The argument name.
* @returns True if the argument is positional, otherwise false.
*/
private isPositional(name: string): boolean {
return !name.startsWith('--');
}
}
/**
* Parser for command-line arguments.
*
* @category Classes
*/
export class ArgsParser implements ArgsParserInterface {
/**
* Configuration options for the parser.
*/
public readonly config: ArgParserConfig;
/**
* Maps argument names to their configuration.
*/
private readonly argsMap: Map<string, ArgConfigExtended> = new Map();
/**
* Maps argument aliases to their corresponding names.
*/
private readonly aliasMap: Map<string, string> = new Map();
/**
* Tracks used argument names and aliases to prevent duplicates.
*/
private readonly usedArgs: Set<string> = new Set();
/**
* Constructs an instance of ArgsParser with the provided argument configurations.
*
* @param config - Configuration options for the parser.
* @param args - An array of argument configurations.
*/
constructor(config: ArgParserConfig, args: ArgConfig[] = []) {
this.config = this.formatConfig(config);
for (const arg of args) {
this.addArgument(arg);
}
}
/**
* Retrieves the help message.
*/
public get help(): string {
const positionalArgs = this.getPositionalArguments();
const optionArgs = this.getOptionArguments();
const positionalArgsRows = [];
for (const arg of positionalArgs) {
positionalArgsRows.push(...formatArgHelp(arg));
}
const optionalArgsRows = [];
for (const arg of optionArgs) {
optionalArgsRows.push(...formatArgHelp(arg));
}
const result = [];
if (positionalArgsRows.length > 0) {
result.push(
['Positional arguments:'],
[''],
...tabTableRows(positionalArgsRows),
);
}
if (optionalArgsRows.length > 0) {
result.push(
['Options:'],
[''],
...tabTableRows(optionalArgsRows),
)
}
return convertToTable(result, 1);
}
/**
* Adds a new argument configuration to the parser.
*
* @param config - The argument configuration.
* @returns The instance of ArgsParser for chaining.
*
* @example
* ```
* const parser = new ArgsParser();
* parser.addArgument({
* name: '--flag',
* alias: '-f',
* type: 'boolean',
* const: true,
* default: false,
* });
* ```
*/
public addArgument(config: ArgConfig): ArgsParser {
validateArgConfig(config, this.getUsedArgs());
this.argsMap.set(config.name, this.extendArgConfig(config));
this.usedArgs.add(config.name);
if (config.alias !== undefined) {
this.aliasMap.set(config.alias, config.name);
this.usedArgs.add(config.alias);
}
return this;
}
/**
* Adds an action configuration to the parser.
*
* @param config - The action configuration.
*
* @returns The updated parser.
*/
addAction(config: ActionConfig): ArgsParser {
return this.addArgument({
...config,
type: 'boolean',
const: true,
});
}
/**
* Adds a help action to the parser.
*
* @param name - The name of the help action.
* @param alias - The alias of the help action.
*
* @returns The updated parser.
*/
addHelpAction(name?: string, alias?: string): ArgsParser {
return this.addAction({
name: name ?? '--help',
alias: (name === undefined && alias === undefined) ? '-h' : alias,
action: 'help',
description: 'Show help and exit',
});
}
/**
* Adds a version action to the parser.
*
* @param name - The name of the version action.
* @param alias - The alias of the version action.
*
* @returns The updated parser.
*/
addVersionAction(name?: string, alias?: string): ArgsParser {
return this.addAction({
name: name ?? '--version',
alias: (name === undefined && alias === undefined) ? '-v' : alias,
action: 'version',
description: 'Show version and exit',
});
}
/**
* Parses the given argument string and returns a collection of parsed arguments.
*
* @param argv - The argument string.
*
* @returns A ParsedArgumentsCollection containing the parsed arguments.
*
* @example
* ```
* const parser = new ArgsParser([
* {
* name: 'action',
* description: 'Action to perform',
* type: 'string',
* },
* {
* name: '--flag',
* description: 'Flag',
* alias: '-f',
* type: 'boolean',
* const: true,
* default: false,
* },
* {
* name: '--values',
* description: 'Values',
* alias: '-v',
* type: 'string',
* nargs: '*',
* default: [],
* }
* ]);
*
* const argv = ['make', '--flag', '-v', 'a', 'b', 'c'];
* const parsedArgs = parser.parse(argv);
*
* console.log(parsedArgs.positional); // { action: 'make' }
* console.log(parsedArgs.optional); // { flag: true, values: ['a', 'b', 'c'] }
* console.log(parsedArgs.get('--values')); // ['a', 'b', 'c']
* ```
*/
public parse(argv: string[]): ParsedArgumentsCollection {
// Initialize a new collection to store the parsed arguments with their values
const result = new ParsedArgumentsCollection();
// Parse the input arguments array into positional and optional parts
let [parsedPositional, parsedOptions] = parseArgsArray(argv);
let error: ArgsParserError | undefined = undefined;
try {
this.processPositionalArgs(parsedPositional, this.getPositionalArguments(), result);
} catch (e) {
error = this.processException(e, error);
}
try {
this.processOptions(parsedOptions, this.getOptionArguments(), result);
} catch (e) {
error = this.processException(e, error);
}
if (error) {
this.processExit(1, error);
}
return result;
}
/**
* Processes positional arguments.
*
* @param parsedValues - A parsed array of positional argument values.
* @param argConfigs - An array of extended argument configurations.
* @param result - The collection where parsed arguments will be added.
*
* @returns The number of positional arguments processed.
*/
private processPositionalArgs(
parsedValues: string[],
argConfigs: ArgConfigExtended[],
result: ParsedArgumentsCollection,
): number {
let count = 0;
// Reverse the parsed positional arguments for easier processing
parsedValues = [...parsedValues].reverse();
// Process positional arguments
for (let i=0; i<argConfigs.length; ++i) {
const argConfig = argConfigs[i];
// Get the remaining positional arguments configurations
const remainingArgConfigs = argConfigs.slice(i+1);
// Check if there are enough positional arguments left
checkEnoughPositionalValues(parsedValues, argConfig, remainingArgConfigs);
// Read the correct number of positional arguments
const value = this.readPositionalArgValues(parsedValues, argConfig, remainingArgConfigs);
// Add the parsed argument to the result
result.add(argConfig.name, this.processArgValue(value, argConfig, result, value.length > 0));
++count;
}
if (!this.config.ignoreUnrecognized) {
// Check if all positional arguments were used
checkAllPositionalValuesUsed(parsedValues);
}
return count;
}
/**
* Processes optional arguments.
*
* @param parsedValuesMap - A map of parsed optional argument keys to their string values.
* @param argConfigs - An array of extended argument configurations.
* @param result - The collection where parsed arguments will be added.
*
* @returns The number of optional arguments processed.
*/
private processOptions(
parsedValuesMap: Record<string, string[]>,
argConfigs: ArgConfigExtended[],
result: ParsedArgumentsCollection,
): number {
let count = 0;
const argConfigsSet = new Set(argConfigs);
// Retrieve the optional argument configurations map
const argConfigsMap = this.getArgConfigMap(Object.keys(parsedValuesMap));
if (!this.config.ignoreUnrecognized) {
// Check if all options were recognized
checkAllOptionsRecognized(parsedValuesMap, argConfigsMap);
}
// Process options
for (const [key, value] of Object.entries(parsedValuesMap)) {
if (argConfigsMap[key] === undefined) {
continue;
}
const argConfig = argConfigsMap[key]!;
result.add(argConfig.name, this.processArgValue(value, argConfig, result, true));
argConfigsSet.delete(argConfig);
++count;
}
// Process optional arguments that were not set and add default if necessary
for (const argConfig of argConfigsSet) {
const value = this.processArgValue([], argConfig, result, false);
if ('default' in argConfig) {
result.add(argConfig.name, value);
}
++count;
}
return count;
}
private processException(e: unknown, previous: ArgsParserError | undefined): ArgsParserError {
if (isExceptionInstanceOf(e, StopException)) {
this.processExit(0, e as StopException);
}
if (previous) {
return previous;
}
if (isExceptionInstanceOf(e, ArgsParserError)) {
return e as ArgsParserError;
}
throw e;
}
private processExit(exitCode: number, e: ArgsParserException) {
if (process !== undefined) {
if (exitCode === 0 && this.config.exitOnStop) {
process.exit(exitCode);
}
if (exitCode !== 0 && this.config.exitOnError) {
console.error(`Error: ${e.message}`);
process.exit(exitCode);
}
}
throw e;
}
/**
* Processes a single argument value.
*
* @param value - The value(s) being processed.
* @param argConfig - The configuration of the argument.
* @param parsed - The parsed result
* @param isset - Whether the argument is set.
*
* @returns The processed value, or undefined if the argument is not set.
*/
private processArgValue(value: string[], argConfig: ArgConfigExtended, parsed: ParsedArgumentsCollection, isset: boolean): unknown {
const validator = createValueValidator(argConfig);
const caster = createValueCaster(argConfig);
validator.validateBeforeCast(value, isset);
let result = caster.cast(value, isset);
validator.validateAfterCast(result);
if (isset && argConfig.action !== undefined) {
result = this.processAction(argConfig, parsed, result);
}
return result;
}
private processAction(config: ArgConfigExtended, parsed: ParsedArgumentsCollection, value: unknown): unknown {
switch (true) {
case config.action === 'help':
return helpAction(value, parsed, this);
case config.action === 'version':
return versionAction(value, parsed, this);
case (typeof config.action === 'function'):
return config.action(value, parsed, this);
}
return value; // TODO maybe throw unknown action
}
/**
* Retrieves the positional arguments.
*
* @returns An array of positional argument configurations.
*/
private getPositionalArguments(): ArgConfigExtended[] {
return [...this.argsMap.values()].filter((x) => x.positional);
}
/**
* Retrieves the optional arguments.
*
* @returns An array of optional argument configurations.
*/
private getOptionArguments(): ArgConfigExtended[] {
return [...this.argsMap.values()].filter((x) => !x.positional);
}
/**
* Retrieves a set of used argument names and aliases.
*
* @returns A set of used argument names and aliases.
*/
private getUsedArgs(): Set<string> {
return new Set(this.usedArgs);
}
/**
* Retrieves a map of argument configurations for the given keys.
*
* @param keys - The keys to retrieve.
*
* @returns A map of argument configurations, where each key is one of the given keys and the value
* is the corresponding ArgConfigExtended.
*/
private getArgConfigMap(keys: string[]): Record<string, ArgConfigExtended | undefined> {
const result: Record<string, ArgConfigExtended | undefined> = {};
for (const key of keys) {
result[key] = this.getArgConfig(key);
}
return result;
}
/**
* Retrieves the argument configuration for a given key.
*
* @param key - The argument key.
*
* @returns The corresponding ArgConfigExtended.
*/
private getArgConfig(key: string): ArgConfigExtended | undefined {
return this.argsMap.get(this.aliasMap.get(key) ?? key);
}
/**
* Extends an argument configuration with extra information.
*
* @param config - The argument configuration.
*
* @returns The argument configuration extended with extra information.
*/
private extendArgConfig(config: ArgConfig): ArgConfigExtended {
return { ...config, ...buildArgExtraConfig(config) };
}
/**
* Reads the value of the positional argument based on the nargs configuration.
*
* @param parsedValues - The parsed positional arguments.
* @param argConfig - The argument configuration.
* @param remainingArgConfigs - The remaining argument configurations.
*
* @returns The value of the positional argument.
*/
private readPositionalArgValues(
parsedValues: string[],
argConfig: ArgConfigExtended,
remainingArgConfigs: ArgConfigExtended[],
): string[] {
const toReadCount = this.getArgsCountToRead(argConfig, remainingArgConfigs, parsedValues);
const value: string[] = [];
for (let i=0; i<toReadCount; ++i) {
value.push(parsedValues.pop()!);
}
return value;
}
/**
* Determines the number of arguments to read based on the nargs configuration.
*
* @param argConfig - The configuration specifying how many arguments can be read.
* @param remainingArgConfigs - The remaining arguments to read.
* @param values - The total number of available arguments.
*
* @returns The number of arguments to read.
*/
private getArgsCountToRead(argConfig: ArgConfigExtended, remainingArgConfigs: ArgConfigExtended[], values: string[]): number {
// If the argument is required and not multiple, read one value.
if (argConfig.required && !argConfig.multiple) {
return Math.min(1, values.length);
}
// The total number of arguments that must be read by the remaining arguments.
const minRemainingValuesCount = remainingArgConfigs.reduce((acc, x) => acc + x.minValuesCount, 0);
// The maximum number of arguments that can be read for the current argument.
let maxCurrentValuesCount = values.length - minRemainingValuesCount;
if (maxCurrentValuesCount < 0) {
// If there are not enough values to read, read as many as are available.
maxCurrentValuesCount = Math.min(argConfig.minValuesCount, values.length);
}
// If the argument is not multiple, read only one value.
if (!argConfig.multiple) {
return Math.min(1, maxCurrentValuesCount);
}
// If the argument has a fixed number of values, read that many.
if (argConfig.valuesCount !== undefined) {
return Math.min(argConfig.valuesCount, maxCurrentValuesCount);
}
// Otherwise, read as many values as are available.
return maxCurrentValuesCount;
}
private formatConfig(config: ArgParserConfig): ArgParserConfig {
return {
...config,
exitOnError: config.exitOnError ?? true,
exitOnStop: config.exitOnStop ?? true,
ignoreUnrecognized: config.ignoreUnrecognized ?? false,
}
}
}
/**
* A class representing a router for command-line arguments.
*
* @category Classes
* @category Router
*/
export class Router implements RouterInterface {
private readonly routes: Record<string, RouterAction>;
private readonly parser: ArgsParserInterface;
/**
* Creates a new Router instance.
*
* @param config - The configuration for the router.
* @param routes - The routes for the router.
*/
constructor(config: ArgParserConfig, routes: Record<string, RouterAction>) {
this.routes = routes;
this.parser = new ArgsParser({
...config,
ignoreUnrecognized: true,
});
this.parser.addArgument({
name: 'action',
type: 'string',
description: 'Action to run',
choices: Object.keys(routes),
});
this.parser.addHelpAction();
this.parser.addVersionAction();
}
/**
* Runs the router.
*
* @param argv - The argument string.
*/
run(argv?: string[]) {
const passedArgv = argv ?? process.argv.slice(2);
const parsed = this.parser.parse(passedArgv);
const actionName = parsed.get<keyof typeof this.routes>('action');
const actionArgsParser = new ArgsParser({
name: `${this.parser.config.name} ${actionName}`,
});
this.routes[actionName](actionArgsParser, passedArgv.slice(1));
}
/**
* Runs the router asynchronously.
*
* @param argv - The argument string.
*/
async runAsync(argv?: string[]): Promise<void> {
const passedArgv = argv ?? process.argv.slice(2);
const parsed = this.parser.parse(passedArgv);
const actionName = parsed.get<keyof typeof this.routes>('action');
const actionArgsParser = new ArgsParser({
name: `${this.parser.config.name} ${actionName}`,
});
await this.routes[actionName](actionArgsParser, passedArgv.slice(1));
}
}