From 45d1128447d1a540aa1193ae62e6909309736b07 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 28 Aug 2021 18:12:55 +1200 Subject: [PATCH 01/23] Proof of concept suggestion for unknown command --- lib/command.js | 20 +++++++++++- lib/distance.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 lib/distance.js diff --git a/lib/command.js b/lib/command.js index 4b44f4568..4b227ce1a 100644 --- a/lib/command.js +++ b/lib/command.js @@ -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 { findSimilar } = require('./distance'); // @ts-check @@ -1528,7 +1529,24 @@ Expecting one of '${allowedValues.join("', '")}'`); */ unknownCommand() { - const message = `error: unknown command '${this.args[0]}'`; + const unknownName = this.args[0]; + + let suggestion = ''; + // Prefer names to aliases. + const exactNames = []; + const aliasedNames = []; + this.commands.forEach((command) => { + if (command._name.length > 1) exactNames.push(command._name); + if (command._aliases) aliasedNames.push(...command._aliases); + }); + if (this._hasImplicitHelpCommand()) exactNames.push(this._helpCommandName); + const candidateNames = exactNames.concat(aliasedNames); + const similarCommand = findSimilar(unknownName, candidateNames); + if (similarCommand) { + suggestion = `\n(Did you mean ${similarCommand}?)`; + } + + const message = `error: unknown command '${unknownName}'${suggestion}`; this._displayError(1, 'commander.unknownCommand', message); }; diff --git a/lib/distance.js b/lib/distance.js new file mode 100644 index 000000000..b8ec05193 --- /dev/null +++ b/lib/distance.js @@ -0,0 +1,82 @@ +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. + const maxDistance = 2; + 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 best match from candidates. Take first of equal matches. + * + * @param {string} word + * @param {string[]} candidates + * @returns string | undefined + */ + +function findSimilar(word, candidates) { + if (!word || !candidates || word.length === 1) return undefined; + + let similar; + let bestSimilarity = 0; + // allow just 1 edit for short strings, 2 edits otherwise + let bestDistance = word.length <= 3 ? 1 : 2; + 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 (distance < bestDistance) { + // better edits + bestDistance = distance; + bestSimilarity = similarity; + similar = candidate; + } else if (distance === bestDistance && similarity > bestSimilarity) { + // better similarity + bestSimilarity = similarity; + similar = candidate; + } + }); + + return similar; +} + +exports.findSimilar = findSimilar; From 764488153ace3827f1d610dfb5b8c3a591171571 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 28 Aug 2021 18:50:46 +1200 Subject: [PATCH 02/23] Leave length check to similarity test --- lib/command.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/command.js b/lib/command.js index 4b227ce1a..6d49cb25f 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1536,7 +1536,7 @@ Expecting one of '${allowedValues.join("', '")}'`); const exactNames = []; const aliasedNames = []; this.commands.forEach((command) => { - if (command._name.length > 1) exactNames.push(command._name); + exactNames.push(command._name); if (command._aliases) aliasedNames.push(...command._aliases); }); if (this._hasImplicitHelpCommand()) exactNames.push(this._helpCommandName); From 23848bcf6f690c3e97e6a100668654bf0c99f825 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 28 Aug 2021 18:50:57 +1200 Subject: [PATCH 03/23] Fix JSDoc --- lib/distance.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/distance.js b/lib/distance.js index b8ec05193..fbb73c93a 100644 --- a/lib/distance.js +++ b/lib/distance.js @@ -48,7 +48,7 @@ function editDistance(a, b) { * * @param {string} word * @param {string[]} candidates - * @returns string | undefined + * @returns {string | undefined} */ function findSimilar(word, candidates) { From eeaeae13cb53a1a2c326f372c30c2051238c3637 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 28 Aug 2021 22:28:28 +1200 Subject: [PATCH 04/23] Add tests --- lib/command.js | 2 +- lib/{distance.js => findSimilar.js} | 0 tests/help.suggestion.test.js | 64 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) rename lib/{distance.js => findSimilar.js} (100%) create mode 100644 tests/help.suggestion.test.js diff --git a/lib/command.js b/lib/command.js index 6d49cb25f..751c81ec3 100644 --- a/lib/command.js +++ b/lib/command.js @@ -7,7 +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 { findSimilar } = require('./distance'); +const { findSimilar } = require('./findSimilar'); // @ts-check diff --git a/lib/distance.js b/lib/findSimilar.js similarity index 100% rename from lib/distance.js rename to lib/findSimilar.js diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js new file mode 100644 index 000000000..c9c3c1c4c --- /dev/null +++ b/tests/help.suggestion.test.js @@ -0,0 +1,64 @@ +const { Command } = require('../typings'); + +function getSuggestion(program, arg) { + let message = ''; + program.exitOverride(); + program.configureOutput({ + writeErr: (str) => { message = str; } + }); + try { + program.parse([arg], { from: 'user' }); + } catch (err) { + } + + const match = message.match(/Did you mean (.*)\?/); + return match ? match[1] : null; +}; + +test.each([ + ['yyy', ['zzz'], null, 'none similar'], + ['a', ['ab'], null, 'no suggestions for single char'], + ['ab', ['a'], null, 'no suggestions of single char'], + ['at', ['cat'], 'cat', '1 insertion'], + ['cat', ['at'], 'at', '1 deletion'], + ['bat', ['cat'], 'cat', '1 substitution'], + ['act', ['cat'], 'cat', '1 transposition'], + ['cxx', ['cat'], null, '2 edits away and short string'], + ['caxx', ['cart'], 'cart', '2 edits away and longer string'], + ['1234567xxx', ['1234567890'], null, '3 edits away is too far'], + ['xat', ['rat', 'cat', 'bat'], 'rat', 'first of similar possibles'], + ['cart', ['camb', 'cant', 'bard'], 'cant', 'closest of different edit distances'], + ['carte', ['crate', 'carted'], 'carted', 'most similar of same edit distances (longer)'] +])('when cli of %s and commands %j then suggest %s because %s', (arg, commandNames, expected) => { + const program = new Command(); + commandNames.forEach(name => { program.command(name); }); + const suggestion = getSuggestion(program, arg); + expect(suggestion).toBe(expected); +}); + +test('when similar single alias then suggest alias', () => { + const program = new Command(); + program.command('xyz') + .alias('car'); + const suggestion = getSuggestion(program, 'bar'); + expect(suggestion).toBe('car'); +}); + +test('when similar hidden alias then suggest alias', () => { + const program = new Command(); + program.command('xyz') + .alias('visible') + .alias('silent'); + const suggestion = getSuggestion(program, 'slent'); + expect(suggestion).toBe('silent'); +}); + +test('when similar command and alias then suggest command', () => { + const program = new Command(); + program.command('aaaaa') + .alias('cat'); + program.command('bat'); + program.command('ccccc'); + const suggestion = getSuggestion(program, 'mat'); + expect(suggestion).toBe('bat'); +}); From ace325903f52244d3a4d0d949f5c29a2230ff7fc Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 28 Aug 2021 22:36:29 +1200 Subject: [PATCH 05/23] Fix import --- lib/findSimilar.js | 8 ++++---- tests/help.suggestion.test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/findSimilar.js b/lib/findSimilar.js index fbb73c93a..fc57acefa 100644 --- a/lib/findSimilar.js +++ b/lib/findSimilar.js @@ -48,13 +48,13 @@ function editDistance(a, b) { * * @param {string} word * @param {string[]} candidates - * @returns {string | undefined} + * @returns {string | null} */ function findSimilar(word, candidates) { - if (!word || !candidates || word.length === 1) return undefined; + if (!word || !candidates || word.length <= 1) return null; - let similar; + let similar = null; let bestSimilarity = 0; // allow just 1 edit for short strings, 2 edits otherwise let bestDistance = word.length <= 3 ? 1 : 2; @@ -65,7 +65,7 @@ function findSimilar(word, candidates) { const length = Math.max(word.length, candidate.length); const similarity = (length - distance) / length; if (distance < bestDistance) { - // better edits + // better edit distance bestDistance = distance; bestSimilarity = similarity; similar = candidate; diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index c9c3c1c4c..ed04c267c 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -1,4 +1,4 @@ -const { Command } = require('../typings'); +const { Command } = require('../'); function getSuggestion(program, arg) { let message = ''; From 28f66c739c294d18db54f22dd1902ce1bb6f3cb5 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sun, 29 Aug 2021 16:50:02 +1200 Subject: [PATCH 06/23] Offer multiple suggestions --- lib/command.js | 22 ++++++++++---------- lib/findSimilar.js | 38 ++++++++++++++++++----------------- tests/help.suggestion.test.js | 27 +++++++++++++------------ 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/lib/command.js b/lib/command.js index 751c81ec3..99dff806a 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1530,20 +1530,20 @@ Expecting one of '${allowedValues.join("', '")}'`); unknownCommand() { const unknownName = this.args[0]; - let suggestion = ''; - // Prefer names to aliases. - const exactNames = []; - const aliasedNames = []; + + const candidateNames = []; this.commands.forEach((command) => { - exactNames.push(command._name); - if (command._aliases) aliasedNames.push(...command._aliases); + candidateNames.push(command._name); + const alias = command.alias(); // Just visible alias + if (alias) candidateNames.push(alias); }); - if (this._hasImplicitHelpCommand()) exactNames.push(this._helpCommandName); - const candidateNames = exactNames.concat(aliasedNames); - const similarCommand = findSimilar(unknownName, candidateNames); - if (similarCommand) { - suggestion = `\n(Did you mean ${similarCommand}?)`; + if (this._hasImplicitHelpCommand()) candidateNames.push(this._helpCommandName); + const similarNames = findSimilar(unknownName, candidateNames); + if (similarNames.length > 1) { + suggestion = `\n(Did you mean one of ${similarNames.join(', ')}?)`; + } else if (similarNames.length === 1) { + suggestion = `\n(Did you mean ${similarNames[0]}?)`; } const message = `error: unknown command '${unknownName}'${suggestion}`; diff --git a/lib/findSimilar.js b/lib/findSimilar.js index fc57acefa..15988645f 100644 --- a/lib/findSimilar.js +++ b/lib/findSimilar.js @@ -1,10 +1,11 @@ +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. - const maxDistance = 2; if (Math.abs(a.length - b.length) > maxDistance) return Math.max(a.length, b.length); // distance between prefix substrings of a and b @@ -44,38 +45,39 @@ function editDistance(a, b) { } /** - * Find best match from candidates. Take first of equal matches. + * Find close matches, restricted to same number of edits. * * @param {string} word * @param {string[]} candidates - * @returns {string | null} + * @returns {string[]} */ function findSimilar(word, candidates) { - if (!word || !candidates || word.length <= 1) return null; - - let similar = null; - let bestSimilarity = 0; - // allow just 1 edit for short strings, 2 edits otherwise - let bestDistance = word.length <= 3 ? 1 : 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 (distance < bestDistance) { - // better edit distance - bestDistance = distance; - bestSimilarity = similarity; - similar = candidate; - } else if (distance === bestDistance && similarity > bestSimilarity) { - // better similarity - bestSimilarity = similarity; - similar = candidate; + // console.error(distance, similarity, candidate); + if (similarity > minSimilarity) { + if (distance < bestDistance) { + // better edit distance, reset the standard + bestDistance = distance; + similar = [candidate]; + } else if (distance === bestDistance) { + similar.push(candidate); + } } }); + similar.sort((a, b) => { + return a.localeCompare(b); + }); + return similar; } diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index ed04c267c..3b3f1320a 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -11,24 +11,25 @@ function getSuggestion(program, arg) { } catch (err) { } - const match = message.match(/Did you mean (.*)\?/); - return match ? match[1] : null; + const match = message.match(/Did you mean (one of )?(.*)\?/); + return match ? match[2] : null; }; test.each([ ['yyy', ['zzz'], null, 'none similar'], - ['a', ['ab'], null, 'no suggestions for single char'], - ['ab', ['a'], null, 'no suggestions of single char'], + ['a', ['b'], null, 'one edit away but not similar'], + ['a', ['ab'], 'ab', 'one edit away'], + ['ab', ['a'], null, 'one edit away'], ['at', ['cat'], 'cat', '1 insertion'], ['cat', ['at'], 'at', '1 deletion'], ['bat', ['cat'], 'cat', '1 substitution'], ['act', ['cat'], 'cat', '1 transposition'], ['cxx', ['cat'], null, '2 edits away and short string'], ['caxx', ['cart'], 'cart', '2 edits away and longer string'], - ['1234567xxx', ['1234567890'], null, '3 edits away is too far'], - ['xat', ['rat', 'cat', 'bat'], 'rat', 'first of similar possibles'], - ['cart', ['camb', 'cant', 'bard'], 'cant', 'closest of different edit distances'], - ['carte', ['crate', 'carted'], 'carted', 'most similar of same edit distances (longer)'] + ['1234567', ['1234567890'], '1234567890', '3 edits away is similar for long string'], + ['123456', ['1234567890'], null, '4 edits is too far'], + ['xat', ['rat', 'cat', 'bat'], 'bat, cat, rat', 'sorted possibles'], + ['cart', ['camb', 'cant', 'bard'], 'cant', 'only closest of different edit distances'] ])('when cli of %s and commands %j then suggest %s because %s', (arg, commandNames, expected) => { const program = new Command(); commandNames.forEach(name => { program.command(name); }); @@ -36,7 +37,7 @@ test.each([ expect(suggestion).toBe(expected); }); -test('when similar single alias then suggest alias', () => { +test('when similar alias then suggest alias', () => { const program = new Command(); program.command('xyz') .alias('car'); @@ -44,21 +45,21 @@ test('when similar single alias then suggest alias', () => { expect(suggestion).toBe('car'); }); -test('when similar hidden alias then suggest alias', () => { +test('when similar hidden alias then not suggested', () => { const program = new Command(); program.command('xyz') .alias('visible') .alias('silent'); const suggestion = getSuggestion(program, 'slent'); - expect(suggestion).toBe('silent'); + expect(suggestion).toBe(null); }); -test('when similar command and alias then suggest command', () => { +test('when similar command and alias then suggest both', () => { const program = new Command(); program.command('aaaaa') .alias('cat'); program.command('bat'); program.command('ccccc'); const suggestion = getSuggestion(program, 'mat'); - expect(suggestion).toBe('bat'); + expect(suggestion).toBe('bat, cat'); }); From 80c396605222b7bec38500c48210c79b6bcbbe6f Mon Sep 17 00:00:00 2001 From: John Gee Date: Sun, 29 Aug 2021 18:08:21 +1200 Subject: [PATCH 07/23] Add search for similar option --- lib/command.js | 23 +++++++++++++++------- lib/{findSimilar.js => suggestSimilar.js} | 24 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) rename lib/{findSimilar.js => suggestSimilar.js} (79%) diff --git a/lib/command.js b/lib/command.js index 99dff806a..f667ae2e0 100644 --- a/lib/command.js +++ b/lib/command.js @@ -7,7 +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 { findSimilar } = require('./findSimilar'); +const { suggestSimilar } = require('./suggestSimilar'); // @ts-check @@ -1501,7 +1501,19 @@ Expecting one of '${allowedValues.join("', '")}'`); unknownOption(flag) { if (this._allowUnknownOption) return; - const message = `error: unknown option '${flag}'`; + let suggestion = ''; + + if (flag.startsWith('--')) { + const candidateFlags = this.options + .filter(option => option.long) + .map(option => option.long); + if (this._hasHelpOption && this._helpLongFlag) candidateFlags.push(this._helpLongFlag); + if (candidateFlags.length > 0) { + suggestion = suggestSimilar(flag, candidateFlags); + } + } + + const message = `error: unknown option '${flag}'${suggestion}`; this._displayError(1, 'commander.unknownOption', message); }; @@ -1539,11 +1551,8 @@ Expecting one of '${allowedValues.join("', '")}'`); if (alias) candidateNames.push(alias); }); if (this._hasImplicitHelpCommand()) candidateNames.push(this._helpCommandName); - const similarNames = findSimilar(unknownName, candidateNames); - if (similarNames.length > 1) { - suggestion = `\n(Did you mean one of ${similarNames.join(', ')}?)`; - } else if (similarNames.length === 1) { - suggestion = `\n(Did you mean ${similarNames[0]}?)`; + if (candidateNames.length > 0) { + suggestion = suggestSimilar(unknownName, candidateNames); } const message = `error: unknown command '${unknownName}'${suggestion}`; diff --git a/lib/findSimilar.js b/lib/suggestSimilar.js similarity index 79% rename from lib/findSimilar.js rename to lib/suggestSimilar.js index 15988645f..9283bc1a0 100644 --- a/lib/findSimilar.js +++ b/lib/suggestSimilar.js @@ -49,10 +49,16 @@ function editDistance(a, b) { * * @param {string} word * @param {string[]} candidates - * @returns {string[]} + * @returns {string} */ -function findSimilar(word, candidates) { +function suggestSimilar(word, 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; @@ -77,8 +83,18 @@ function findSimilar(word, candidates) { similar.sort((a, b) => { return 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 similar; + return ''; } -exports.findSimilar = findSimilar; +exports.suggestSimilar = suggestSimilar; From 84bde40de872e0b15d75cf387e5d01464e8a98c6 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 30 Aug 2021 20:04:28 +1200 Subject: [PATCH 08/23] Add global options to suggestions --- lib/command.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/command.js b/lib/command.js index f667ae2e0..dd8d6e360 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1504,13 +1504,18 @@ Expecting one of '${allowedValues.join("', '")}'`); let suggestion = ''; if (flag.startsWith('--')) { - const candidateFlags = this.options - .filter(option => option.long) - .map(option => option.long); - if (this._hasHelpOption && this._helpLongFlag) candidateFlags.push(this._helpLongFlag); - if (candidateFlags.length > 0) { - suggestion = suggestSimilar(flag, candidateFlags); - } + // Looping to pick up the global options too + let command = this; + do { + const candidateFlags = command.options + .filter(option => option.long) + .map(option => option.long); + if (command._hasHelpOption && command._helpLongFlag) candidateFlags.push(command._helpLongFlag); + if (candidateFlags.length > 0) { + suggestion = suggestSimilar(flag, candidateFlags); + } + command = command.parent; + } while (command && !command._enablePositionalOptions); } const message = `error: unknown option '${flag}'${suggestion}`; From e2524baa9163537fcb90a9abd15ed5fcb92d2fac Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 30 Aug 2021 20:06:41 +1200 Subject: [PATCH 09/23] Show unknown (global) option rather than help --- lib/command.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/command.js b/lib/command.js index dd8d6e360..38de957be 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1214,6 +1214,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 { From ad8874fc80b70e7332bf59ce2fa97c173e484663 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 30 Aug 2021 22:19:23 +1200 Subject: [PATCH 10/23] Add tests for help command and option suggestions --- tests/help.suggestion.test.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index 3b3f1320a..1f5515864 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -63,3 +63,38 @@ test('when similar command and alias then suggest both', () => { const suggestion = getSuggestion(program, 'mat'); expect(suggestion).toBe('bat, cat'); }); + +test('when implicit help command then help is candidate for suggestion', () => { + const program = new Command(); + program.command('sub'); + const suggestion = getSuggestion(program, 'hepl'); + expect(suggestion).toBe('help'); +}); + +test('when help command disabled then not candidate for suggestion', () => { + const program = new Command(); + program.addHelpCommand(false); + program.command('sub'); + const suggestion = getSuggestion(program, 'hepl'); + expect(suggestion).toBe(null); +}); + +test('when default help option then --help is candidate for suggestion', () => { + const program = new Command(); + const suggestion = getSuggestion(program, '--hepl'); + expect(suggestion).toBe('--help'); +}); + +test('when custom help option then --custom-help is candidate for suggestion', () => { + const program = new Command(); + program.helpOption('-H, --custom-help'); + const suggestion = getSuggestion(program, '--custom-hepl'); + expect(suggestion).toBe('--custom-help'); +}); + +test('when help option disabled then not candidate for suggestion', () => { + const program = new Command(); + program.helpOption(false); + const suggestion = getSuggestion(program, '--hepl'); + expect(suggestion).toBe(null); +}); From b139504327780f8627a40db2012c91bb43c656c8 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 30 Aug 2021 23:29:45 +1200 Subject: [PATCH 11/23] Fix option suggestions for subcommands, and first raft of tests for option suggestions --- lib/command.js | 12 ++++-- tests/help.suggestion.test.js | 79 ++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/lib/command.js b/lib/command.js index 38de957be..ff6e190ab 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1506,17 +1506,21 @@ Expecting one of '${allowedValues.join("', '")}'`); if (flag.startsWith('--')) { // Looping to pick up the global options too + const candidateFlags = []; + if (this._hasHelpOption && this._helpLongFlag) candidateFlags.push(this._helpLongFlag); let command = this; do { - const candidateFlags = command.options + const moreFlags = command.options .filter(option => option.long) .map(option => option.long); - if (command._hasHelpOption && command._helpLongFlag) candidateFlags.push(command._helpLongFlag); - if (candidateFlags.length > 0) { - suggestion = suggestSimilar(flag, candidateFlags); + if (moreFlags.length > 0) { + candidateFlags.push(...moreFlags); } command = command.parent; } while (command && !command._enablePositionalOptions); + if (candidateFlags.length > 0) { + suggestion = suggestSimilar(flag, candidateFlags); + } } const message = `error: unknown option '${flag}'${suggestion}`; diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index 1f5515864..fae0cbee7 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -7,7 +7,10 @@ function getSuggestion(program, arg) { writeErr: (str) => { message = str; } }); try { - program.parse([arg], { from: 'user' }); + // Passing in an array for a few of the tests. + const args = Array.isArray(arg) ? arg : [arg]; + expect(Array.isArray(args)).toBeTruthy(); + program.parse(args, { from: 'user' }); } catch (err) { } @@ -98,3 +101,77 @@ test('when help option disabled then not candidate for suggestion', () => { const suggestion = getSuggestion(program, '--hepl'); expect(suggestion).toBe(null); }); + +// Easy to just run same tests as for commands with cut and paste! +// Note: length calculations disregard the leading -- +test.each([ + ['--yyy', ['--zzz'], null, 'none similar'], + ['--a', ['--b'], null, 'one edit away but not similar'], + ['--a', ['--ab'], '--ab', 'one edit away'], + ['--ab', ['--a'], null, 'one edit away'], + ['--at', ['--cat'], '--cat', '1 insertion'], + ['--cat', ['--at'], '--at', '1 deletion'], + ['--bat', ['--cat'], '--cat', '1 substitution'], + ['--act', ['--cat'], '--cat', '1 transposition'], + ['--cxx', ['--cat'], null, '2 edits away and short string'], + ['--caxx', ['--cart'], '--cart', '2 edits away and longer string'], + ['--1234567', ['--1234567890'], '--1234567890', '3 edits away is similar for long string'], + ['--123456', ['--1234567890'], null, '4 edits is too far'], + ['--xat', ['--rat', '--cat', '--bat'], '--bat, --cat, --rat', 'sorted possibles'], + ['--cart', ['--camb', '--cant', '--bard'], '--cant', 'only closest of different edit distances'] +])('when cli of %s and options %j then suggest %s because %s', (arg, commandNames, expected) => { + const program = new Command(); + commandNames.forEach(name => { program.option(name); }); + const suggestion = getSuggestion(program, arg); + expect(suggestion).toBe(expected); +}); + +test('when no options then no suggestion', () => { + // Checking nothing blows up as much as no suggestion! + const program = new Command(); + program + .exitOverride() + .helpOption(false); + const suggestion = getSuggestion(program, '--option'); + expect(suggestion).toBe(null); +}); + +test('when subcommand option then candidate for subcommand option suggestion', () => { + const program = new Command(); + program.exitOverride(); + program.command('sub') + .option('-l,--local'); + const suggestion = getSuggestion(program, ['sub', '--loca']); + expect(suggestion).toBe('--local'); +}); + +test('when global option then candidate for subcommand option suggestion', () => { + const program = new Command(); + program.exitOverride(); + program.option('-g, --global'); + program.command('sub'); + const suggestion = getSuggestion(program, ['sub', '--globla']); + expect(suggestion).toBe('--global'); +}); + +test('when global option but positionalOptions then not candidate for subcommand suggestion', () => { + const program = new Command(); + program + .exitOverride() + .enablePositionalOptions(); + program.option('-g, --global'); + program.command('sub'); + const suggestion = getSuggestion(program, ['sub', '--globla']); + expect(suggestion).toBe(null); +}); + +test('when global and local options then both candidates', () => { + const program = new Command(); + program + .exitOverride(); + program.option('--cat'); + program.command('sub') + .option('--rat'); + const suggestion = getSuggestion(program, ['sub', '--bat']); + expect(suggestion).toBe('--cat, --rat'); +}); From 388361ea5a7a8d7f5d7acd048a0c0728bf2714f7 Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 17:27:34 +1200 Subject: [PATCH 12/23] Do not suggest hidden candidates. Remove duplicates. --- lib/command.js | 22 +++++++--------------- lib/suggestSimilar.js | 11 ++++++----- tests/help.suggestion.test.js | 24 +++++++++++++++++++++++- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/lib/command.js b/lib/command.js index ff6e190ab..70e093d4f 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1506,21 +1506,16 @@ Expecting one of '${allowedValues.join("', '")}'`); if (flag.startsWith('--')) { // Looping to pick up the global options too - const candidateFlags = []; - if (this._hasHelpOption && this._helpLongFlag) candidateFlags.push(this._helpLongFlag); + let candidateFlags = []; let command = this; do { - const moreFlags = command.options + const moreFlags = command.createHelp().visibleOptions(command) .filter(option => option.long) .map(option => option.long); - if (moreFlags.length > 0) { - candidateFlags.push(...moreFlags); - } + candidateFlags = candidateFlags.concat(moreFlags); command = command.parent; } while (command && !command._enablePositionalOptions); - if (candidateFlags.length > 0) { - suggestion = suggestSimilar(flag, candidateFlags); - } + suggestion = suggestSimilar(flag, candidateFlags); } const message = `error: unknown option '${flag}'${suggestion}`; @@ -1555,15 +1550,12 @@ Expecting one of '${allowedValues.join("', '")}'`); let suggestion = ''; const candidateNames = []; - this.commands.forEach((command) => { - candidateNames.push(command._name); + this.createHelp().visibleCommands(this).forEach((command) => { + candidateNames.push(command.name()); const alias = command.alias(); // Just visible alias if (alias) candidateNames.push(alias); }); - if (this._hasImplicitHelpCommand()) candidateNames.push(this._helpCommandName); - if (candidateNames.length > 0) { - suggestion = suggestSimilar(unknownName, candidateNames); - } + suggestion = suggestSimilar(unknownName, candidateNames); const message = `error: unknown command '${unknownName}'${suggestion}`; this._displayError(1, 'commander.unknownCommand', message); diff --git a/lib/suggestSimilar.js b/lib/suggestSimilar.js index 9283bc1a0..f77aa4d30 100644 --- a/lib/suggestSimilar.js +++ b/lib/suggestSimilar.js @@ -53,6 +53,10 @@ function editDistance(a, b) { */ 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); @@ -71,7 +75,7 @@ function suggestSimilar(word, candidates) { // console.error(distance, similarity, candidate); if (similarity > minSimilarity) { if (distance < bestDistance) { - // better edit distance, reset the standard + // better edit distance, throw away previous worse matches bestDistance = distance; similar = [candidate]; } else if (distance === bestDistance) { @@ -80,9 +84,7 @@ function suggestSimilar(word, candidates) { } }); - similar.sort((a, b) => { - return a.localeCompare(b); - }); + similar.sort((a, b) => a.localeCompare(b)); if (searchingOptions) { similar = similar.map(candidate => `--${candidate}`); } @@ -93,7 +95,6 @@ function suggestSimilar(word, candidates) { if (similar.length === 1) { return `\n(Did you mean ${similar[0]}?)`; } - return ''; } diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index fae0cbee7..092cad5cf 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -1,4 +1,4 @@ -const { Command } = require('../'); +const { Command, Option } = require('../'); function getSuggestion(program, arg) { let message = ''; @@ -175,3 +175,25 @@ test('when global and local options then both candidates', () => { const suggestion = getSuggestion(program, ['sub', '--bat']); expect(suggestion).toBe('--cat, --rat'); }); + +test('when command hidden then not suggested as candidate', () => { + const program = new Command(); + program.command('secret', { hidden: true }); + const suggestion = getSuggestion(program, 'secrt'); + expect(suggestion).toBe(null); +}); + +test('when option hidden then not suggested as candidate', () => { + const program = new Command(); + program.addOption(new Option('--secret').hideHelp()); + const suggestion = getSuggestion(program, '--secrt'); + expect(suggestion).toBe(null); +}); + +test('when may be duplicate identical candidates then only return one', () => { + const program = new Command(); + program.exitOverride(); + program.command('sub'); + const suggestion = getSuggestion(program, ['sub', '--hepl']); + expect(suggestion).toBe('--help'); +}); From 511a33a78d7f3de0e39c5bae33e3ada90fd91f8d Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 17:28:22 +1200 Subject: [PATCH 13/23] Tiny comment change --- lib/suggestSimilar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/suggestSimilar.js b/lib/suggestSimilar.js index f77aa4d30..a565b6c5d 100644 --- a/lib/suggestSimilar.js +++ b/lib/suggestSimilar.js @@ -54,7 +54,7 @@ function editDistance(a, b) { function suggestSimilar(word, candidates) { if (!candidates || candidates.length === 0) return ''; - // Remove possible duplicates + // remove possible duplicates candidates = Array.from(new Set(candidates)); const searchingOptions = word.startsWith('--'); From 2cf094217604c52f480ea3dcb401f56b85bb7b40 Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 17:51:33 +1200 Subject: [PATCH 14/23] Add test for fixed behaviour, unknown option before subcommand --- tests/command.unknownOption.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/command.unknownOption.test.js b/tests/command.unknownOption.test.js index 7ab72237d..0bd4261c9 100644 --- a/tests/command.unknownOption.test.js +++ b/tests/command.unknownOption.test.js @@ -96,3 +96,18 @@ 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'); +}); From b3e75bdac574a19cd4eda6a50ba87489657b75be Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 18:01:52 +1200 Subject: [PATCH 15/23] Remove low value local variable --- lib/command.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/command.js b/lib/command.js index 70e093d4f..91f7d9292 100644 --- a/lib/command.js +++ b/lib/command.js @@ -1552,8 +1552,8 @@ Expecting one of '${allowedValues.join("', '")}'`); const candidateNames = []; this.createHelp().visibleCommands(this).forEach((command) => { candidateNames.push(command.name()); - const alias = command.alias(); // Just visible alias - if (alias) candidateNames.push(alias); + // just visible alias + if (command.alias()) candidateNames.push(command.alias()); }); suggestion = suggestSimilar(unknownName, candidateNames); From d44669fef39e57b868e70ccead3b2ee44bb6a225 Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 18:26:00 +1200 Subject: [PATCH 16/23] Suppress output from test --- tests/command.unknownOption.test.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/command.unknownOption.test.js b/tests/command.unknownOption.test.js index 0bd4261c9..608f3f148 100644 --- a/tests/command.unknownOption.test.js +++ b/tests/command.unknownOption.test.js @@ -95,19 +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'); + 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'); + }); }); From 2b0f899b7627b62bfe746909a4337ed293333ccd Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 19:19:58 +1200 Subject: [PATCH 17/23] Add showSuggestionAfterError --- lib/command.js | 31 +++++++++--- tests/command.chain.test.js | 6 +++ .../command.showSuggestionAfterError.test.js | 50 +++++++++++++++++++ tests/help.suggestion.test.js | 25 +++++----- typings/index.d.ts | 7 ++- typings/index.test-d.ts | 4 ++ 6 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tests/command.showSuggestionAfterError.test.js diff --git a/lib/command.js b/lib/command.js index 91f7d9292..47c3bfd3f 100644 --- a/lib/command.js +++ b/lib/command.js @@ -52,6 +52,7 @@ class Command extends EventEmitter { this._lifeCycleHooks = {}; // a hash of arrays /** @type {boolean | string} */ this._showHelpAfterError = false; + this._showSuggestionAfterError = true; // see .configureOutput() for docs this._outputConfiguration = { @@ -100,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; } @@ -234,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. * @@ -1504,7 +1517,7 @@ Expecting one of '${allowedValues.join("', '")}'`); if (this._allowUnknownOption) return; let suggestion = ''; - if (flag.startsWith('--')) { + if (flag.startsWith('--') && this._showSuggestionAfterError) { // Looping to pick up the global options too let candidateFlags = []; let command = this; @@ -1549,13 +1562,15 @@ Expecting one of '${allowedValues.join("', '")}'`); const unknownName = this.args[0]; let suggestion = ''; - 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); + 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); diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index e80c2b292..09fc6e5a3 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -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(); diff --git a/tests/command.showSuggestionAfterError.test.js b/tests/command.showSuggestionAfterError.test.js new file mode 100644 index 000000000..76f379355 --- /dev/null +++ b/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); +}); diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index 092cad5cf..7c9fdbd18 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -1,15 +1,23 @@ const { Command, Option } = require('../'); +// Note: setting up shared command configuration in getSuggestion, +// and looking for possible subcommand 'sub'. + function getSuggestion(program, arg) { let message = ''; - program.exitOverride(); - program.configureOutput({ - writeErr: (str) => { message = str; } - }); + program + .showSuggestionAfterError() // make sure on + .exitOverride() + .configureOutput({ + writeErr: (str) => { message = str; } + }); + // Do the same setup for subcommand. + const sub = program._findCommand('sub'); + if (sub) sub.copyInheritedSettings(program); + try { // Passing in an array for a few of the tests. const args = Array.isArray(arg) ? arg : [arg]; - expect(Array.isArray(args)).toBeTruthy(); program.parse(args, { from: 'user' }); } catch (err) { } @@ -130,7 +138,6 @@ test('when no options then no suggestion', () => { // Checking nothing blows up as much as no suggestion! const program = new Command(); program - .exitOverride() .helpOption(false); const suggestion = getSuggestion(program, '--option'); expect(suggestion).toBe(null); @@ -138,7 +145,6 @@ test('when no options then no suggestion', () => { test('when subcommand option then candidate for subcommand option suggestion', () => { const program = new Command(); - program.exitOverride(); program.command('sub') .option('-l,--local'); const suggestion = getSuggestion(program, ['sub', '--loca']); @@ -147,7 +153,6 @@ test('when subcommand option then candidate for subcommand option suggestion', ( test('when global option then candidate for subcommand option suggestion', () => { const program = new Command(); - program.exitOverride(); program.option('-g, --global'); program.command('sub'); const suggestion = getSuggestion(program, ['sub', '--globla']); @@ -157,7 +162,6 @@ test('when global option then candidate for subcommand option suggestion', () => test('when global option but positionalOptions then not candidate for subcommand suggestion', () => { const program = new Command(); program - .exitOverride() .enablePositionalOptions(); program.option('-g, --global'); program.command('sub'); @@ -167,8 +171,6 @@ test('when global option but positionalOptions then not candidate for subcommand test('when global and local options then both candidates', () => { const program = new Command(); - program - .exitOverride(); program.option('--cat'); program.command('sub') .option('--rat'); @@ -192,7 +194,6 @@ test('when option hidden then not suggested as candidate', () => { test('when may be duplicate identical candidates then only return one', () => { const program = new Command(); - program.exitOverride(); program.command('sub'); const suggestion = getSuggestion(program, ['sub', '--hepl']); expect(suggestion).toBe('--help'); diff --git a/typings/index.d.ts b/typings/index.d.ts index 725764b6a..43eeae563 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -412,7 +412,7 @@ export class Command { * * (Used internally when adding a command using `.command()` so subcommands inherit parent settings.) */ - copyInheritedSettings(sourceCommand: Command): this; + copyInheritedSettings(sourceCommand: Command): this; /** * Display the help or a custom message after an error occurs. @@ -420,6 +420,11 @@ export class Command { showHelpAfterError(displayHelp?: boolean | string): this; /** + * Display suggestion of similar commands for unknown commands, or options for unknown options. + */ + showSuggestionAfterError(displaySuggestion?: boolean): this; + + /** * Register callback `fn` for the command. * * @example diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index d37a0e386..ba1051092 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -293,6 +293,10 @@ expectType(program.showHelpAfterError()); expectType(program.showHelpAfterError(true)); expectType(program.showHelpAfterError('See --help')); +// showSuggestionAfterError +expectType(program.showSuggestionAfterError()); +expectType(program.showSuggestionAfterError(false)); + // configureOutput expectType(program.configureOutput({ })); expectType(program.configureOutput()); From cb931b001692cf67f4cc59d44b19fa5aa0f7fffd Mon Sep 17 00:00:00 2001 From: John Gee Date: Tue, 31 Aug 2021 19:27:02 +1200 Subject: [PATCH 18/23] Fix arg for parse --- tests/command.showSuggestionAfterError.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/command.showSuggestionAfterError.test.js b/tests/command.showSuggestionAfterError.test.js index 76f379355..2e596f408 100644 --- a/tests/command.showSuggestionAfterError.test.js +++ b/tests/command.showSuggestionAfterError.test.js @@ -9,7 +9,7 @@ function getSuggestion(program, arg) { }); try { - program.parse(arg, { from: 'user' }); + program.parse([arg], { from: 'user' }); } catch (err) { } From 30bd2d2eed061f09dc6ae1f39890e9a154dc9fd5 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 4 Sep 2021 10:14:42 +1200 Subject: [PATCH 19/23] Suggestions off by default for now --- lib/command.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/command.js b/lib/command.js index 47c3bfd3f..184bc0b79 100644 --- a/lib/command.js +++ b/lib/command.js @@ -52,7 +52,7 @@ class Command extends EventEmitter { this._lifeCycleHooks = {}; // a hash of arrays /** @type {boolean | string} */ this._showHelpAfterError = false; - this._showSuggestionAfterError = true; + this._showSuggestionAfterError = false; // see .configureOutput() for docs this._outputConfiguration = { From 1b151eeb81c766562a9d1a6634e35d636c6b3c53 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 4 Sep 2021 10:15:07 +1200 Subject: [PATCH 20/23] Add to README --- Readme.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Readme.md b/Readme.md index 6e84460ce..24a324be6 100644 --- a/Readme.md +++ b/Readme.md @@ -692,6 +692,18 @@ error: unknown option '--unknown' (add --help for additional information) ``` +You can also show suggestions after an error for an unrecognised 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. From 858aadd6392c018c4b5cadd40ac92a6f8ae5ab08 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 4 Sep 2021 10:21:50 +1200 Subject: [PATCH 21/23] Remove development trace statement --- lib/suggestSimilar.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/suggestSimilar.js b/lib/suggestSimilar.js index a565b6c5d..9a4066c71 100644 --- a/lib/suggestSimilar.js +++ b/lib/suggestSimilar.js @@ -72,7 +72,6 @@ function suggestSimilar(word, candidates) { const distance = editDistance(word, candidate); const length = Math.max(word.length, candidate.length); const similarity = (length - distance) / length; - // console.error(distance, similarity, candidate); if (similarity > minSimilarity) { if (distance < bestDistance) { // better edit distance, throw away previous worse matches From 4529eb4bd92a302bb5135cc34a02da67686cc40c Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 4 Sep 2021 10:56:53 +1200 Subject: [PATCH 22/23] Describe scenario using same terms as error --- Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 24a324be6..a43dfc058 100644 --- a/Readme.md +++ b/Readme.md @@ -692,7 +692,7 @@ error: unknown option '--unknown' (add --help for additional information) ``` -You can also show suggestions after an error for an unrecognised command or option. +You can also show suggestions after an error for an unknown command or option. ```js program.showSuggestionAfterError(); From fbed16758dc9498e1a7a23280dcb9905ebaf0154 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 4 Sep 2021 14:34:30 +1200 Subject: [PATCH 23/23] Add test that command:* listener blocks command suggestion --- tests/help.suggestion.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/help.suggestion.test.js b/tests/help.suggestion.test.js index 7c9fdbd18..711eb1411 100644 --- a/tests/help.suggestion.test.js +++ b/tests/help.suggestion.test.js @@ -110,6 +110,16 @@ test('when help option disabled then not candidate for suggestion', () => { expect(suggestion).toBe(null); }); +test('when command:* listener and unknown command then no suggestion', () => { + // Because one use for command:* was to handle unknown commands. + // Listener actually stops error being thrown, but we just care about affect on suggestion in this test. + const program = new Command(); + program.on('command:*', () => {}); + program.command('rat'); + const suggestion = getSuggestion(program, 'cat'); + expect(suggestion).toBe(null); +}); + // Easy to just run same tests as for commands with cut and paste! // Note: length calculations disregard the leading -- test.each([