Repository: yargs/yargs
Pull request #1540 (2020-02-09 20:22:00)

feat: introduces strictCommands() subset of strict mode
I have met your demands @boneskull 

This seemed like a reasonable feature request, and I'm excited to see whether you get use out of it.

Code review appreciated.

---

~BREAKING CHANGE: if you use `.demandCommand()` or `.demandCommand(1)`, in conjunction
with defining commands, `.strictCommands()` is enabled automatically.~

Since the breaking changes to `yargs-parser` were for undocumented behavior (and 15.x.x was actually a regression with regards to the parsing of booleans, I'm going to try to release 15.x.x as a non-breaking change; if the candidate release seems to be breaking too many folks, we can revisit this decision).

bcoe: @boneskull @mleguen I think I'm going to hold off on the breaking change until the next major, based on conversations we had in the tooling chat.

Patch:
diff --git a/docs/api.md b/docs/api.md
index e6659dd..9f55c07 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1401,6 +1401,13 @@ corresponding description, will be reported as an error.
 
 Unrecognized commands will also be reported as errors.
 
+.strictCommands([enabled=true])
+---------
+
+Similar to `.strict()`, except that it only applies to unrecognized commands. A
+user can still provide arbitrary options, but unknown positional commands
+will raise an error.
+
 <a name="string"></a>.string(key)
 ------------
 
diff --git a/lib/validation.js b/lib/validation.js
index 35659a3..5af0c72 100644
--- a/lib/validation.js
+++ b/lib/validation.js
@@ -120,6 +120,32 @@ module.exports = function validation (yargs, usage, y18n) {
     }
   }
 
+  self.unknownCommands = function unknownCommands (argv, aliases, positionalMap) {
+    const commandKeys = yargs.getCommandInstance().getCommands()
+    const unknown = []
+    const currentContext = yargs.getContext()
+
+    if ((currentContext.commands.length > 0) || (commandKeys.length > 0)) {
+      argv._.slice(currentContext.commands.length).forEach((key) => {
+        if (commandKeys.indexOf(key) === -1) {
+          unknown.push(key)
+        }
+      })
+    }
+
+    if (unknown.length > 0) {
+      usage.fail(__n(
+        'Unknown command: %s',
+        'Unknown commands: %s',
+        unknown.length,
+        unknown.join(', ')
+      ))
+      return true
+    } else {
+      return false
+    }
+  }
+
   // check for a key that is not an alias, or for which every alias is new,
   // implying that it was invented by the parser, e.g., during camelization
   self.isValidAndSomeAliasIsNotNew = function isValidAndSomeAliasIsNotNew (key, aliases) {
diff --git a/yargs.js b/yargs.js
index d9a30ac..238900f 100644
--- a/yargs.js
+++ b/yargs.js
@@ -167,6 +167,7 @@ function Yargs (processArgs, cwd, parentRequire) {
     validation.freeze()
     command.freeze()
     frozen.strict = strict
+    frozen.strictCommands = strictCommands
     frozen.completionCommand = completionCommand
     frozen.output = output
     frozen.exitError = exitError
@@ -190,6 +191,7 @@ function Yargs (processArgs, cwd, parentRequire) {
     validation.unfreeze()
     command.unfreeze()
     strict = frozen.strict
+    strictCommands = frozen.strictCommands
     completionCommand = frozen.completionCommand
     parseFn = frozen.parseFn
     parseContext = frozen.parseContext
@@ -795,6 +797,14 @@ function Yargs (processArgs, cwd, parentRequire) {
   }
   self.getStrict = () => strict
 
+  let strictCommands = false
+  self.strictCommands = function (enabled) {
+    argsert('[boolean]', [enabled], arguments.length)
+    strictCommands = enabled !== false
+    return self
+  }
+  self.getStrictCommands = () => strictCommands
+
   let parserConfig = {}
   self.parserConfiguration = function parserConfiguration (config) {
     argsert('<object>', [config], arguments.length)
@@ -1219,7 +1229,13 @@ function Yargs (processArgs, cwd, parentRequire) {
     if (parseErrors) throw new YError(parseErrors.message)
     validation.nonOptionCount(argv)
     validation.requiredArguments(argv)
-    if (strict) validation.unknownArguments(argv, aliases, positionalMap)
+    let failedStrictCommands = false
+    if (strictCommands) {
+      failedStrictCommands = validation.unknownCommands(argv, aliases, positionalMap)
+    }
+    if (strict && !failedStrictCommands) {
+      validation.unknownArguments(argv, aliases, positionalMap)
+    }
     validation.customChecks(argv, aliases)
     validation.limitedChoices(argv)
     validation.implications(argv)


Test patch:
diff --git a/test/validation.js b/test/validation.js
index e9dc0d6..ebd7d73 100644
--- a/test/validation.js
+++ b/test/validation.js
@@ -939,4 +939,91 @@ describe('validation tests', () => {
         .parse()
     })
   })
+
+  describe('strictCommands', () => {
+    it('succeeds in parse if command is known', () => {
+      const parsed = yargs('foo -a 10')
+        .strictCommands()
+        .command('foo', 'foo command')
+        .parse()
+      parsed.a.should.equal(10)
+      parsed._.should.eql(['foo'])
+    })
+
+    it('succeeds in parse if top level and inner command are known', () => {
+      const parsed = yargs('foo bar --cool beans')
+        .strictCommands()
+        .command('foo', 'foo command', (yargs) => {
+          yargs.command('bar')
+        })
+        .parse()
+      parsed.cool.should.equal('beans')
+      parsed._.should.eql(['foo', 'bar'])
+    })
+
+    it('fails with error if command is unknown', (done) => {
+      yargs('blerg -a 10')
+        .strictCommands()
+        .command('foo', 'foo command')
+        .fail((msg) => {
+          msg.should.equal('Unknown command: blerg')
+          return done()
+        })
+        .parse()
+    })
+
+    it('fails with error if inner command is unknown', (done) => {
+      yargs('foo blarg --cool beans')
+        .strictCommands()
+        .command('foo', 'foo command', (yargs) => {
+          yargs.command('bar')
+        })
+        .fail((msg) => {
+          msg.should.equal('Unknown command: blarg')
+          return done()
+        })
+        .parse()
+    })
+
+    // TODO(bcoe): consider implementing this behvaior in the next major version of yargs:
+    //
+    // // for the special case of yargs.demandCommand() and yargs.demandCommand(1), if
+    // // yargs has been configured with commands, we automatically enable strictCommands.
+    // if (commandKeys.length && demandedCommands._ && demandedCommands._.min === 1 && demandedCommands._.max === Infinity) {
+    //   yargs.strictCommands()
+    // }
+    // it('enables strict commands if commands used in conjunction with demandCommand', (done) => {
+    //   yargs('blerg -a 10')
+    //     .demandCommand()
+    //     .command('foo', 'foo command')
+    //     .fail((msg) => {
+    //       msg.should.equal('Unknown command: blerg')
+    //       return done()
+    //     })
+    //     .parse()
+    // })
+
+    it('does not apply implicit strictCommands to inner commands', () => {
+      const parse = yargs('foo blarg --cool beans')
+        .demandCommand()
+        .command('foo', 'foo command', (yargs) => {
+          yargs.command('bar')
+        })
+        .parse()
+      parse.cool.should.equal('beans')
+      parse._.should.eql(['foo', 'blarg'])
+    })
+
+    it('allows strictCommands to be applied to inner commands', (done) => {
+      yargs('foo blarg')
+        .command('foo', 'foo command', (yargs) => {
+          yargs.command('bar').strictCommands()
+        })
+        .fail((msg) => {
+          msg.should.equal('Unknown command: blarg')
+          return done()
+        })
+        .parse()
+    })
+  })
 })