Repository: yargs/yargs
Pull request #580 (2016-08-07 04:56:20)

feat: adds recommendCommands() for command suggestions
working from @maxrimue's branch for `recommendCommands()`, and implementing @nexdrew's code-review.

This pull request gets us to `v1` of command recommendations (thanks @maxrimue for the hard work) addressing some of the nits in #562 (@maxrimue sorry for @nexdrew and I providing such a difficult review).

Here's a breakdown of some of the changes made:

* rather than pulling in another dependency, I simply added a helper module for calculating levenshtein distance: this is a fairly simple algorithm, and I could imagine us using it in other places -- might as well dance around adding another dependency to yargs.
* we now handle the edge-case that @nexdrew outlined with nested arguments significantly better, by avoiding making recommendations until after we've walked through all the command handlers.
* we now recommend commands using the validation module, this allows us to piggyback off existing functionality in `usage.js`, and `validation.js`.
* we now use the same method signature as `strict()` for `recommendCommands()` rather than trying to be overly fancy with argument overriding.

reviewers: @maxrimue @nexdrew I plan on stubbornly marching us towards `yargs@5.x` over the next week, appreciate your patience.


Patch:
diff --git a/README.md b/README.md
index 8dfdf5a..1f911cd 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-yargs
+  yargs
 ========
 
 Yargs be a node.js library fer hearties tryin' ter parse optstrings.
@@ -452,6 +452,49 @@ var argv = require('yargs')
   .argv
 ```
 
+<a name="coerce"></a>.coerce(key, fn)
+----------------
+
+Provide a synchronous function to coerce or transform the value(s) given on the
+command line for `key`.
+
+The coercion function should accept one argument, representing the parsed value
+from the command line, and should return a new value or throw an error. The
+returned value will be used as the value for `key` (or one of its aliases) in
+`argv`. If the function throws, the error will be treated as a validation
+failure, delegating to either a custom [`.fail()`](#fail) handler or printing
+the error message in the console.
+
+```js
+var argv = require('yargs')
+  .coerce('file', function (arg) {
+    return require('fs').readFileSync(arg, 'utf8')
+  })
+  .argv
+```
+
+Optionally `.coerce()` can take an object that maps several keys to their
+respective coercion function.
+
+```js
+var argv = require('yargs')
+  .coerce({
+    date: Date.parse,
+    json: JSON.parse
+  })
+  .argv
+```
+
+You can also map the same function to several keys at one time. Just pass an
+array of keys as the first argument to `.coerce()`:
+
+```js
+var path = require('path')
+var argv = require('yargs')
+  .coerce(['src', 'dest'], path.resolve)
+  .argv
+```
+
 .command(cmd, desc, [builder], [handler])
 -----------------------------------------
 .command(cmd, desc, [module])
@@ -1005,7 +1048,7 @@ By default, yargs exits the process when the user passes a help flag, uses the
 `.exitProcess(false)` disables this behavior, enabling further actions after
 yargs have been validated.
 
-.fail(fn)
+<a name="fail"></a>.fail(fn)
 ---------
 
 Method to execute when a failure occurs, rather than printing the failure message.
@@ -1324,6 +1367,7 @@ Valid `opt` keys include:
 - `array`: boolean, interpret option as an array, see [`array()`](#array)
 - `boolean`: boolean, interpret option as a boolean flag, see [`boolean()`](#boolean)
 - `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)
 - `config`: boolean, interpret option as a path to a JSON config file, see [`config()`](#config)
 - `configParser`: function, provide a custom config parsing function, see [`config()`](#config)
 - `count`: boolean, interpret option as a count of boolean flags, see [`count()`](#count)
@@ -1362,6 +1406,12 @@ as a configuration object.
 `cwd` can optionally be provided, the package.json will be read
 from this location.
 
+.recommendCommands()
+---------------------------
+
+Should yargs provide suggestions regarding similar commands if no matching
+command is found?
+
 .require(key, [msg | boolean])
 ------------------------------
 .required(key, [msg | boolean])
diff --git a/lib/command.js b/lib/command.js
index 804f385..b6e4922 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -34,9 +34,7 @@ module.exports = function (yargs, usage, validation) {
     handlers[parsedCommand.cmd] = {
       original: cmd,
       handler: handler,
-      // TODO: default to a noop builder in
-      // yargs@5.x
-      builder: builder,
+      builder: builder || {},
       demanded: parsedCommand.demanded,
       optional: parsedCommand.optional
     }
@@ -129,7 +127,7 @@ module.exports = function (yargs, usage, validation) {
     var currentContext = yargs.getContext()
     var parentCommands = currentContext.commands.slice()
     currentContext.commands.push(command)
-    if (commandHandler.builder && typeof commandHandler.builder === 'function') {
+    if (typeof commandHandler.builder === 'function') {
       // a function can be provided, which builds
       // up a yargs chain and possibly returns it.
       innerArgv = commandHandler.builder(yargs.reset(parsed.aliases))
@@ -145,7 +143,7 @@ module.exports = function (yargs, usage, validation) {
       } else {
         innerArgv = yargs.parsed.argv
       }
-    } else if (commandHandler.builder && typeof commandHandler.builder === 'object') {
+    } else if (typeof commandHandler.builder === 'object') {
       // as a short hand, an object can instead be provided, specifying
       // the options that a command takes.
       innerArgv = yargs.reset(parsed.aliases)
@@ -156,7 +154,7 @@ module.exports = function (yargs, usage, validation) {
       innerArgv = innerArgv.argv
     }
 
-    populatePositional(commandHandler, innerArgv, currentContext)
+    populatePositional(commandHandler, innerArgv, currentContext, yargs)
 
     if (commandHandler.handler) {
       commandHandler.handler(innerArgv)
@@ -165,7 +163,7 @@ module.exports = function (yargs, usage, validation) {
     return innerArgv
   }
 
-  function populatePositional (commandHandler, argv, context) {
+  function populatePositional (commandHandler, argv, context, yargs) {
     argv._ = argv._.slice(context.commands.length) // nuke the current commands
     var demanded = commandHandler.demanded.slice(0)
     var optional = commandHandler.optional.slice(0)
@@ -178,6 +176,7 @@ module.exports = function (yargs, usage, validation) {
       if (!argv._.length) break
       if (demand.variadic) argv[demand.cmd] = argv._.splice(0)
       else argv[demand.cmd] = argv._.shift()
+      postProcessPositional(yargs, argv, demand.cmd)
     }
 
     while (optional.length) {
@@ -186,11 +185,24 @@ module.exports = function (yargs, usage, validation) {
       if (!argv._.length) break
       if (maybe.variadic) argv[maybe.cmd] = argv._.splice(0)
       else argv[maybe.cmd] = argv._.shift()
+      postProcessPositional(yargs, argv, maybe.cmd)
     }
 
     argv._ = context.commands.concat(argv._)
   }
 
+  // TODO move positional arg logic to yargs-parser and remove this duplication
+  function postProcessPositional (yargs, argv, key) {
+    var coerce = yargs.getOptions().coerce[key]
+    if (typeof coerce === 'function') {
+      try {
+        argv[key] = coerce(argv[key])
+      } catch (err) {
+        yargs.getUsageInstance().fail(err.message, err)
+      }
+    }
+  }
+
   self.reset = function () {
     handlers = {}
     return self
diff --git a/lib/levenshtein.js b/lib/levenshtein.js
new file mode 100644
index 0000000..6ec216f
--- /dev/null
+++ b/lib/levenshtein.js
@@ -0,0 +1,47 @@
+/*
+Copyright (c) 2011 Andrei Mackenzie
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+// levenshtein distance algorithm, pulled from Andrei Mackenzie's MIT licensed.
+// gist, which can be found here: https://gist.github.com/andrei-m/982927
+
+// Compute the edit distance between the two given strings
+module.exports = function (a, b) {
+  if (a.length === 0) return b.length
+  if (b.length === 0) return a.length
+
+  var matrix = []
+
+  // increment along the first column of each row
+  var i
+  for (i = 0; i <= b.length; i++) {
+    matrix[i] = [i]
+  }
+
+  // increment each column in the first row
+  var j
+  for (j = 0; j <= a.length; j++) {
+    matrix[0][j] = j
+  }
+
+  // Fill in the rest of the matrix
+  for (i = 1; i <= b.length; i++) {
+    for (j = 1; j <= a.length; j++) {
+      if (b.charAt(i - 1) === a.charAt(j - 1)) {
+        matrix[i][j] = matrix[i - 1][j - 1]
+      } else {
+        matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // substitution
+                                Math.min(matrix[i][j - 1] + 1, // insertion
+                                         matrix[i - 1][j] + 1)) // deletion
+      }
+    }
+  }
+
+  return matrix[b.length][a.length]
+}
diff --git a/lib/usage.js b/lib/usage.js
index 7c53aa5..822d8ef 100644
--- a/lib/usage.js
+++ b/lib/usage.js
@@ -34,9 +34,9 @@ module.exports = function (yargs, y18n) {
   var failureOutput = false
   self.fail = function (msg, err) {
     if (fails.length) {
-      fails.forEach(function (f) {
-        f(msg, err)
-      })
+      for (var i = fails.length - 1; i >= 0; --i) {
+        fails[i](msg, err)
+      }
     } else {
       if (yargs.getExitProcess()) setBlocking(true)
 
@@ -391,7 +391,7 @@ module.exports = function (yargs, y18n) {
 
   self.reset = function (globalLookup) {
     // do not reset wrap here
-    fails = []
+    // do not reset fails here
     failMessage = null
     failureOutput = false
     usage = undefined
diff --git a/lib/validation.js b/lib/validation.js
index d9a3364..cc45378 100644
--- a/lib/validation.js
+++ b/lib/validation.js
@@ -11,18 +11,19 @@ module.exports = function (yargs, usage, y18n) {
   // arguments were provided, i.e., '_'.
   self.nonOptionCount = function (argv) {
     const demanded = yargs.getDemanded()
-    const _s = argv._.length
+    // don't count currently executing commands
+    const _s = argv._.length - yargs.getContext().commands.length
 
     if (demanded._ && (_s < demanded._.count || _s > demanded._.max)) {
       if (demanded._.msg !== undefined) {
         usage.fail(demanded._.msg)
       } else if (_s < demanded._.count) {
         usage.fail(
-          __('Not enough non-option arguments: got %s, need at least %s', argv._.length, demanded._.count)
+          __('Not enough non-option arguments: got %s, need at least %s', _s, demanded._.count)
         )
       } else {
         usage.fail(
-          __('Too many non-option arguments: got %s, maximum of %s', argv._.length, demanded._.max)
+          __('Too many non-option arguments: got %s, maximum of %s', _s, demanded._.max)
         )
       }
     }
@@ -273,6 +274,23 @@ module.exports = function (yargs, usage, y18n) {
     }
   }
 
+  self.recommendCommands = function (cmd, potentialCommands) {
+    const distance = require('./levenshtein')
+    const threshold = 3 // if it takes more than three edits, let's move on.
+    potentialCommands = potentialCommands.sort(function (a, b) { return b.length - a.length })
+
+    var recommended = null
+    var bestDistance = Infinity
+    for (var i = 0, candidate; (candidate = potentialCommands[i]) !== undefined; i++) {
+      var d = distance(cmd, candidate)
+      if (d <= threshold && d < bestDistance) {
+        bestDistance = d
+        recommended = candidate
+      }
+    }
+    if (recommended) usage.fail(__('Did you mean %s?', recommended))
+  }
+
   self.reset = function (globalLookup) {
     implied = objFilter(implied, function (k, v) {
       return globalLookup[k]
diff --git a/locales/de.json b/locales/de.json
index 8586498..86c874c 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -33,5 +33,6 @@
   "Invalid JSON config file: %s": "Fehlerhafte JSON-Config Datei: %s",
   "Path to JSON config file": "Pfad zur JSON-Config Datei",
   "Show help": "Hilfe anzeigen",
-  "Show version number": "Version anzeigen"
+  "Show version number": "Version anzeigen",
+  "Did you mean %s?": "Meintest du %s?"
 }
diff --git a/locales/en.json b/locales/en.json
index 0fab5cc..59d4eb0 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -33,5 +33,6 @@
   "Invalid JSON config file: %s": "Invalid JSON config file: %s",
   "Path to JSON config file": "Path to JSON config file",
   "Show help": "Show help",
-  "Show version number": "Show version number"
+  "Show version number": "Show version number",
+  "Did you mean %s?": "Did you mean %s?"
 }
diff --git a/locales/zh.json b/locales/zh.json
deleted file mode 100644
index b25c7b9..0000000
--- a/locales/zh.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
-  "Commands:": "命令：",
-  "Options:": "选项：",
-  "Examples:": "示例：",
-  "boolean": "布尔",
-  "count": "计数",
-  "string": "字符串",
-  "number": "数字",
-  "array": "数组",
-  "required": "必需",
-  "default:": "默认值:",
-  "choices:": "可选值:",
-  "generated-value": "生成的值",
-  "Not enough non-option arguments: got %s, need at least %s": "缺少 non-option 参数：传入了 %s 个, 至少需要 %s 个",
-  "Too many non-option arguments: got %s, maximum of %s": "non-option 参数过多：传入了 %s 个, 最大允许 %s 个",
-  "Missing argument value: %s": {
-    "one": "没有给此选项指定值：%s",
-    "other": "没有给这些选项指定值：%s"
-  },
-  "Missing required argument: %s": {
-    "one": "缺少必须的选项：%s",
-    "other": "缺少这些必须的选项：%s"
-  },
-  "Unknown argument: %s": {
-    "one": "无法识别的选项：%s",
-    "other": "无法识别这些选项：%s"
-  },
-  "Invalid values:": "无效的选项值：",
-  "Argument: %s, Given: %s, Choices: %s": "选项名称: %s, 传入的值: %s, 可选的值：%s",
-  "Argument check failed: %s": "选项值验证失败：%s",
-  "Implications failed:": "缺少依赖的选项：",
-  "Not enough arguments following: %s": "没有提供足够的值给此选项：%s",
-  "Invalid JSON config file: %s": "无效的 JSON 配置文件：%s",
-  "Path to JSON config file": "JSON 配置文件的路径",
-  "Show help": "显示帮助信息",
-  "Show version number": "显示版本号"
-}
diff --git a/package.json b/package.json
index a176645..aa15d61 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
     "cliui": "^3.2.0",
     "decamelize": "^1.1.1",
     "get-caller-file": "^1.0.1",
-    "lodash.assign": "^4.0.3",
+    "lodash.assign": "^4.2.0",
     "os-locale": "^1.4.0",
     "read-pkg-up": "^1.0.1",
     "require-directory": "^2.1.1",
@@ -25,7 +25,7 @@
     "which-module": "^1.0.0",
     "window-size": "^0.2.0",
     "y18n": "^3.2.1",
-    "yargs-parser": "^2.4.1"
+    "yargs-parser": "^3.2.0"
   },
   "devDependencies": {
     "chai": "^3.4.1",
diff --git a/yargs.js b/yargs.js
index 3377b1d..7b9c338 100644
--- a/yargs.js
+++ b/yargs.js
@@ -98,7 +98,7 @@ function Yargs (processArgs, cwd, parentRequire) {
 
     var objectOptions = [
       'narg', 'key', 'alias', 'default', 'defaultDescription',
-      'config', 'choices', 'demanded'
+      'config', 'choices', 'demanded', 'coerce'
     ]
 
     arrayOptions.forEach(function (k) {
@@ -113,7 +113,7 @@ function Yargs (processArgs, cwd, parentRequire) {
       })
     })
 
-    tmpOptions.envPrefix = undefined
+    tmpOptions.envPrefix = options.envPrefix
     options = tmpOptions
 
     // if this is the first time being executed, create
@@ -244,6 +244,19 @@ function Yargs (processArgs, cwd, parentRequire) {
     return self
   }
 
+  self.coerce = function (key, fn) {
+    if (typeof key === 'object' && !Array.isArray(key)) {
+      Object.keys(key).forEach(function (k) {
+        self.coerce(k, key[k])
+      })
+    } else {
+      [].concat(key).forEach(function (k) {
+        options.coerce[k] = fn
+      })
+    }
+    return self
+  }
+
   self.count = function (counts) {
     options.count.push.apply(options.count, [].concat(counts))
     return self
@@ -403,6 +416,8 @@ function Yargs (processArgs, cwd, parentRequire) {
         self.normalize(key)
       } if ('choices' in opt) {
         self.choices(key, opt.choices)
+      } if ('coerce' in opt) {
+        self.coerce(key, opt.coerce)
       } if ('group' in opt) {
         self.group(key, opt.group)
       } if (opt.global) {
@@ -625,6 +640,12 @@ function Yargs (processArgs, cwd, parentRequire) {
     return detectLocale
   }
 
+  var recommendCommands
+  self.recommendCommands = function () {
+    recommendCommands = true
+    return self
+  }
+
   self.getUsageInstance = function () {
     return usage
   }
@@ -700,10 +721,21 @@ function Yargs (processArgs, cwd, parentRequire) {
       // if there's a handler associated with a
       // command defer processing to it.
       var handlerKeys = command.getCommands()
-      for (var i = 0, cmd; (cmd = argv._[i]) !== undefined; i++) {
-        if (~handlerKeys.indexOf(cmd) && cmd !== completionCommand) {
-          setPlaceholderKeys(argv)
-          return command.runCommand(cmd, self, parsed)
+      if (handlerKeys.length) {
+        var firstUnknownCommand
+        for (var i = 0, cmd; (cmd = argv._[i]) !== undefined; i++) {
+          if (~handlerKeys.indexOf(cmd) && cmd !== completionCommand) {
+            setPlaceholderKeys(argv)
+            return command.runCommand(cmd, self, parsed)
+          } else if (!firstUnknownCommand && cmd !== completionCommand) {
+            firstUnknownCommand = cmd
+          }
+        }
+
+        // recommend a command if recommendCommands() has
+        // been enabled, and no commands were found to execute
+        if (recommendCommands && firstUnknownCommand) {
+          validation.recommendCommands(firstUnknownCommand, handlerKeys)
         }
       }
 


Test patch:
diff --git a/test/command.js b/test/command.js
index 3d0888a..7ca7d55 100644
--- a/test/command.js
+++ b/test/command.js
@@ -547,6 +547,23 @@ describe('Command', function () {
     })
   })
 
+  // see: https://github.com/yargs/yargs/pull/553
+  it('preserves top-level envPrefix', function () {
+    process.env.FUN_DIP_STICK = 'yummy'
+    process.env.FUN_DIP_POWDER = 'true'
+    yargs('eat')
+      .env('FUN_DIP')
+      .global('stick') // this does not actually need to be global
+      .command('eat', 'Adult supervision recommended', function (yargs) {
+        return yargs.boolean('powder').exitProcess(false)
+      }, function (argv) {
+        argv.should.have.property('powder').and.be.true
+        argv.should.have.property('stick').and.equal('yummy')
+      })
+      .exitProcess(false)
+      .argv
+  })
+
   // addresses https://github.com/yargs/yargs/issues/514.
   it('respects order of positional arguments when matching commands', function () {
     var output = []
diff --git a/test/completion.js b/test/completion.js
index 7e082b0..4c52a8d 100644
--- a/test/completion.js
+++ b/test/completion.js
@@ -34,7 +34,7 @@ describe('Completion', function () {
           .argv
       })
 
-      r.logs.should.include('foo')
+      // should not suggest foo for completion unless foo is subcommand of apple
       r.logs.should.not.include('apple')
     })
 
diff --git a/test/usage.js b/test/usage.js
index 05bbcd9..4a97779 100644
--- a/test/usage.js
+++ b/test/usage.js
@@ -501,7 +501,7 @@ describe('usage tests', function () {
             try {
               return yargs('test')
                 .fail(function () {
-                  console.log('is not triggered')
+                  console.log('is triggered last')
                 })
                 .exitProcess(false)
                 .wrap(null)
@@ -511,7 +511,7 @@ describe('usage tests', function () {
                       console.log([error.name, error.message])
                     })
                     .exitProcess(false)
-
+                }, function (argv) {
                   throw new Error('foo')
                 })
                 .argv
@@ -519,7 +519,7 @@ describe('usage tests', function () {
 
             }
           })
-          r.logs.should.deep.equal([['Error', 'foo']])
+          r.logs.should.deep.equal([['Error', 'foo'], 'is triggered last'])
           r.should.have.property('exit').and.be.false
         })
       })
diff --git a/test/validation.js b/test/validation.js
index 641b22f..777aaee 100644
--- a/test/validation.js
+++ b/test/validation.js
@@ -135,6 +135,30 @@ describe('validation tests', function () {
         })
         .argv
     })
+
+    it('interprets min relative to command', function () {
+      var failureMsg
+      yargs('lint')
+        .command('lint', 'Lint a file', function (yargs) {
+          yargs.demand(1).fail(function (msg) {
+            failureMsg = msg
+          })
+        })
+        .argv
+      expect(failureMsg).to.equal('Not enough non-option arguments: got 0, need at least 1')
+    })
+
+    it('interprets max relative to command', function () {
+      var failureMsg
+      yargs('lint one.js two.js')
+        .command('lint', 'Lint a file', function (yargs) {
+          yargs.demand(0, 1).fail(function (msg) {
+            failureMsg = msg
+          })
+        })
+        .argv
+      expect(failureMsg).to.equal('Too many non-option arguments: got 2, maximum of 1')
+    })
   })
 
   describe('choices', function () {
diff --git a/test/yargs.js b/test/yargs.js
index e491d24..fe1459f 100644
--- a/test/yargs.js
+++ b/test/yargs.js
@@ -203,6 +203,7 @@ describe('yargs dsl tests', function () {
         .alias('foo', 'bar')
         .string('foo')
         .choices('foo', ['bar', 'baz'])
+        .coerce('foo', function (foo) { return foo + 'bar' })
         .implies('foo', 'snuh')
         .group('foo', 'Group:')
         .strict()
@@ -220,13 +221,14 @@ describe('yargs dsl tests', function () {
         narg: {},
         defaultDescription: {},
         choices: {},
+        coerce: {},
         requiresArg: [],
         skipValidation: [],
         count: [],
         normalize: [],
         number: [],
         config: {},
-        envPrefix: undefined,
+        envPrefix: 'YARGS', // preserved as global
         global: ['help'],
         demanded: {}
       }
@@ -259,6 +261,40 @@ describe('yargs dsl tests', function () {
         .argv
     })
 
+    it('recommends a similar command if no command handler is found', function () {
+      var r = checkOutput(function () {
+        yargs(['boat'])
+          .command('goat')
+          .recommendCommands()
+          .argv
+      })
+
+      r.errors[1].should.match(/Did you mean goat/)
+    })
+
+    it('does not recommend a similiar command if no similar command exists', function () {
+      var r = checkOutput(function () {
+        yargs(['foo'])
+          .command('nothingSimilar')
+          .recommendCommands()
+          .argv
+      })
+
+      r.logs.should.be.empty
+    })
+
+    it('recommends the longest match first', function () {
+      var r = checkOutput(function () {
+        yargs(['boat'])
+          .command('bot')
+          .command('goat')
+          .recommendCommands()
+          .argv
+      })
+
+      r.errors[1].should.match(/Did you mean goat/)
+    })
+
     it("skips executing top-level command if builder's help is executed", function () {
       var r = checkOutput(function () {
         yargs(['blerg', '-h'])
@@ -1258,6 +1294,138 @@ describe('yargs dsl tests', function () {
       q.logs[0].split('\n').should.deep.equal(expected)
     })
   })
+
+  describe('.coerce()', function () {
+    it('supports string and function args (as option key and coerce function)', function () {
+      var argv = yargs(['--file', path.join(__dirname, 'fixtures', 'package.json')])
+        .coerce('file', function (arg) {
+          return JSON.parse(fs.readFileSync(arg, 'utf8'))
+        })
+        .argv
+      expect(argv.file).to.have.property('version').and.equal('9.9.9')
+    })
+
+    it('supports object arg (as map of multiple options)', function () {
+      var argv = yargs('--expand abc --range 1..3')
+        .coerce({
+          expand: function (arg) {
+            return arg.split('')
+          },
+          range: function (arg) {
+            var arr = arg.split('..').map(Number)
+            return { begin: arr[0], end: arr[1] }
+          }
+        })
+        .argv
+      expect(argv.expand).to.deep.equal(['a', 'b', 'c'])
+      expect(argv.range).to.have.property('begin').and.equal(1)
+      expect(argv.range).to.have.property('end').and.equal(3)
+    })
+
+    it('supports array and function args (as option keys and coerce function)', function () {
+      var argv = yargs(['--src', 'in', '--dest', 'out'])
+        .coerce(['src', 'dest'], function (arg) {
+          return path.resolve(arg)
+        })
+        .argv
+      argv.src.should.match(/in/).and.have.length.above(2)
+      argv.dest.should.match(/out/).and.have.length.above(3)
+    })
+
+    it('allows an error to be handled by fail() handler', function () {
+      var msg
+      var err
+      yargs('--json invalid')
+        .coerce('json', function (arg) {
+          return JSON.parse(arg)
+        })
+        .fail(function (m, e) {
+          msg = m
+          err = e
+        })
+        .argv
+      expect(msg).to.match(/Unexpected token i/)
+      expect(err).to.exist
+    })
+
+    it('supports an option alias', function () {
+      var argv = yargs('-d 2016-08-12')
+        .coerce('date', Date.parse)
+        .alias('date', 'd')
+        .argv
+      argv.date.should.equal(1470960000000)
+    })
+
+    it('supports a global option within command', function () {
+      var regex
+      yargs('check --regex x')
+        .global('regex')
+        .coerce('regex', RegExp)
+        .command('check', 'Check something', {}, function (argv) {
+          regex = argv.regex
+        })
+        .argv
+      expect(regex).to.be.an.instanceof(RegExp)
+      regex.toString().should.equal('/x/')
+    })
+
+    it('is supported by .option()', function () {
+      var argv = yargs('--env SHELL=/bin/bash')
+        .option('env', {
+          coerce: function (arg) {
+            var arr = arg.split('=')
+            return { name: arr[0], value: arr[1] || '' }
+          }
+        })
+        .argv
+      expect(argv.env).to.have.property('name').and.equal('SHELL')
+      expect(argv.env).to.have.property('value').and.equal('/bin/bash')
+    })
+
+    it('supports positional and variadic args for a command', function () {
+      var age
+      var dates
+      yargs('add 30days 2016-06-13 2016-07-18')
+        .command('add <age> [dates..]', 'Testing', function (yargs) {
+          return yargs
+            .coerce('age', function (arg) {
+              return parseInt(arg, 10) * 86400000
+            })
+            .coerce('dates', function (arg) {
+              return arg.map(function (str) {
+                return new Date(str)
+              })
+            })
+        }, function (argv) {
+          age = argv.age
+          dates = argv.dates
+        })
+        .argv
+      expect(age).to.equal(2592000000)
+      expect(dates).to.have.lengthOf(2)
+      dates[0].toString().should.equal(new Date('2016-06-13').toString())
+      dates[1].toString().should.equal(new Date('2016-07-18').toString())
+    })
+
+    it('allows an error from positional arg to be handled by fail() handler', function () {
+      var msg
+      var err
+      yargs('throw ball')
+        .command('throw <msg>', false, function (yargs) {
+          return yargs
+            .coerce('msg', function (arg) {
+              throw new Error(arg)
+            })
+            .fail(function (m, e) {
+              msg = m
+              err = e
+            })
+        })
+        .argv
+      expect(msg).to.equal('ball')
+      expect(err).to.exist
+    })
+  })
 })
 
 describe('yargs context', function () {