Skip to content
This repository was archived by the owner on Oct 17, 2024. It is now read-only.

Commit 0329844

Browse files
authored
show similar commands with an edit distance of 2 or less (#206)
Fixes #201 Uses the Optimal string alignment distance algorithm to compute edit distance, which allows for additions, removals, substitutions, and swaps - all as 1 cost operations. Suggests any commands within an edit distance of <=2 by default, which is configurable. You can set it to 0 to disable the feature.
1 parent 3315d53 commit 0329844

File tree

5 files changed

+252
-5
lines changed

5 files changed

+252
-5
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 2.2.0
2+
3+
* Suggest similar commands if an unknown command is encountered, when using the
4+
`CommandRunner`.
5+
* The max edit distance for suggestions defaults to 2, but can be configured
6+
using the `suggestionDistanceLimit` parameter on the constructor. You can
7+
set it to `0` to disable the feature.
8+
19
## 2.1.1
210

311
* Fix a bug with `mandatory` options which caused a null assertion failure when

lib/command_runner.dart

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@ class CommandRunner<T> {
7777
ArgParser get argParser => _argParser;
7878
final ArgParser _argParser;
7979

80-
CommandRunner(this.executableName, this.description, {int? usageLineLength})
80+
/// The maximum edit distance allowed when suggesting possible intended
81+
/// commands.
82+
///
83+
/// Set to `0` in order to disable suggestions, defaults to `2`.
84+
final int suggestionDistanceLimit;
85+
86+
CommandRunner(this.executableName, this.description,
87+
{int? usageLineLength, this.suggestionDistanceLimit = 2})
8188
: _argParser = ArgParser(usageLineLength: usageLineLength) {
8289
argParser.addFlag('help',
8390
abbr: 'h', negatable: false, help: 'Print this usage information.');
@@ -158,13 +165,19 @@ class CommandRunner<T> {
158165

159166
command.usageException('Missing subcommand for "$commandString".');
160167
} else {
168+
var requested = argResults.rest[0];
169+
170+
// Build up a help message containing similar commands, if found.
171+
var similarCommands =
172+
_similarCommandsText(requested, commands.values);
173+
161174
if (command == null) {
162175
usageException(
163-
'Could not find a command named "${argResults.rest[0]}".');
176+
'Could not find a command named "$requested".$similarCommands');
164177
}
165178

166179
command.usageException('Could not find a subcommand named '
167-
'"${argResults.rest[0]}" for "$commandString".');
180+
'"$requested" for "$commandString".$similarCommands');
168181
}
169182
}
170183

@@ -196,6 +209,34 @@ class CommandRunner<T> {
196209
return (await command.run()) as T?;
197210
}
198211

212+
// Returns help text for commands similar to `name`, in sorted order.
213+
String _similarCommandsText(String name, Iterable<Command<T>> commands) {
214+
if (suggestionDistanceLimit <= 0) return '';
215+
var distances = <Command<T>, int>{};
216+
var candidates =
217+
SplayTreeSet<Command<T>>((a, b) => distances[a]! - distances[b]!);
218+
for (var command in commands) {
219+
if (command.hidden) continue;
220+
var distance = _editDistance(name, command.name);
221+
if (distance <= suggestionDistanceLimit) {
222+
distances[command] = distance;
223+
candidates.add(command);
224+
}
225+
}
226+
if (candidates.isEmpty) return '';
227+
228+
var similar = StringBuffer();
229+
similar
230+
..writeln()
231+
..writeln()
232+
..writeln('Did you mean one of these?');
233+
for (var command in candidates) {
234+
similar.writeln(' ${command.name}');
235+
}
236+
237+
return similar.toString();
238+
}
239+
199240
String _wrap(String text, {int? hangingIndent}) => wrapText(text,
200241
length: argParser.usageLineLength, hangingIndent: hangingIndent);
201242
}
@@ -433,3 +474,43 @@ String _getCommandUsage(Map<String, Command> commands,
433474

434475
return buffer.toString();
435476
}
477+
478+
/// Returns the edit distance between `from` and `to`.
479+
//
480+
/// Allows for edits, deletes, substitutions, and swaps all as single cost.
481+
///
482+
/// See https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance
483+
int _editDistance(String from, String to) {
484+
// Add a space in front to mimic indexing by 1 instead of 0.
485+
from = ' $from';
486+
to = ' $to';
487+
var distances = [
488+
for (var i = 0; i < from.length; i++)
489+
[
490+
for (var j = 0; j < to.length; j++)
491+
if (i == 0) j else if (j == 0) i else 0,
492+
],
493+
];
494+
495+
for (var i = 1; i < from.length; i++) {
496+
for (var j = 1; j < to.length; j++) {
497+
// Removals from `from`.
498+
var min = distances[i - 1][j] + 1;
499+
// Additions to `from`.
500+
min = math.min(min, distances[i][j - 1] + 1);
501+
// Substitutions (and equality).
502+
min = math.min(
503+
min,
504+
distances[i - 1][j - 1] +
505+
// Cost is zero if substitution was not actually necessary.
506+
(from[i] == to[j] ? 0 : 1));
507+
// Allows for basic swaps, but no additional edits of swapped regions.
508+
if (i > 1 && j > 1 && from[i] == to[j - 1] && from[i - 1] == to[j]) {
509+
min = math.min(min, distances[i - 2][j - 2] + 1);
510+
}
511+
distances[i][j] = min;
512+
}
513+
}
514+
515+
return distances.last.last;
516+
}

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: args
2-
version: 2.1.1
2+
version: 2.2.0
33
homepage: https://github.com/dart-lang/args
44
description: >-
55
Library for defining parsers for parsing raw command-line arguments into a set

test/command_runner_test.dart

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,154 @@ information about a command.'''));
239239
completes);
240240
});
241241

242+
group('suggests similar commands', () {
243+
test('deletions', () {
244+
var command = FooCommand();
245+
runner.addCommand(command);
246+
247+
for (var typo in ['afoo', 'foao', 'fooa']) {
248+
expect(() => runner.run([typo]), throwsUsageException('''
249+
Could not find a command named "$typo".
250+
251+
Did you mean one of these?
252+
foo
253+
''', anything));
254+
}
255+
});
256+
257+
test('additions', () {
258+
var command = LongCommand();
259+
runner.addCommand(command);
260+
261+
for (var typo in ['ong', 'lng', 'lon']) {
262+
expect(() => runner.run([typo]), throwsUsageException('''
263+
Could not find a command named "$typo".
264+
265+
Did you mean one of these?
266+
long
267+
''', anything));
268+
}
269+
});
270+
271+
test('substitutions', () {
272+
var command = LongCommand();
273+
runner.addCommand(command);
274+
275+
for (var typo in ['aong', 'lang', 'lona']) {
276+
expect(() => runner.run([typo]), throwsUsageException('''
277+
Could not find a command named "$typo".
278+
279+
Did you mean one of these?
280+
long
281+
''', anything));
282+
}
283+
});
284+
285+
test('swaps', () {
286+
var command = LongCommand();
287+
runner.addCommand(command);
288+
289+
for (var typo in ['olng', 'lnog', 'logn']) {
290+
expect(() => runner.run([typo]), throwsUsageException('''
291+
Could not find a command named "$typo".
292+
293+
Did you mean one of these?
294+
long
295+
''', anything));
296+
}
297+
});
298+
299+
test('combinations', () {
300+
var command = LongCommand();
301+
runner.addCommand(command);
302+
303+
for (var typo in ['oln', 'on', 'lgn', 'alogn']) {
304+
expect(() => runner.run([typo]), throwsUsageException('''
305+
Could not find a command named "$typo".
306+
307+
Did you mean one of these?
308+
long
309+
''', anything));
310+
}
311+
});
312+
313+
test('sorts by relevance', () {
314+
var a = CustomNameCommand('abcd');
315+
runner.addCommand(a);
316+
var b = CustomNameCommand('bcd');
317+
runner.addCommand(b);
318+
319+
expect(() => runner.run(['abdc']), throwsUsageException('''
320+
Could not find a command named "abdc".
321+
322+
Did you mean one of these?
323+
abcd
324+
bcd
325+
''', anything));
326+
327+
expect(() => runner.run(['bdc']), throwsUsageException('''
328+
Could not find a command named "bdc".
329+
330+
Did you mean one of these?
331+
bcd
332+
abcd
333+
''', anything));
334+
});
335+
336+
test('omits commands with an edit distance over 2', () {
337+
var command = LongCommand();
338+
runner.addCommand(command);
339+
340+
for (var typo in ['llllong', 'aolgn', 'abcg', 'longggg']) {
341+
expect(
342+
() => runner.run([typo]),
343+
throwsUsageException(
344+
'Could not find a command named "$typo".', anything));
345+
}
346+
});
347+
348+
test('max edit distance is configurable', () {
349+
runner = CommandRunner('test', 'A test command runner.',
350+
suggestionDistanceLimit: 1)
351+
..addCommand(LongCommand());
352+
expect(
353+
() => runner.run(['ng']),
354+
throwsUsageException(
355+
'Could not find a command named "ng".', anything));
356+
357+
runner = CommandRunner('test', 'A test command runner.',
358+
suggestionDistanceLimit: 3)
359+
..addCommand(LongCommand());
360+
expect(() => runner.run(['g']), throwsUsageException('''
361+
Could not find a command named "g".
362+
363+
Did you mean one of these?
364+
long
365+
''', anything));
366+
});
367+
368+
test('supports subcommands', () {
369+
var command = FooCommand();
370+
command.addSubcommand(LongCommand());
371+
runner.addCommand(command);
372+
expect(() => runner.run(['foo', 'ong']), throwsUsageException('''
373+
Could not find a subcommand named "ong" for "test foo".
374+
375+
Did you mean one of these?
376+
long
377+
''', anything));
378+
});
379+
380+
test('doesn\'t show hidden commands', () {
381+
var command = HiddenCommand();
382+
runner.addCommand(command);
383+
expect(
384+
() => runner.run(['hidde']),
385+
throwsUsageException(
386+
'Could not find a command named "hidde".', anything));
387+
});
388+
});
389+
242390
group('with --help', () {
243391
test('with no command prints the usage', () {
244392
expect(() => runner.run(['--help']), prints('''

test/test_utils.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ class AllowAnythingCommand extends Command {
223223
}
224224
}
225225

226+
class CustomNameCommand extends Command {
227+
@override
228+
final String name;
229+
230+
CustomNameCommand(this.name);
231+
232+
@override
233+
String get description => 'A command with a custom name';
234+
}
235+
226236
void throwsIllegalArg(function, {String? reason}) {
227237
expect(function, throwsArgumentError, reason: reason);
228238
}
@@ -231,7 +241,7 @@ void throwsFormat(ArgParser parser, List<String> args) {
231241
expect(() => parser.parse(args), throwsFormatException);
232242
}
233243

234-
Matcher throwsUsageException(String message, String usage) =>
244+
Matcher throwsUsageException(Object? message, Object? usage) =>
235245
throwsA(isA<UsageException>()
236246
.having((e) => e.message, 'message', message)
237247
.having((e) => e.usage, 'usage', usage));

0 commit comments

Comments
 (0)