diff --git a/src/domain/command.ts b/src/domain/command.ts index cca68dcb..b5de6a77 100644 --- a/src/domain/command.ts +++ b/src/domain/command.ts @@ -20,6 +20,8 @@ export interface GluegunCommand { file?: string /** A reference to the plugin that contains this command. */ plugin?: Plugin + /** Hints for parsing options with yargs-parser */ + options?: object } /** @@ -35,6 +37,7 @@ export class Command implements GluegunCommand { public alias public dashed public plugin + public options constructor(props?: GluegunCommand) { this.name = null @@ -46,6 +49,7 @@ export class Command implements GluegunCommand { this.alias = [] this.dashed = false this.plugin = null + this.options = null if (props) Object.assign(this, props) } diff --git a/src/loaders/command-loader.test.ts b/src/loaders/command-loader.test.ts index 6bdfcfd1..3bff4d2c 100644 --- a/src/loaders/command-loader.test.ts +++ b/src/loaders/command-loader.test.ts @@ -36,6 +36,9 @@ test('load command from preload', async () => { alias: ['z'], dashed: true, run: toolbox => 'ran!', + options: { + alias: { foo: 'f' }, + }, }) expect(command.name).toBe('hello') @@ -46,4 +49,5 @@ test('load command from preload', async () => { expect(command.file).toBe(null) expect(command.dashed).toBe(true) expect(command.commandPath).toEqual(['hello']) + expect(command.options).toEqual({ alias: { foo: 'f' } }) }) diff --git a/src/loaders/command-loader.ts b/src/loaders/command-loader.ts index 57666279..c1cd509a 100644 --- a/src/loaders/command-loader.ts +++ b/src/loaders/command-loader.ts @@ -57,6 +57,7 @@ export function loadCommandFromFile(file: string, options: Options = {}): Comman command.hidden = Boolean(commandModule.hidden) command.alias = reject(isNil, is(Array, commandModule.alias) ? commandModule.alias : [commandModule.alias]) command.run = commandModule.run + command.options = commandModule.options } else { throw new Error(`Error: Couldn't load command ${command.name} -- needs a "run" property with a function.`) } @@ -74,5 +75,6 @@ export function loadCommandFromPreload(preload: GluegunCommand): Command { command.file = null command.dashed = Boolean(preload.dashed) command.commandPath = preload.commandPath || [preload.name] + command.options = preload.options return command } diff --git a/src/runtime/run.ts b/src/runtime/run.ts index f23a3d86..bfa2905c 100644 --- a/src/runtime/run.ts +++ b/src/runtime/run.ts @@ -1,5 +1,5 @@ import { EmptyToolbox, GluegunToolbox } from '../domain/toolbox' -import { createParams, parseParams } from '../toolbox/parameter-tools' +import { createParams, parseParams, parseRawCommand } from '../toolbox/parameter-tools' import { Runtime } from './runtime' import { findCommand } from './runtime-find-command' import { Options } from '../domain/options' @@ -27,16 +27,28 @@ export async function run( toolbox.runtime = this // parse the parameters initially - toolbox.parameters = parseParams(rawCommand, extraOptions) + rawCommand = parseRawCommand(rawCommand) + + console.log('RAW COMMAND:', rawCommand) // find the command, and parse out aliases - const { command, array } = findCommand(this, toolbox.parameters) + const { command, args } = findCommand(this, rawCommand) + + console.log('COMMAND:', command) + console.log('ARGS: ', args) + + // parse the command parameters + toolbox.parameters = parseParams(command, args, extraOptions) + + console.log('PARAMETERS:', toolbox.parameters) + + console.log('----') // rebuild the parameters, now that we know the plugin and command toolbox.parameters = createParams({ plugin: command.plugin && command.plugin.name, command: command.name, - array, + array: toolbox.parameters.array, options: toolbox.parameters.options, raw: rawCommand, argv: process.argv, diff --git a/src/runtime/runtime-find-command.ts b/src/runtime/runtime-find-command.ts index 81384421..b551e1bb 100644 --- a/src/runtime/runtime-find-command.ts +++ b/src/runtime/runtime-find-command.ts @@ -1,6 +1,6 @@ import { Command } from '../domain/command' import { Runtime } from './runtime' -import { GluegunParameters, GluegunToolbox } from '../domain/toolbox' +import { GluegunToolbox } from '../domain/toolbox' import { equals } from '../toolbox/utils' /** @@ -8,20 +8,21 @@ import { equals } from '../toolbox/utils' * set of parameters and plugins. * * @param runtime The current runtime. - * @param parameters The parameters passed in - * @returns object with plugin, command, and array + * @param args Command, options and parameters as string array. + * @returns object with plugin, command, array, and args */ -export function findCommand(runtime: Runtime, parameters: GluegunParameters): { command: Command; array: string[] } { +export function findCommand(runtime: Runtime, args: string[]): { command: Command; args: string[] } { // the commandPath, which could be something like: // > movie list actors 2015 // [ 'list', 'actors', '2015' ] // here, the '2015' might not actually be a command, but it's part of it - const commandPath = parameters.array + const commandPath = args // the part of the commandPath that doesn't match a command // in the above example, it will end up being [ '2015' ] - let tempPathRest = commandPath - let commandPathRest = tempPathRest + let pathRest = commandPath + let outputArgs = [] + let potentialOptionValue = false // a fallback command const commandNotFound = new Command({ @@ -34,9 +35,9 @@ export function findCommand(runtime: Runtime, parameters: GluegunParameters): { // start by setting it to the default command, in case we don't find one let targetCommand: Command = runtime.defaultCommand || commandNotFound - // if the commandPath is empty, it could be a dashed command, like --help + // if there were no args, return the fallback command if (commandPath.length === 0) { - targetCommand = findDashedCommand(runtime.commands, parameters.options) || targetCommand + return { command: runtime.defaultCommand || commandNotFound, args: [] } } // store the resolved path as we go @@ -45,31 +46,62 @@ export function findCommand(runtime: Runtime, parameters: GluegunParameters): { // we loop through each segment of the commandPath, looking for aliases among // parent commands, and expand those. commandPath.forEach((currName: string) => { + console.log('CURR NAME', currName) + // cut another piece off the front of the commandPath - tempPathRest = tempPathRest.slice(1) + pathRest = pathRest.slice(1) - // find a command that fits the previous path + currentName, which can be an alias - let segmentCommand = runtime.commands - .slice() // dup so we keep the original order - .sort(sortCommands) - .find(command => equals(command.commandPath.slice(0, -1), resolvedPath) && command.matchesAlias(currName)) + if (currName.startsWith('-')) { + outputArgs.push(currName) + potentialOptionValue = true + } else { + let prefix = [...resolvedPath, currName] - if (segmentCommand) { - // found another candidate as the "endpoint" command - targetCommand = segmentCommand + // find a command that matches the path prefix so far + currName (which may be an alias) + let commandWithPrefix = runtime.commands + .slice() // dup so we keep the original order + .sort(sortCommands) + .find(command => commandHasPrefix(command, prefix)) - // since we found a command, the "commandPathRest" gets updated to the tempPathRest - commandPathRest = tempPathRest + console.log('COMMAND WITH PREFIX', commandWithPrefix) + console.log('PATH REST', pathRest) - // add the current command to the resolvedPath - resolvedPath = resolvedPath.concat([segmentCommand.name]) - } else { - // no command found, let's add the segment as-is to the command path - resolvedPath = resolvedPath.concat([currName]) + if (commandWithPrefix) { + // make sure we don't mistake option values for commands, i.e., + // for `--some-option cmd cmd` treat the first `cmd` as the option + // value and the second as a command path segment + if (potentialOptionValue) { + if (pathRest.slice(1, 1) === [currName]) { + console.log('PUSH TO ARGS', currName) + outputArgs.push(currName) + } else { + console.log('PUSH TO RESOLVED PATH', commandWithPrefix.commandPath[prefix.length - 1]) + resolvedPath.push(commandWithPrefix.commandPath[prefix.length - 1]) + } + } else { + console.log('PUSH TO RESOLVED PATH', commandWithPrefix.commandPath[prefix.length - 1]) + resolvedPath.push(commandWithPrefix.commandPath[prefix.length - 1]) + } + } else { + console.log('PUSH TO ARGS', currName) + // no command includes currName in its prefix, assume it's an option value + outputArgs.push(currName) + } + + potentialOptionValue = false } }, []) - return { command: targetCommand, array: commandPathRest } + console.log('RESOLVED PATH:', resolvedPath) + console.log('OUTPUT ARGS:', outputArgs) + + targetCommand = + findCommandWithPath(runtime.commands, resolvedPath) || + findDashedCommand(runtime.commands, outputArgs) || + runtime.defaultCommand || + commandNotFound + + return { command: targetCommand, args: outputArgs } } // sorts shortest to longest commandPaths, so we always check the shortest ones first @@ -77,8 +109,39 @@ function sortCommands(a, b) { return a.commandPath.length < b.commandPath.length ? -1 : 1 } +// returns true if the command has the given path prefix +function commandHasPrefix(command: Command, prefix: string[]): boolean { + console.log('COMMAND HAS PREFIX', command.commandPath, command.aliases, prefix) + + if (prefix.length > command.commandPath.length) { + return false + } + + for (let i = 0; i < prefix.length - 1; i++) { + if (command.commandPath[i] !== prefix[i]) { + return false + } + } + + if (command.commandPath[prefix.length - 1] === prefix[prefix.length - 1]) { + return true + } + + if (command.matchesAlias(prefix[prefix.length - 1])) { + return true + } + + return false +} + +// finds the command that matches the given path +function findCommandWithPath(commands: Command[], commandPath: string[]): Command | undefined { + return commands.find(command => equals(command.commandPath, commandPath)) +} + // finds dashed commands -function findDashedCommand(commands, options) { - const dashedOptions = Object.keys(options).filter(k => options[k] === true) - return commands.filter(c => c.dashed).find(c => c.matchesAlias(dashedOptions)) +function findDashedCommand(commands, args) { + const names = args.filter(a => a.startsWith('-')).map(a => a.replace(/^-+/, '').replace(/=.*$/, '')) + console.log('FIND DASHED COMMANDS:', names) + return commands.filter(c => c.dashed).find(c => c.matchesAlias(names)) } diff --git a/src/toolbox/parameter-tools.ts b/src/toolbox/parameter-tools.ts index 5c680ed8..693e8f5b 100644 --- a/src/toolbox/parameter-tools.ts +++ b/src/toolbox/parameter-tools.ts @@ -1,19 +1,17 @@ import { GluegunParameters } from '../domain/toolbox' import { Options } from '../domain/options' import { equals, is } from './utils' +import { Command } from '../domain/command' const COMMAND_DELIMITER = ' ' /** - * Parses given command arguments into a more useful format. + * Parses the raw command into an array of strings. * * @param commandArray Command string or list of command parts. - * @param extraOpts Extra options. - * @returns Normalized parameters. + * @returns The command as an array of strings. */ -export function parseParams(commandArray: string | string[], extraOpts: Options = {}): GluegunParameters { - const yargsParse = require('yargs-parser') - +export function parseRawCommand(commandArray: string | string[]): string[] { // use the command line args if not passed in if (is(String, commandArray)) { commandArray = (commandArray as string).split(COMMAND_DELIMITER) @@ -24,8 +22,22 @@ export function parseParams(commandArray: string | string[], extraOpts: Options commandArray = commandArray.slice(2) } + return commandArray as string[] +} + +/** + * Parses given command arguments into a more useful format. + * + * @param command Command the parameters are for. + * @param args: Array of argument strings. + * @param extraOpts Extra options. + * @returns Normalized parameters. + */ +export function parseParams(command: Command, args: string[], extraOpts: Options = {}): GluegunParameters { + const yargsParse = require('yargs-parser') + // chop it up yargsParse! - const parsed = yargsParse(commandArray) + const parsed = yargsParse(args, command.options || {}) const array = parsed._.slice() delete parsed._ const options = { ...parsed, ...extraOpts }