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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab38339..59f7a33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+## 2.2.0
+
+* Suggest similar commands if an unknown command is encountered, when using the
+ `CommandRunner`.
+ * The max edit distance for suggestions defaults to 2, but can be configured
+ using the `suggestionDistanceLimit` parameter on the constructor. You can
+ set it to `0` to disable the feature.
+
## 2.1.1
* Fix a bug with `mandatory` options which caused a null assertion failure when
diff --git a/lib/command_runner.dart b/lib/command_runner.dart
index 90b6a42..ff6da4a 100644
--- a/lib/command_runner.dart
+++ b/lib/command_runner.dart
@@ -77,7 +77,14 @@
ArgParser get argParser => _argParser;
final ArgParser _argParser;
- CommandRunner(this.executableName, this.description, {int? usageLineLength})
+ /// The maximum edit distance allowed when suggesting possible intended
+ /// commands.
+ ///
+ /// Set to `0` in order to disable suggestions, defaults to `2`.
+ final int suggestionDistanceLimit;
+
+ CommandRunner(this.executableName, this.description,
+ {int? usageLineLength, this.suggestionDistanceLimit = 2})
: _argParser = ArgParser(usageLineLength: usageLineLength) {
argParser.addFlag('help',
abbr: 'h', negatable: false, help: 'Print this usage information.');
@@ -158,13 +165,19 @@
command.usageException('Missing subcommand for "$commandString".');
} else {
+ var requested = argResults.rest[0];
+
+ // Build up a help message containing similar commands, if found.
+ var similarCommands =
+ _similarCommandsText(requested, commands.values);
+
if (command == null) {
usageException(
- 'Could not find a command named "${argResults.rest[0]}".');
+ 'Could not find a command named "$requested".$similarCommands');
}
command.usageException('Could not find a subcommand named '
- '"${argResults.rest[0]}" for "$commandString".');
+ '"$requested" for "$commandString".$similarCommands');
}
}
@@ -196,6 +209,34 @@
return (await command.run()) as T?;
}
+ // Returns help text for commands similar to `name`, in sorted order.
+ String _similarCommandsText(String name, Iterable<Command<T>> commands) {
+ if (suggestionDistanceLimit <= 0) return '';
+ var distances = <Command<T>, int>{};
+ var candidates =
+ SplayTreeSet<Command<T>>((a, b) => distances[a]! - distances[b]!);
+ for (var command in commands) {
+ if (command.hidden) continue;
+ var distance = _editDistance(name, command.name);
+ if (distance <= suggestionDistanceLimit) {
+ distances[command] = distance;
+ candidates.add(command);
+ }
+ }
+ if (candidates.isEmpty) return '';
+
+ var similar = StringBuffer();
+ similar
+ ..writeln()
+ ..writeln()
+ ..writeln('Did you mean one of these?');
+ for (var command in candidates) {
+ similar.writeln(' ${command.name}');
+ }
+
+ return similar.toString();
+ }
+
String _wrap(String text, {int? hangingIndent}) => wrapText(text,
length: argParser.usageLineLength, hangingIndent: hangingIndent);
}
@@ -433,3 +474,43 @@
return buffer.toString();
}
+
+/// Returns the edit distance between `from` and `to`.
+//
+/// Allows for edits, deletes, substitutions, and swaps all as single cost.
+///
+/// See https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Optimal_string_alignment_distance
+int _editDistance(String from, String to) {
+ // Add a space in front to mimic indexing by 1 instead of 0.
+ from = ' $from';
+ to = ' $to';
+ var distances = [
+ for (var i = 0; i < from.length; i++)
+ [
+ for (var j = 0; j < to.length; j++)
+ if (i == 0) j else if (j == 0) i else 0,
+ ],
+ ];
+
+ for (var i = 1; i < from.length; i++) {
+ for (var j = 1; j < to.length; j++) {
+ // Removals from `from`.
+ var min = distances[i - 1][j] + 1;
+ // Additions to `from`.
+ min = math.min(min, distances[i][j - 1] + 1);
+ // Substitutions (and equality).
+ min = math.min(
+ min,
+ distances[i - 1][j - 1] +
+ // Cost is zero if substitution was not actually necessary.
+ (from[i] == to[j] ? 0 : 1));
+ // Allows for basic swaps, but no additional edits of swapped regions.
+ if (i > 1 && j > 1 && from[i] == to[j - 1] && from[i - 1] == to[j]) {
+ min = math.min(min, distances[i - 2][j - 2] + 1);
+ }
+ distances[i][j] = min;
+ }
+ }
+
+ return distances.last.last;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index e3db8cd..332a9bd 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: args
-version: 2.1.1
+version: 2.2.0
homepage: https://github.com/dart-lang/args
description: >-
Library for defining parsers for parsing raw command-line arguments into a set
diff --git a/test/command_runner_test.dart b/test/command_runner_test.dart
index 5f2ebaa..4cc6066 100644
--- a/test/command_runner_test.dart
+++ b/test/command_runner_test.dart
@@ -239,6 +239,154 @@
completes);
});
+ group('suggests similar commands', () {
+ test('deletions', () {
+ var command = FooCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['afoo', 'foao', 'fooa']) {
+ expect(() => runner.run([typo]), throwsUsageException('''
+Could not find a command named "$typo".
+
+Did you mean one of these?
+ foo
+''', anything));
+ }
+ });
+
+ test('additions', () {
+ var command = LongCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['ong', 'lng', 'lon']) {
+ expect(() => runner.run([typo]), throwsUsageException('''
+Could not find a command named "$typo".
+
+Did you mean one of these?
+ long
+''', anything));
+ }
+ });
+
+ test('substitutions', () {
+ var command = LongCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['aong', 'lang', 'lona']) {
+ expect(() => runner.run([typo]), throwsUsageException('''
+Could not find a command named "$typo".
+
+Did you mean one of these?
+ long
+''', anything));
+ }
+ });
+
+ test('swaps', () {
+ var command = LongCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['olng', 'lnog', 'logn']) {
+ expect(() => runner.run([typo]), throwsUsageException('''
+Could not find a command named "$typo".
+
+Did you mean one of these?
+ long
+''', anything));
+ }
+ });
+
+ test('combinations', () {
+ var command = LongCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['oln', 'on', 'lgn', 'alogn']) {
+ expect(() => runner.run([typo]), throwsUsageException('''
+Could not find a command named "$typo".
+
+Did you mean one of these?
+ long
+''', anything));
+ }
+ });
+
+ test('sorts by relevance', () {
+ var a = CustomNameCommand('abcd');
+ runner.addCommand(a);
+ var b = CustomNameCommand('bcd');
+ runner.addCommand(b);
+
+ expect(() => runner.run(['abdc']), throwsUsageException('''
+Could not find a command named "abdc".
+
+Did you mean one of these?
+ abcd
+ bcd
+''', anything));
+
+ expect(() => runner.run(['bdc']), throwsUsageException('''
+Could not find a command named "bdc".
+
+Did you mean one of these?
+ bcd
+ abcd
+''', anything));
+ });
+
+ test('omits commands with an edit distance over 2', () {
+ var command = LongCommand();
+ runner.addCommand(command);
+
+ for (var typo in ['llllong', 'aolgn', 'abcg', 'longggg']) {
+ expect(
+ () => runner.run([typo]),
+ throwsUsageException(
+ 'Could not find a command named "$typo".', anything));
+ }
+ });
+
+ test('max edit distance is configurable', () {
+ runner = CommandRunner('test', 'A test command runner.',
+ suggestionDistanceLimit: 1)
+ ..addCommand(LongCommand());
+ expect(
+ () => runner.run(['ng']),
+ throwsUsageException(
+ 'Could not find a command named "ng".', anything));
+
+ runner = CommandRunner('test', 'A test command runner.',
+ suggestionDistanceLimit: 3)
+ ..addCommand(LongCommand());
+ expect(() => runner.run(['g']), throwsUsageException('''
+Could not find a command named "g".
+
+Did you mean one of these?
+ long
+''', anything));
+ });
+
+ test('supports subcommands', () {
+ var command = FooCommand();
+ command.addSubcommand(LongCommand());
+ runner.addCommand(command);
+ expect(() => runner.run(['foo', 'ong']), throwsUsageException('''
+Could not find a subcommand named "ong" for "test foo".
+
+Did you mean one of these?
+ long
+''', anything));
+ });
+
+ test('doesn\'t show hidden commands', () {
+ var command = HiddenCommand();
+ runner.addCommand(command);
+ expect(
+ () => runner.run(['hidde']),
+ throwsUsageException(
+ 'Could not find a command named "hidde".', anything));
+ });
+ });
+
group('with --help', () {
test('with no command prints the usage', () {
expect(() => runner.run(['--help']), prints('''
diff --git a/test/test_utils.dart b/test/test_utils.dart
index 5136f48..9f2d3df 100644
--- a/test/test_utils.dart
+++ b/test/test_utils.dart
@@ -223,6 +223,16 @@
}
}
+class CustomNameCommand extends Command {
+ @override
+ final String name;
+
+ CustomNameCommand(this.name);
+
+ @override
+ String get description => 'A command with a custom name';
+}
+
void throwsIllegalArg(function, {String? reason}) {
expect(function, throwsArgumentError, reason: reason);
}
@@ -231,7 +241,7 @@
expect(() => parser.parse(args), throwsFormatException);
}
-Matcher throwsUsageException(String message, String usage) =>
+Matcher throwsUsageException(Object? message, Object? usage) =>
throwsA(isA<UsageException>()
.having((e) => e.message, 'message', message)
.having((e) => e.usage, 'usage', usage));