Skip to content

Commit

Permalink
feat: concrete option type
Browse files Browse the repository at this point in the history
  • Loading branch information
yjl9903 committed Jun 21, 2022
1 parent 8a6e697 commit d23bd4c
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 101 deletions.
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ cli.run(process.argv.slice(2))

If you are using IDEs that support TypeScript (like [Visual Studio Code](https://code.visualstudio.com/)), move your cursor to the parameter `option` in this `dev` command, and then you will find the `option` is automatically typed with `{ host: string, port: string }` or `Record<'host' | 'port', string>`.

![vscode1](./images/vscode1.png)

![vscode2](./images/vscode2.png)
![vscode](./images/vscode.png)

### Limitation

Expand Down
File renamed without changes
Binary file removed images/vscode2.png
Binary file not shown.
60 changes: 40 additions & 20 deletions src/breadc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createDefaultLogger } from './logger';
import { Option, OptionConfig } from './option';
import { Command, CommandConfig, createHelpCommand, createVersionCommand } from './command';

export class Breadc<GlobalOption extends string | never = never> {
export class Breadc<GlobalOption extends object = {}> {
private readonly name: string;
private readonly _version: string;
private readonly description?: string | string[];
Expand All @@ -22,7 +22,7 @@ export class Breadc<GlobalOption extends string | never = never> {
this.name = name;
this._version = option.version ?? 'unknown';
this.description = option.description;
this.logger = option.logger ?? createDefaultLogger(name);
this.logger = createDefaultLogger(name, option.logger);

const breadc = {
name: this.name,
Expand Down Expand Up @@ -103,34 +103,34 @@ export class Breadc<GlobalOption extends string | never = never> {
return output;
}

option<F extends string>(
option<F extends string, T = string>(
format: F,
description: string,
config?: Omit<OptionConfig, 'description'>
): Breadc<GlobalOption | ExtractOption<F>>;
config?: Omit<OptionConfig<T>, 'description'>
): Breadc<GlobalOption & ExtractOption<F>>;

option<F extends string>(
option<F extends string, T = string>(
format: F,
config?: OptionConfig
): Breadc<GlobalOption | ExtractOption<F>>;
config?: OptionConfig<T>
): Breadc<GlobalOption & ExtractOption<F>>;

option<F extends string>(
option<F extends string, T = string>(
format: F,
configOrDescription: OptionConfig | string = '',
otherConfig: Omit<OptionConfig, 'description'> = {}
): Breadc<GlobalOption | ExtractOption<F>> {
const config: OptionConfig =
configOrDescription: OptionConfig<T> | string = '',
otherConfig: Omit<OptionConfig<T>, 'description'> = {}
): Breadc<GlobalOption & ExtractOption<F>> {
const config: OptionConfig<T> =
typeof configOrDescription === 'object'
? configOrDescription
: { ...otherConfig, description: configOrDescription };

try {
const option = new Option(format, config);
this.options.push(option);
const option = new Option<F, T>(format, config);
this.options.push(option as unknown as Option);
} catch (error: any) {
this.logger.warn(error.message);
}
return this as Breadc<GlobalOption | ExtractOption<F>>;
return this as Breadc<GlobalOption & ExtractOption<F>>;
}

command<F extends string>(
Expand Down Expand Up @@ -163,32 +163,52 @@ export class Breadc<GlobalOption extends string | never = never> {
}

parse(args: string[]): ParseResult {
const allowOptions = [this.options, this.commands.map((c) => c.options)].flat() as Option[];
const allowOptions: Option[] = [...this.options, ...this.commands.flatMap((c) => c.options)];

const alias = allowOptions.reduce((map: Record<string, string>, o) => {
if (o.shortcut) {
map[o.shortcut] = o.name;
}
return map;
}, {});
const defaults = allowOptions.reduce((map: Record<string, string>, o) => {
if (o.default) {
map[o.name] = o.default;
}
return map;
}, {});

const argv = minimist(args, {
string: allowOptions.filter((o) => o.type === 'string').map((o) => o.name),
boolean: allowOptions.filter((o) => o.type === 'boolean').map((o) => o.name),
alias
alias,
default: defaults,
unknown: (t) => {
if (t[0] !== '-') return true;
else {
if (['--help', '-h', '--version', '-v'].includes(t)) {
return true;
} else {
this.logger.warn(`Find unknown flag "${t}"`);
return false;
}
}
}
});

for (const shortcut of Object.keys(alias)) {
delete argv[shortcut];
}

// Try non-default command first
for (const command of this.commands) {
if (!command.default && command.shouldRun(argv)) {
return command.parseArgs(argv);
return command.parseArgs(argv, this.options);
}
}
// Then try default command
if (this.defaultCommand) {
return this.defaultCommand.parseArgs(argv);
return this.defaultCommand.parseArgs(argv, this.options);
}

const argumentss = argv['_'];
Expand Down
64 changes: 40 additions & 24 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ export interface CommandConfig {
description?: string;
}

export class Command<
F extends string = string,
GlobalOption extends string | never = never,
CommandOption extends string | never = never
> {
export class Command<F extends string = string, CommandOption extends object = {}> {
private static MaxDep = 5;

private readonly conditionFn?: ConditionFn;
Expand All @@ -32,7 +28,7 @@ export class Command<
readonly description: string;
readonly options: Option[] = [];

private actionFn?: ActionFn<ExtractCommand<F>, GlobalOption | CommandOption>;
private actionFn?: ActionFn<ExtractCommand<F>, CommandOption>;

constructor(format: F, config: CommandConfig & { condition?: ConditionFn; logger: Logger }) {
this.format = config.condition
Expand All @@ -53,34 +49,34 @@ export class Command<
}
}

option<OF extends string>(
option<OF extends string, T = string>(
format: OF,
description: string,
config?: Omit<OptionConfig, 'description'>
): Command<F, GlobalOption, CommandOption | ExtractOption<OF>>;
config?: Omit<OptionConfig<T>, 'description'>
): Command<F, CommandOption & ExtractOption<OF>>;

option<OF extends string>(
option<OF extends string, T = string>(
format: OF,
config?: OptionConfig
): Command<F, GlobalOption, CommandOption | ExtractOption<OF>>;
config?: OptionConfig<T>
): Command<F, CommandOption & ExtractOption<OF>>;

option<OF extends string>(
option<OF extends string, T = string>(
format: OF,
configOrDescription: OptionConfig | string = '',
otherConfig: Omit<OptionConfig, 'description'> = {}
): Command<F, GlobalOption, CommandOption | ExtractOption<OF>> {
const config: OptionConfig =
configOrDescription: OptionConfig<T> | string = '',
otherConfig: Omit<OptionConfig<T>, 'description'> = {}
): Command<F, CommandOption & ExtractOption<OF>> {
const config: OptionConfig<T> =
typeof configOrDescription === 'object'
? configOrDescription
: { ...otherConfig, description: configOrDescription };

try {
const option = new Option(format, config);
this.options.push(option);
const option = new Option<OF, T>(format, config);
this.options.push(option as unknown as Option);
} catch (error: any) {
this.logger.warn(error.message);
}
return this as Command<F, GlobalOption, CommandOption | ExtractOption<OF>>;
return this as Command<F, CommandOption & ExtractOption<OF>>;
}

get hasConditionFn(): boolean {
Expand All @@ -105,7 +101,7 @@ export class Command<
}
}

parseArgs(args: ParsedArgs): ParseResult {
parseArgs(args: ParsedArgs, globalOptions: Option[]): ParseResult {
if (this.conditionFn) {
const argumentss: any[] = args['_'];
const options: Record<string, string> = args;
Expand Down Expand Up @@ -144,18 +140,38 @@ export class Command<
}
}

const options: Record<string, string> = args;
const fullOptions = globalOptions.concat(this.options).reduce((map, o) => {
map.set(o.name, o);
return map;
}, new Map<string, Option>());
const options: Record<string, any> = args;
delete options['_'];

for (const [name, rawOption] of fullOptions) {
if (rawOption.required) {
if (options[name] === undefined) {
options[name] = false;
} else if (options[name] === '') {
options[name] = true;
}
} else {
if (options[name] === false) {
options[name] = undefined;
} else if (!(name in options)) {
options[name] = undefined;
}
}
}

return {
// @ts-ignore
command: this,
arguments: argumentss,
options: args
options
};
}

action(fn: ActionFn<ExtractCommand<F>, GlobalOption | CommandOption>) {
action(fn: ActionFn<ExtractCommand<F>, CommandOption>) {
this.actionFn = fn;
return this;
}
Expand Down
24 changes: 16 additions & 8 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import type { Logger } from './types';
import type { Logger, LoggerFn } from './types';

import createDebug from 'debug';
import { blue, red, yellow } from 'kolorist';

export function createDefaultLogger(name: string): Logger {
export function createDefaultLogger(name: string, logger?: Logger | LoggerFn): Logger {
if (!!logger && typeof logger === 'object') {
return logger;
}

const debug = createDebug(name + ':breadc');
const println: LoggerFn =
!!logger && typeof logger === 'function'
? logger
: (message: string, ...args: any[]) => {
console.log(message, ...args);
};

return {
println(message) {
console.log(message);
},
println,
info(message, ...args) {
console.log(`${blue('INFO')} ${message}`, ...args);
println(`${blue('INFO')} ${message}`, ...args);
},
warn(message, ...args) {
console.log(`${yellow('WARN')} ${message}`, ...args);
println(`${yellow('WARN')} ${message}`, ...args);
},
error(message, ...args) {
console.log(`${red('ERROR')} ${message}`, ...args);
println(`${red('ERROR')} ${message}`, ...args);
},
debug(message, ...args) {
debug(message, ...args);
Expand Down
10 changes: 6 additions & 4 deletions src/option.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { ExtractOption } from './types';

export interface OptionConfig<T = string> {
description?: string;
default?: T;
Expand All @@ -15,18 +13,20 @@ export interface OptionConfig<T = string> {
* + --option <arg>
* + --option [arg]
*/
export class Option<T extends string = string, U = ExtractOption<T>> {
export class Option<T extends string = string, F = string> {
private static OptionRE = /^(-[a-zA-Z], )?--([a-zA-Z.]+)( \[[a-zA-Z]+\]| <[a-zA-Z]+>)?$/;

readonly name: string;
readonly shortcut?: string;
readonly default?: F;
readonly format: string;
readonly description: string;
readonly type: 'string' | 'boolean';
readonly required: boolean;

readonly construct: (rawText: string | undefined) => any;

constructor(format: T, config: OptionConfig = {}) {
constructor(format: T, config: OptionConfig<F> = {}) {
this.format = format;

const match = Option.OptionRE.exec(format);
Expand All @@ -45,6 +45,8 @@ export class Option<T extends string = string, U = ExtractOption<T>> {
}

this.description = config.description ?? '';
this.required = format.indexOf('<') !== -1;
this.default = config.default;
this.construct = config.construct ?? ((text) => text ?? config.default ?? undefined);
}
}
Loading

0 comments on commit d23bd4c

Please sign in to comment.