Support multiple packages in 'dart pub add' (#3283)

diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index f268381..d540978 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -13,6 +13,7 @@
 import '../exceptions.dart';
 import '../git.dart';
 import '../io.dart';
+import '../language_version.dart';
 import '../log.dart' as log;
 import '../package.dart';
 import '../package_name.dart';
@@ -33,9 +34,10 @@
   @override
   String get name => 'add';
   @override
-  String get description => 'Add a dependency to pubspec.yaml.';
+  String get description => 'Add dependencies to pubspec.yaml.';
   @override
-  String get argumentsDescription => '<package>[:<constraint>] [options]';
+  String get argumentsDescription =>
+      '<package>[:<constraint>] [<package2>[:<constraint2>]...] [options]';
   @override
   String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-add';
   @override
@@ -53,19 +55,24 @@
   bool get hasGitOptions => gitUrl != null || gitRef != null || gitPath != null;
   bool get hasHostOptions => hostUrl != null;
 
+  bool get isHosted => !hasGitOptions && path == null && path == null;
+
   AddCommand() {
     argParser.addFlag('dev',
         abbr: 'd',
         negatable: false,
-        help: 'Adds package to the development dependencies instead.');
+        help: 'Adds to the development dependencies instead.');
 
     argParser.addOption('git-url', help: 'Git URL of the package');
     argParser.addOption('git-ref',
         help: 'Git branch or commit to be retrieved');
     argParser.addOption('git-path', help: 'Path of git package in repository');
     argParser.addOption('hosted-url', help: 'URL of package host server');
-    argParser.addOption('path', help: 'Local path');
-    argParser.addOption('sdk', help: 'SDK source for package');
+    argParser.addOption('path', help: 'Add package from local path');
+    argParser.addOption('sdk',
+        help: 'add package from SDK source',
+        allowed: ['flutter'],
+        valueHelp: '[flutter]');
     argParser.addFlag(
       'example',
       help:
@@ -84,23 +91,28 @@
     argParser.addFlag('precompile',
         help: 'Build executables in immediate dependencies.');
     argParser.addOption('directory',
-        abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir');
+        abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir');
   }
 
   @override
   Future<void> runProtected() async {
     if (argResults.rest.isEmpty) {
-      usageException('Must specify a package to be added.');
-    } else if (argResults.rest.length > 1) {
-      usageException('Takes only a single argument.');
+      usageException('Must specify at least one package to be added.');
+    } else if (argResults.rest.length > 1 && gitUrl != null) {
+      usageException('Can only add a single git package at a time.');
+    } else if (argResults.rest.length > 1 && path != null) {
+      usageException('Can only add a single local package at a time.');
     }
+    final languageVersion = entrypoint.root.pubspec.languageVersion;
+    final updates =
+        argResults.rest.map((p) => _parsePackage(p, languageVersion)).toList();
 
-    final packageInformation = _parsePackage(argResults.rest.first);
-    final package = packageInformation.first;
-
-    /// Perform version resolution in-memory.
-    final updatedPubSpec =
-        await _addPackageToPubspec(entrypoint.root.pubspec, package);
+    var updatedPubSpec = entrypoint.root.pubspec;
+    for (final update in updates) {
+      /// Perform version resolution in-memory.
+      updatedPubSpec =
+          await _addPackageToPubspec(updatedPubSpec, update.packageRange);
+    }
 
     late SolveResult solveResult;
 
@@ -113,7 +125,9 @@
       solveResult = await resolveVersions(
           SolveType.upgrade, cache, Package.inMemory(updatedPubSpec));
     } on GitException {
-      dataError('Unable to resolve package "${package.name}" with the given '
+      final packageRange = updates.first.packageRange;
+      dataError(
+          'Unable to resolve package "${packageRange.name}" with the given '
           'git parameters.');
     } on SolveFailure catch (e) {
       dataError(e.message);
@@ -122,24 +136,26 @@
       dataError(e.message);
     }
 
-    final resultPackage = solveResult.packages
-        .firstWhere((packageId) => packageId.name == package.name);
+    /// Verify the results for each package.
+    for (final update in updates) {
+      final packageRange = update.packageRange;
+      final name = packageRange.name;
+      final resultPackage = solveResult.packages
+          .firstWhere((packageId) => packageId.name == name);
 
-    /// Assert that [resultPackage] is within the original user's expectations.
-    var constraint = package.constraint;
-    if (!constraint.allows(resultPackage.version)) {
-      var dependencyOverrides = updatedPubSpec.dependencyOverrides;
-      if (dependencyOverrides.isNotEmpty) {
-        dataError(
-            '"${package.name}" resolved to "${resultPackage.version}" which '
-            'does not satisfy constraint "${package.constraint}". This could be '
-            'caused by "dependency_overrides".');
+      /// Assert that [resultPackage] is within the original user's expectations.
+      var constraint = packageRange.constraint;
+      if (!constraint.allows(resultPackage.version)) {
+        var dependencyOverrides = updatedPubSpec.dependencyOverrides;
+        if (dependencyOverrides.isNotEmpty) {
+          dataError('"$name" resolved to "${resultPackage.version}" which '
+              'does not satisfy constraint "$constraint". This could be '
+              'caused by "dependency_overrides".');
+        }
+        dataError('"$name" resolved to "${resultPackage.version}" which '
+            'does not satisfy constraint "$constraint".');
       }
-      dataError(
-          '"${package.name}" resolved to "${resultPackage.version}" which '
-          'does not satisfy constraint "${package.constraint}".');
     }
-
     if (isDryRun) {
       /// Even if it is a dry run, run `acquireDependencies` so that the user
       /// gets a report on the other packages that might change version due
@@ -158,7 +174,7 @@
       /// ensure that the modification timestamp on `pubspec.lock` and
       /// `.dart_tool/package_config.json` is newer than `pubspec.yaml`,
       /// ensuring that [entrypoint.assertUptoDate] will pass.
-      _updatePubspec(resultPackage, packageInformation, isDev);
+      _updatePubspec(solveResult.packages, updates, isDev);
 
       /// Create a new [Entrypoint] since we have to reprocess the updated
       /// pubspec file.
@@ -274,9 +290,7 @@
   ///
   /// If any of the other git options are defined when `--git-url` is not
   /// defined, an error will be thrown.
-  Pair<PackageRange, dynamic> _parsePackage(String package) {
-    ArgumentError.checkNotNull(package, 'package');
-
+  _ParseResult _parsePackage(String package, LanguageVersion languageVersion) {
     final _conflictingFlagSets = [
       ['git-url', 'git-ref', 'git-path'],
       ['hosted-url'],
@@ -380,13 +394,18 @@
           .withConstraint(constraint ?? VersionConstraint.any);
       pubspecInformation = {'sdk': sdk};
     } else {
-      final hostInfo =
-          hasHostOptions ? {'url': hostUrl, 'name': packageName} : null;
-
-      if (hostInfo == null) {
-        pubspecInformation = constraint?.toString();
+      // Hosted
+      final Object? hostInfo;
+      if (hasHostOptions) {
+        hostInfo = languageVersion.supportsShorterHostedSyntax
+            ? hostUrl
+            : {'url': hostUrl, 'name': packageName};
+        pubspecInformation = {
+          'hosted': hostInfo,
+        };
       } else {
-        pubspecInformation = {'hosted': hostInfo};
+        hostInfo = null;
+        pubspecInformation = constraint?.toString();
       }
 
       packageRange = cache.hosted.source
@@ -408,66 +427,64 @@
       };
     }
 
-    return Pair(packageRange, pubspecInformation);
+    return _ParseResult(packageRange, pubspecInformation);
   }
 
   /// Writes the changes to the pubspec file.
-  void _updatePubspec(PackageId resultPackage,
-      Pair<PackageRange, dynamic> packageInformation, bool isDevelopment) {
-    ArgumentError.checkNotNull(resultPackage, 'resultPackage');
-    ArgumentError.checkNotNull(packageInformation, 'pubspecInformation');
-
-    final package = packageInformation.first;
-    var pubspecInformation = packageInformation.last;
-
-    if ((sdk != null || hasHostOptions) &&
-        pubspecInformation is Map &&
-        pubspecInformation['version'] == null) {
-      /// We cannot simply assign the value of version since it is likely that
-      /// [pubspecInformation] takes on the type
-      /// [Map<String, Map<String, String>>]
-      pubspecInformation = {
-        ...pubspecInformation,
-        'version': '^${resultPackage.version}'
-      };
-    }
-
-    final dependencyKey = isDevelopment ? 'dev_dependencies' : 'dependencies';
-    final packagePath = [dependencyKey, package.name];
-
+  void _updatePubspec(List<PackageId> resultPackages,
+      List<_ParseResult> updates, bool isDevelopment) {
     final yamlEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
     log.io('Reading ${entrypoint.pubspecPath}.');
     log.fine('Contents:\n$yamlEditor');
 
-    /// Handle situations where the user might not have the dependencies or
-    /// dev_dependencies map.
-    if (yamlEditor.parseAt([dependencyKey],
-            orElse: () => YamlScalar.wrap(null)).value ==
-        null) {
-      yamlEditor.update([dependencyKey],
-          {package.name: pubspecInformation ?? '^${resultPackage.version}'});
-    } else {
-      yamlEditor.update(
-          packagePath, pubspecInformation ?? '^${resultPackage.version}');
-    }
+    for (final update in updates) {
+      final packageRange = update.packageRange;
+      final name = packageRange.name;
+      final resultId = resultPackages.firstWhere((id) => id.name == name);
+      var description = update.description;
 
-    log.fine('Added ${package.name} to "$dependencyKey".');
-
-    /// Remove the package from dev_dependencies if we are adding it to
-    /// dependencies. Refer to [_addPackageToPubspec] for additional discussion.
-    if (!isDevelopment) {
-      final devDependenciesNode = yamlEditor
-          .parseAt(['dev_dependencies'], orElse: () => YamlScalar.wrap(null));
-
-      if (devDependenciesNode is YamlMap &&
-          devDependenciesNode.containsKey(package.name)) {
-        if (devDependenciesNode.length == 1) {
-          yamlEditor.remove(['dev_dependencies']);
-        } else {
-          yamlEditor.remove(['dev_dependencies', package.name]);
+      if (isHosted) {
+        final inferredConstraint =
+            VersionConstraint.compatibleWith(resultId.version).toString();
+        if (description == null) {
+          description = inferredConstraint;
+        } else if (description is Map && description['version'] == null) {
+          /// We cannot simply assign the value of version since it is likely that
+          /// [description] takes on the type
+          /// [Map<String, Map<String, String>>]
+          description = {...description, 'version': '^${resultId.version}'};
         }
+      }
 
-        log.fine('Removed ${package.name} from "dev_dependencies".');
+      final dependencyKey = isDevelopment ? 'dev_dependencies' : 'dependencies';
+      final packagePath = [dependencyKey, name];
+
+      /// Ensure we have a [dependencyKey] map in the `pubspec.yaml`.
+      if (yamlEditor.parseAt([dependencyKey],
+              orElse: () => YamlScalar.wrap(null)).value ==
+          null) {
+        yamlEditor.update([dependencyKey], {});
+      }
+      yamlEditor.update(packagePath, description);
+
+      log.fine('Added ${packageRange.name} to "$dependencyKey".');
+
+      /// Remove the package from dev_dependencies if we are adding it to
+      /// dependencies. Refer to [_addPackageToPubspec] for additional discussion.
+      if (!isDevelopment) {
+        final devDependenciesNode = yamlEditor
+            .parseAt(['dev_dependencies'], orElse: () => YamlScalar.wrap(null));
+
+        if (devDependenciesNode is YamlMap &&
+            devDependenciesNode.containsKey(name)) {
+          if (devDependenciesNode.length == 1) {
+            yamlEditor.remove(['dev_dependencies']);
+          } else {
+            yamlEditor.remove(['dev_dependencies', name]);
+          }
+
+          log.fine('Removed $name from "dev_dependencies".');
+        }
       }
     }
 
@@ -475,3 +492,9 @@
     writeTextFile(entrypoint.pubspecPath, yamlEditor.toString());
   }
 }
+
+class _ParseResult {
+  PackageRange packageRange;
+  Object? description;
+  _ParseResult(this.packageRange, this.description);
+}
diff --git a/lib/src/language_version.dart b/lib/src/language_version.dart
index e294014..ad331b2 100644
--- a/lib/src/language_version.dart
+++ b/lib/src/language_version.dart
@@ -69,6 +69,21 @@
 
   bool get supportsNullSafety => this >= firstVersionWithNullSafety;
 
+  /// Minimum language version at which short hosted syntax is supported.
+  ///
+  /// This allows `hosted` dependencies to be expressed as:
+  /// ```yaml
+  /// dependencies:
+  ///   foo:
+  ///     hosted: https://some-pub.com/path
+  ///     version: ^1.0.0
+  /// ```
+  ///
+  /// At older versions, `hosted` dependencies had to be a map with a `url` and
+  /// a `name` key.
+  bool get supportsShorterHostedSyntax =>
+      this >= firstVersionWithShorterHostedSyntax;
+
   @override
   int compareTo(LanguageVersion other) {
     if (major != other.major) return major.compareTo(other.major);
@@ -89,6 +104,7 @@
 
   static const defaultLanguageVersion = LanguageVersion(2, 7);
   static const firstVersionWithNullSafety = LanguageVersion(2, 12);
+  static const firstVersionWithShorterHostedSyntax = LanguageVersion(2, 15);
 
   /// Transform language version to string that can be parsed with
   /// [LanguageVersion.parse].
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index e371363..b3fa729 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -232,8 +232,7 @@
       return _HostedDescription(packageName, defaultUrl);
     }
 
-    final canUseShorthandSyntax =
-        languageVersion >= _minVersionForShorterHostedSyntax;
+    final canUseShorthandSyntax = languageVersion.supportsShorterHostedSyntax;
 
     if (description is String) {
       // Old versions of pub (pre Dart 2.15) interpret `hosted: foo` as
@@ -256,7 +255,7 @@
         } else {
           throw FormatException(
             'Using `hosted: <url>` is only supported with a minimum SDK '
-            'constraint of $_minVersionForShorterHostedSyntax.',
+            'constraint of ${LanguageVersion.firstVersionWithShorterHostedSyntax}.',
           );
         }
       }
@@ -271,7 +270,7 @@
 
     if (name is! String) {
       throw FormatException("The 'name' key must have a string value without "
-          'a minimum Dart SDK constraint of $_minVersionForShorterHostedSyntax.0 or higher.');
+          'a minimum Dart SDK constraint of ${LanguageVersion.firstVersionWithShorterHostedSyntax}.0 or higher.');
     }
 
     var url = defaultUrl;
@@ -286,21 +285,6 @@
     return _HostedDescription(name, url);
   }
 
-  /// Minimum language version at which short hosted syntax is supported.
-  ///
-  /// This allows `hosted` dependencies to be expressed as:
-  /// ```yaml
-  /// dependencies:
-  ///   foo:
-  ///     hosted: https://some-pub.com/path
-  ///     version: ^1.0.0
-  /// ```
-  ///
-  /// At older versions, `hosted` dependencies had to be a map with a `url` and
-  /// a `name` key.
-  static const LanguageVersion _minVersionForShorterHostedSyntax =
-      LanguageVersion(2, 15);
-
   static final RegExp _looksLikePackageName =
       RegExp(r'^[a-zA-Z_]+[a-zA-Z0-9_]*$');
 }
diff --git a/test/add/common/add_test.dart b/test/add/common/add_test.dart
index 69f33fe..1a1e9df 100644
--- a/test/add/common/add_test.dart
+++ b/test/add/common/add_test.dart
@@ -37,29 +37,6 @@
   });
 
   group('normally', () {
-    test('fails if extra arguments are passed', () async {
-      final server = await servePackages();
-      server.serve('foo', '1.2.2');
-
-      await d.dir(appPath, [
-        d.pubspec({'name': 'myapp'})
-      ]).create();
-
-      await pubAdd(
-          args: ['foo', '^1.2.2'],
-          exitCode: exit_codes.USAGE,
-          error: contains('Takes only a single argument.'));
-
-      await d.dir(appPath, [
-        d.pubspec({
-          'name': 'myapp',
-        }),
-        d.nothing('.dart_tool/package_config.json'),
-        d.nothing('pubspec.lock'),
-        d.nothing('.packages'),
-      ]).validate();
-    });
-
     test('adds a package from a pub server', () async {
       final server = await servePackages();
       server.serve('foo', '1.2.3');
@@ -73,6 +50,24 @@
       await d.appDir({'foo': '1.2.3'}).validate();
     });
 
+    test('adds multiple package from a pub server', () async {
+      final server = await servePackages();
+      server.serve('foo', '1.2.3');
+      server.serve('bar', '1.1.0');
+      server.serve('baz', '2.5.3');
+
+      await d.appDir({}).create();
+
+      await pubAdd(args: ['foo:1.2.3', 'bar:1.1.0', 'baz:2.5.3']);
+
+      await d.cacheDir(
+          {'foo': '1.2.3', 'bar': '1.1.0', 'baz': '2.5.3'}).validate();
+      await d.appPackagesFile(
+          {'foo': '1.2.3', 'bar': '1.1.0', 'baz': '2.5.3'}).validate();
+      await d
+          .appDir({'foo': '1.2.3', 'bar': '1.1.0', 'baz': '2.5.3'}).validate();
+    });
+
     test(
         'does not remove empty dev_dependencies while adding to normal dependencies',
         () async {
diff --git a/test/add/git/git_test.dart b/test/add/git/git_test.dart
index 7bc51f0..00bd334 100644
--- a/test/add/git/git_test.dart
+++ b/test/add/git/git_test.dart
@@ -184,4 +184,18 @@
       })
     ]).validate();
   });
+
+  test('fails if multiple packages passed for git source', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+        args: ['foo', 'bar', 'baz', '--git-url', '../foo.git'],
+        exitCode: exit_codes.USAGE,
+        error: contains('Can only add a single git package at a time.'));
+  });
 }
diff --git a/test/add/hosted/non_default_pub_server_test.dart b/test/add/hosted/non_default_pub_server_test.dart
index eea6c09..0a83ac5 100644
--- a/test/add/hosted/non_default_pub_server_test.dart
+++ b/test/add/hosted/non_default_pub_server_test.dart
@@ -35,6 +35,46 @@
     }).validate();
   });
 
+  test('adds multiple packages from a non-default pub server', () async {
+    // Make the default server serve errors. Only the custom server should
+    // be accessed.
+    (await servePackages()).serveErrors();
+
+    final server = await servePackages();
+    server.serve('foo', '1.1.0');
+    server.serve('foo', '1.2.3');
+    server.serve('bar', '0.2.5');
+    server.serve('bar', '3.2.3');
+    server.serve('baz', '0.1.3');
+    server.serve('baz', '1.3.5');
+
+    await d.appDir({}).create();
+
+    final url = server.url;
+
+    await pubAdd(
+        args: ['foo:1.2.3', 'bar:3.2.3', 'baz:1.3.5', '--hosted-url', url]);
+
+    await d.cacheDir({'foo': '1.2.3', 'bar': '3.2.3', 'baz': '1.3.5'},
+        port: server.port).validate();
+    await d.appPackagesFile(
+        {'foo': '1.2.3', 'bar': '3.2.3', 'baz': '1.3.5'}).validate();
+    await d.appDir({
+      'foo': {
+        'version': '1.2.3',
+        'hosted': {'name': 'foo', 'url': url}
+      },
+      'bar': {
+        'version': '3.2.3',
+        'hosted': {'name': 'bar', 'url': url}
+      },
+      'baz': {
+        'version': '1.3.5',
+        'hosted': {'name': 'baz', 'url': url}
+      }
+    }).validate();
+  });
+
   test('fails when adding from an invalid url', () async {
     ensureGit();
 
diff --git a/test/add/path/absolute_path_test.dart b/test/add/path/absolute_path_test.dart
index 5b7e726..b15ec2d 100644
--- a/test/add/path/absolute_path_test.dart
+++ b/test/add/path/absolute_path_test.dart
@@ -41,6 +41,28 @@
     }).validate();
   });
 
+  test('fails when adding multiple packages through local path', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+    final absolutePath = path.join(d.sandbox, 'foo');
+
+    await pubAdd(
+        args: ['foo:2.0.0', 'bar:0.1.3', 'baz:1.3.1', '--path', absolutePath],
+        error: contains('Can only add a single local package at a time.'),
+        exitCode: exit_codes.USAGE);
+
+    await d.appDir({}).validate();
+    await d.dir(appPath, [
+      d.nothing('.dart_tool/package_config.json'),
+      d.nothing('pubspec.lock'),
+      d.nothing('.packages'),
+    ]).validate();
+  });
+
   test('fails when adding with an invalid version constraint', () async {
     ensureGit();
 
diff --git a/test/testdata/goldens/help_test/pub add --help.txt b/test/testdata/goldens/help_test/pub add --help.txt
index 5d32e5e..2a37c21 100644
--- a/test/testdata/goldens/help_test/pub add --help.txt
+++ b/test/testdata/goldens/help_test/pub add --help.txt
@@ -2,22 +2,23 @@
 
 ## Section 0
 $ pub add --help
-Add a dependency to pubspec.yaml.
+Add dependencies to pubspec.yaml.
 
-Usage: pub add <package>[:<constraint>] [options]
+Usage: pub add <package>[:<constraint>] [<package2>[:<constraint2>]...] [options]
 -h, --help               Print this usage information.
--d, --dev                Adds package to the development dependencies instead.
+-d, --dev                Adds to the development dependencies instead.
     --git-url            Git URL of the package
     --git-ref            Git branch or commit to be retrieved
     --git-path           Path of git package in repository
     --hosted-url         URL of package host server
-    --path               Local path
-    --sdk                SDK source for package
+    --path               Add package from local path
+    --sdk=<[flutter]>    add package from SDK source
+                         [flutter]
     --[no-]offline       Use cached packages instead of accessing the network.
 -n, --dry-run            Report what dependencies would change but don't change
                          any.
     --[no-]precompile    Build executables in immediate dependencies.
--C, --directory=<dir>    Run this in the directory<dir>.
+-C, --directory=<dir>    Run this in the directory <dir>.
 
 Run "pub help" to see global options.
 See https://dart.dev/tools/pub/cmd/pub-add for detailed documentation.