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

Suggestion for unknown command and unknown option #1590

Merged
merged 23 commits into from Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
45d1128
Proof of concept suggestion for unknown command
shadowspawn Aug 28, 2021
7644881
Leave length check to similarity test
shadowspawn Aug 28, 2021
23848bc
Fix JSDoc
shadowspawn Aug 28, 2021
eeaeae1
Add tests
shadowspawn Aug 28, 2021
ace3259
Fix import
shadowspawn Aug 28, 2021
28f66c7
Offer multiple suggestions
shadowspawn Aug 29, 2021
80c3966
Add search for similar option
shadowspawn Aug 29, 2021
84bde40
Add global options to suggestions
shadowspawn Aug 30, 2021
e2524ba
Show unknown (global) option rather than help
shadowspawn Aug 30, 2021
ad8874f
Add tests for help command and option suggestions
shadowspawn Aug 30, 2021
b139504
Fix option suggestions for subcommands, and first raft of tests for o…
shadowspawn Aug 30, 2021
388361e
Do not suggest hidden candidates. Remove duplicates.
shadowspawn Aug 31, 2021
511a33a
Tiny comment change
shadowspawn Aug 31, 2021
2cf0942
Add test for fixed behaviour, unknown option before subcommand
shadowspawn Aug 31, 2021
b3e75bd
Remove low value local variable
shadowspawn Aug 31, 2021
d44669f
Suppress output from test
shadowspawn Aug 31, 2021
2b0f899
Add showSuggestionAfterError
shadowspawn Aug 31, 2021
cb931b0
Fix arg for parse
shadowspawn Aug 31, 2021
30bd2d2
Suggestions off by default for now
shadowspawn Sep 3, 2021
1b151ee
Add to README
shadowspawn Sep 3, 2021
858aadd
Remove development trace statement
shadowspawn Sep 3, 2021
4529eb4
Describe scenario using same terms as error
shadowspawn Sep 3, 2021
fbed167
Add test that command:* listener blocks command suggestion
shadowspawn Sep 4, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions Readme.md
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
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
@@ -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
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
@@ -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
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');
});
});