fix: flag negation

This commit is contained in:
2023-12-11 22:18:13 +02:00
committed by Chen Asraf
parent fa29138fd1
commit a42a854719
5 changed files with 102 additions and 87 deletions

View File

@@ -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<Args extends ArgsObject = ArgsObject> {
}
get optionPrefixes(): Prefixes {
return this.getPrefixes()
}
private getPrefixes(): Prefixes {
return {
optionPrefix: this.optionPrefix,
aliasPrefix: this.optionAliasPrefix,
@@ -177,11 +182,9 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
* or by prefixing the alias with `^` instead of `-`. This is configurable via the command's
* configuration.
*/
flag(config: Omit<OptionConfig<boolean>, 'parse' | 'isDefault'>): MassargCommand<Args>
flag(config: FlagConfig): MassargCommand<Args>
flag(config: MassargFlag): MassargCommand<Args>
flag(
config: Omit<OptionConfig<boolean>, 'parse' | 'isDefault'> | MassargFlag,
): MassargCommand<Args> {
flag(config: FlagConfig | MassargFlag): MassargCommand<Args> {
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<Args extends ArgsObject = ArgsObject> {
}
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<Args[keyof Args]>(
res.value,
this.args[res.key as keyof Args],
@@ -377,7 +382,7 @@ export class MassargCommand<Args extends ArgsObject = ArgsObject> {
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 }

View File

@@ -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'
)

View File

@@ -50,12 +50,14 @@ export type OptionConfig<T = unknown, Args extends ArgsObject = ArgsObject> = z.
ReturnType<typeof OptionConfig<T, Args>>
>
export const FlagConfig = OptionConfig<boolean>(z.any()).merge(
z.object({
/** Whether the flag can be negated, e.g. `--no-verbose` */
negatable: z.boolean().optional(),
}),
)
export const FlagConfig = OptionConfig<boolean>(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<typeof FlagConfig>
export type Parser<Args extends ArgsObject = ArgsObject, OptionType extends any = any> = (
@@ -99,7 +101,7 @@ export type ArrayOptionConfig<T = unknown> = 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<OptionType extends any = unknown, Args extends ArgsOb
return this.outputName || toCamelCase(this.name)
}
_parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue<OptionType> {
// TODO: support --option=value
parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue<OptionType> {
let input = ''
try {
if (!this._match(argv[0], prefixes)) {
@@ -221,25 +222,21 @@ export class MassargOption<OptionType extends any = unknown, Args extends ArgsOb
static findNameInArg(arg: string, prefixes: Prefixes): string {
const { optionPrefix, aliasPrefix, negateFlagPrefix, negateAliasPrefix } = prefixes
if (arg.startsWith(optionPrefix)) {
// negate full prefix
if (arg.startsWith(negateFlagPrefix)) {
return arg.slice(negateFlagPrefix.length)
}
return arg.slice(optionPrefix.length)
}
// negate full prefix
if (arg.startsWith(negateFlagPrefix)) {
return arg.slice(negateFlagPrefix.length)
}
// short prefix
if (arg.startsWith(aliasPrefix) || arg.startsWith(negateAliasPrefix)) {
return arg.slice(aliasPrefix.length)
if (arg.startsWith(optionPrefix)) {
return arg.slice(optionPrefix.length)
}
// negate short prefix
if (arg.startsWith(negateAliasPrefix)) {
return arg.slice(negateAliasPrefix.length)
}
// short prefix
if (arg.startsWith(aliasPrefix) || arg.startsWith(negateAliasPrefix)) {
return arg.slice(aliasPrefix.length)
}
return '<blank>'
}
}
@@ -268,9 +265,9 @@ export class MassargNumber extends MassargOption<number> {
})
}
_parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue<number> {
parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue<number> {
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<boolean> {
this.negatable = options.negatable ?? false
}
_parseDetails(argv: string[], prefixes: Prefixes): ArgvValue<boolean> {
parseDetails(argv: string[], options: ArgsObject, prefixes: Prefixes): ArgvValue<boolean> {
try {
const isNegation =
argv[0]?.startsWith(prefixes.negateAliasPrefix) ||
@@ -350,9 +347,9 @@ export class MassargFlag extends MassargOption<boolean> {
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({

View File

@@ -91,15 +91,17 @@ export function deepMerge<T1, T2>(obj1: T1, obj2: T2): NonNullable<T1> & 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 {

View File

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