-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Suggestion for unknown command and unknown option (#1590)
* 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
1 parent
2911e0e
commit 91ccfd5
Showing
9 changed files
with
449 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.