Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
})

// $ node ./cli --my-flag A --my-flag B
parsed.flags.myFlag // => ['A', 'B']

Check warning on line 126 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

#### Aliases
Expand All @@ -138,7 +138,7 @@
})

// $ node ./cli -m hello
parsed.flags.myFlag // => 'hello'

Check warning on line 141 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

#### Default values
Expand All @@ -147,7 +147,7 @@
When using mutable values (eg. objects/arrays) as a default, pass in a function that creates it to avoid mutation-related bugs.

```ts
const parsed = typeFlag({

Check warning on line 150 in README.md

View workflow job for this annotation

GitHub Actions / Test

'parsed' is assigned a value but never used. Allowed unused vars must match /^_/u
someNumber: {
type: Number,
default: 1
Expand All @@ -172,7 +172,7 @@
})

// $ node ./cli --someString hello --some-string world
parsed.flags.someString // => ['hello', 'world']

Check warning on line 175 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

### Unknown flags
Expand All @@ -182,7 +182,7 @@
const parsed = typeFlag({})

// $ node ./cli --some-flag --some-flag=1234
parsed.unknownFlags // => { 'some-flag': [true, '1234'] }

Check warning on line 185 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

### Arguments
Expand All @@ -196,9 +196,9 @@
})

// $ node ./cli --my-flag value arg1 -- --my-flag world
parsed.flags.myFlag // => ['value']

Check warning on line 199 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed._ // => ['arg1', '--my-flag', 'world']

Check warning on line 200 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
parsed._['--'] // => ['--my-flag', 'world']

Check warning on line 201 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

### Flag value delimiters
Expand Down Expand Up @@ -232,8 +232,8 @@
)

// $ node ./cli --unknown=hello
parsed._ // => []

Check warning on line 235 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
argv // => ['--unknown=hello']

Check warning on line 236 in README.md

View workflow job for this annotation

GitHub Actions / Test

Expected an assignment or function call and instead saw an expression
```

#### Ignoring everything after the first argument
Expand Down Expand Up @@ -369,23 +369,42 @@
```

### 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-<name>` 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']
```

Expand Down Expand Up @@ -456,6 +475,9 @@
flagOrArgv: string,
value: string | undefined
) => boolean | void

// Enable --no-<flag> negation for boolean flags
booleanNegation?: boolean
}
```

Expand Down
28 changes: 26 additions & 2 deletions src/type-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const parsed = typeFlag({
export const typeFlag = <Schemas extends Flags>(
schemas: Schemas,
argv: string[] = process.argv.slice(2),
{ ignore }: TypeFlagOptions = {},
{ ignore, booleanNegation }: TypeFlagOptions = {},
) => {
const removeArgvs: Index[] = [];
const flagRegistry = createRegistry(schemas);
Expand All @@ -58,9 +58,27 @@ export const typeFlag = <Schemas extends Flags>(
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,
)
Expand Down Expand Up @@ -93,6 +111,12 @@ export const typeFlag = <Schemas extends Flags>(
);
}

if (negatedBase) {
flagRegistry[negatedBase][0].push(false);
removeArgvs.push(flagIndex);
return;
}

if (!hasOwn(unknownFlags, name)) {
unknownFlags[name] = [];
}
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,13 @@ export type TypeFlagOptions = {
* Optional function to skip certain argv elements from parsing.
*/
ignore?: IgnoreFunction;

/**
* Enable `--no-<flag>` 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;
};
128 changes: 128 additions & 0 deletions tests/specs/type-flag/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,134 @@ describe('Parsing', () => {
expect<string[]>(argv).toStrictEqual([]);
});

describe('booleanNegation', () => {
const negation = { booleanNegation: true };

test('basic negation', () => {
const parsed = typeFlag({
verbose: Boolean,
}, ['--no-verbose'], negation);

expect<boolean | undefined>(parsed.flags.verbose).toBe(false);
});

test('last-wins: --verbose then --no-verbose', () => {
const parsed = typeFlag({
verbose: Boolean,
}, ['--verbose', '--no-verbose'], negation);

expect<boolean | undefined>(parsed.flags.verbose).toBe(false);
});

test('last-wins: --no-verbose then --verbose', () => {
const parsed = typeFlag({
verbose: Boolean,
}, ['--no-verbose', '--verbose'], negation);

expect<boolean | undefined>(parsed.flags.verbose).toBe(true);
});

test('array boolean', () => {
const parsed = typeFlag({
verbose: [Boolean],
}, ['--no-verbose', '--verbose'], negation);

expect<boolean[]>(parsed.flags.verbose).toStrictEqual([false, true]);
});

test('only affects boolean flags', () => {
const parsed = typeFlag({
verbose: Boolean,
count: Number,
}, ['--no-verbose', '--no-count'], negation);

expect<boolean | undefined>(parsed.flags.verbose).toBe(false);
expect<number | undefined>(parsed.flags.count).toBe(undefined);
expect<Record<string, (string | boolean)[]>>(parsed.unknownFlags).toStrictEqual({
'no-count': [true],
});
});

test('kebab-case negation', () => {
const parsed = typeFlag({
someFlag: Boolean,
}, ['--no-some-flag'], negation);

expect<boolean | undefined>(parsed.flags.someFlag).toBe(false);
});

test('argv mutation', () => {
const argv = ['--no-verbose', 'arg'];
const parsed = typeFlag({
verbose: Boolean,
}, argv, negation);

expect<boolean | undefined>(parsed.flags.verbose).toBe(false);
expect<string[]>(argv).toStrictEqual([]);
});

test('explicit value is ignored for negation', () => {
const parsed = typeFlag({
verbose: Boolean,
}, ['--no-verbose=true'], negation);

expect<boolean | undefined>(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<boolean>(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<boolean | undefined>(parsed.flags.verbose).toBe(undefined);
expect<Record<string, (string | boolean)[]>>(parsed.unknownFlags).toStrictEqual({
'no-verbose': [true],
});
});
});

describe('Default flag value', () => {
test('Types and parsing', () => {
const argv: string[] = [];
Expand Down
Loading