Skip to content

Commit

Permalink
refactor: coercion (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
nullishamy authored Aug 30, 2023
1 parent 9fbc821 commit 61e40ae
Show file tree
Hide file tree
Showing 10 changed files with 394 additions and 369 deletions.
136 changes: 73 additions & 63 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,55 @@ export interface DefaultArgTypes {
rest?: string
}

export interface ArgsState {

arguments: PrefixTree<InternalArgument>
argumentsList: InternalArgument[]

commands: PrefixTree<InternalCommand>
commandsList: InternalCommand[]

resolvers: Resolver[]
builtins: Builtin[]
footerLines: string[]
headerLines: string[]
}

/**
* The root class for the library. Generally, it represents a configured parser which can then be used to parse arbitrary input strings.
*
* It will hold all the state needed to parse inputs. This state is modified through the various helper methods defined on this class.
*/
export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
// We store both a tree and a list so that we can iterate all values efficiently
public arguments: PrefixTree<InternalArgument> = new PrefixTree()
public argumentsList: InternalArgument[] = []

public commands: PrefixTree<InternalCommand> = new PrefixTree()
public commandsList: InternalCommand[] = []

public resolvers: Resolver[] = []
public builtins: Builtin[] = []
public footerLines: string[] = []
public headerLines: string[] = []

public readonly opts: StoredParserOpts
public readonly _state: ArgsState

private positionalIndex = 0

/**
* Constructs a new parser with empty state, ready for configuration.
* @param opts - The parser options to use
* @param existingState - The previous state to uses
*/
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: []
}
}

/**
Expand All @@ -72,7 +90,7 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
* @returns this
*/
public resolver (resolver: Resolver): Args<TArgTypes> {
this.resolvers.push(resolver)
this._state.resolvers.push(resolver)
return this
}

Expand All @@ -82,7 +100,7 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
* @returns this
*/
public builtin (builtin: Builtin): Args<TArgTypes> {
this.builtins.push(builtin)
this._state.builtins.push(builtin)
return this
}

Expand All @@ -103,11 +121,11 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
command: TCommand,
inherit = false
): Args<TArgTypes> {
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})`)
}
Expand All @@ -118,20 +136,20 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
})

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,
parser,
isBase: true
})

this.commandsList.push({
this._state.commandsList.push({
inner: command,
name,
aliases,
Expand All @@ -140,23 +158,23 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
})

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,
parser,
isBase: false
})

this.commandsList.push({
this._state.commandsList.push({
inner: command,
name,
aliases,
Expand Down Expand Up @@ -186,19 +204,19 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
}

const slicedKey = key.slice(1, key.length - 1)
if (this.arguments.has(slicedKey)) {
if (this._state.arguments.has(slicedKey)) {
throw new SchemaError(`duplicate positional key '${slicedKey}'`)
}

const index = this.positionalIndex++
this.arguments.insert(slicedKey, {
this._state.arguments.insert(slicedKey, {
type: 'positional',
inner: arg,
key: slicedKey,
index
})

this.argumentsList.push({
this._state.argumentsList.push({
type: 'positional',
inner: arg,
key: slicedKey,
Expand Down Expand Up @@ -234,16 +252,16 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
}
})

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: arg,
Expand All @@ -252,15 +270,15 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
})
}

this.arguments.insert(longFlag, {
this._state.arguments.insert(longFlag, {
type: 'flag',
isLongFlag: true,
inner: arg,
longFlag,
aliases
})

this.argumentsList.push({
this._state.argumentsList.push({
type: 'flag',
isLongFlag: true,
inner: arg,
Expand Down Expand Up @@ -329,15 +347,15 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
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 {
positionals.push(value)
}
}

if (positionals.filter(p => p.inner._meta.isMultiType).length > 1) {
if (positionals.filter(p => p.inner._state.isMultiType).length > 1) {
return Err(new SchemaError('multiple multi-type positionals found'))
}
return Ok(this)
Expand All @@ -359,9 +377,9 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
*/
public footer (line: string, append = true): Args<TArgTypes> {
if (append) {
this.footerLines.push(line)
this._state.footerLines.push(line)
} else {
this.footerLines = [line]
this._state.footerLines = [line]
}

return this
Expand All @@ -375,9 +393,9 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
*/
public header (line: string, append = true): Args<TArgTypes> {
if (append) {
this.headerLines.push(line)
this._state.headerLines.push(line)
} else {
this.headerLines = [line]
this._state.headerLines = [line]
}

return this
Expand All @@ -401,61 +419,55 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {

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
)
}

if (!coercionResult.ok) {
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
}
Expand All @@ -471,7 +483,7 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
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
}
Expand All @@ -485,8 +497,8 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
// 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
})
}

Expand All @@ -512,9 +524,7 @@ export class Args<TArgTypes extends DefaultArgTypes = DefaultArgTypes> {
return result.val
}

public reset (): void {
this.arguments = new PrefixTree()
this.commands = new PrefixTree()
this.resolvers = []
public clone (opts: ParserOpts = this.opts): Args<TArgTypes> {
return new Args(opts, this._state)
}
}
8 changes: 4 additions & 4 deletions src/builder/argument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type CoercionResult<T> = CoercionResultOk<T> | CoercionResultErr

export type ArgumentType = string

interface ArgumentMeta<T> {
interface ArgumentState<T> {
specifiedDefault: T | undefined
dependencies: string[]
requiredUnlessPresent: string[]
Expand All @@ -28,7 +28,7 @@ interface ArgumentMeta<T> {
otherParsers: Array<MinimalArgument<CoercedValue>>
}

export type MinimalArgument<T> = Pick<Argument<T>, '_meta' | 'coerce' | 'type' | 'negate'>
export type MinimalArgument<T> = Pick<Argument<T>, '_state' | 'coerce' | 'type' | 'negate'>

export abstract class Argument<T> {
protected _specifiedDefault: T | undefined = undefined
Expand All @@ -44,11 +44,11 @@ export abstract class Argument<T> {
protected _negated: boolean = false

// Internal getter to avoid cluttering completion with ^ our private fields that need to be accessed by other internal APIs
// Conveniently also means we encapsulate our data, so it cannot be easily tampered with by outside people (assuming they do not break type safety)
// Conveniently also means we encapsulate our data, so it cannot be easily tampered with by consumers
/**
* @internal
*/
get _meta (): ArgumentMeta<T> {
get _state (): ArgumentState<T> {
return {
specifiedDefault: this._specifiedDefault,
dependencies: this._dependencies,
Expand Down
Loading

0 comments on commit 61e40ae

Please sign in to comment.