Skip to content
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: add InferredFlags type #473

Merged
merged 11 commits into from Aug 23, 2022
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -74,6 +74,7 @@
"shx": "^0.3.4",
"sinon": "^11.1.2",
"ts-node": "^9.1.1",
"tsd": "^0.22.0",
"typescript": "4.5.5"
},
"engines": {
Expand Down
27 changes: 7 additions & 20 deletions src/flags.ts
@@ -1,23 +1,14 @@
import {OptionFlag, Definition, BooleanFlag, EnumFlagOptions, Default} from './interfaces'
import * as Parser from './parser'
import {OptionFlag, BooleanFlag, EnumFlagOptions, Default} from './interfaces'
import {custom, boolean} from './parser'
import Command from './command'
export {boolean, integer, url, directory, file, string, build, option, custom} from './parser'

export function build<T>(defaults: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>): Definition<T>
export function build(defaults: Partial<OptionFlag<string>>): Definition<string>
export function build<T>(defaults: Partial<OptionFlag<T>>): Definition<T> {
return Parser.flags.build<T>(defaults as any)
}

export function option<T>(options: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>) {
return build<T>(options)()
}

export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true} & ({required: true} | { default: Default<T> })): OptionFlag<T[]>
export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true} & ({required: true} | { default: Default<T[]> })): OptionFlag<T[]>
export function _enum<T = string>(opts: EnumFlagOptions<T> & {multiple: true}): OptionFlag<T[] | undefined>
export function _enum<T = string>(opts: EnumFlagOptions<T> & ({required: true} | { default: Default<T> })): OptionFlag<T>
export function _enum<T = string>(opts: EnumFlagOptions<T>): OptionFlag<T | undefined>
export function _enum<T = string>(opts: EnumFlagOptions<T>): OptionFlag<T> | OptionFlag<T[]> | OptionFlag<T | undefined> | OptionFlag<T[] | undefined> {
return build<T>({
return custom<T, EnumFlagOptions<T>>({
async parse(input) {
if (!opts.options.includes(input)) throw new Error(`Expected --${this.name}=${input} to be one of: ${opts.options.join(', ')}`)
return input as unknown as T
Expand All @@ -29,12 +20,8 @@ export function _enum<T = string>(opts: EnumFlagOptions<T>): OptionFlag<T> | Opt

export {_enum as enum}

const stringFlag = build({})
export {stringFlag as string}
export {boolean, integer, url, directory, file} from './parser'

export const version = (opts: Partial<BooleanFlag<boolean>> = {}) => {
return Parser.flags.boolean({
return boolean({
description: 'Show CLI version.',
...opts,
parse: async (_: any, cmd: Command) => {
Expand All @@ -45,7 +32,7 @@ export const version = (opts: Partial<BooleanFlag<boolean>> = {}) => {
}

export const help = (opts: Partial<BooleanFlag<boolean>> = {}) => {
return Parser.flags.boolean({
return boolean({
description: 'Show CLI help.',
...opts,
parse: async (_: any, cmd: Command) => {
Expand Down
33 changes: 33 additions & 0 deletions src/interfaces/flags.ts
@@ -0,0 +1,33 @@
import {FlagInput} from './parser'

/**
* Infer the flags that are returned by Command.parse. This is useful for when you want to assign the flags as a class property.
*
* @example
* export type StatusFlags = Interfaces.InferredFlags<typeof Status.flags & typeof Status.globalFlags>
*
* export abstract class BaseCommand extends Command {
* static enableJsonFlag = true
*
* static globalFlags = {
* config: Flags.string({
* description: 'specify config file',
* }),
* }
* }
*
* export default class Status extends BaseCommand {
* static flags = {
* force: Flags.boolean({char: 'f', description: 'a flag'}),
* }
*
* public flags!: StatusFlags
*
* public async run(): Promise<StatusFlags> {
* const result = await this.parse(Status)
* this.flags = result.flags
* return result.flags
* }
* }
*/
export type InferredFlags<T> = T extends FlagInput<infer F> ? F & { json: boolean | undefined; } : unknown
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any way to test this? Maybe by building a test class and having a flags property like your example, and then asserting things about its type?

What I'd like to do is prevent some future flag changes/refactoring/type fixing from breaking this feature.

1 change: 1 addition & 0 deletions src/interfaces/index.ts
Expand Up @@ -19,3 +19,4 @@ export {PJSON} from './pjson'
export {Plugin, PluginOptions, Options} from './plugin'
export {Topic} from './topic'
export {TSConfig} from './ts-config'
export {InferredFlags} from './flags'
68 changes: 49 additions & 19 deletions src/interfaces/parser.ts
Expand Up @@ -109,10 +109,30 @@ export type FlagProps = {
* Shows this flag in a separate list in the help.
*/
helpGroup?: string;
/**
* Accept an environment variable as input
*/
env?: string;
/**
* If true, the flag will not be shown in the help.
*/
hidden?: boolean;
/**
* If true, the flag will be required.
*/
required?: boolean;
/**
* List of flags that this flag depends on.
*/
dependsOn?: string[];
/**
* List of flags that cannot be used with this flag.
*/
exclusive?: string[];
/**
* Exactly one of these flags must be provided.
*/
exactlyOne?: string[];
}

export type BooleanFlagProps = FlagProps & {
Expand All @@ -127,38 +147,48 @@ export type OptionFlagProps = FlagProps & {
multiple: boolean;
}

export type FlagBase<T, I> = FlagProps & {
exactlyOne?: string[];
/**
* also accept an environment variable as input
*/
env?: string;
parse(input: I, context: any): Promise<T>;
export type FlagParser<T, I, P = any> = (input: I, context: any, opts: P & OptionFlag<T>) => Promise<T>

export type FlagBase<T, I, P = any> = FlagProps & {
parse: FlagParser<T, I, P>;
}

export type BooleanFlag<T> = FlagBase<T, boolean> & BooleanFlagProps & {
/**
* specifying a default of false is the same not specifying a default
* specifying a default of false is the same as not specifying a default
*/
default?: Default<boolean>;
}
export type OptionFlag<T> = FlagBase<T, string> & OptionFlagProps & {

export type CustomOptionFlag<T, P = any> = FlagBase<T, string, P> & OptionFlagProps & {
defaultHelp?: DefaultHelp<T>;
input: string[];
} & ({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nice.

default?: Default<T | undefined>;
multiple: false;
} | {
default?: Default<T[] | undefined>;
multiple: true;
})

export type OptionFlag<T> = FlagBase<T, string> & OptionFlagProps & {
defaultHelp?: DefaultHelp<T>;
input: string[];
}
} & ({
default?: Default<T | undefined>;
multiple: false;
} | {
default?: Default<T[] | undefined>;
multiple: true;
})

export type Definition<T> = {
export type Definition<T, P = Record<string, unknown>> = {
(
options: { multiple: true } & ({ required: true } | { default: Default<T> }) &
Partial<OptionFlag<T>>,
options: P & { multiple: true } & ({ required: true } | { default: Default<T[]> }) & Partial<OptionFlag<T>>
): OptionFlag<T[]>;
(options: { multiple: true } & Partial<OptionFlag<T[]>>): OptionFlag<T[] | undefined>;
(
options: ({ required: true } | { default: Default<T> }) &
Partial<OptionFlag<T>>,
): OptionFlag<T>;
(options?: Partial<OptionFlag<T>>): OptionFlag<T | undefined>;
(options: P & { multiple: true } & Partial<OptionFlag<T[]>>): OptionFlag<T[] | undefined>;
(options: P & ({ required: true } | { default: Default<T> }) & Partial<OptionFlag<T>>): OptionFlag<T>;
(options?: P & Partial<OptionFlag<T>>): OptionFlag<T | undefined>;
}

export type EnumFlagOptions<T> = Partial<OptionFlag<T>> & {
Expand Down
133 changes: 70 additions & 63 deletions src/parser/flags.ts
@@ -1,20 +1,54 @@
// tslint:disable interface-over-type-literal

import {URL} from 'url'

import {Definition, OptionFlag, BooleanFlag, Default} from '../interfaces'
import {Definition, OptionFlag, BooleanFlag} from '../interfaces'
import * as fs from 'fs'
import {FlagParser, CustomOptionFlag} from '../interfaces/parser'

/**
* Create a custom flag.
*
* @example
* type Id = string
* type IdOpts = { startsWith: string; length: number };
*
* export const myFlag = custom<Id, IdOpts>({
* parse: async (input, opts) => {
* if (input.startsWith(opts.startsWith) && input.length === opts.length) {
* return input
* }
*
* throw new Error('Invalid id')
* },
* })
*/
export function custom<T, P>(
defaults: {parse: FlagParser<T, string, P>} & Partial<CustomOptionFlag<T, P>>,
): Definition<T, P>
export function custom<T = string, P = Record<string, unknown>>(defaults: Partial<CustomOptionFlag<T, P>>): Definition<T, P>
export function custom<T, P = Record<string, unknown>>(defaults: Partial<CustomOptionFlag<T, P>>): Definition<T, P> {
return (options: any = {}) => {
return {
parse: async (i: string, _context: any, _opts: P) => i,
...defaults,
...options,
input: [] as string[],
multiple: Boolean(options.multiple === undefined ? defaults.multiple : options.multiple),
type: 'option',
}
}
}

/**
* @deprecated Use Flags.custom instead.
*/
export function build<T>(
defaults: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>,
): Definition<T>
export function build(
defaults: Partial<OptionFlag<string>>,
): Definition<string>
export function build(defaults: Partial<OptionFlag<string>>): Definition<string>
export function build<T>(defaults: Partial<OptionFlag<T>>): Definition<T> {
return (options: any = {}) => {
return {
parse: async (i: string, _: any) => i,
parse: async (i: string, _context: any) => i,
...defaults,
...options,
input: [] as string[],
Expand All @@ -35,67 +69,40 @@ export function boolean<T = boolean>(
} as BooleanFlag<T>
}

export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & {multiple: true} & ({required: true} | { default: Default<number> })): OptionFlag<number[]>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & {multiple: true}): OptionFlag<number[] | undefined>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } & ({required: true} | { default: Default<number> })): OptionFlag<number>
export function integer(opts?: Partial<OptionFlag<number>> & {min?: number; max?: number }): OptionFlag<number | undefined>
export function integer(opts: Partial<OptionFlag<number>> & {min?: number; max?: number } = {}): OptionFlag<number> | OptionFlag<number[]> | OptionFlag<number | undefined> | OptionFlag<number[] | undefined> {
return build({
...opts,
parse: async input => {
if (!/^-?\d+$/.test(input))
throw new Error(`Expected an integer but received: ${input}`)
const num = Number.parseInt(input, 10)
if (opts.min !== undefined && num < opts.min)
throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`)
if (opts.max !== undefined && num > opts.max)
throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`)
return opts.parse ? opts.parse(input, 1) : num
},
})()
}
export const integer = custom<number, {min?: number; max?: number;}>({
parse: async (input, ctx, opts) => {
if (!/^-?\d+$/.test(input))
throw new Error(`Expected an integer but received: ${input}`)
const num = Number.parseInt(input, 10)
if (opts.min !== undefined && num < opts.min)
throw new Error(`Expected an integer greater than or equal to ${opts.min} but received: ${input}`)
if (opts.max !== undefined && num > opts.max)
throw new Error(`Expected an integer less than or equal to ${opts.max} but received: ${input}`)
return num
},
})

export function directory(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true} & ({required: true} | { default: Default<string> })): OptionFlag<string[]>
export function directory(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true}): OptionFlag<string[] | undefined>
export function directory(opts: { exists?: boolean } & Partial<OptionFlag<string>> & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function directory(opts?: { exists?: boolean } & Partial<OptionFlag<string>>): OptionFlag<string | undefined>
export function directory(opts: { exists?: boolean } & Partial<OptionFlag<string>> = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
return build<string>({
...opts,
parse: async (input: string) => {
if (opts.exists) {
// 2nd "context" arg is required but unused
return opts.parse ? opts.parse(await dirExists(input), true) : dirExists(input)
}

return opts.parse ? opts.parse(input, true) : input
},
})()
}
export const directory = custom<string, {exists?: boolean}>({
parse: async (input, _, opts) => {
if (opts.exists) return dirExists(input)

export function file(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true} & ({required: true} | { default: Default<string> })): OptionFlag<string[]>
export function file(opts: Partial<OptionFlag<string>> & { exists?: boolean } & {multiple: true}): OptionFlag<string[] | undefined>
export function file(opts: { exists?: boolean } & Partial<OptionFlag<string>> & ({required: true} | { default: Default<string> })): OptionFlag<string>
export function file(opts?: { exists?: boolean } & Partial<OptionFlag<string>>): OptionFlag<string | undefined>
export function file(opts: { exists?: boolean } & Partial<OptionFlag<string>> = {}): OptionFlag<string> | OptionFlag<string[]> | OptionFlag<string | undefined> | OptionFlag<string[] | undefined> {
return build<string>({
...opts,
parse: async (input: string) => {
if (opts.exists) {
// 2nd "context" arg is required but unused
return opts.parse ? opts.parse(await fileExists(input), true) : fileExists(input)
}

return opts.parse ? opts.parse(input, true) : input
},
})()
}
return input
},
})

export const file = custom<string, {exists?: boolean}>({
parse: async (input, _, opts) => {
if (opts.exists) return fileExists(input)

return input
},
})

/**
* Initializes a string as a URL. Throws an error
* if the string is not a valid URL.
*/
export const url = build({
export const url = custom<URL>({
parse: async input => {
try {
return new URL(input)
Expand All @@ -108,10 +115,10 @@ export const url = build({
export function option<T>(
options: {parse: OptionFlag<T>['parse']} & Partial<OptionFlag<T>>,
) {
return build<T>(options)()
return custom<T>(options)()
}

const stringFlag = build({})
const stringFlag = custom({})
export {stringFlag as string}

export const defaultFlags = {
Expand Down
4 changes: 1 addition & 3 deletions src/parser/index.ts
Expand Up @@ -31,6 +31,4 @@ export async function parse<TFlags, GFlags, TArgs extends { [name: string]: stri
return output as ParserOutput<TFlags, GFlags, TArgs>
}

const {boolean, integer, url, directory, file} = flags

export {boolean, integer, url, directory, file}
export {boolean, integer, url, directory, file, string, build, option, custom} from './flags'