Adding a `pub remove` command (#2620)

* Initial implementation of `pub remove <package>`
diff --git a/lib/src/command/remove.dart b/lib/src/command/remove.dart
new file mode 100644
index 0000000..dc3bd26
--- /dev/null
+++ b/lib/src/command/remove.dart
@@ -0,0 +1,116 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:yaml_edit/yaml_edit.dart';
+
+import '../command.dart';
+import '../entrypoint.dart';
+import '../io.dart';
+import '../log.dart' as log;
+import '../package.dart';
+import '../pubspec.dart';
+import '../solver.dart';
+
+/// Handles the `remove` pub command. Removes dependencies from `pubspec.yaml`,
+/// and performs an operation similar to `pub get`. Unlike `pub add`, this
+/// command supports the removal of multiple dependencies.
+class RemoveCommand extends PubCommand {
+  @override
+  String get name => 'remove';
+  @override
+  String get description => 'Removes a dependency from the current package.';
+  @override
+  String get invocation => 'pub remove <package>';
+  @override
+  String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-remove';
+  @override
+  bool get isOffline => argResults['offline'];
+
+  bool get isDryRun => argResults['dry-run'];
+
+  RemoveCommand() {
+    argParser.addFlag('offline',
+        help: 'Use cached packages instead of accessing the network.');
+
+    argParser.addFlag('dry-run',
+        abbr: 'n',
+        negatable: false,
+        help: "Report what dependencies would change but don't change any.");
+
+    argParser.addFlag('precompile',
+        help: 'Precompile executables in immediate dependencies.');
+  }
+
+  @override
+  Future run() async {
+    if (argResults.rest.isEmpty) {
+      usageException('Must specify a package to be removed.');
+    }
+
+    final packages = Set<String>.from(argResults.rest);
+
+    if (isDryRun) {
+      final rootPubspec = entrypoint.root.pubspec;
+      final newPubspec = _removePackagesFromPubspec(rootPubspec, packages);
+      final newRoot = Package.inMemory(newPubspec);
+
+      await Entrypoint.global(newRoot, entrypoint.lockFile, cache)
+          .acquireDependencies(SolveType.GET,
+              precompile: argResults['precompile']);
+    } else {
+      /// Update the pubspec.
+      _writeRemovalToPubspec(packages);
+
+      await Entrypoint.current(cache).acquireDependencies(SolveType.GET,
+          precompile: argResults['precompile']);
+    }
+  }
+
+  Pubspec _removePackagesFromPubspec(Pubspec original, Set<String> packages) {
+    final originalDependencies = original.dependencies.values;
+    final originalDevDependencies = original.devDependencies.values;
+
+    final newDependencies = originalDependencies
+        .where((dependency) => !packages.contains(dependency.name));
+    final newDevDependencies = originalDevDependencies
+        .where((dependency) => !packages.contains(dependency.name));
+
+    return Pubspec(
+      original.name,
+      version: original.version,
+      sdkConstraints: original.sdkConstraints,
+      dependencies: newDependencies,
+      devDependencies: newDevDependencies,
+      dependencyOverrides: original.dependencyOverrides.values,
+    );
+  }
+
+  /// Writes the changes to the pubspec file
+  void _writeRemovalToPubspec(Set<String> packages) {
+    ArgumentError.checkNotNull(packages, 'packages');
+
+    final yamlEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
+
+    for (var package in packages) {
+      var found = false;
+
+      /// There may be packages where the dependency is declared both in
+      /// dependencies and dev_dependencies.
+      for (final dependencyKey in ['dependencies', 'dev_dependencies']) {
+        if (yamlEditor.parseAt([dependencyKey, package], orElse: () => null) !=
+            null) {
+          yamlEditor.remove([dependencyKey, package]);
+          found = true;
+        }
+      }
+
+      if (!found) {
+        log.warning('Package "$package" was not found in pubspec.yaml!');
+      }
+
+      /// Windows line endings are already handled by [yamlEditor]
+      writeTextFile(entrypoint.pubspecPath, yamlEditor.toString());
+    }
+  }
+}
diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart
index 4a3f547..d5e98a0 100644
--- a/lib/src/command_runner.dart
+++ b/lib/src/command_runner.dart
@@ -22,6 +22,7 @@
 import 'command/list_package_dirs.dart';
 import 'command/logout.dart';
 import 'command/outdated.dart';
+import 'command/remove.dart';
 import 'command/run.dart';
 import 'command/serve.dart';
 import 'command/upgrade.dart';
@@ -115,6 +116,7 @@
     addCommand(ListPackageDirsCommand());
     addCommand(LishCommand());
     addCommand(OutdatedCommand());
+    addCommand(RemoveCommand());
     addCommand(RunCommand());
     addCommand(ServeCommand());
     addCommand(UpgradeCommand());
diff --git a/test/descriptor/yaml.dart b/test/descriptor/yaml.dart
index fc56ee3..da79cd1 100644
--- a/test/descriptor/yaml.dart
+++ b/test/descriptor/yaml.dart
@@ -14,9 +14,7 @@
 
 import '../descriptor.dart';
 
-/// Describes a YAML file and its contents.
 class YamlDescriptor extends FileDescriptor {
-  /// Contents of the YAML file. Must be valid YAML.
   final String _contents;
 
   YamlDescriptor(String name, this._contents) : super.protected(name);
diff --git a/test/pub_test.dart b/test/pub_test.dart
index 0d0c91b..3e52f94 100644
--- a/test/pub_test.dart
+++ b/test/pub_test.dart
@@ -38,6 +38,7 @@
           logout      Log out of pub.dartlang.org.
           outdated    Analyze your dependencies to find which ones can be upgraded.
           publish     Publish the current package to pub.dartlang.org.
+          remove      Removes a dependency from the current package.
           run         Run an executable from a package.
           upgrade     Upgrade the current package's dependencies to latest versions.
           uploader    Manage uploaders for a package on pub.dartlang.org.
diff --git a/test/remove/remove_test.dart b/test/remove/remove_test.dart
new file mode 100644
index 0000000..50dba5d
--- /dev/null
+++ b/test/remove/remove_test.dart
@@ -0,0 +1,215 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io' show File;
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../descriptor/yaml.dart';
+import '../test_pub.dart';
+
+void main() {
+  test('removes a package from dependencies', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+    await d.appDir({'foo': '1.2.3'}).create();
+    await pubGet();
+
+    await pubRemove(args: ['foo']);
+
+    await d.cacheDir({}).validate();
+    await d.appPackagesFile({}).validate();
+    await d.appDir({}).validate();
+  });
+
+  test('dry-run does not actually remove dependency', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+    await d.appDir({'foo': '1.2.3'}).create();
+    await pubGet();
+
+    await pubRemove(
+        args: ['foo', '--dry-run'],
+        output: allOf([
+          contains('These packages are no longer being depended on:'),
+          contains('- foo 1.2.3')
+        ]));
+
+    await d.appDir({'foo': '1.2.3'}).validate();
+  });
+
+  test('prints a warning if package does not exist', () async {
+    await d.appDir().create();
+    await pubRemove(
+        args: ['foo'],
+        warning: contains('Package "foo" was not found in pubspec.yaml!'));
+
+    await d.appDir().validate();
+  });
+
+  test('prints a warning if the dependencies map does not exist', () async {
+    await d.dir(appPath, [
+      d.pubspec({'name': 'myapp'})
+    ]).create();
+    await pubRemove(
+        args: ['foo'],
+        warning: contains('Package "foo" was not found in pubspec.yaml!'));
+
+    await d.dir(appPath, [
+      d.pubspec({'name': 'myapp'})
+    ]).validate();
+  });
+
+  test('removes a package from dev_dependencies', () async {
+    await servePackages((builder) => builder.serve('foo', '1.2.3'));
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dev_dependencies': {'foo': '1.2.3'}
+      })
+    ]).create();
+    await pubGet();
+
+    await pubRemove(args: ['foo']);
+
+    await d.cacheDir({}).validate();
+    await d.appPackagesFile({}).validate();
+
+    await d.dir(appPath, [
+      d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+    ]).validate();
+  });
+
+  test('removes multiple packages from dependencies and dev_dependencies',
+      () async {
+    await servePackages((builder) {
+      builder.serve('foo', '1.2.3');
+      builder.serve('bar', '2.3.4');
+      builder.serve('baz', '3.2.1');
+      builder.serve('jfj', '0.2.1');
+    });
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {'bar': '>=2.3.4', 'jfj': '0.2.1'},
+        'dev_dependencies': {'foo': '^1.2.3', 'baz': '3.2.1'}
+      })
+    ]).create();
+    await pubGet();
+
+    await pubRemove(args: ['foo', 'bar', 'baz']);
+
+    await d.cacheDir({'jfj': '0.2.1'}).validate();
+    await d.appPackagesFile({'jfj': '0.2.1'}).validate();
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {'jfj': '0.2.1'},
+        'dev_dependencies': {}
+      })
+    ]).validate();
+  });
+
+  test('removes git dependencies', () async {
+    await servePackages((builder) => builder.serve('bar', '1.2.3'));
+
+    ensureGit();
+    final repo = d.git('foo.git', [
+      d.dir('subdir', [d.libPubspec('foo', '1.0.0'), d.libDir('foo', '1.0.0')])
+    ]);
+    await repo.create();
+
+    await d.appDir({
+      'foo': {
+        'git': {'url': '../foo.git', 'path': 'subdir'}
+      },
+      'bar': '1.2.3'
+    }).create();
+
+    await pubGet();
+
+    await pubRemove(args: ['foo']);
+    await d.appPackagesFile({'bar': '1.2.3'}).validate();
+    await d.appDir({'bar': '1.2.3'}).validate();
+  });
+
+  test('removes path dependencies', () async {
+    await servePackages((builder) => builder.serve('bar', '1.2.3'));
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+
+    await d.appDir({
+      'foo': {'path': '../foo'},
+      'bar': '1.2.3'
+    }).create();
+
+    await pubGet();
+
+    await pubRemove(args: ['foo']);
+    await d.appPackagesFile({'bar': '1.2.3'}).validate();
+    await d.appDir({'bar': '1.2.3'}).validate();
+  });
+
+  test('removes hosted dependencies', () async {
+    await servePackages((builder) => builder.serve('bar', '2.0.1'));
+
+    var server = await PackageServer.start((builder) {
+      builder.serve('foo', '1.2.3');
+    });
+
+    await d.appDir({
+      'foo': {
+        'version': '1.2.3',
+        'hosted': {'name': 'foo', 'url': 'http://localhost:${server.port}'}
+      },
+      'bar': '2.0.1'
+    }).create();
+
+    await pubGet();
+
+    await pubRemove(args: ['foo']);
+    await d.appPackagesFile({'bar': '2.0.1'}).validate();
+    await d.appDir({'bar': '2.0.1'}).validate();
+  });
+
+  test('preserves comments', () async {
+    await servePackages((builder) {
+      builder.serve('bar', '1.0.0');
+      builder.serve('foo', '1.0.0');
+    });
+
+    final initialPubspec = YamlDescriptor('pubspec.yaml', '''
+      name: myapp
+      dependencies: # comment A
+          # comment B
+          bar: 1.0.0 
+          # comment C
+          foo: 1.0.0 # comment D
+        # comment E
+    ''');
+    await d.dir(appPath, [initialPubspec]).create();
+
+    await pubGet();
+
+    await pubRemove(args: ['bar']);
+
+    final fullPath = p.join(d.sandbox, appPath, 'pubspec.yaml');
+    expect(File(fullPath).existsSync(), true);
+    final contents = File(fullPath).readAsStringSync();
+    expect(
+        contents,
+        allOf([
+          contains('# comment A'),
+          contains('# comment B'),
+          contains('# comment C'),
+          contains('# comment D'),
+          contains('# comment E')
+        ]));
+  });
+}
diff --git a/test/test_pub.dart b/test/test_pub.dart
index 8c5cc73..14986a7 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -93,6 +93,8 @@
 Try `pub outdated` for more information.$)'''));
   static final downgrade = RunCommand('downgrade',
       RegExp(r'(No dependencies changed\.|Changed \d+ dependenc(y|ies)!)$'));
+  static final remove = RunCommand(
+      'remove', RegExp(r'Got dependencies!|Changed \d+ dependenc(y|ies)!'));
 
   final String name;
   final RegExp success;
@@ -214,6 +216,21 @@
         exitCode: exitCode,
         environment: environment);
 
+Future pubRemove(
+        {Iterable<String> args,
+        output,
+        error,
+        warning,
+        int exitCode,
+        Map<String, String> environment}) =>
+    pubCommand(RunCommand.remove,
+        args: args,
+        output: output,
+        error: error,
+        warning: warning,
+        exitCode: exitCode,
+        environment: environment);
+
 /// Schedules starting the "pub [global] run" process and validates the
 /// expected startup output.
 ///