Skip to content

Commit

Permalink
refactor: move argv parsing to a dedicated parser
Browse files Browse the repository at this point in the history
  • Loading branch information
thetutlage committed Apr 12, 2019
1 parent bf0eab5 commit e6450b4
Show file tree
Hide file tree
Showing 4 changed files with 338 additions and 165 deletions.
9 changes: 9 additions & 0 deletions packages/ace/src/Contracts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ParsedOptions } from 'getopts'

/*
* @adonisjs/ace
*
Expand Down Expand Up @@ -27,6 +29,12 @@ export type CommandFlag = {
default?: any,
}

export type GlobalFlagHandler = (
value: any,
parsed: ParsedOptions,
command?: CommandConstructorContract,
) => void

/**
* Shape of grouped commands. Required when displaying
* help
Expand All @@ -51,5 +59,6 @@ export interface CommandConstructorContract {
* The shape of command class
*/
export interface CommandContract {
parsed?: ParsedOptions,
handle (): Promise<void>,
}
114 changes: 35 additions & 79 deletions packages/ace/src/Kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
* file that was distributed with this source code.
*/

import * as getopts from 'getopts'
import { CommandConstructorContract, CommandContract } from '../Contracts'
import { CommandConstructorContract, CommandFlag, GlobalFlagHandler } from '../Contracts'
import { Parser } from '../Parser'

/**
* Ace kernel class is used to register, find and invoke commands by
* parsing `process.argv` value.
* parsing `process.argv.splice(2)` value.
*/
export class Kernel {
private _commands: { [name: string]: CommandConstructorContract } = {}
private _flags: { [name: string]: CommandFlag & { handler: GlobalFlagHandler } } = {}

/**
* Register an array of commands
Expand All @@ -28,6 +29,19 @@ export class Kernel {
return this
}

/**
* Register a global flag to be set on any command. The flag callback is
* executed before executing the registered command.
*/
public flag (
name: string,
handler: GlobalFlagHandler,
options: Partial<Pick<CommandFlag, Exclude<keyof CommandFlag, 'name'>>>,
): this {
this._flags[name] = Object.assign({ name, handler, type: 'boolean' }, options)
return this
}

/**
* Finds the command from the command line argv array. If command for
* the given name doesn't exists, then it will return `null`.
Expand All @@ -52,92 +66,34 @@ export class Kernel {
* Makes instance of a given command by processing command line arguments
* and setting them on the command instance
*/
public make<T extends CommandConstructorContract> (command: T, argv: string[]): CommandContract {
/**
* List of arguments that are required
*/
const requiredArgs = command.args.filter((arg) => arg.required)

/**
* Building getopts options by inspecting all flags. Make sure to
* check `getops` documentation to understand what all options
* does.
*/
const options = command.flags.reduce((result: getopts.Options, flag) => {
/**
* Register alias (when exists)
*/
if (flag.alias) {
result.alias![flag.alias] = flag.name
}

/**
* Register flag as boolean when `flag.type === 'boolean'`
*/
if (flag.type === 'boolean') {
result.boolean!.push(flag.name)
}

/**
* Register flag as string when `flag.type === 'string' | 'array'`
*/
if (['string', 'array'].indexOf(flag.type) > -1) {
result.string!.push(flag.name)
}

/**
* Set default value when defined on the flag
*/
if (flag.default !== undefined) {
result.default![flag.name] = flag.default
}

return result
}, { alias: {}, boolean: [], default: {}, string: [] })

/**
* Parsed options via getopts
*/
const parsed = getopts(argv.slice(1), options)

/**
* Raise exception when required arguments are missing
*/
if (parsed._.length < requiredArgs.length) {
throw new Error(`Missing value for ${requiredArgs[parsed._.length].name} argument`)
public async handle (argv: string[]) {
if (!argv.length) {
return
}

const commandInstance = new command()
const hasMentionedCommand = !argv[0].startsWith('-')
const parser = new Parser(this._flags)

/**
* Sharing parsed output with the command
* Parse flags when no command is defined
*/
commandInstance['parsed'] = parsed
if (!hasMentionedCommand) {
parser.parse(argv)
return
}

/**
* Set value for args
* If command doesn't exists, then raise an error for same
*/
command.args.forEach((arg, index) => {
commandInstance[arg.name] = parsed._[index]
})
const command = this.find(argv)
if (!command) {
throw new Error(`${argv[0]} is not a registered command`)
}

/**
* Set value for flags
* Parse argv and execute the `handle` method.
*/
command.flags.forEach((flag) => {
const value = parsed[flag.name]
commandInstance[flag.name] = flag.type === 'array' && !Array.isArray(value) ? [value] : value
})

return commandInstance
}

/**
* Execute the command instance by parsing `process.argv`
*/
public async exec (command: CommandConstructorContract, argv: string[]): Promise<CommandContract> {
const commandInstance = this.make(command, argv)
await commandInstance.handle()
return commandInstance
const commandInstance = parser.parse(argv.splice(1), command)
return commandInstance.handle()
}
}
139 changes: 139 additions & 0 deletions packages/ace/src/Parser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* @adonisjs/ace
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import * as getopts from 'getopts'
import { CommandFlag, GlobalFlagHandler, CommandConstructorContract, CommandContract } from '../Contracts'

/**
* Parser parses the argv array and executes the flag global handlers along with
* the command handler.
*/
export class Parser {
constructor (
private _registeredFlags: {[name: string]: CommandFlag & { handler: GlobalFlagHandler }},
) {}

/**
* Processes ace command flag to set the options for `getopts`.
*/
private _processFlag (flag: CommandFlag, options: getopts.Options) {
/**
* Register alias (when exists)
*/
if (flag.alias) {
options.alias![flag.alias] = flag.name
}

/**
* Register flag as boolean when `flag.type === 'boolean'`
*/
if (flag.type === 'boolean') {
options.boolean!.push(flag.name)
}

/**
* Register flag as string when `flag.type === 'string' | 'array'`
*/
if (['string', 'array'].indexOf(flag.type) > -1) {
options.string!.push(flag.name)
}

/**
* Set default value when defined on the flag
*/
if (flag.default !== undefined) {
options.default![flag.name] = flag.default
}
}

/**
* Casts the flag value to an array, if original type was
* array and value isn't an array.
*/
private _castFlag (flag: CommandFlag, value: any): any {
return flag.type === 'array' && !Array.isArray(value) ? [value] : value
}

/**
* Validates the arguments required by a command.
*/
private _validateArgs (args: string[], command: CommandConstructorContract) {
const requiredArgs = command!.args.filter((arg) => arg.required)
if (args.length < requiredArgs.length) {
throw new Error(`Missing value for ${requiredArgs[args.length].name} argument`)
}
}

/**
* Parses argv and executes the command and global flags handlers
*/
public parse (argv: string[]): void
public parse (argv: string[], command: CommandConstructorContract): CommandContract
public parse (argv: string[], command?: CommandConstructorContract): void | CommandContract {
let options = { alias: {}, boolean: [], default: {}, string: [] }
const globalFlags = Object.keys(this._registeredFlags)

/**
* Build options from global flags
*/
globalFlags.forEach((name) => this._processFlag(this._registeredFlags[name], options))

/**
* Build options from command flags
*/
if (command) {
command.flags.forEach((flag) => this._processFlag(flag, options))
}

/**
* Parsing argv with the previously built options
*/
const parsed = getopts(argv, options)

/**
* Loop over global flags and call their handlers when value
* is defined for that flag. The global handlers are called
* first and they can `exit` the process (if they want).
*/
globalFlags.forEach((name) => {
if (parsed[name] || parsed[name] === false) {
const value = this._castFlag(this._registeredFlags[name], parsed[name])
this._registeredFlags[name].handler(value, parsed, command)
}
})

/**
* Return early if no command exists
*/
if (!command) {
return
}

this._validateArgs(parsed._, command)

const commandInstance = new command()
commandInstance.parsed = parsed

/**
* Set value for args
*/
command.args.forEach((arg, index) => {
commandInstance[arg.name] = parsed._[index]
})

/**
* Set value for flags
*/
command.flags.forEach((flag) => {
commandInstance[flag.name] = this._castFlag(flag, parsed[flag.name])
})

return commandInstance
}
}
Loading

0 comments on commit e6450b4

Please sign in to comment.