Add support for argument name aliases (#182)

Closes https://github.com/dart-lang/args/issues/181

Add `aliases` named argument to `addFlag`, `addOption`, and `addMultiOption`, as well as a public `findByNameOrAlias` method on `ArgParser`. This allows you to provide aliases for an argument name, which eases the transition from one argument name to another.

Keys are _not_ added to the `options` map for each alias.

cc @Hixie
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5de6a9..d09d006 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 2.1.0-dev
+
+* Add `aliases` named argument to `addFlag`, `addOption`, and `addMultiOption`,
+  as well as a public `findByNameOrAlias` method on `ArgParser`. This allows
+  you to provide aliases for an argument name, which eases the transition from
+  one argument name to another.
+
 ## 2.0.0
 
 * Stable null safety release.
diff --git a/lib/src/allow_anything_parser.dart b/lib/src/allow_anything_parser.dart
index 831e3a6..becff98 100644
--- a/lib/src/allow_anything_parser.dart
+++ b/lib/src/allow_anything_parser.dart
@@ -35,7 +35,8 @@
       bool? defaultsTo = false,
       bool negatable = true,
       void Function(bool)? callback,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     throw UnsupportedError(
         "ArgParser.allowAnything().addFlag() isn't supported.");
   }
@@ -51,7 +52,8 @@
       void Function(String?)? callback,
       bool allowMultiple = false,
       bool? splitCommas,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     throw UnsupportedError(
         "ArgParser.allowAnything().addOption() isn't supported.");
   }
@@ -66,7 +68,8 @@
       Iterable<String>? defaultsTo,
       void Function(List<String>)? callback,
       bool splitCommas = true,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     throw UnsupportedError(
         "ArgParser.allowAnything().addMultiOption() isn't supported.");
   }
@@ -96,4 +99,7 @@
 
   @override
   Option? findByAbbreviation(String abbr) => null;
+
+  @override
+  Option? findByNameOrAlias(String name) => null;
 }
diff --git a/lib/src/arg_parser.dart b/lib/src/arg_parser.dart
index 4279738..87f7a02 100644
--- a/lib/src/arg_parser.dart
+++ b/lib/src/arg_parser.dart
@@ -16,6 +16,9 @@
   final Map<String, Option> _options;
   final Map<String, ArgParser> _commands;
 
+  /// A map of aliases to the option names they alias.
+  final Map<String, String> _aliases;
+
   /// The options that have been defined for this parser.
   final Map<String, Option> options;
 
@@ -53,7 +56,7 @@
   /// the parser stops parsing as soon as it finds an argument that is neither
   /// an option nor a command.
   factory ArgParser({bool allowTrailingOptions = true, int? usageLineLength}) =>
-      ArgParser._(<String, Option>{}, <String, ArgParser>{},
+      ArgParser._(<String, Option>{}, <String, ArgParser>{}, <String, String>{},
           allowTrailingOptions: allowTrailingOptions,
           usageLineLength: usageLineLength);
 
@@ -66,6 +69,7 @@
   factory ArgParser.allowAnything() = AllowAnythingParser;
 
   ArgParser._(Map<String, Option> options, Map<String, ArgParser> commands,
+      this._aliases,
       {bool allowTrailingOptions = true, this.usageLineLength})
       : _options = options,
         options = UnmodifiableMapView(options),
@@ -116,6 +120,9 @@
   ///
   /// If [hide] is `true`, this option won't be included in [usage].
   ///
+  /// If [aliases] is provided, these are used as aliases for [name]. These
+  /// aliases will not appear as keys in the [options] map.
+  ///
   /// Throws an [ArgumentError] if:
   ///
   /// * There is already an option named [name].
@@ -126,7 +133,8 @@
       bool? defaultsTo = false,
       bool negatable = true,
       void Function(bool)? callback,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     _addOption(
         name,
         abbr,
@@ -138,7 +146,8 @@
         callback == null ? null : (value) => callback(value as bool),
         OptionType.flag,
         negatable: negatable,
-        hide: hide);
+        hide: hide,
+        aliases: aliases);
   }
 
   /// Defines an option that takes a value.
@@ -174,6 +183,9 @@
   ///
   /// If [hide] is `true`, this option won't be included in [usage].
   ///
+  /// If [aliases] is provided, these are used as aliases for [name]. These
+  /// aliases will not appear as keys in the [options] map.
+  ///
   /// Throws an [ArgumentError] if:
   ///
   /// * There is already an option with name [name].
@@ -186,10 +198,11 @@
       Map<String, String>? allowedHelp,
       String? defaultsTo,
       void Function(String?)? callback,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     _addOption(name, abbr, help, valueHelp, allowed, allowedHelp, defaultsTo,
         callback, OptionType.single,
-        hide: hide);
+        hide: hide, aliases: aliases);
   }
 
   /// Defines an option that takes multiple values.
@@ -225,6 +238,9 @@
   ///
   /// If [hide] is `true`, this option won't be included in [usage].
   ///
+  /// If [aliases] is provided, these are used as aliases for [name]. These
+  /// aliases will not appear as keys in the [options] map.
+  ///
   /// Throws an [ArgumentError] if:
   ///
   /// * There is already an option with name [name].
@@ -238,7 +254,8 @@
       Iterable<String>? defaultsTo,
       void Function(List<String>)? callback,
       bool splitCommas = true,
-      bool hide = false}) {
+      bool hide = false,
+      List<String> aliases = const []}) {
     _addOption(
         name,
         abbr,
@@ -250,7 +267,8 @@
         callback == null ? null : (value) => callback(value as List<String>),
         OptionType.multiple,
         splitCommas: splitCommas,
-        hide: hide);
+        hide: hide,
+        aliases: aliases);
   }
 
   void _addOption(
@@ -265,10 +283,11 @@
       OptionType type,
       {bool negatable = false,
       bool? splitCommas,
-      bool hide = false}) {
-    // Make sure the name isn't in use.
-    if (_options.containsKey(name)) {
-      throw ArgumentError('Duplicate option "$name".');
+      bool hide = false,
+      List<String> aliases = const []}) {
+    var allNames = [name, ...aliases];
+    if (allNames.any((name) => findByNameOrAlias(name) != null)) {
+      throw ArgumentError('Duplicate option or alias "$name".');
     }
 
     // Make sure the abbreviation isn't too long or in use.
@@ -282,9 +301,15 @@
 
     var option = newOption(name, abbr, help, valueHelp, allowed, allowedHelp,
         defaultsTo, callback, type,
-        negatable: negatable, splitCommas: splitCommas, hide: hide);
+        negatable: negatable,
+        splitCommas: splitCommas,
+        hide: hide,
+        aliases: aliases);
     _options[name] = option;
     _optionsAndSeparators.add(option);
+    for (var alias in aliases) {
+      _aliases[alias] = name;
+    }
   }
 
   /// Adds a separator line to the usage.
@@ -309,7 +334,7 @@
 
   /// Returns the default value for [option].
   dynamic defaultFor(String option) {
-    var value = options[option];
+    var value = findByNameOrAlias(option);
     if (value == null) {
       throw ArgumentError('No option named $option');
     }
@@ -327,4 +352,8 @@
     }
     return null;
   }
+
+  /// Finds the option whose name or alias matches [name], or `null` if no
+  /// option has that name or alias.
+  Option? findByNameOrAlias(String name) => options[_aliases[name] ?? name];
 }
diff --git a/lib/src/option.dart b/lib/src/option.dart
index a85acc8..7a91a4b 100644
--- a/lib/src/option.dart
+++ b/lib/src/option.dart
@@ -18,10 +18,14 @@
     OptionType type,
     {bool? negatable,
     bool? splitCommas,
-    bool hide = false}) {
+    bool hide = false,
+    List<String> aliases = const []}) {
   return Option._(name, abbr, help, valueHelp, allowed, allowedHelp, defaultsTo,
       callback, type,
-      negatable: negatable, splitCommas: splitCommas, hide: hide);
+      negatable: negatable,
+      splitCommas: splitCommas,
+      hide: hide,
+      aliases: aliases);
 }
 
 /// A command-line option.
@@ -73,6 +77,9 @@
   /// Whether this option should be hidden from usage documentation.
   final bool hide;
 
+  /// All aliases for [name].
+  final List<String> aliases;
+
   /// Whether the option is boolean-valued flag.
   bool get isFlag => type == OptionType.flag;
 
@@ -94,7 +101,8 @@
       OptionType type,
       {this.negatable,
       bool? splitCommas,
-      this.hide = false})
+      this.hide = false,
+      this.aliases = const []})
       : allowed = allowed == null ? null : List.unmodifiable(allowed),
         allowedHelp =
             allowedHelp == null ? null : Map.unmodifiable(allowedHelp),
diff --git a/lib/src/parser.dart b/lib/src/parser.dart
index c14a01d..9cfaa60 100644
--- a/lib/src/parser.dart
+++ b/lib/src/parser.dart
@@ -243,7 +243,7 @@
       return false;
     }
 
-    var option = grammar.options[name];
+    var option = grammar.findByNameOrAlias(name);
     if (option != null) {
       args.removeFirst();
       if (option.isFlag) {
@@ -261,7 +261,7 @@
     } else if (name.startsWith('no-')) {
       // See if it's a negated flag.
       name = name.substring('no-'.length);
-      option = grammar.options[name];
+      option = grammar.findByNameOrAlias(name);
       if (option == null) {
         // Walk up to the parent command if possible.
         validate(parent != null, 'Could not find an option named "$name".');
diff --git a/pubspec.yaml b/pubspec.yaml
index f39c81f..c980dba 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: args
-version: 2.0.0
+version: 2.1.0-dev
 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/args_test.dart b/test/args_test.dart
index d3bfd44..04bbc47 100644
--- a/test/args_test.dart
+++ b/test/args_test.dart
@@ -184,6 +184,40 @@
     });
   });
 
+  group('ArgParser.findByNameOrAlias', () {
+    test('returns null if there is no match', () {
+      var parser = ArgParser();
+      expect(parser.findByNameOrAlias('a'), isNull);
+    });
+
+    test('can find options by alias', () {
+      var parser = ArgParser()..addOption('a', aliases: ['b']);
+      expect(parser.findByNameOrAlias('b'),
+          isA<Option>().having((o) => o.name, 'name', 'a'));
+    });
+
+    test('can find flags by alias', () {
+      var parser = ArgParser()..addFlag('a', aliases: ['b']);
+      expect(parser.findByNameOrAlias('b'),
+          isA<Option>().having((o) => o.name, 'name', 'a'));
+    });
+
+    test('does not allow duplicate aliases', () {
+      var parser = ArgParser()..addOption('a', aliases: ['b']);
+      throwsIllegalArg(() => parser.addOption('c', aliases: ['b']));
+    });
+
+    test('does not allow aliases that conflict with existing names', () {
+      var parser = ArgParser()..addOption('a', aliases: ['b']);
+      throwsIllegalArg(() => parser.addOption('c', aliases: ['a']));
+    });
+
+    test('does not allow names that conflict with existing aliases', () {
+      var parser = ArgParser()..addOption('a', aliases: ['b']);
+      throwsIllegalArg(() => parser.addOption('b'));
+    });
+  });
+
   group('ArgResults', () {
     group('options', () {
       test('returns the provided options', () {
diff --git a/test/parse_test.dart b/test/parse_test.dart
index a785f6e..c90219e 100644
--- a/test/parse_test.dart
+++ b/test/parse_test.dart
@@ -70,6 +70,19 @@
         var results = parser.parse(['--$allCharacters']);
         expect(results[allCharacters], isTrue);
       });
+
+      test('can match by alias', () {
+        var parser = ArgParser()..addFlag('a', aliases: ['b']);
+        var results = parser.parse(['--b']);
+        expect(results['a'], isTrue);
+      });
+
+      test('can be negated by alias', () {
+        var parser = ArgParser()
+          ..addFlag('a', aliases: ['b'], defaultsTo: true, negatable: true);
+        var results = parser.parse(['--no-b']);
+        expect(results['a'], isFalse);
+      });
     });
 
     group('flags negated with "no-"', () {
@@ -245,6 +258,12 @@
           parser.parse(['--a=v,w', '--a=x']);
           expect(a, equals(['v', 'w', 'x']));
         });
+
+        test('can mix and match alias and regular name', () {
+          var parser = ArgParser()..addMultiOption('a', aliases: ['b']);
+          var results = parser.parse(['--a=1', '--b=2']);
+          expect(results['a'], ['1', '2']);
+        });
       });
     });
 
@@ -484,6 +503,12 @@
         expect(results['verbose'], equals('chatty'));
         expect(results['Verbose'], equals('no'));
       });
+
+      test('can be set by alias', () {
+        var parser = ArgParser()..addOption('a', aliases: ['b']);
+        var results = parser.parse(['--b=1']);
+        expect(results['a'], '1');
+      });
     });
 
     group('remaining args', () {