New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: support complex flag relationships #468
Changes from 10 commits
ace31f3
0fe00cb
f96df37
bbf8c9d
3c58582
c67882a
4deb315
c966aaf
8553952
57c1c97
1e2aceb
c455eda
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -1,3 +1,4 @@ | ||||||||||
/* eslint-disable no-await-in-loop */ | ||||||||||
import {CLIError} from '../errors' | ||||||||||
|
||||||||||
import { | ||||||||||
|
@@ -6,9 +7,10 @@ import { | |||||||||
RequiredFlagError, | ||||||||||
UnexpectedArgsError, | ||||||||||
} from './errors' | ||||||||||
import {ParserArg, ParserInput, ParserOutput, Flag} from '../interfaces' | ||||||||||
import {ParserArg, ParserInput, ParserOutput, Flag, CompletableFlag} from '../interfaces' | ||||||||||
import {FlagRelationship} from '../interfaces/parser' | ||||||||||
|
||||||||||
export function validate(parse: { | ||||||||||
export async function validate(parse: { | ||||||||||
input: ParserInput; | ||||||||||
output: ParserOutput; | ||||||||||
}) { | ||||||||||
|
@@ -54,43 +56,13 @@ export function validate(parse: { | |||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
function validateFlags() { | ||||||||||
async function validateFlags() { | ||||||||||
for (const [name, flag] of Object.entries(parse.input.flags)) { | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd love to see if parallelizing this would help with perf (both the various validations for each flag, AND doing all the flags together) |
||||||||||
if (parse.output.flags[name] !== undefined) { | ||||||||||
for (const also of flag.dependsOn || []) { | ||||||||||
if (!parse.output.flags[also]) { | ||||||||||
throw new CLIError( | ||||||||||
`--${also}= must also be provided when using --${name}=`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
for (const also of flag.exclusive || []) { | ||||||||||
// do not enforce exclusivity for flags that were defaulted | ||||||||||
if ( | ||||||||||
parse.output.metadata.flags[also] && | ||||||||||
parse.output.metadata.flags[also].setFromDefault | ||||||||||
) | ||||||||||
continue | ||||||||||
if ( | ||||||||||
parse.output.metadata.flags[name] && | ||||||||||
parse.output.metadata.flags[name].setFromDefault | ||||||||||
) | ||||||||||
continue | ||||||||||
if (parse.output.flags[also]) { | ||||||||||
throw new CLIError( | ||||||||||
`--${also}= cannot also be provided when using --${name}=`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
for (const also of flag.exactlyOne || []) { | ||||||||||
if (also !== name && parse.output.flags[also]) { | ||||||||||
throw new CLIError( | ||||||||||
`--${also}= cannot also be provided when using --${name}=`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
await validateRelationships(name, flag) | ||||||||||
await validateDependsOn(name, flag.dependsOn ?? []) | ||||||||||
await validateExclusive(name, flag.exclusive ?? []) | ||||||||||
validateExactlyOne(name, flag.exactlyOne ?? []) | ||||||||||
} else if (flag.required) { | ||||||||||
throw new RequiredFlagError({parse, flag}) | ||||||||||
} else if (flag.exactlyOne && flag.exactlyOne.length > 0) { | ||||||||||
|
@@ -99,6 +71,96 @@ export function validate(parse: { | |||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
async function resolveFlags(flags: FlagRelationship[]): Promise<Record<string, unknown>> { | ||||||||||
const promises = flags.map(async flag => { | ||||||||||
if (typeof flag === 'string') { | ||||||||||
return [flag, parse.output.flags[flag]] | ||||||||||
} | ||||||||||
|
||||||||||
const result = await flag.when(parse.output.flags) | ||||||||||
return result ? [flag.name, parse.output.flags[flag.name]] : null | ||||||||||
}) | ||||||||||
const resolved = await Promise.all(promises) | ||||||||||
return Object.fromEntries(resolved.filter(r => r !== null) as [string, unknown][]) | ||||||||||
} | ||||||||||
|
||||||||||
async function validateExclusive(name: string, flags: FlagRelationship[]) { | ||||||||||
const resolved = await resolveFlags(flags) | ||||||||||
const keys = Object.keys(resolved).reduce((acc, key) => { | ||||||||||
if (resolved[key]) acc.push(key) | ||||||||||
return acc | ||||||||||
}, [] as string[]) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure why this reduce needs to be here. Another design would be Object.entries(resolved).forEach( and use 1 && conditional to check what you're checking to get to an error
that way it's only 1 iteration, the conditionals can be in whatever order is most likely to resolve soonest and it'll fail on the first error |
||||||||||
|
||||||||||
for (const flag of keys) { | ||||||||||
// do not enforce exclusivity for flags that were defaulted | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. consider making this configurable. Flag B exists, but can't be used unless you make Flag A something other than its default |
||||||||||
if ( | ||||||||||
parse.output.metadata.flags[flag] && | ||||||||||
parse.output.metadata.flags[flag].setFromDefault | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
etc on the others. |
||||||||||
) | ||||||||||
continue | ||||||||||
if ( | ||||||||||
parse.output.metadata.flags[name] && | ||||||||||
parse.output.metadata.flags[name].setFromDefault | ||||||||||
) | ||||||||||
continue | ||||||||||
if (parse.output.flags[flag]) { | ||||||||||
throw new CLIError( | ||||||||||
`--${flag}=${parse.output.flags[flag]} cannot also be provided when using --${name}`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
function validateExactlyOne(name: string, exactlyOne: FlagRelationship[]) { | ||||||||||
for (const flag of exactlyOne || []) { | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. defaults in the signature
Suggested change
|
||||||||||
const flagName = typeof flag === 'string' ? flag : flag.name | ||||||||||
if (flagName !== name && parse.output.flags[flagName]) { | ||||||||||
throw new CLIError( | ||||||||||
`--${flagName} cannot also be provided when using --${name}`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
async function validateDependsOn(name: string, flags: FlagRelationship[]) { | ||||||||||
const resolved = await resolveFlags(flags) | ||||||||||
const foundAll = Object.values(resolved).every(Boolean) | ||||||||||
if (!foundAll) { | ||||||||||
const formattedFlags = Object.keys(resolved).map(f => `--${f}`).join(', ') | ||||||||||
throw new CLIError( | ||||||||||
`All of the following must be provided when using --${name}: ${formattedFlags}`, | ||||||||||
) | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
async function validateSome(name: string, flags: FlagRelationship[]) { | ||||||||||
const resolved = await resolveFlags(flags) | ||||||||||
const foundAtLeastOne = Object.values(resolved).some(Boolean) | ||||||||||
if (!foundAtLeastOne) { | ||||||||||
const formattedFlags = Object.keys(resolved).map(f => `--${f}`).join(', ') | ||||||||||
throw new CLIError(`One of the following must be provided when using --${name}: ${formattedFlags}`) | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
async function validateRelationships(name: string, flag: CompletableFlag<any>) { | ||||||||||
if (!flag.relationships) return | ||||||||||
for (const relationship of flag.relationships) { | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth parallelizing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ux idea: as written, you'll get a failure on the first validation to throw. For the user, they'll fix one error and then hit the next. You could run this with Promise.allSettled and maybe display all the errors in the output. |
||||||||||
const flags = relationship.flags ?? [] | ||||||||||
|
||||||||||
if (relationship.type === 'all') { | ||||||||||
await validateDependsOn(name, flags) | ||||||||||
} | ||||||||||
|
||||||||||
if (relationship.type === 'some') { | ||||||||||
await validateSome(name, flags) | ||||||||||
} | ||||||||||
|
||||||||||
if (relationship.type === 'none') { | ||||||||||
await validateExclusive(name, flags) | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
validateArgs() | ||||||||||
validateFlags() | ||||||||||
await validateFlags() | ||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider
one
or1
since we've already got anexactlyOne
since it happened often enough.