Skip to content

Commit

Permalink
Suggestion for unknown command and unknown option (#1590)
Browse files Browse the repository at this point in the history
* Proof of concept suggestion for unknown command

* Leave length check to similarity test

* Fix JSDoc

* Add tests

* Fix import

* Offer multiple suggestions

* Add search for similar option

* Add global options to suggestions

* Show unknown (global) option rather than help

* Add tests for help command and option suggestions

* Fix option suggestions for subcommands, and first raft of tests for option suggestions

* Do not suggest hidden candidates. Remove duplicates.

* Tiny comment change

* Add test for fixed behaviour, unknown option before subcommand

* Remove low value local variable

* Suppress output from test

* Add showSuggestionAfterError

* Fix arg for parse

* Suggestions off by default for now

* Add to README

* Remove development trace statement

* Describe scenario using same terms as error

* Add test that command:* listener blocks command suggestion
  • Loading branch information
shadowspawn authored Sep 6, 2021
1 parent 2911e0e commit 91ccfd5
Show file tree
Hide file tree
Showing 9 changed files with 449 additions and 3 deletions.
12 changes: 12 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,18 @@ error: unknown option '--unknown'
(add --help for additional information)
```
You can also show suggestions after an error for an unknown command or option.
```js
program.showSuggestionAfterError();
```
```sh
$ pizza --hepl
error: unknown option '--hepl'
(Did you mean --help?)
```
### Display help from code
`.help()`: display help information and exit immediately. You can optionally pass `{ error: true }` to display on stderr and exit with an error status.
Expand Down
48 changes: 46 additions & 2 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { Argument, humanReadableArgName } = require('./argument.js');
const { CommanderError } = require('./error.js');
const { Help } = require('./help.js');
const { Option, splitOptionFlags } = require('./option.js');
const { suggestSimilar } = require('./suggestSimilar');

// @ts-check

Expand Down Expand Up @@ -51,6 +52,7 @@ class Command extends EventEmitter {
this._lifeCycleHooks = {}; // a hash of arrays
/** @type {boolean | string} */
this._showHelpAfterError = false;
this._showSuggestionAfterError = false;

// see .configureOutput() for docs
this._outputConfiguration = {
Expand Down Expand Up @@ -99,6 +101,7 @@ class Command extends EventEmitter {
this._allowExcessArguments = sourceCommand._allowExcessArguments;
this._enablePositionalOptions = sourceCommand._enablePositionalOptions;
this._showHelpAfterError = sourceCommand._showHelpAfterError;
this._showSuggestionAfterError = sourceCommand._showSuggestionAfterError;

return this;
}
Expand Down Expand Up @@ -233,6 +236,17 @@ class Command extends EventEmitter {
return this;
}

/**
* Display suggestion of similar commands for unknown commands, or options for unknown options.
*
* @param {boolean} [displaySuggestion]
* @return {Command} `this` command for chaining
*/
showSuggestionAfterError(displaySuggestion = true) {
this._showSuggestionAfterError = !!displaySuggestion;
return this;
}

/**
* Add a prepared subcommand.
*
Expand Down Expand Up @@ -1213,6 +1227,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
this._processArguments();
}
} else if (this.commands.length) {
checkForUnknownOptions();
// This command has subcommands and nothing hooked up at this level, so display help (and exit).
this.help({ error: true });
} else {
Expand Down Expand Up @@ -1500,7 +1515,23 @@ Expecting one of '${allowedValues.join("', '")}'`);

unknownOption(flag) {
if (this._allowUnknownOption) return;
const message = `error: unknown option '${flag}'`;
let suggestion = '';

if (flag.startsWith('--') && this._showSuggestionAfterError) {
// Looping to pick up the global options too
let candidateFlags = [];
let command = this;
do {
const moreFlags = command.createHelp().visibleOptions(command)
.filter(option => option.long)
.map(option => option.long);
candidateFlags = candidateFlags.concat(moreFlags);
command = command.parent;
} while (command && !command._enablePositionalOptions);
suggestion = suggestSimilar(flag, candidateFlags);
}

const message = `error: unknown option '${flag}'${suggestion}`;
this._displayError(1, 'commander.unknownOption', message);
};

Expand Down Expand Up @@ -1528,7 +1559,20 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/

unknownCommand() {
const message = `error: unknown command '${this.args[0]}'`;
const unknownName = this.args[0];
let suggestion = '';

if (this._showSuggestionAfterError) {
const candidateNames = [];
this.createHelp().visibleCommands(this).forEach((command) => {
candidateNames.push(command.name());
// just visible alias
if (command.alias()) candidateNames.push(command.alias());
});
suggestion = suggestSimilar(unknownName, candidateNames);
}

const message = `error: unknown command '${unknownName}'${suggestion}`;
this._displayError(1, 'commander.unknownCommand', message);
};

Expand Down
100 changes: 100 additions & 0 deletions lib/suggestSimilar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const maxDistance = 3;

function editDistance(a, b) {
// https://en.wikipedia.org/wiki/Damerau–Levenshtein_distance
// Calculating optimal string alignment distance, no substring is edited more than once.
// (Simple implementation.)

// Quick early exit, return worst case.
if (Math.abs(a.length - b.length) > maxDistance) return Math.max(a.length, b.length);

// distance between prefix substrings of a and b
const d = [];

// pure deletions turn a into empty string
for (let i = 0; i <= a.length; i++) {
d[i] = [i];
}
// pure insertions turn empty string into b
for (let j = 0; j <= b.length; j++) {
d[0][j] = j;
}

// fill matrix
for (let j = 1; j <= b.length; j++) {
for (let i = 1; i <= a.length; i++) {
let cost = 1;
if (a[i - 1] === b[j - 1]) {
cost = 0;
} else {
cost = 1;
}
d[i][j] = Math.min(
d[i - 1][j] + 1, // deletion
d[i][j - 1] + 1, // insertion
d[i - 1][j - 1] + cost // substitution
);
// transposition
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
}
}

return d[a.length][b.length];
}

/**
* Find close matches, restricted to same number of edits.
*
* @param {string} word
* @param {string[]} candidates
* @returns {string}
*/

function suggestSimilar(word, candidates) {
if (!candidates || candidates.length === 0) return '';
// remove possible duplicates
candidates = Array.from(new Set(candidates));

const searchingOptions = word.startsWith('--');
if (searchingOptions) {
word = word.slice(2);
candidates = candidates.map(candidate => candidate.slice(2));
}

let similar = [];
let bestDistance = maxDistance;
const minSimilarity = 0.4;
candidates.forEach((candidate) => {
if (candidate.length <= 1) return; // no one character guesses

const distance = editDistance(word, candidate);
const length = Math.max(word.length, candidate.length);
const similarity = (length - distance) / length;
if (similarity > minSimilarity) {
if (distance < bestDistance) {
// better edit distance, throw away previous worse matches
bestDistance = distance;
similar = [candidate];
} else if (distance === bestDistance) {
similar.push(candidate);
}
}
});

similar.sort((a, b) => a.localeCompare(b));
if (searchingOptions) {
similar = similar.map(candidate => `--${candidate}`);
}

if (similar.length > 1) {
return `\n(Did you mean one of ${similar.join(', ')}?)`;
}
if (similar.length === 1) {
return `\n(Did you mean ${similar[0]}?)`;
}
return '';
}

exports.suggestSimilar = suggestSimilar;
6 changes: 6 additions & 0 deletions tests/command.chain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ describe('Command methods that should return this for chaining', () => {
expect(result).toBe(program);
});

test('when call .showSuggestionAfterError() then returns this', () => {
const program = new Command();
const result = program.showSuggestionAfterError();
expect(result).toBe(program);
});

test('when call .copyInheritedSettings() then returns this', () => {
const program = new Command();
const cmd = new Command();
Expand Down
50 changes: 50 additions & 0 deletions tests/command.showSuggestionAfterError.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const { Command } = require('../');

function getSuggestion(program, arg) {
let message = '';
program
.exitOverride()
.configureOutput({
writeErr: (str) => { message = str; }
});

try {
program.parse([arg], { from: 'user' });
} catch (err) {
}

const match = message.match(/Did you mean (one of )?(.*)\?/);
return match ? match[2] : null;
};

test('when unknown command and showSuggestionAfterError() then show suggestion', () => {
const program = new Command();
program.showSuggestionAfterError();
program.command('example');
const suggestion = getSuggestion(program, 'exampel');
expect(suggestion).toBe('example');
});

test('when unknown command and showSuggestionAfterError(false) then do not show suggestion', () => {
const program = new Command();
program.showSuggestionAfterError(false);
program.command('example');
const suggestion = getSuggestion(program, 'exampel');
expect(suggestion).toBe(null);
});

test('when unknown option and showSuggestionAfterError() then show suggestion', () => {
const program = new Command();
program.showSuggestionAfterError();
program.option('--example');
const suggestion = getSuggestion(program, '--exampel');
expect(suggestion).toBe('--example');
});

test('when unknown option and showSuggestionAfterError(false) then do not show suggestion', () => {
const program = new Command();
program.showSuggestionAfterError(false);
program.option('--example');
const suggestion = getSuggestion(program, '--exampel');
expect(suggestion).toBe(null);
});
15 changes: 15 additions & 0 deletions tests/command.unknownOption.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,19 @@ describe('unknownOption', () => {
}
expect(caughtErr.code).toBe('commander.unknownOption');
});

test('when specify unknown global option before subcommand then error', () => {
const program = new commander.Command();
program
.exitOverride();
program.command('sub');

let caughtErr;
try {
program.parse(['--NONSENSE', 'sub'], { from: 'user' });
} catch (err) {
caughtErr = err;
}
expect(caughtErr.code).toBe('commander.unknownOption');
});
});
Loading

0 comments on commit 91ccfd5

Please sign in to comment.