From 5129528339579af6e778c8c4195717539dd4f189 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 14 Dec 2023 10:24:42 +0200 Subject: [PATCH] fix: command/option parsing priorities --- src/command.ts | 81 +++++++++++++++++++++++++++++------------------ src/help.ts | 2 ++ src/sample.ts | 14 ++++++-- src/style.ts | 4 +-- src/utils.ts | 8 +++++ test/help.test.ts | 18 +++++++++++ 6 files changed, 92 insertions(+), 35 deletions(-) diff --git a/src/command.ts b/src/command.ts index c27f2f0..546eb52 100644 --- a/src/command.ts +++ b/src/command.ts @@ -14,8 +14,9 @@ import { Prefixes, FlagConfig, } from './option' -import { DeepRequired, setOrPush, deepMerge } from './utils' +import { DeepRequired, setOrPush, deepMerge, getErrorMessage } from './utils' import { MassargExample, ExampleConfig } from './example' +import { format } from './style' export const CommandConfig = (args: z.ZodType) => z.object({ @@ -187,14 +188,7 @@ export class MassargCommand { flag(config: FlagConfig | MassargFlag): MassargCommand { try { const flag = config instanceof MassargFlag ? config : new MassargFlag(config) - const existing = this.options.find((c) => c.name === flag.name) - if (existing) { - throw new ValidationError({ - code: 'duplicate_flag', - message: `Flag "${flag.name}" already exists`, - path: [this.name, flag.name], - }) - } + this.assertNotDuplicate(flag) this.options.push(flag as MassargOption) return this } catch (e) { @@ -234,24 +228,8 @@ export class MassargCommand { config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config as TypedOptionConfig) - const existing = this.options.find((c) => c.name === option.name) - if (existing) { - 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.assertNotDuplicate(option) + this.assertOnlyOneDefault(option) this.options.push(option as MassargOption) return this } catch (e) { @@ -266,6 +244,43 @@ export class MassargCommand { } } + private assertNotDuplicate(option: MassargOption) { + 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( + option: MassargOption, + ) { + 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. * @@ -323,7 +338,12 @@ export class MassargCommand { args?: Partial, parent?: MassargCommand, ): Promise | 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[]) { @@ -409,8 +429,7 @@ export class MassargCommand { // default option - passes arg value even without flag name const defaultOption = this.options.find((o) => o.isDefault) if (defaultOption) { - _argv = this.parseOption(`--${defaultOption.name}`, [arg, ..._argv]) - _argv.shift() + this.parseOption(`--${defaultOption.name}`, [arg]) continue } // not parsed by any step, add to extra key @@ -466,7 +485,7 @@ export class MassargHelpCommand< const _config = CommandConfig(z.any()).parse({ name: 'help', 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) => { if (args.command) { const command = parent.commands.find((c) => c.name === args.command) diff --git a/src/help.ts b/src/help.ts index f207f8b..57549c5 100644 --- a/src/help.ts +++ b/src/help.ts @@ -394,6 +394,7 @@ function generateHelpTable !r.hidden) @@ -422,6 +423,7 @@ function generateHelpTable({ description: 'Add a component', aliases: ['a'], run: (opts, parser) => { - parser.printHelp() - console.log('Adding component', opts.component) + console.log('Adding component', opts) }, }) .option({ @@ -25,6 +24,7 @@ const addCmd = massarg<{ component: string }>({ description: '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'], + isDefault: true, // aliases: "" as never, }) .option({ @@ -81,6 +81,9 @@ const main = massarg({ bindCommand: true, headerText: 'This is a header', footerText: 'This is a footer', + optionOptions: { + displayNegations: true, + }, }) .main((opts, parser) => { console.log('Main command - printing all opts') @@ -94,6 +97,13 @@ const main = massarg({ name: 'bool', description: 'Example boolean option', 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({ name: 'number', diff --git a/src/style.ts b/src/style.ts index aa9d75d..6a1bb47 100644 --- a/src/style.ts +++ b/src/style.ts @@ -1,6 +1,6 @@ import z from 'zod' -import { zodEnumFromObjKeys, strConcat } from './utils' -export { strConcat } +import { zodEnumFromObjKeys } from './utils' +export { strConcat, indent } from './utils' export const ansiStyles = { reset: '\x1b[0m', diff --git a/src/utils.ts b/src/utils.ts index d37dae4..0c7b6e1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import z from 'zod' +import { ValidationError } from './error' /** @internal */ export function setOrPush( @@ -113,3 +114,10 @@ export function toPascalCase(str: string): string { .map((s) => s[0].toUpperCase() + s.slice(1)) .join('') } + +export function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message + } + return String(err) +} diff --git a/test/help.test.ts b/test/help.test.ts index c03e9a7..8da548d 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -87,6 +87,24 @@ describe('prints help from option', () => { command2.parse(['--help']) 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', () => {