blob: 196a7d65fba7c2ed1d60e4e6a014859fb90b3518 [file] [log] [blame]
// 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:collection/collection.dart' show IterableExtension;
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import '../command.dart';
import '../entrypoint.dart';
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';
import '../pubspec.dart';
import '../solver.dart';
import '../source/git.dart';
import '../source/hosted.dart';
import '../source/path.dart';
import '../utils.dart';
/// Handles the `add` pub command. Adds a dependency to `pubspec.yaml` and gets
/// the package. The user may pass in a git constraint, host url, or path as
/// requirements. If no such options are passed in, this command will do a
/// resolution to find the latest version of the package that is compatible with
/// the other dependencies in `pubspec.yaml`, and then enter that as the lower
/// bound in a ^x.y.z constraint.
///
/// Currently supports only adding one dependency at a time.
class AddCommand extends PubCommand {
@override
String get name => 'add';
@override
String get description => 'Add dependencies to pubspec.yaml.';
@override
String get argumentsDescription =>
'<package>[:<constraint>] [<package2>[:<constraint2>]...] [options]';
@override
String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-add';
@override
bool get isOffline => argResults['offline'];
bool get isDev => argResults['dev'];
bool get isDryRun => argResults['dry-run'];
String? get gitUrl => argResults['git-url'];
String? get gitPath => argResults['git-path'];
String? get gitRef => argResults['git-ref'];
String? get hostUrl => argResults['hosted-url'];
String? get path => argResults['path'];
String? get sdk => argResults['sdk'];
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 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: 'Add package from local path');
argParser.addOption('sdk',
help: 'add package from SDK source',
allowed: ['flutter'],
valueHelp: '[flutter]');
argParser.addFlag(
'example',
help:
'Also update dependencies in `example/` after modifying pubspec.yaml in the root package (if it exists).',
hide: true,
);
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: 'Build executables in immediate dependencies.');
argParser.addOption('directory',
abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir');
argParser.addFlag('legacy-packages-file',
help: 'Generate the legacy ".packages" file', negatable: false);
}
@override
Future<void> runProtected() async {
if (argResults.rest.isEmpty) {
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();
var updatedPubSpec = entrypoint.root.pubspec;
for (final update in updates) {
/// Perform version resolution in-memory.
updatedPubSpec = await _addPackageToPubspec(updatedPubSpec, update);
}
late SolveResult solveResult;
try {
/// Use [SolveType.UPGRADE] to solve for the highest version of [package]
/// in case [package] was already a transitive dependency. In the case
/// where the user specifies a version constraint, this serves to ensure
/// that a resolution exists before we update pubspec.yaml.
// TODO(sigurdm): We should really use a spinner here.
solveResult = await resolveVersions(
SolveType.upgrade, cache, Package.inMemory(updatedPubSpec));
} on GitException {
final name = updates.first.ref.name;
dataError('Unable to resolve package "$name" with the given '
'git parameters.');
} on SolveFailure catch (e) {
dataError(e.message);
} on WrappedException catch (e) {
/// [WrappedException]s may appear if an invalid [hostUrl] is passed in.
dataError(e.message);
}
/// Verify the results for each package.
for (final update in updates) {
final ref = update.ref;
final name = ref.name;
final resultPackage = solveResult.packages
.firstWhere((packageId) => packageId.name == name);
/// Assert that [resultPackage] is within the original user's expectations.
final constraint = update.constraint;
if (constraint != null && !constraint.allows(resultPackage.version)) {
final 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".');
}
}
}
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
/// to this new dependency.
final newRoot = Package.inMemory(updatedPubSpec);
await Entrypoint.inMemory(newRoot, cache,
solveResult: solveResult, lockFile: entrypoint.lockFile)
.acquireDependencies(SolveType.get,
dryRun: true,
precompile: argResults['precompile'],
analytics: analytics,
generateDotPackages: false);
} else {
/// Update the `pubspec.yaml` before calling [acquireDependencies] to
/// 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(solveResult.packages, updates, isDev);
/// Create a new [Entrypoint] since we have to reprocess the updated
/// pubspec file.
final updatedEntrypoint = Entrypoint(directory, cache);
await updatedEntrypoint.acquireDependencies(
SolveType.get,
precompile: argResults['precompile'],
analytics: analytics,
generateDotPackages: argResults['legacy-packages-file'],
);
if (argResults['example'] && entrypoint.example != null) {
await entrypoint.example!.acquireDependencies(
SolveType.get,
precompile: argResults['precompile'],
onlyReportSuccessOrFailure: true,
analytics: analytics,
generateDotPackages: argResults['legacy-packages-file'],
);
}
}
if (isOffline) {
log.warning('Warning: Packages added when offline may not resolve to '
'the latest compatible version available.');
}
}
/// Creates a new in-memory [Pubspec] by adding [package] to the
/// dependencies of [original].
Future<Pubspec> _addPackageToPubspec(
Pubspec original, _ParseResult package) async {
final name = package.ref.name;
final dependencies = [...original.dependencies.values];
var devDependencies = [...original.devDependencies.values];
final dependencyNames = dependencies.map((dependency) => dependency.name);
final devDependencyNames =
devDependencies.map((devDependency) => devDependency.name);
final range =
package.ref.withConstraint(package.constraint ?? VersionConstraint.any);
if (isDev) {
/// TODO(walnut): Change the error message once pub upgrade --bump is
/// released
if (devDependencyNames.contains(name)) {
dataError('"$name" is already in "dev_dependencies". '
'Use "pub upgrade $name" to upgrade to a later version!');
}
/// If package is originally in dependencies and we wish to add it to
/// dev_dependencies, this is a redundant change, and we should not
/// remove the package from dependencies, since it might cause the user's
/// code to break.
if (dependencyNames.contains(name)) {
dataError('"$name" is already in "dependencies". '
'Use "pub remove $name" to remove it before adding it '
'to "dev_dependencies"');
}
devDependencies.add(range);
} else {
/// TODO(walnut): Change the error message once pub upgrade --bump is
/// released
if (dependencyNames.contains(name)) {
dataError('"$name" is already in "dependencies". '
'Use "pub upgrade $name" to upgrade to a later version!');
}
/// If package is originally in dev_dependencies and we wish to add it to
/// dependencies, we remove the package from dev_dependencies, since it is
/// now redundant.
if (devDependencyNames.contains(name)) {
log.message('"$name" was found in dev_dependencies. '
'Removing "$name" and adding it to dependencies instead.');
devDependencies = devDependencies.where((d) => d.name != name).toList();
}
dependencies.add(range);
}
return Pubspec(
original.name,
version: original.version,
sdkConstraints: original.sdkConstraints,
dependencies: dependencies,
devDependencies: devDependencies,
dependencyOverrides: original.dependencyOverrides.values,
);
}
/// Parse [package] to return the corresponding [PackageRange], as well as its
/// representation in `pubspec.yaml`.
///
/// [package] must be written in the format
/// `<package-name>[:<version-constraint>]`, where quotations should be used
/// if necessary.
///
/// Examples:
/// ```
/// retry
/// retry:2.0.0
/// retry:^2.0.0
/// retry:'>=2.0.0'
/// retry:'>2.0.0 <3.0.1'
/// 'retry:>2.0.0 <3.0.1'
/// retry:any
/// ```
///
/// If a version constraint is provided when the `--path` or any of the
/// `--git-<option>` options are used, a [PackageParseError] will be thrown.
///
/// Packages must either be a git, hosted, sdk, or path package. Mixing of
/// options is not allowed and will cause a [PackageParseError] to be thrown.
///
/// If any of the other git options are defined when `--git-url` is not
/// defined, an error will be thrown.
_ParseResult _parsePackage(String package, LanguageVersion languageVersion) {
final _conflictingFlagSets = [
['git-url', 'git-ref', 'git-path'],
['hosted-url'],
['path'],
['sdk'],
];
for (final flag
in _conflictingFlagSets.expand((s) => s).where(argResults.wasParsed)) {
final conflictingFlag = _conflictingFlagSets
.where((s) => !s.contains(flag))
.expand((s) => s)
.firstWhereOrNull(argResults.wasParsed);
if (conflictingFlag != null) {
usageException(
'Packages can only have one source, "pub add" flags "--$flag" and '
'"--$conflictingFlag" are conflicting.');
}
}
final splitPackage = package.split(':');
final packageName = splitPackage[0];
/// There shouldn't be more than one `:` in the package information
if (splitPackage.length > 2) {
usageException('Invalid package and version constraint: $package');
}
/// We want to allow for [constraint] to take on a `null` value here to
/// preserve the fact that the user did not specify a constraint.
VersionConstraint? constraint;
try {
constraint = splitPackage.length == 2
? VersionConstraint.parse(splitPackage[1])
: null;
} on FormatException catch (e) {
usageException('Invalid version constraint: ${e.message}');
}
/// The package to be added.
late final PackageRef ref;
final path = this.path;
if (hasGitOptions) {
final gitUrl = this.gitUrl;
if (gitUrl == null) {
usageException('The `--git-url` is required for git dependencies.');
}
Uri parsed;
try {
parsed = Uri.parse(gitUrl);
} on FormatException catch (e) {
usageException('The --git-url must be a valid url: ${e.message}.');
}
/// Process the git options to return the simplest representation to be
/// added to the pubspec.
ref = PackageRef(
packageName,
GitDescription(
url: parsed.toString(),
containingDir: p.current,
ref: gitRef,
path: gitPath,
),
);
} else if (path != null) {
ref = PackageRef(
packageName, PathDescription(p.absolute(path), p.isRelative(path)));
} else if (sdk != null) {
ref = cache.sdk.parseRef(packageName, sdk);
} else {
ref = PackageRef(
packageName,
HostedDescription(
packageName,
hostUrl ?? cache.hosted.defaultUrl,
),
);
}
return _ParseResult(ref, constraint);
}
/// Writes the changes to the pubspec file.
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');
final dependencyKey = isDevelopment ? 'dev_dependencies' : 'dependencies';
for (final update in updates) {
final constraint = update.constraint;
final ref = update.ref;
final name = ref.name;
final resultId = resultPackages.firstWhere((id) => id.name == name);
var description = ref.description;
final versionConstraintString =
constraint == null ? '^${resultId.version}' : constraint.toString();
late Object? pubspecInformation;
if (description is HostedDescription &&
description.url == cache.hosted.defaultUrl) {
pubspecInformation = versionConstraintString;
} else {
pubspecInformation = {
ref.source.name: ref.description.serializeForPubspec(
containingDir: entrypoint.root.dir,
languageVersion: entrypoint.root.pubspec.languageVersion),
if (description is HostedDescription || constraint != null)
'version': versionConstraintString
};
}
if (yamlEditor.parseAt(
[dependencyKey],
orElse: () => YamlScalar.wrap(null),
).value ==
null) {
// Handle the case where [dependencyKey] does not already exist.
// We ensure it is in Block-style by default.
yamlEditor.update(
[dependencyKey],
wrapAsYamlNode({name: pubspecInformation},
collectionStyle: CollectionStyle.BLOCK));
} else {
final packagePath = [dependencyKey, name];
yamlEditor.update(packagePath, pubspecInformation);
}
/// 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".');
}
}
}
/// Windows line endings are already handled by [yamlEditor]
writeTextFile(entrypoint.pubspecPath, yamlEditor.toString());
}
}
class _ParseResult {
PackageRef ref;
VersionConstraint? constraint;
_ParseResult(this.ref, this.constraint);
}