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

Add support for command-specific yargs-parser option specs #464

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/domain/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface GluegunCommand<TContext extends Toolbox = Toolbox> {
file?: string
/** A reference to the plugin that contains this command. */
plugin?: Plugin
/** Hints for parsing options with yargs-parser */
options?: object
}

/**
Expand All @@ -35,6 +37,7 @@ export class Command implements GluegunCommand<Toolbox> {
public alias
public dashed
public plugin
public options

constructor(props?: GluegunCommand) {
this.name = null
Expand All @@ -46,6 +49,7 @@ export class Command implements GluegunCommand<Toolbox> {
this.alias = []
this.dashed = false
this.plugin = null
this.options = null
if (props) Object.assign(this, props)
}

Expand Down
4 changes: 4 additions & 0 deletions src/loaders/command-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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' } })
})
2 changes: 2 additions & 0 deletions src/loaders/command-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`)
}
Expand All @@ -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
}
20 changes: 16 additions & 4 deletions src/runtime/run.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 92 additions & 29 deletions src/runtime/runtime-find-command.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
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'

/**
* This function performs some somewhat complex logic to find a command for a given
* 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({
Expand All @@ -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
Expand All @@ -45,40 +46,102 @@ 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
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))
}
26 changes: 19 additions & 7 deletions src/toolbox/parameter-tools.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 }
Expand Down