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));