diff --git a/src/args.ts b/src/args.ts index 2e24840..9316d33 100644 --- a/src/args.ts +++ b/src/args.ts @@ -33,37 +33,54 @@ export interface DefaultArgTypes { rest?: string } -export class Args { - // We store both a tree and a list so that we can iterate all values efficiently - public arguments: PrefixTree = new PrefixTree() - public argumentsList: InternalArgument[] = [] +export interface ArgsState { + + arguments: PrefixTree + argumentsList: InternalArgument[] - public commands: PrefixTree = new PrefixTree() - public commandsList: InternalCommand[] = [] + commands: PrefixTree + commandsList: InternalCommand[] - public resolvers: Resolver[] = [] - public builtins: Builtin[] = [] - public footerLines: string[] = [] - public headerLines: string[] = [] + resolvers: Resolver[] + builtins: Builtin[] + footerLines: string[] + headerLines: string[] +} +export class Args { public readonly opts: StoredParserOpts + public readonly _state: ArgsState private positionalIndex = 0 - constructor (opts: ParserOpts) { + constructor (opts: ParserOpts, existingState?: ArgsState) { this.opts = { ...defaultParserOpts, ...opts } + + this._state = existingState ?? { + // We store both a tree and a list so that we can iterate all values efficiently + arguments: new PrefixTree(), + argumentsList: [], + + commands: new PrefixTree(), + commandsList: [], + + resolvers: [...this.opts.resolvers], + builtins: [], + footerLines: [], + headerLines: [] + } } public resolver (resolver: Resolver): Args { - this.resolvers.push(resolver) + this._state.resolvers.push(resolver) return this } public builtin (builtin: Builtin): Args { - this.builtins.push(builtin) + this._state.builtins.push(builtin) return this } @@ -72,11 +89,11 @@ export class Args { command: TCommand, inherit = false ): Args { - if (this.commands.has(name)) { + if (this._state.commands.has(name)) { throw new CommandError(`command '${name}' already declared`) } - const existingBuiltin = this.builtins.find(b => b.commandTriggers.includes(name) || aliases.some(a => b.commandTriggers.includes(a))) + const existingBuiltin = this._state.builtins.find(b => b.commandTriggers.includes(name) || aliases.some(a => b.commandTriggers.includes(a))) if (existingBuiltin) { throw new CommandError(`command '${name}' conflicts with builtin '${existingBuiltin.id}' (${existingBuiltin.constructor.name})`) } @@ -87,12 +104,12 @@ export class Args { }) if (inherit) { - parser.arguments = this.arguments + parser._state.arguments = this._state.arguments } parser = command.args(parser) - this.commands.insert(name, { + this._state.commands.insert(name, { inner: command, name, aliases, @@ -100,7 +117,7 @@ export class Args { isBase: true }) - this.commandsList.push({ + this._state.commandsList.push({ inner: command, name, aliases, @@ -109,15 +126,15 @@ export class Args { }) for (const alias of aliases) { - if (this.commands.has(alias)) { + if (this._state.commands.has(alias)) { throw new CommandError(`command alias '${alias}' already declared`) } - const existingBuiltin = this.builtins.find(b => b.commandTriggers.includes(alias)) + const existingBuiltin = this._state.builtins.find(b => b.commandTriggers.includes(alias)) if (existingBuiltin) { throw new CommandError(`command alias '${alias}' conflicts with builtin '${existingBuiltin.id}' (${existingBuiltin.constructor.name})`) } - this.commands.insert(alias, { + this._state.commands.insert(alias, { inner: command, name, aliases, @@ -125,7 +142,7 @@ export class Args { isBase: false }) - this.commandsList.push({ + this._state.commandsList.push({ inner: command, name, aliases, @@ -150,14 +167,14 @@ export class Args { const slicedKey = key.slice(1, key.length - 1) const index = this.positionalIndex++ - this.arguments.insert(slicedKey, { + this._state.arguments.insert(slicedKey, { type: 'positional', inner: declaration, key: slicedKey, index }) - this.argumentsList.push({ + this._state.argumentsList.push({ type: 'positional', inner: declaration, key: slicedKey, @@ -184,16 +201,16 @@ export class Args { } }) - if (this.arguments.has(longFlag)) { + if (this._state.arguments.has(longFlag)) { throw new SchemaError(`duplicate long flag '${_longFlag}'`) } for (const alias of aliases) { - if (this.arguments.has(alias.value)) { + if (this._state.arguments.has(alias.value)) { throw new SchemaError(`duplicate alias '${getAliasDenotion(alias)}'`) } - this.arguments.insert(alias.value, { + this._state.arguments.insert(alias.value, { type: 'flag', isLongFlag: true, inner: declaration, @@ -202,7 +219,7 @@ export class Args { }) } - this.arguments.insert(longFlag, { + this._state.arguments.insert(longFlag, { type: 'flag', isLongFlag: true, inner: declaration, @@ -210,7 +227,7 @@ export class Args { aliases }) - this.argumentsList.push({ + this._state.argumentsList.push({ type: 'flag', isLongFlag: true, inner: declaration, @@ -265,7 +282,7 @@ export class Args { const positionals: InternalPositionalArgument[] = [] const flags: InternalFlagArgument[] = [] - for (const value of this.argumentsList) { + for (const value of this._state.argumentsList) { if (value.type === 'flag') { flags.push(value) } else { @@ -285,9 +302,9 @@ export class Args { public footer (line: string, append = true): Args { if (append) { - this.footerLines.push(line) + this._state.footerLines.push(line) } else { - this.footerLines = [line] + this._state.footerLines = [line] } return this @@ -295,9 +312,9 @@ export class Args { public header (line: string, append = true): Args { if (append) { - this.headerLines.push(line) + this._state.headerLines.push(line) } else { - this.headerLines = [line] + this._state.headerLines = [line] } return this @@ -314,37 +331,31 @@ export class Args { const tokens = tokenResult.val - const parseResult = parse(tokens, this.commands, this.opts) + const parseResult = parse(tokens, this._state, this.opts) if (!parseResult.ok) { return parseResult } - const { command } = parseResult.val + const { command: parsedCommand } = parseResult.val // If we located a command, tell coerce to use its parser instead of our own let coercionResult - if (command.type === 'default' && !this.commands.empty() && this.opts.mustProvideCommand) { + if (parsedCommand.type === 'default' && !this._state.commands.empty() && this.opts.mustProvideCommand) { return Err(new CommandError('no command provided but one was expected')) } - if (command.type === 'user') { - const commandParser = command.internal.parser + if (parsedCommand.type === 'user') { + const commandParser = parsedCommand.internal.parser coercionResult = await coerce( parseResult.val, commandParser.opts, - commandParser.arguments, - commandParser.argumentsList, - [...commandParser.opts.resolvers, ...commandParser.resolvers], - commandParser.builtins + commandParser._state ) } else { coercionResult = await coerce( parseResult.val, this.opts, - this.arguments, - this.argumentsList, - [...this.opts.resolvers, ...this.resolvers], - this.builtins + this._state ) } @@ -352,23 +363,23 @@ export class Args { return coercionResult } - const coercion = coercionResult.val + const { args, parsed: { command, rest } } = coercionResult.val // No command was found, just return the args - if (coercion.command.type === 'default') { + if (command.type === 'default') { return Ok({ mode: 'args', - args: this.intoObject(coercion.args, coercion.rest?.value) + args: this.intoObject(args, rest?.value) }) } // Builtin found, execute it and run, regardless of caller preference // builtins will always override the 'default' behaviour, so need to run - if (coercion.command.type === 'builtin') { + if (command.type === 'builtin') { let executionResult try { - await coercion.command.command.run(this, ...this.intoRaw(parseResult.val), coercion.command.trigger) + await command.command.run(this, ...this.intoRaw(parseResult.val), command.trigger) } catch (err) { executionResult = err } @@ -384,7 +395,7 @@ export class Args { let executionResult try { - await coercion.command.internal.inner.run(this.intoObject(coercion.args, coercion.rest?.value)) + await command.internal.inner.run(this.intoObject(args, rest?.value)) } catch (err) { executionResult = err } @@ -398,8 +409,8 @@ export class Args { // Command was found, return it return Ok({ mode: 'command', - parsedArgs: this.intoObject(coercion.args, coercion.rest?.value), - command: coercion.command.internal.inner + parsedArgs: this.intoObject(args, rest?.value), + command: command.internal.inner }) } @@ -418,9 +429,7 @@ export class Args { return result.val } - public reset (): void { - this.arguments = new PrefixTree() - this.commands = new PrefixTree() - this.resolvers = [] + public clone (opts: ParserOpts = this.opts): Args { + return new Args(opts, this._state) } } diff --git a/src/internal/parse/coerce.ts b/src/internal/parse/coerce.ts index f492c9e..943b29c 100644 --- a/src/internal/parse/coerce.ts +++ b/src/internal/parse/coerce.ts @@ -1,16 +1,17 @@ -import { Builtin, CoercionResult, Resolver, MinimalArgument } from '../../builder' +import { ArgsState } from '../../args' +import { Resolver, MinimalArgument } from '../../builder' import { CoercionError, CommandError, InternalError } from '../../error' import { StoredParserOpts } from '../../opts' import { PrefixTree } from '../prefix-tree' import { Err, Ok, Result } from '../result' import { getArgDenotion } from '../util' -import { AnyParsedFlagArgument, DefaultCommand, ParsedArguments, ParsedLongArgument, ParsedPositionalArgument, ParsedRestArgument, ParsedShortArgumentSingle, UserCommand } from './parser' -import { CoercedValue, InternalArgument, InternalFlagArgument, InternalPositionalArgument } from './types' +import { AnyParsedFlagArgument, ParsedArguments, ParsedLongArgument, ParsedPositionalArgument, ParsedShortArgumentSingle } from './parser' +import { validateCommandSchematically, validateFlagSchematically, validatePositionalSchematically, coerceMultiType } from './schematic-validation' +import { CoercedValue, InternalArgument } from './types' export interface CoercedArguments { - command: UserCommand | DefaultCommand | BuiltinCommand args: Map - rest: ParsedRestArgument | undefined + parsed: ParsedArguments } export interface CoercedMultiValue { @@ -27,197 +28,9 @@ export interface CoercedSingleValue { coerced: CoercedValue } -export interface BuiltinCommand { - type: 'builtin' - command: Builtin - trigger: string -} - -function validateFlagSchematically ( - flags: Map, - argument: InternalFlagArgument, - opts: StoredParserOpts, - resolveres: Resolver[] -): Result { - let foundFlags = flags.get(argument.longFlag) - if (argument.aliases.length && !foundFlags) { - for (const alias of argument.aliases) { - foundFlags = flags.get(alias.value) - - if (foundFlags) { - break - } - } - } - - let { specifiedDefault, unspecifiedDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._meta - - // Test our resolvers to see if any of them have a value, so we know whether to reject below - let resolversHaveValue = false - - for (const resolver of resolveres) { - if (resolver.keyExists(argument.longFlag, opts)) { - resolversHaveValue = true - } - } - - // Must be at the top, we modify `optional` behaviour - for (const presentKey of requiredUnlessPresent) { - const presence = flags.get(presentKey) - if (presence !== undefined) { - optional = true - } - } - - // If no definitions were provided - if (!foundFlags?.length && !optional && unspecifiedDefault === undefined && !resolversHaveValue) { - return Err(new CoercionError(argument.inner.type, '', `argument '--${argument.longFlag}' is missing, with no unspecified default`, getArgDenotion(argument))) - } - - for (const foundFlag of foundFlags ?? []) { - // Groups will be checked for unrecognised flags later - if (foundFlag && foundFlag.type === 'short-group') { - return Ok(foundFlags) - } - - // If no values were passed to a definition - if (!optional && specifiedDefault === undefined && !foundFlag.values.length && !resolversHaveValue) { - return Err(new CoercionError(argument.inner.type, '', `argument '${argument.longFlag}' is not declared as optional, does not have a default, and was not provided a value`, getArgDenotion(argument))) - } - - for (const dependency of dependencies) { - const dependencyValue = flags.get(dependency) - if (!dependencyValue) { - return Err(new CoercionError('a value', '', `unmet dependency '--${dependency}' for '--${argument.longFlag}'`, getArgDenotion(argument))) - } - } - - for (const conflict of conflicts) { - const conflictValue = flags.get(conflict) - // Require both the argument we're checking against (the base) and the conflict to exist - if (conflictValue !== undefined && foundFlags?.length) { - return Err(new CoercionError(`--${conflict} to not be passed`, conflictValue.map(c => c.rawInput).join(' '), `argument '--${conflict}' conflicts with '--${argument.longFlag}'`, getArgDenotion(argument))) - } - } - - if (exclusive && flags.size > 1) { - return Err(new CoercionError('no other args to be passed', `${flags.size - 1} other arguments`, `argument '--${argument.longFlag}' is exclusive and cannot be used with other arguments`, getArgDenotion(argument))) - } - } - - return Ok(foundFlags) -} - -function validatePositionalSchematically ( - positionals: Map, - argument: InternalPositionalArgument, - opts: StoredParserOpts, - middlewares: Resolver[] -): Result { - const foundFlag = positionals.get(argument.index) - const { unspecifiedDefault, optional } = argument.inner._meta - - // Test our middlewares to see if any of them have a value, so we know whether to reject below - let middlewaresHaveValue = false - - for (const middleware of middlewares) { - if (middleware.keyExists(argument.key, opts)) { - middlewaresHaveValue = true - } - } - - if (!optional && unspecifiedDefault === undefined && !foundFlag?.values && !middlewaresHaveValue) { - return Err(new CoercionError(argument.inner.type, '', `positional argument '${argument.key}' is not declared as optional, does not have a default, and was not provided a value`, argument.key)) - } - - return Ok(foundFlag) -} - -async function parseMulti (inputValues: string[], argument: InternalArgument): Promise> { - const eachParserResults: Map, Array>> = new Map() - - for (const value of inputValues) { - for (const parser of [argument.inner, ...argument.inner._meta.otherParsers]) { - const parsed = await parser.coerce(value) - const alreadyParsed = eachParserResults.get(parser) ?? [] - - alreadyParsed.push(parsed) - eachParserResults.set(parser, alreadyParsed) - } - } - - interface SingleError { - value: string - error: Error - } - interface GroupedError { - parser: MinimalArgument - errors: SingleError[] - } - - interface CoercedGroup { - parser: MinimalArgument - values: CoercedValue[] - } - - const coercedGroups: CoercedGroup[] = [] - const groupedErrors: GroupedError[] = [] - - for (const [parser, results] of eachParserResults.entries()) { - const errors: SingleError[] = [] - const coerced = [] - - for (const result of results) { - if (result.ok) { - coerced.push(result.returnedValue) - } else { - errors.push({ - value: result.passedValue, - error: result.error - }) - } - } - - if (errors.length) { - groupedErrors.push({ - parser, - errors - }) - } else { - coercedGroups.push({ - parser, - values: coerced - }) - } - } - - // If no parsers could resolve the values (no succesful groups) - if (groupedErrors.length && !coercedGroups.length) { - const errors = groupedErrors.flatMap(group => { - return group.errors.map(error => { - return new CoercionError(argument.inner.type, error.value, `could not parse a '${group.parser.type}' because ${error.error.message}`, getArgDenotion(argument)) - }) - }) - - return Err(errors) - } - - // Take the first group that managed to coerce all of the values - const selectedGroup = coercedGroups.slice(0, 1)[0] - if (!selectedGroup) { - throw new InternalError(`no selected group, but errors were not caught either? coerced: ${JSON.stringify(coercedGroups)}, errors: ${JSON.stringify(groupedErrors)}`) - } - - return Ok({ - isMulti: true, - coerced: selectedGroup.values, - raw: inputValues - }) -} - -async function parseSingle (inputValues: string[], argument: InternalArgument): Promise> { +async function coerceSingleArgument (inputValue: string, argument: InternalArgument): Promise> { const parsers = [argument.inner, ...argument.inner._meta.otherParsers] - const results = await Promise.all(parsers.map(async parser => [parser, await parser.coerce(inputValues[0])] as const)) + const results = await Promise.all(parsers.map(async parser => [parser, await parser.coerce(inputValue)] as const)) const errors: Array<{ error: Error @@ -241,7 +54,7 @@ async function parseSingle (inputValues: string[], argument: InternalArgument): if (errors.length && coerced === null) { return Err(errors.map(error => { - return new CoercionError(argument.inner.type, inputValues[0], `could not parse a '${error.parser.type}' because ${error.error.message}`, getArgDenotion(argument)) + return new CoercionError(argument.inner.type, inputValue, `could not parse a '${error.parser.type}' because ${error.error.message}`, getArgDenotion(argument)) })) } @@ -252,23 +65,10 @@ async function parseSingle (inputValues: string[], argument: InternalArgument): return Ok({ isMulti: false, coerced, - raw: inputValues[0] + raw: inputValue }) } -function validateCommand (command: UserCommand, opts: StoredParserOpts): Result { - const { deprecated, deprecationMessage } = command.internal.inner.opts - - // Do not run deprecated commands - if (deprecated && opts.deprecatedCommands === 'error') { - return Err(new CommandError(deprecationMessage)) - } else if (deprecated && opts.deprecatedCommands === 'unknown-command') { - return Err(new CommandError(`unknown command '${command.internal.name}'`)) - } - - return Ok(undefined) -} - interface ResolvedDefault { isDefault: true value: CoercedSingleValue | CoercedMultiValue @@ -297,7 +97,7 @@ async function resolveArgumentDefault ( continue } - const coercionResult = await parseSingle([value], argument) + const coercionResult = await coerceSingleArgument(value, argument) if (!coercionResult.ok) { return coercionResult } @@ -350,6 +150,7 @@ async function resolveArgumentDefault ( } }) } + if (userArgument.negated) { argument.inner.negate() } @@ -378,7 +179,7 @@ async function resolveArgumentDefault ( }) } -function handleMultipleDefinitions (argument: InternalArgument, opts: StoredParserOpts): Result<'overwrite' | 'skip' | 'append', CoercionError> { +function handleAdditionalArgumentDefinition (argument: InternalArgument, opts: StoredParserOpts): Result<'overwrite' | 'skip' | 'append', CoercionError> { const { arrayMultipleDefinitions, tooManyDefinitions } = opts if (argument.inner._meta.isMultiType) { if (arrayMultipleDefinitions === 'append') { @@ -403,7 +204,7 @@ function handleMultipleDefinitions (argument: InternalArgument, opts: StoredPars throw new InternalError(`unhandled: array: ${arrayMultipleDefinitions} tooMany: ${tooManyDefinitions}`) } -function handleDefinitionChecking ( +function handleUnmatchedArgument ( definition: AnyParsedFlagArgument, internalArgs: PrefixTree, opts: StoredParserOpts @@ -443,72 +244,33 @@ function handleDefinitionChecking ( return Ok('continue') } -function matchBuiltin (args: ParsedArguments, builtins: Builtin[]): undefined | [Builtin, string] { - const { flags, command } = args - - const keysToSearch = new Set() - // If a user command could not be resolved, attempt to find whatever value was there anyways - // It is challenging to resolve builtins at parse time, so this enables us to delay it until coercion time - if (command.type === 'default') { - if (command.key) { - keysToSearch.add(command.key) - } - } else { - keysToSearch.add(command.internal.name) - command.internal.aliases.forEach(alias => keysToSearch.add(alias)) - } - - for (const builtin of builtins) { - const matchingFlag = builtin.argumentTriggers.find(flag => flags.has(flag)) - if (matchingFlag) { - return [builtin, matchingFlag] - } - - const matchingCommand = builtin.commandTriggers.find(cmd => keysToSearch.has(cmd)) - if (matchingCommand) { - return [builtin, matchingCommand] - } - } - - return undefined -} - export async function coerce ( args: ParsedArguments, opts: StoredParserOpts, - internalArgs: PrefixTree, - internalArgsList: InternalArgument[], - resolvers: Resolver[], - builtins: Builtin[] + state: ArgsState ): Promise> { const out: Map = new Map() const { command, flags, positionals } = args + const { argumentsList, resolvers, arguments: argumentsTree } = state // Before trying commands or further coercion, see if we match a builtin - const builtinSearch = matchBuiltin(args, builtins) - if (builtinSearch) { - const [foundBuiltin, trigger] = builtinSearch + if (command.type === 'builtin') { return Ok({ args: out, - command: { - type: 'builtin', - command: foundBuiltin, - trigger - }, - rest: undefined + parsed: args }) } // Validate the command to make sure we can run it if (command.type !== 'default') { - const result = validateCommand(command, opts) + const result = validateCommandSchematically(command, opts) if (!result.ok) { return result } } // Iterate the declarations, to weed out any missing arguments - for (const argument of internalArgsList) { + for (const argument of argumentsList) { // Validate 'schema-level' properties, such as optionality, depedencies, etc // Do NOT consider 'value-level' properties such as value correctness let findResult @@ -536,7 +298,7 @@ export async function coerce ( // Multiple definitions found, let's see what we should do with them if (Array.isArray(userArgument) && userArgument.length > 1) { - const multipleBehaviourResult = handleMultipleDefinitions(argument, opts) + const multipleBehaviourResult = handleAdditionalArgumentDefinition(argument, opts) if (!multipleBehaviourResult.ok) { return multipleBehaviourResult } @@ -589,9 +351,13 @@ export async function coerce ( let coercionResult if (argument.inner._meta.isMultiType) { - coercionResult = await parseMulti(inputValues, argument) + coercionResult = await coerceMultiType(inputValues, argument) } else { - coercionResult = await parseSingle(inputValues, argument) + if (inputValues.length !== 1) { + throw new InternalError(`input values length was > 1, got ${inputValues} (len: ${inputValues.length})`) + } + + coercionResult = await coerceSingleArgument(inputValues[0], argument) } if (!coercionResult.ok) { @@ -608,7 +374,7 @@ export async function coerce ( // Then, iterate the parsed values, to weed out excess arguments for (const definitions of flags.values()) { for (const definition of definitions) { - const result = handleDefinitionChecking(definition, internalArgs, opts) + const result = handleUnmatchedArgument(definition, argumentsTree, opts) if (!result.ok) { return result } @@ -620,8 +386,7 @@ export async function coerce ( } return Ok({ - command, - rest: args.rest, + parsed: args, args: out }) } diff --git a/src/internal/parse/parser.ts b/src/internal/parse/parser.ts index a0ff6e3..ffb3bb6 100644 --- a/src/internal/parse/parser.ts +++ b/src/internal/parse/parser.ts @@ -1,3 +1,5 @@ +import { ArgsState } from '../../args' +import { Builtin } from '../../builder' import { InternalError, ParseError } from '../../error' import { StoredParserOpts } from '../../opts' import { PrefixTree } from '../prefix-tree' @@ -59,8 +61,14 @@ export interface DefaultCommand { key: string | undefined } +export interface BuiltinCommand { + type: 'builtin' + command: Builtin + trigger: string +} + export interface ParsedArguments { - command: UserCommand | DefaultCommand + command: UserCommand | DefaultCommand | BuiltinCommand flags: Map rest: ParsedRestArgument | undefined positionals: Map @@ -149,9 +157,19 @@ function parseString (tokens: TokenIterator, ...skipPrecedingTokens: TokenType[] return Ok(out) } -function extractCommand (rootKey: string, tokens: TokenIterator, commands: PrefixTree): [DefaultCommand | UserCommand, string | undefined] { +function extractCommand (rootKey: string, tokens: TokenIterator, commands: PrefixTree, builtins: Builtin[]): [DefaultCommand | UserCommand | BuiltinCommand, string | undefined] { const command = commands.findOrUndefined(rootKey) - let lastKnownKey + const builtin = builtins.find(b => b.commandTriggers.includes(rootKey)) + + if (builtin) { + const commandResult = { + type: 'builtin', + command: builtin, + trigger: rootKey + } as const + + return [commandResult, rootKey] + } // If we could not locate a command, we must assume the first argument is a positional. // Coercion will confirm or deny this later. @@ -164,6 +182,8 @@ function extractCommand (rootKey: string, tokens: TokenIterator, commands: Prefi return [commandResult, rootKey] } + let lastKnownKey + let subcommand = command let subcommandKey: Result const keyParts = [rootKey] @@ -332,7 +352,7 @@ function parseFlag (tokens: TokenIterator, opts: StoredParserOpts): Result, + { commands, builtins }: ArgsState, opts: StoredParserOpts ): Result { const positionals: Map = new Map() @@ -341,10 +361,10 @@ export function parse ( // 1) Determine if we have a command (and maybe) a subcommand passed in the arguments const rootKey = parseString(tokens, 'whitespace') - let commandObject: DefaultCommand | UserCommand + let commandObject: DefaultCommand | UserCommand | BuiltinCommand if (rootKey.ok) { - const [commandResult, lastKnownKey] = extractCommand(rootKey.val, tokens, commands) + const [commandResult, lastKnownKey] = extractCommand(rootKey.val, tokens, commands, builtins) commandObject = commandResult if (lastKnownKey) { positionals.set(0, { @@ -387,6 +407,18 @@ export function parse ( // Flags with assignable names if (type === 'long' || type === 'short-single') { + // See if our flag is a builtin trigger + const builtin = builtins.find(b => b.argumentTriggers.includes(flag.key)) + if (builtin) { + commandObject = { + type: 'builtin', + command: builtin, + trigger: flag.key + } + } + + // Don't return though, we need to parse out all the flags. + // Coercion will abort early if we give them a builtin, so that it can be run const definitions = flags.get(flag.key) ?? [] definitions.push(flag) flags.set(flag.key, definitions) diff --git a/src/internal/parse/schematic-validation.ts b/src/internal/parse/schematic-validation.ts new file mode 100644 index 0000000..3db2fdc --- /dev/null +++ b/src/internal/parse/schematic-validation.ts @@ -0,0 +1,203 @@ +import { Resolver, MinimalArgument, CoercionResult } from '../../builder' +import { CommandError, CoercionError, InternalError } from '../../error' +import { StoredParserOpts } from '../../opts' +import { Result, Err, Ok } from '../result' +import { getArgDenotion } from '../util' +import { CoercedMultiValue } from './coerce' +import { UserCommand, AnyParsedFlagArgument, ParsedPositionalArgument } from './parser' +import { InternalFlagArgument, InternalPositionalArgument, InternalArgument, CoercedValue } from './types' + +export function validateCommandSchematically (command: UserCommand, opts: StoredParserOpts): Result { + const { deprecated, deprecationMessage } = command.internal.inner.opts + + // Do not run deprecated commands + if (deprecated && opts.deprecatedCommands === 'error') { + return Err(new CommandError(deprecationMessage)) + } else if (deprecated && opts.deprecatedCommands === 'unknown-command') { + return Err(new CommandError(`unknown command '${command.internal.name}'`)) + } + + return Ok(undefined) +} + +export function validateFlagSchematically ( + flags: Map, + argument: InternalFlagArgument, + opts: StoredParserOpts, + resolveres: Resolver[] +): Result { + let foundFlags = flags.get(argument.longFlag) + if (argument.aliases.length && !foundFlags) { + for (const alias of argument.aliases) { + foundFlags = flags.get(alias.value) + + if (foundFlags) { + break + } + } + } + + let { specifiedDefault, unspecifiedDefault, optional, dependencies, conflicts, exclusive, requiredUnlessPresent } = argument.inner._meta + + // Test our resolvers to see if any of them have a value, so we know whether to reject below + let resolversHaveValue = false + + for (const resolver of resolveres) { + if (resolver.keyExists(argument.longFlag, opts)) { + resolversHaveValue = true + } + } + + // Must be at the top, we modify `optional` behaviour + for (const presentKey of requiredUnlessPresent) { + const presence = flags.get(presentKey) + if (presence !== undefined) { + optional = true + } + } + + // If no definitions were provided + if (!foundFlags?.length && !optional && unspecifiedDefault === undefined && !resolversHaveValue) { + return Err(new CoercionError(argument.inner.type, '', `argument '--${argument.longFlag}' is missing, with no unspecified default`, getArgDenotion(argument))) + } + + for (const foundFlag of foundFlags ?? []) { + // Groups will be checked for unrecognised flags later + if (foundFlag && foundFlag.type === 'short-group') { + return Ok(foundFlags) + } + + // If no values were passed to a definition + if (!optional && specifiedDefault === undefined && !foundFlag.values.length && !resolversHaveValue) { + return Err(new CoercionError(argument.inner.type, '', `argument '${argument.longFlag}' is not declared as optional, does not have a default, and was not provided a value`, getArgDenotion(argument))) + } + + for (const dependency of dependencies) { + const dependencyValue = flags.get(dependency) + if (!dependencyValue) { + return Err(new CoercionError('a value', '', `unmet dependency '--${dependency}' for '--${argument.longFlag}'`, getArgDenotion(argument))) + } + } + + for (const conflict of conflicts) { + const conflictValue = flags.get(conflict) + // Require both the argument we're checking against (the base) and the conflict to exist + if (conflictValue !== undefined && foundFlags?.length) { + return Err(new CoercionError(`--${conflict} to not be passed`, conflictValue.map(c => c.rawInput).join(' '), `argument '--${conflict}' conflicts with '--${argument.longFlag}'`, getArgDenotion(argument))) + } + } + + if (exclusive && flags.size > 1) { + return Err(new CoercionError('no other args to be passed', `${flags.size - 1} other arguments`, `argument '--${argument.longFlag}' is exclusive and cannot be used with other arguments`, getArgDenotion(argument))) + } + } + + return Ok(foundFlags) +} + +export function validatePositionalSchematically ( + positionals: Map, + argument: InternalPositionalArgument, + opts: StoredParserOpts, + middlewares: Resolver[] +): Result { + const foundFlag = positionals.get(argument.index) + const { unspecifiedDefault, optional } = argument.inner._meta + + // Test our middlewares to see if any of them have a value, so we know whether to reject below + let middlewaresHaveValue = false + + for (const middleware of middlewares) { + if (middleware.keyExists(argument.key, opts)) { + middlewaresHaveValue = true + } + } + + if (!optional && unspecifiedDefault === undefined && !foundFlag?.values && !middlewaresHaveValue) { + return Err(new CoercionError(argument.inner.type, '', `positional argument '${argument.key}' is not declared as optional, does not have a default, and was not provided a value`, argument.key)) + } + + return Ok(foundFlag) +} + +export async function coerceMultiType (inputValues: string[], argument: InternalArgument): Promise> { + const eachParserResults: Map, Array>> = new Map() + + for (const value of inputValues) { + for (const parser of [argument.inner, ...argument.inner._meta.otherParsers]) { + const parsed = await parser.coerce(value) + const alreadyParsed = eachParserResults.get(parser) ?? [] + + alreadyParsed.push(parsed) + eachParserResults.set(parser, alreadyParsed) + } + } + + interface SingleError { + value: string + error: Error + } + interface GroupedError { + parser: MinimalArgument + errors: SingleError[] + } + + interface CoercedGroup { + parser: MinimalArgument + values: CoercedValue[] + } + + const coercedGroups: CoercedGroup[] = [] + const groupedErrors: GroupedError[] = [] + + for (const [parser, results] of eachParserResults.entries()) { + const errors: SingleError[] = [] + const coerced = [] + + for (const result of results) { + if (result.ok) { + coerced.push(result.returnedValue) + } else { + errors.push({ + value: result.passedValue, + error: result.error + }) + } + } + + if (errors.length) { + groupedErrors.push({ + parser, + errors + }) + } else { + coercedGroups.push({ + parser, + values: coerced + }) + } + } + + // If no parsers could resolve the values (no succesful groups) + if (groupedErrors.length && !coercedGroups.length) { + const errors = groupedErrors.flatMap(group => { + return group.errors.map(error => { + return new CoercionError(argument.inner.type, error.value, `could not parse a '${group.parser.type}' because ${error.error.message}`, getArgDenotion(argument)) + }) + }) + + return Err(errors) + } + + // Take the first group that managed to coerce all of the values + const selectedGroup = coercedGroups.slice(0, 1)[0] + if (!selectedGroup) { + throw new InternalError(`no selected group, but errors were not caught either? coerced: ${JSON.stringify(coercedGroups)}, errors: ${JSON.stringify(groupedErrors)}`) + } + + return Ok({ + isMulti: true, + coerced: selectedGroup.values, + raw: inputValues + }) +} diff --git a/src/internal/parse/types.ts b/src/internal/parse/types.ts index 3507e96..561c732 100644 --- a/src/internal/parse/types.ts +++ b/src/internal/parse/types.ts @@ -33,15 +33,4 @@ export interface InternalCommand { parser: Args<{}> } -export interface ParsedPair { - ident: string - values: string[] -} - -export interface RuntimeValue { - pair: ParsedPair | undefined - argument: InternalArgument - parsed: unknown[] -} - export type CoercedValue = string | boolean | number | undefined | object | bigint diff --git a/src/util/help.ts b/src/util/help.ts index ae99a91..66e3b47 100644 --- a/src/util/help.ts +++ b/src/util/help.ts @@ -9,7 +9,8 @@ import { getAliasDenotion } from '../internal/util' * @returns the generated help string */ export function generateHelp (parser: Args<{}>): string { - const { argumentsList, commandsList, opts, builtins } = parser + const { argumentsList, commandsList, builtins, headerLines, footerLines } = parser._state + const { opts } = parser const renderArgument = (value: InternalArgument): string => { const { optional, isMultiType } = value.inner._meta @@ -55,13 +56,13 @@ export function generateHelp (parser: Args<{}>): string { if (cmd.aliases.length) { nameString = `[${cmd.name}, ${cmd.aliases.join(', ')}]` } - return `${opts.programName} ${nameString} ${cmd.parser.argumentsList.filter(filterPrimary).map(arg => renderArgument(arg)).join(' ')}` + return `${opts.programName} ${nameString} ${cmd.parser._state.argumentsList.filter(filterPrimary).map(arg => renderArgument(arg)).join(' ')}` }).join('\n') const builtinString = builtins.map(builtin => builtin.helpInfo()).join('\n') return ` -${opts.programName} ${opts.programDescription && ` - ${opts.programDescription}`} ${parser.headerLines.length ? '\n' + parser.headerLines.join('\n') : ''} +${opts.programName} ${opts.programDescription && ` - ${opts.programDescription}`} ${headerLines.length ? '\n' + headerLines.join('\n') : ''} Usage: ${opts.programName} ${usageString} @@ -70,5 +71,5 @@ ${commandString || 'None'} Builtins: ${builtinString || 'None'} -${parser.footerLines.join('\n')}`.trim() +${footerLines.join('\n')}`.trim() } diff --git a/test/parsing/coerce.test.ts b/test/parsing/coerce.test.ts index f183395..07eed3c 100644 --- a/test/parsing/coerce.test.ts +++ b/test/parsing/coerce.test.ts @@ -12,7 +12,7 @@ describe('Coercion tests', () => { const coerced = await parseAndCoerce('--number 1', parserOpts, [flag]) - expect(coerced.command).toEqual({ + expect(coerced.parsed.command).toEqual({ type: 'default' }) expect(coerced.args.get(flag)).toEqual({ @@ -31,7 +31,7 @@ describe('Coercion tests', () => { const coerced = await parseAndCoerce('--bool true', parserOpts, [flag]) - expect(coerced.command).toEqual({ + expect(coerced.parsed.command).toEqual({ type: 'default' }) expect(coerced.args.get(flag)).toEqual({ @@ -50,7 +50,7 @@ describe('Coercion tests', () => { const coerced = await parseAndCoerce('helloworld', parserOpts, [flag]) - expect(coerced.command).toEqual({ + expect(coerced.parsed.command).toEqual({ type: 'default', key: 'helloworld' }) @@ -70,7 +70,7 @@ describe('Coercion tests', () => { const coerced = await parseAndCoerce('--string helloworld', parserOpts, [flag]) - expect(coerced.command).toEqual({ + expect(coerced.parsed.command).toEqual({ type: 'default' }) expect(coerced.args.get(flag)).toEqual({ @@ -89,7 +89,7 @@ describe('Coercion tests', () => { const coerced = await parseAndCoerce('--string "hello world"', parserOpts, [flag]) - expect(coerced.command).toEqual({ + expect(coerced.parsed.command).toEqual({ type: 'default' }) expect(coerced.args.get(flag)).toEqual({ diff --git a/test/parsing/utils.ts b/test/parsing/utils.ts index c3fba76..ab946f6 100644 --- a/test/parsing/utils.ts +++ b/test/parsing/utils.ts @@ -1,6 +1,6 @@ import assert from 'assert' -import { MinimalArgument, StoredParserOpts, defaultCommandOpts } from '../../src' +import { ArgsState, MinimalArgument, StoredParserOpts, defaultCommandOpts } from '../../src' import { CoercedArguments, coerce } from '../../src/internal/parse/coerce' import { tokenise } from '../../src/internal/parse/lexer' import { ParsedArguments, parse } from '../../src/internal/parse/parser' @@ -76,7 +76,18 @@ export function lexAndParse (argStr: string, opts: StoredParserOpts, commands: I return acc }, new PrefixTree()) - const parsed = parse(tokens.val, commandMap, opts) + const state: ArgsState = { + arguments: new PrefixTree(), + argumentsList: [], + commands: commandMap, + commandsList: commands, + builtins: [], + resolvers: opts.resolvers, + footerLines: [], + headerLines: [] + } + + const parsed = parse(tokens.val, state, opts) if (!parsed.ok) { throw parsed.err } @@ -99,7 +110,18 @@ export async function parseAndCoerce (argStr: string, opts: StoredParserOpts, ar return acc }, new PrefixTree()) - const coerced = await coerce(parsed, opts, argMap, args, opts.resolvers, []) + const state: ArgsState = { + arguments: argMap, + argumentsList: args, + commands: new PrefixTree(), + commandsList: [], + builtins: [], + resolvers: opts.resolvers, + footerLines: [], + headerLines: [] + } + + const coerced = await coerce(parsed, opts, state) if (!coerced.ok) { assert(Array.isArray(coerced.err)) throw new Error(coerced.err.map(e => e.message).join('\n'))