diff --git a/src/command.ts b/src/command.ts index 119ae54..79b4858 100644 --- a/src/command.ts +++ b/src/command.ts @@ -12,6 +12,7 @@ import { OPT_SHORT_PREFIX, NEGATE_SHORT_PREFIX, Prefixes, + FlagConfig, } from './option' import { DeepRequired, setOrPush, deepMerge } from './utils' import { MassargExample, ExampleConfig } from './example' @@ -99,6 +100,10 @@ export class MassargCommand { } get optionPrefixes(): Prefixes { + return this.getPrefixes() + } + + private getPrefixes(): Prefixes { return { optionPrefix: this.optionPrefix, aliasPrefix: this.optionAliasPrefix, @@ -177,11 +182,9 @@ export class MassargCommand { * or by prefixing the alias with `^` instead of `-`. This is configurable via the command's * configuration. */ - flag(config: Omit, 'parse' | 'isDefault'>): MassargCommand + flag(config: FlagConfig): MassargCommand flag(config: MassargFlag): MassargCommand - flag( - config: Omit, 'parse' | 'isDefault'> | MassargFlag, - ): 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) @@ -320,15 +323,17 @@ export class MassargCommand { } private parseOption(arg: string, argv: string[]) { - const option = this.options.find((o) => o._match(arg, this.optionPrefixes)) + const prefixes = { ...this.optionPrefixes } + const option = this.options.find((o) => o._match(arg, prefixes)) if (!option) { throw new ValidationError({ - path: [MassargOption.findNameInArg(arg, this.optionPrefixes)], + path: [MassargOption.findNameInArg(arg, prefixes)], code: 'unknown_option', message: 'Unknown option', }) } - const res = option._parseDetails([arg, ...argv], { ...this.args }, this.optionPrefixes) + const res = option.parseDetails([arg, ...argv], { ...this.args }, prefixes) + this.args[res.key as keyof Args] = setOrPush( res.value, this.args[res.key as keyof Args], @@ -377,7 +382,7 @@ export class MassargCommand { while (_argv.length) { const arg = _argv.shift()! // make sure option exists - const found = this.options.some((o) => o._isOption(arg, this.optionPrefixes)) + const found = this.options.some((o) => o._isOption(arg, { ...this.optionPrefixes })) if (found) { _argv = this.parseOption(arg, _argv) _args = { ..._args, ...this.args } diff --git a/src/help.ts b/src/help.ts index 2756b2b..f5dc39e 100644 --- a/src/help.ts +++ b/src/help.ts @@ -214,9 +214,9 @@ export class HelpGenerator { } const maxNameLength = this.config.useGlobalTableColumns ? Math.max( - getMaxNameLength(entry.options.map((e) => getItemDetails(e, optionOptions))), - getMaxNameLength(entry.commands.map((e) => getItemDetails(e, commandOptions))), - ) + getMaxNameLength(entry.options.map((e) => getItemDetails(e, optionOptions))), + getMaxNameLength(entry.commands.map((e) => getItemDetails(e, commandOptions))), + ) : undefined const options = generateHelpTable(entry.options, optionOptions, maxNameLength).trimEnd() const commands = generateHelpTable(entry.commands, commandOptions, maxNameLength).trimEnd() @@ -228,21 +228,21 @@ export class HelpGenerator { _wrap(format(description, this.config.exampleOptions.descriptionStyle), 4), ], input && - _wrap( - format( - [this.config.exampleOptions.inputPrefix, input].filter(Boolean).join(' '), - this.config.exampleOptions.inputStyle, + _wrap( + format( + [this.config.exampleOptions.inputPrefix, input].filter(Boolean).join(' '), + this.config.exampleOptions.inputStyle, + ), + 4, ), - 4, - ), output && - _wrap( - format( - [this.config.exampleOptions.outputPrefix, output].filter(Boolean).join(' '), - this.config.exampleOptions.outputStyle, + _wrap( + format( + [this.config.exampleOptions.outputPrefix, output].filter(Boolean).join(' '), + this.config.exampleOptions.outputStyle, + ), + 4, ), - 4, - ), ) }) .join('\n') @@ -253,17 +253,17 @@ export class HelpGenerator { _wrap( usageText ? strConcat( - format('Usage:', this.config.usageStyle.prefix), - format(usageText, this.config.usageStyle.main), - ) + format('Usage:', this.config.usageStyle.prefix), + format(usageText, this.config.usageStyle.main), + ) : [ - format(`Usage:`, this.config.usageStyle.prefix), - format(entry.name, this.config.usageStyle.main), - commands.length && format('[command]', this.config.usageStyle.command), - options.length && format('[options]', this.config.usageStyle.options), - ] - .filter(Boolean) - .join(' '), + format(`Usage:`, this.config.usageStyle.prefix), + format(entry.name, this.config.usageStyle.main), + commands.length && format('[command]', this.config.usageStyle.command), + options.length && format('[options]', this.config.usageStyle.options), + ] + .filter(Boolean) + .join(' '), ), headerText.length && ['', format(headerText, this.config.descriptionStyle)], entry.description.length && [ @@ -271,27 +271,27 @@ export class HelpGenerator { _wrap(format(entry.description, this.config.descriptionStyle)), ], commands.length && - indent([ - '', - format( - entry.parent ? `Commands for ${entry.name}:` : 'Commands:', - this.config.subtitleStyle, - ), - '', - indent(commands), - ]), + indent([ + '', + format( + entry.parent ? `Commands for ${entry.name}:` : 'Commands:', + this.config.subtitleStyle, + ), + '', + indent(commands), + ]), options.length && - indent([ - '', - format( - entry.parent ? `Options for ${entry.name}:` : 'Options:', - this.config.subtitleStyle, - ), - '', - indent(options), - ]), + indent([ + '', + format( + entry.parent ? `Options for ${entry.name}:` : 'Options:', + this.config.subtitleStyle, + ), + '', + indent(options), + ]), examples.length && - indent(['', format('Examples:', this.config.subtitleStyle), '', indent(examples)]), + indent(['', format('Examples:', this.config.subtitleStyle), '', indent(examples)]), footerText.length && ['', _wrap(format(footerText, this.config.descriptionStyle))], ) + '\n' ) diff --git a/src/option.ts b/src/option.ts index 3854c25..e5cbf41 100644 --- a/src/option.ts +++ b/src/option.ts @@ -50,12 +50,14 @@ export type OptionConfig = z. ReturnType> > -export const FlagConfig = OptionConfig(z.any()).merge( - z.object({ - /** Whether the flag can be negated, e.g. `--no-verbose` */ - negatable: z.boolean().optional(), - }), -) +export const FlagConfig = OptionConfig(z.any()) + .omit({ parse: true, isDefault: true }) + .merge( + z.object({ + /** Whether the flag can be negated, e.g. `--no-verbose` */ + negatable: z.boolean().optional(), + }), + ) export type FlagConfig = z.infer export type Parser = ( @@ -99,7 +101,7 @@ export type ArrayOptionConfig = z.infer< // TODO turn to options export const OPT_FULL_PREFIX = '--' export const OPT_SHORT_PREFIX = '-' -export const NEGATE_FULL_PREFIX = 'no-' +export const NEGATE_FULL_PREFIX = '--no-' export const NEGATE_SHORT_PREFIX = '^' export type Prefixes = { @@ -172,8 +174,7 @@ export class MassargOption { - // TODO: support --option=value + parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue { let input = '' try { if (!this._match(argv[0], prefixes)) { @@ -221,25 +222,21 @@ export class MassargOption' } } @@ -268,9 +265,9 @@ export class MassargNumber extends MassargOption { }) } - _parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue { + parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue { try { - const { argv: _argv, value } = super._parseDetails(argv, options, prefixes) + const { argv: _argv, value } = super.parseDetails(argv, options, prefixes) if (isNaN(value)) { throw new ParseError({ path: [this.name], @@ -326,7 +323,7 @@ export class MassargFlag extends MassargOption { this.negatable = options.negatable ?? false } - _parseDetails(argv: string[], prefixes: Prefixes): ArgvValue { + parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue { try { const isNegation = argv[0]?.startsWith(prefixes.negateAliasPrefix) || @@ -350,9 +347,9 @@ export class MassargFlag extends MassargOption { argv.shift() if (isNegation) { - return { key: this.name, value: false, argv } + return { key: this.getOutputName(), value: false, argv } } - return { key: this.name, value: true, argv } + return { key: this.getOutputName(), value: true, argv } } catch (e) { if (isZodError(e)) { throw new ParseError({ diff --git a/src/utils.ts b/src/utils.ts index 0d39830..55e8bd5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -91,15 +91,17 @@ export function deepMerge(obj1: T1, obj2: T2): NonNullable & NonNull * regular spaced strings. */ export function splitWords(str: string): string[] { - return str - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/([a-zA-Z])([0-9])/g, '$1 $2') - .replace(/([0-9])([a-zA-Z])/g, '$1 $2') - .replace(/([a-z])([_-])/g, '$1 $2') - .replace(/([_-])([a-zA-Z])/g, '$1 $2') - .split(/[_-]/) - .map((s) => s.trim()) - .filter(Boolean) + return ( + str + .replace(/([a-z])([A-Z])/g, '$1 $2') + // .replace(/([a-zA-Z])([0-9])/g, '$1 $2') + .replace(/([0-9])([a-zA-Z])/g, '$1 $2') + .replace(/([a-z])([_-])/g, '$1 $2') + .replace(/([_-])([a-zA-Z])/g, '$1 $2') + .split(/[_-]/) + .map((s) => s.trim()) + .filter(Boolean) + ) } export function toCamelCase(str: string): string { diff --git a/test/option.test.ts b/test/option.test.ts index fa57c15..fd6262d 100644 --- a/test/option.test.ts +++ b/test/option.test.ts @@ -104,9 +104,20 @@ describe('flag', () => { ).toThrow('Expected string, received number') }) describe('negation', () => { - test('default', () => { + test('no negation', () => { const command = massarg(opts).flag({ name: 'test2', description: 'test2', aliases: [] }) - expect(command.getArgs(['--no-test2'])).toThrow('is not negatable') + expect(() => command.getArgs(['--no-test2'])).toThrow( + 'test2: Option test2 cannot be negated (received: "--no-test2")', + ) + }) + test('negation', () => { + const command = massarg(opts).flag({ + name: 'test2', + description: 'test2', + aliases: [], + negatable: true, + }) + expect(command.getArgs(['--no-test2'])).toHaveProperty('test2', false) }) }) })