Skip to content

Commit

Permalink
feat: introduce .positional() for configuring positional arguments (#967
Browse files Browse the repository at this point in the history
)
  • Loading branch information
bcoe committed Oct 12, 2017
1 parent 3bb8771 commit cb16460
Show file tree
Hide file tree
Showing 10 changed files with 550 additions and 89 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -4,3 +4,4 @@ node_modules/
*.swp
test.js
coverage
package-lock.json
27 changes: 23 additions & 4 deletions docs/advanced.md
Expand Up @@ -8,13 +8,14 @@ In this section we cover some of the advanced features available in this API:

### Default Commands

To specify a default command use the character `*`. A default command
To specify a default command use the string `*` or `$0`. A default command
will be run if the positional arguments provided match no known
commands:
commands. tldr; default commands allow you to define the entry point to your
application using a similar API to subcommands.

```js
const argv = require('yargs')
.command('*', 'the default command', () => {}, (argv) => {
.command('$0', 'the default command', () => {}, (argv) => {
console.log('this command will be run by default')
})
```
Expand All @@ -26,7 +27,7 @@ Default commands can also be used as a command alias, like so:

```js
const argv = require('yargs')
.command(['serve', '*'], 'the serve command', () => {}, (argv) => {
.command(['serve', '$0'], 'the serve command', () => {}, (argv) => {
console.log('this command will be run by default')
})
```
Expand Down Expand Up @@ -73,6 +74,24 @@ yargs.command('download <url> [files..]', 'download several files')
.argv
```

#### Describing Positional Arguments

You can use the method [`.positional()`](/docs/api.md#positionalkey-opt) in a command's builder function to describe and configure a positional argument:

```js
yargs.command('get <source> [proxy]', 'make a get HTTP request', (yargs) => {
yargs.positional('source', {
describe: 'URL to fetch content from',
type: 'string',
default: 'http://www.google.com'
}).positional('proxy', {
describe: 'optional proxy URL'
})
})
.help()
.argv
```

### Command Execution

When a command is given on the command line, yargs will execute the following:
Expand Down
37 changes: 37 additions & 0 deletions docs/api.md
Expand Up @@ -1068,6 +1068,43 @@ as a configuration object.
`cwd` can optionally be provided, the package.json will be read
from this location.

.positional(key, opt)
------------

`.positional()` allows you to configure a command's positional arguments
with an API similar to [`.option()`](#optionkey-opt). `.positional()`
should be called in a command's builder function, and is not
available on the top-level yargs instance.

> _you can describe top-level positional arguments using
[default commands](/docs/advanced.md#default-commands)._

```js
const argv = require('yargs')('run --help')
.command('run <port> <guid>', 'run the server', (yargs) => {
yargs.positional('guid', {
describe: 'a unique identifier for the server',
type: 'string'
})
}).argv
console.log(argv)
```

Valid `opt` keys include:

- `alias`: string or array of strings, see [`alias()`](#alias)
- `choices`: value or array of values, limit valid option arguments to a predefined set, see [`choices()`](#choices)
- `coerce`: function, coerce or transform parsed command line values into another value, see [`coerce()`](#coerce)
- `conflicts`: string or object, require certain keys not to be set, see [`conflicts()`](#conflicts)
- `default`: value, set a default value for the option, see [`default()`](#default)
- `desc`/`describe`/`description`: string, the option description for help content, see [`describe()`](#describe)
- `implies`: string or object, require certain keys to be set, see [`implies()`](#implies)
- `normalize`: boolean, apply `path.normalize()` to the option, see [`normalize()`](#normalize)
- `type`: one of the following strings
- `'boolean'`: synonymous for `boolean: true`, see [`boolean()`](#boolean)
- `'number'`: synonymous for `number: true`, see [`number()`](#number)
- `'string'`: synonymous for `string: true`, see [`string()`](#string)

.recommendCommands()
---------------------------

Expand Down
125 changes: 83 additions & 42 deletions lib/command.js
@@ -1,9 +1,10 @@
'use strict'
const path = require('path')

const inspect = require('util').inspect
const camelCase = require('camelcase')
const path = require('path')
const Parser = require('yargs-parser')

const DEFAULT_MARKER = '*'
const DEFAULT_MARKER = /(\*)|(\$0)/

// handles parsing positional arguments,
// and populating argv with said positional
Expand Down Expand Up @@ -43,7 +44,7 @@ module.exports = function command (yargs, usage, validation) {
// check for default and filter out '*''
let isDefault = false
const parsedAliases = [parsedCommand.cmd].concat(aliases).filter((c) => {
if (c === DEFAULT_MARKER) {
if (DEFAULT_MARKER.test(c)) {
isDefault = true
return false
}
Expand All @@ -59,6 +60,7 @@ module.exports = function command (yargs, usage, validation) {
demanded: parsedCommand.demanded,
optional: parsedCommand.optional
}
handlers['*'] = defaultCommand
return
}

Expand Down Expand Up @@ -181,7 +183,10 @@ module.exports = function command (yargs, usage, validation) {
let innerYargs = null
let positionalMap = {}

if (command) currentContext.commands.push(command)
if (command) {
currentContext.commands.push(command)
currentContext.fullCommands.push(commandHandler.original)
}
if (typeof commandHandler.builder === 'function') {
// a function can be provided, which builds
// up a yargs chain and possibly returns it.
Expand Down Expand Up @@ -226,7 +231,10 @@ module.exports = function command (yargs, usage, validation) {
commandHandler.handler(innerArgv)
}

if (command) currentContext.commands.pop()
if (command) {
currentContext.commands.pop()
currentContext.fullCommands.pop()
}
numFiles = currentContext.files.length - numFiles
if (numFiles > 0) currentContext.files.splice(numFiles * -1, numFiles)

Expand All @@ -245,59 +253,92 @@ module.exports = function command (yargs, usage, validation) {

while (demanded.length) {
const demand = demanded.shift()
populatePositional(demand, argv, yargs, positionalMap)
populatePositional(demand, argv, positionalMap)
}

while (optional.length) {
const maybe = optional.shift()
populatePositional(maybe, argv, yargs, positionalMap)
populatePositional(maybe, argv, positionalMap)
}

argv._ = context.commands.concat(argv._)

postProcessPositionals(argv, positionalMap, self.cmdToParseOptions(commandHandler.original))

return positionalMap
}

// populate a single positional argument and its
// aliases onto argv.
function populatePositional (positional, argv, yargs, positionalMap) {
// "positional" consists of the positional.cmd, an array representing
// the positional's name and aliases, and positional.variadic
// indicating whether or not it is a variadic array.
let variadics = null
let value = null
for (let i = 0, cmd; (cmd = positional.cmd[i]) !== undefined; i++) {
if (positional.variadic) {
if (variadics) argv[cmd] = variadics.slice(0)
else argv[cmd] = variadics = argv._.splice(0)
} else {
if (!value && !argv._.length) continue
if (value) argv[cmd] = value
else argv[cmd] = value = argv._.shift()
}
positionalMap[cmd] = true
postProcessPositional(yargs, argv, cmd)
addCamelCaseExpansions(argv, cmd)
function populatePositional (positional, argv, positionalMap, parseOptions) {
const cmd = positional.cmd[0]
if (positional.variadic) {
positionalMap[cmd] = argv._.splice(0).map(String)
} else {
if (argv._.length) positionalMap[cmd] = [String(argv._.shift())]
}
}

// TODO move positional arg logic to yargs-parser and remove this duplication
function postProcessPositional (yargs, argv, key) {
const coerce = yargs.getOptions().coerce[key]
if (typeof coerce === 'function') {
try {
argv[key] = coerce(argv[key])
} catch (err) {
yargs.getUsageInstance().fail(err.message, err)
}
// we run yargs-parser against the positional arguments
// applying the same parsing logic used for flags.
function postProcessPositionals (argv, positionalMap, parseOptions) {
// combine the parsing hints we've inferred from the command
// string with explicitly configured parsing hints.
const options = Object.assign({}, yargs.getOptions())
options.default = Object.assign(parseOptions.default, options.default)
options.alias = Object.assign(parseOptions.alias, options.alias)
options.array = options.array.concat(parseOptions.array)

const unparsed = []
Object.keys(positionalMap).forEach((key) => {
[].push.apply(unparsed, positionalMap[key].map((value) => {
return `--${key} ${value}`
}))
})
const parsed = Parser.detailed(unparsed.join(' '), options)

if (parsed.error) {
yargs.getUsageInstance().fail(parsed.error.message, parsed.error)
} else {
Object.keys(parsed.argv).forEach((key) => {
if (key !== '_') argv[key] = parsed.argv[key]
})
}
}

function addCamelCaseExpansions (argv, option) {
if (/-/.test(option)) {
const cc = camelCase(option)
if (typeof argv[option] === 'object') argv[cc] = argv[option].slice(0)
else argv[cc] = argv[option]
self.cmdToParseOptions = function (cmdString) {
const parseOptions = {
array: [],
default: {},
alias: {},
demand: {}
}

const parsed = self.parseCommand(cmdString)
parsed.demanded.forEach((d) => {
const cmds = d.cmd.slice(0)
const cmd = cmds.shift()
if (d.variadic) {
parseOptions.array.push(cmd)
parseOptions.default[cmd] = []
}
cmds.forEach((c) => {
parseOptions.alias[cmd] = c
})
parseOptions.demand[cmd] = true
})

parsed.optional.forEach((o) => {
const cmds = o.cmd.slice(0)
const cmd = cmds.shift()
if (o.variadic) {
parseOptions.array.push(cmd)
parseOptions.default[cmd] = []
}
cmds.forEach((c) => {
parseOptions.alias[cmd] = c
})
})

return parseOptions
}

self.reset = () => {
Expand Down
10 changes: 9 additions & 1 deletion lib/usage.js
Expand Up @@ -80,6 +80,9 @@ module.exports = function usage (yargs, y18n) {
return usages.reduce((usageMsg, msg) => `${usageMsg}${msg}\n`, '') || undefined
}

const positionalGroupName = 'Positionals:'
self.getPositionalGroupName = () => positionalGroupName

let examples = []
self.example = (cmd, description) => {
examples.push([cmd, description || ''])
Expand Down Expand Up @@ -222,7 +225,12 @@ module.exports = function usage (yargs, y18n) {
// actually generate the switches string --foo, -f, --bar.
const switches = normalizedKeys.reduce((acc, key) => {
acc[key] = [ key ].concat(options.alias[key] || [])
.map(sw => (sw.length > 1 ? '--' : '-') + sw)
.map(sw => {
// for the special positional group don't
// add '--' or '-' prefix.
if (groupName === positionalGroupName) return sw
else return (sw.length > 1 ? '--' : '-') + sw
})
.join(', ')

return acc
Expand Down
7 changes: 3 additions & 4 deletions package.json
Expand Up @@ -12,7 +12,6 @@
"LICENSE"
],
"dependencies": {
"camelcase": "^4.1.0",
"cliui": "^3.2.0",
"decamelize": "^1.1.1",
"get-caller-file": "^1.0.1",
Expand All @@ -24,7 +23,7 @@
"string-width": "^2.0.0",
"which-module": "^2.0.0",
"y18n": "^3.2.1",
"yargs-parser": "^7.0.0"
"yargs-parser": "^8.0.0"
},
"devDependencies": {
"chai": "^3.4.1",
Expand All @@ -35,15 +34,15 @@
"es6-promise": "^4.0.2",
"hashish": "0.0.4",
"mocha": "^3.0.1",
"nyc": "^10.3.0",
"nyc": "^11.2.1",
"rimraf": "^2.5.0",
"standard": "^8.6.0",
"standard-version": "^4.2.0",
"which": "^1.2.9",
"yargs-test-extends": "^1.0.1"
},
"scripts": {
"pretest": "standard",
"posttest": "standard",
"test": "nyc --cache mocha --require ./test/before.js --timeout=8000 --check-leaks",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"release": "standard-version"
Expand Down
9 changes: 9 additions & 0 deletions test/command.js
Expand Up @@ -1218,6 +1218,15 @@ describe('Command', () => {
.argv
})

it('allows $0 as an alias for a default command', (done) => {
yargs('9999')
.command('$0 [port]', 'default command', noop, (argv) => {
argv.port.should.equal(9999)
return done()
})
.argv
})

it('does not execute default command if another command is provided', (done) => {
yargs('run bcoe --foo bar')
.command('*', 'default command', noop, (argv) => {})
Expand Down

0 comments on commit cb16460

Please sign in to comment.