diff --git a/README.md b/README.md index 41bb0e6..a439417 100644 --- a/README.md +++ b/README.md @@ -369,23 +369,42 @@ env // => { TOKEN: 123, CI: true } ``` ### Inverting a boolean -To invert a boolean flag, `false` must be passed in with the `=` operator (or any other value delimiters). +Enable `booleanNegation` to support `--no-` prefixed flags for boolean negation: ```ts +const argv = process.argv.slice(2) const parsed = typeFlag({ - booleanFlag: Boolean -}) + verbose: Boolean +}, argv, { booleanNegation: true }) + +// $ node ./cli --no-verbose +parsed.flags.verbose // => false +``` + +Last-wins semantics apply naturally: +```ts +// $ node ./cli --verbose --no-verbose +parsed.flags.verbose // => false -// $ node ./cli --boolean-flag=false -parsed.flags.booleanFlag // => false +// $ node ./cli --no-verbose --verbose +parsed.flags.verbose // => true ``` -Without explicitly specfying the flag value via `=`, the `false` will be parsed as a separate argument. +The `--no-` prefix only applies to flags defined as `Boolean` in the schema. For non-boolean or unregistered flags, `--no-` is treated as an unknown flag. + +You can also invert a boolean by passing `false` explicitly with the `=` operator (or any other value delimiter): + +```ts +// $ node ./cli --verbose=false +parsed.flags.verbose // => false +``` + +Without explicitly specifying the flag value via `=`, `false` will be parsed as a separate argument: ```ts -// $ node ./cli --boolean-flag false -parsed.flags.booleanFlag // => true +// $ node ./cli --verbose false +parsed.flags.verbose // => true parsed._ // => ['false'] ``` @@ -456,6 +475,9 @@ type Options = { flagOrArgv: string, value: string | undefined ) => boolean | void + + // Enable --no- negation for boolean flags + booleanNegation?: boolean } ``` diff --git a/src/type-flag.ts b/src/type-flag.ts index ed5d1e9..91ce79a 100644 --- a/src/type-flag.ts +++ b/src/type-flag.ts @@ -45,7 +45,7 @@ const parsed = typeFlag({ export const typeFlag = ( schemas: Schemas, argv: string[] = process.argv.slice(2), - { ignore }: TypeFlagOptions = {}, + { ignore, booleanNegation }: TypeFlagOptions = {}, ) => { const removeArgvs: Index[] = []; const flagRegistry = createRegistry(schemas); @@ -58,9 +58,27 @@ export const typeFlag = ( const isAlias = flagIndex.length === ALIAS_INDEX_LENGTH; const isValid = isAlias || name.length > 1; const isKnownFlag = isValid && hasOwn(flagRegistry, name); + + let negatedBase: string | undefined; + if ( + !isKnownFlag + && booleanNegation + && !isAlias + && name.length > 3 + && name.startsWith('no-') + ) { + const baseName = name.slice(3); + if ( + hasOwn(flagRegistry, baseName) + && flagRegistry[baseName][1] === Boolean + ) { + negatedBase = baseName; + } + } + if ( ignore?.( - isKnownFlag ? KNOWN_FLAG : UNKNOWN_FLAG, + isKnownFlag || negatedBase ? KNOWN_FLAG : UNKNOWN_FLAG, name, explicitValue, ) @@ -93,6 +111,12 @@ export const typeFlag = ( ); } + if (negatedBase) { + flagRegistry[negatedBase][0].push(false); + removeArgvs.push(flagIndex); + return; + } + if (!hasOwn(unknownFlags, name)) { unknownFlags[name] = []; } diff --git a/src/types.ts b/src/types.ts index 467a758..49d4f02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -210,4 +210,13 @@ export type TypeFlagOptions = { * Optional function to skip certain argv elements from parsing. */ ignore?: IgnoreFunction; + + /** + * Enable `--no-` negation for boolean flags. + * + * When enabled, `--no-verbose` is equivalent to `--verbose=false`. + * Only applies to flags defined as `Boolean` in the schema. + * Last-wins semantics apply between `--flag` and `--no-flag`. + */ + booleanNegation?: boolean; }; diff --git a/tests/specs/type-flag/parsing.ts b/tests/specs/type-flag/parsing.ts index 16e4048..be56478 100644 --- a/tests/specs/type-flag/parsing.ts +++ b/tests/specs/type-flag/parsing.ts @@ -860,6 +860,134 @@ describe('Parsing', () => { expect(argv).toStrictEqual([]); }); + describe('booleanNegation', () => { + const negation = { booleanNegation: true }; + + test('basic negation', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--no-verbose'], negation); + + expect(parsed.flags.verbose).toBe(false); + }); + + test('last-wins: --verbose then --no-verbose', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--verbose', '--no-verbose'], negation); + + expect(parsed.flags.verbose).toBe(false); + }); + + test('last-wins: --no-verbose then --verbose', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--no-verbose', '--verbose'], negation); + + expect(parsed.flags.verbose).toBe(true); + }); + + test('array boolean', () => { + const parsed = typeFlag({ + verbose: [Boolean], + }, ['--no-verbose', '--verbose'], negation); + + expect(parsed.flags.verbose).toStrictEqual([false, true]); + }); + + test('only affects boolean flags', () => { + const parsed = typeFlag({ + verbose: Boolean, + count: Number, + }, ['--no-verbose', '--no-count'], negation); + + expect(parsed.flags.verbose).toBe(false); + expect(parsed.flags.count).toBe(undefined); + expect>(parsed.unknownFlags).toStrictEqual({ + 'no-count': [true], + }); + }); + + test('kebab-case negation', () => { + const parsed = typeFlag({ + someFlag: Boolean, + }, ['--no-some-flag'], negation); + + expect(parsed.flags.someFlag).toBe(false); + }); + + test('argv mutation', () => { + const argv = ['--no-verbose', 'arg']; + const parsed = typeFlag({ + verbose: Boolean, + }, argv, negation); + + expect(parsed.flags.verbose).toBe(false); + expect(argv).toStrictEqual([]); + }); + + test('explicit value is ignored for negation', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--no-verbose=true'], negation); + + expect(parsed.flags.verbose).toBe(false); + }); + + test('schema with alias and default', () => { + const parsed = typeFlag({ + verbose: { + type: Boolean, + alias: 'v', + default: true, + }, + }, ['--no-verbose'], negation); + + expect(parsed.flags.verbose).toBe(false); + }); + + test('no-prefixed flags do not appear in output', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--no-verbose'], negation); + + expect(Object.keys(parsed.flags)).toStrictEqual(['verbose']); + }); + + test('ignore callback receives no- prefixed name as known-flag', () => { + const ignoredFlags: [string, string][] = []; + typeFlag( + { + verbose: Boolean, + }, + ['--no-verbose', '--no-unknown'], + { + booleanNegation: true, + ignore: (type, flagName) => { + ignoredFlags.push([type, flagName]); + return false; + }, + }, + ); + + expect(ignoredFlags).toStrictEqual([ + ['known-flag', 'no-verbose'], + ['unknown-flag', 'no-unknown'], + ]); + }); + + test('disabled by default', () => { + const parsed = typeFlag({ + verbose: Boolean, + }, ['--no-verbose']); + + expect(parsed.flags.verbose).toBe(undefined); + expect>(parsed.unknownFlags).toStrictEqual({ + 'no-verbose': [true], + }); + }); + }); + describe('Default flag value', () => { test('Types and parsing', () => { const argv: string[] = [];