fix: command/option parsing priorities

This commit is contained in:
2023-12-14 10:24:42 +02:00
parent 4660ba659f
commit a3090f9e05
6 changed files with 92 additions and 35 deletions

View File

@@ -14,8 +14,9 @@ import {
Prefixes, Prefixes,
FlagConfig, FlagConfig,
} from './option' } from './option'
import { DeepRequired, setOrPush, deepMerge } from './utils' import { DeepRequired, setOrPush, deepMerge, getErrorMessage } from './utils'
import { MassargExample, ExampleConfig } from './example' import { MassargExample, ExampleConfig } from './example'
import { format } from './style'
export const CommandConfig = <RunArgs extends ArgsObject = ArgsObject>(args: z.ZodType<RunArgs>) => export const CommandConfig = <RunArgs extends ArgsObject = ArgsObject>(args: z.ZodType<RunArgs>) =>
z.object({ z.object({
@@ -187,14 +188,7 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
flag(config: FlagConfig | MassargFlag): MassargCommand<Args> { flag(config: FlagConfig | MassargFlag): MassargCommand<Args> {
try { try {
const flag = config instanceof MassargFlag ? config : new MassargFlag(config) const flag = config instanceof MassargFlag ? config : new MassargFlag(config)
const existing = this.options.find((c) => c.name === flag.name) this.assertNotDuplicate(flag)
if (existing) {
throw new ValidationError({
code: 'duplicate_flag',
message: `Flag "${flag.name}" already exists`,
path: [this.name, flag.name],
})
}
this.options.push(flag as MassargOption) this.options.push(flag as MassargOption)
return this return this
} catch (e) { } catch (e) {
@@ -234,24 +228,8 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
config instanceof MassargOption config instanceof MassargOption
? config ? config
: MassargOption.fromTypedConfig(config as TypedOptionConfig<T, A>) : MassargOption.fromTypedConfig(config as TypedOptionConfig<T, A>)
const existing = this.options.find((c) => c.name === option.name) this.assertNotDuplicate(option)
if (existing) { this.assertOnlyOneDefault<T, A>(option)
throw new ValidationError({
code: 'duplicate_option',
message: `Option "${option.name}" already exists`,
path: [this.name, option.name],
})
}
if (option.isDefault) {
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
throw new ValidationError({
code: 'duplicate_default_option',
message: `Option "${option.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`,
path: [this.name, option.name],
})
}
}
this.options.push(option as MassargOption) this.options.push(option as MassargOption)
return this return this
} catch (e) { } catch (e) {
@@ -266,6 +244,43 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
} }
} }
private assertNotDuplicate<T = string, A extends ArgsObject = Args>(option: MassargOption<T, A>) {
const existingName = this.options.find((c) => c.name === option.name),)
if (existingName) {
throw new ValidationError({
code: 'duplicate_option_name',
message: `Option name "${existingName.name}" already exists`,
path: [this.name, option.name],
})
}
const existingAlias = this.options.find((c) =>
c.aliases.some((a) => option.aliases.includes(a)),
)
if (existingAlias) {
const alias = option.aliases.find((a) => existingAlias.aliases.includes(a))!
throw new ValidationError({
code: 'duplicate_option_alias',
message: `Option alias "${alias}" already exists on option "${existingAlias.name}"`,
path: [this.name, option.name],
})
}
}
private assertOnlyOneDefault<T = string, A extends ArgsObject = Args>(
option: MassargOption<T, A>,
) {
if (option.isDefault) {
const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) {
throw new ValidationError({
code: 'duplicate_default_option',
message: `Option "${option.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`,
path: [this.name, option.name],
})
}
}
}
/** /**
* Adds an example to this command. * Adds an example to this command.
* *
@@ -323,7 +338,12 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
args?: Partial<Args>, args?: Partial<Args>,
parent?: MassargCommand<Args>, parent?: MassargCommand<Args>,
): Promise<void> | void { ): Promise<void> | void {
this.getArgs(argv, args, parent, true) try {
this.getArgs(argv, args, parent, true)
} catch (e) {
const message = getErrorMessage(e)
console.error(format(message, { color: 'red' }))
}
} }
private parseOption(arg: string, argv: string[]) { private parseOption(arg: string, argv: string[]) {
@@ -409,8 +429,7 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
// default option - passes arg value even without flag name // default option - passes arg value even without flag name
const defaultOption = this.options.find((o) => o.isDefault) const defaultOption = this.options.find((o) => o.isDefault)
if (defaultOption) { if (defaultOption) {
_argv = this.parseOption(`--${defaultOption.name}`, [arg, ..._argv]) this.parseOption(`--${defaultOption.name}`, [arg])
_argv.shift()
continue continue
} }
// not parsed by any step, add to extra key // not parsed by any step, add to extra key
@@ -466,7 +485,7 @@ export class MassargHelpCommand<
const _config = CommandConfig(z.any()).parse({ const _config = CommandConfig(z.any()).parse({
name: 'help', name: 'help',
aliases: ['h'], aliases: ['h'],
description: 'Print help for this command, or a subcommand if specified', description: 'Print help for this command, or a sub-command if specified',
run: (args: { command?: string }, parent) => { run: (args: { command?: string }, parent) => {
if (args.command) { if (args.command) {
const command = parent.commands.find((c) => c.name === args.command) const command = parent.commands.find((c) => c.name === args.command)

View File

@@ -394,6 +394,7 @@ function generateHelpTable<T extends GenerateTableCommandConfig | GenerateTableO
aliasPrefix, aliasPrefix,
negatePrefix, negatePrefix,
negateAliasPrefix, negateAliasPrefix,
displayNegations,
}), }),
) )
.filter((r) => !r.hidden) .filter((r) => !r.hidden)
@@ -422,6 +423,7 @@ function generateHelpTable<T extends GenerateTableCommandConfig | GenerateTableO
} }
currentRow += `${word} ` currentRow += `${word} `
} }
subRows.push(currentRow)
if (!compact) { if (!compact) {
subRows.push('') subRows.push('')

View File

@@ -16,8 +16,7 @@ const addCmd = massarg<{ component: string }>({
description: 'Add a component', description: 'Add a component',
aliases: ['a'], aliases: ['a'],
run: (opts, parser) => { run: (opts, parser) => {
parser.printHelp() console.log('Adding component', opts)
console.log('Adding component', opts.component)
}, },
}) })
.option({ .option({
@@ -25,6 +24,7 @@ const addCmd = massarg<{ component: string }>({
description: description:
'Component to add. Ut consectetur eu et occaecat enim magna amet eiusmod laboris deserunt proident culpa nulla ipsum adipiscing ullamco laboris sed est', 'Component to add. Ut consectetur eu et occaecat enim magna amet eiusmod laboris deserunt proident culpa nulla ipsum adipiscing ullamco laboris sed est',
aliases: ['c'], aliases: ['c'],
isDefault: true,
// aliases: "" as never, // aliases: "" as never,
}) })
.option({ .option({
@@ -81,6 +81,9 @@ const main = massarg<A>({
bindCommand: true, bindCommand: true,
headerText: 'This is a header', headerText: 'This is a header',
footerText: 'This is a footer', footerText: 'This is a footer',
optionOptions: {
displayNegations: true,
},
}) })
.main((opts, parser) => { .main((opts, parser) => {
console.log('Main command - printing all opts') console.log('Main command - printing all opts')
@@ -94,6 +97,13 @@ const main = massarg<A>({
name: 'bool', name: 'bool',
description: 'Example boolean option', description: 'Example boolean option',
aliases: ['b'], aliases: ['b'],
negatable: true,
})
.option({
name: 'string',
description:
'Laborum qui ex do consectetur magna. Ex do consectetur magna officia, consequat. Magna officia consequat labore veniam proident exercitation occaecat. Consequat labore veniam proident exercitation occaecat. Veniam proident exercitation occaecat aliquip.',
aliases: ['s'],
}) })
.option({ .option({
name: 'number', name: 'number',

View File

@@ -1,6 +1,6 @@
import z from 'zod' import z from 'zod'
import { zodEnumFromObjKeys, strConcat } from './utils' import { zodEnumFromObjKeys } from './utils'
export { strConcat } export { strConcat, indent } from './utils'
export const ansiStyles = { export const ansiStyles = {
reset: '\x1b[0m', reset: '\x1b[0m',

View File

@@ -1,4 +1,5 @@
import z from 'zod' import z from 'zod'
import { ValidationError } from './error'
/** @internal */ /** @internal */
export function setOrPush<T>( export function setOrPush<T>(
@@ -113,3 +114,10 @@ export function toPascalCase(str: string): string {
.map((s) => s[0].toUpperCase() + s.slice(1)) .map((s) => s[0].toUpperCase() + s.slice(1))
.join('') .join('')
} }
export function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message
}
return String(err)
}

View File

@@ -87,6 +87,24 @@ describe('prints help from option', () => {
command2.parse(['--help']) command2.parse(['--help'])
expect(mainCmd).not.toHaveBeenCalled() expect(mainCmd).not.toHaveBeenCalled()
}) })
test('when default option exists', () => {
const command = massarg(opts)
.option({
name: 'test',
aliases: ['t'],
description: 'test',
isDefault: true,
})
.help({
bindOption: true,
})
const log = jest.spyOn(console, 'log').mockImplementation((...a) => {
console.info(...a)
})
command.parse(['--help'])
expect(log).toHaveBeenCalled()
})
}) })
test('help string', () => { test('help string', () => {