// Copyright (c) 2012, 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. /// Attempts to resolve a set of version constraints for a package dependency /// graph and select an appropriate set of best specific versions for all /// dependent packages. It works iteratively and tries to reach a stable /// solution where the constraints of all dependencies are met. If it fails to /// reach a solution after a certain number of iterations, it assumes the /// dependency graph is unstable and reports and error. /// /// There are two fundamental operations in the process of iterating over the /// graph: /// /// 1. Changing the selected concrete version of some package. (This includes /// adding and removing a package too, which is considering changing the /// version to or from "none".) In other words, a node has changed. /// 2. Changing the version constraint that one package places on another. In /// other words, and edge has changed. /// /// Both of these events have a corresponding (potentional) async operation and /// roughly cycle back and forth between each other. When we change the version /// of package changes, we asynchronously load the pubspec for the new version. /// When that's done, we compare the dependencies of the new version versus the /// old one. For everything that differs, we change those constraints between /// this package and that dependency. /// /// When a constraint on a package changes, we re-calculate the overall /// constraint on that package. I.e. with a shared dependency, we intersect all /// of the constraints that its depending packages place on it. If that overall /// constraint changes (say from "<3.0.0" to "<2.5.0"), then the currently /// picked version for that package may fall outside of the new constraint. If /// that happens, we find the new best version that meets the updated constraint /// and then the change the package to use that version. That cycles back up to /// the beginning again. library version_solver; import 'dart:async'; import 'dart:collection' show Queue; import 'dart:json' as json; import 'dart:math'; import 'lock_file.dart'; import 'log.dart' as log; import 'package.dart'; import 'pubspec.dart'; import 'source.dart'; import 'source_registry.dart'; import 'utils.dart'; import 'version.dart'; /// Attempts to select the best concrete versions for all of the transitive /// dependencies of [root] taking into account all of the [VersionConstraint]s /// that those dependencies place on each other and the requirements imposed by /// [lockFile]. If successful, completes to a [Map] that maps package names to /// the selected version for that package. If it fails, the future will complete /// with a [NoVersionException], [DisjointConstraintException], or /// [CouldNotSolveException]. Future> resolveVersions(SourceRegistry sources, Package root, LockFile lockFile) { log.message('Resolving dependencies...'); return new VersionSolver(sources, root, lockFile).solve(); } class VersionSolver { final SourceRegistry _sources; final Package _root; final LockFile lockFile; final PubspecCache _pubspecs; final Map _packages; final Queue _work; int _numIterations = 0; VersionSolver(SourceRegistry sources, this._root, this.lockFile) : _sources = sources, _pubspecs = new PubspecCache(sources), _packages = {}, _work = new Queue(); /// Tell the version solver to use the most recent version of [package] that /// exists in whatever source it's installed from. If that version violates /// constraints imposed by other dependencies, an error will be raised when /// solving the versions, even if an earlier compatible version exists. void useLatestVersion(String package) { // TODO(nweiz): How do we want to detect and handle unknown dependencies // here? getDependency(package).useLatestVersion = true; lockFile.packages.remove(package); } Future> solve() { // Kick off the work by adding the root package at its concrete version to // the dependency graph. var ref = new PackageRef.root(_root); enqueue(new AddConstraint('(entrypoint)', ref)); _pubspecs.cache(ref.atVersion(_root.version), _root.pubspec); Future processNextWorkItem(_) { while (true) { // Stop if we are done. if (_work.isEmpty) return new Future.immediate(buildResults()); // If we appear to be stuck in a loop, then we probably have an unstable // graph, bail. We guess this based on a rough heuristic that it should // only take a certain number of steps to solve a graph with a given // number of connections. // TODO(rnystrom): These numbers here are magic and arbitrary. Tune // when we have a better picture of real-world package topologies. _numIterations++; if (_numIterations > max(50, _packages.length * 5)) { throw new CouldNotSolveException(); } // Run the first work item. var future = _work.removeFirst().process(this); // If we have an async operation to perform, chain the loop to resume // when it's done. Otherwise, just loop synchronously. if (future != null) { return future.then(processNextWorkItem); } } } return processNextWorkItem(null); } void enqueue(WorkItem work) { _work.add(work); } Dependency getDependency(String package) { // There can be unused dependencies in the graph, so just create an empty // one if needed. _packages.putIfAbsent(package, () => new Dependency(package)); return _packages[package]; } /// Sets the best selected version of [package] to [version]. void setVersion(String package, Version version) { _packages[package].version = version; } /// Returns the most recent version of [dependency] that satisfies all of its /// version constraints. Future getBestVersion(Dependency dependency) { return dependency.getVersions().then((versions) { var best = null; for (var version in versions) { if (dependency.useLatestVersion || dependency.constraint.allows(version)) { if (best == null || version > best) best = version; } } // TODO(rnystrom): Better exception. if (best == null) { if (tryUnlockDepender(dependency)) return null; throw new NoVersionException(dependency.name, dependency.constraint, dependency._refs); } else if (!dependency.constraint.allows(best)) { if (tryUnlockDepender(dependency)) return null; throw new CouldNotUpdateException( dependency.name, dependency.constraint, best); } return best; }); } /// Looks for a package that depends (transitively) on [dependency] and has /// its version locked in the lockfile. If one is found, enqueues an /// [UnlockPackage] work item for it and returns true. Otherwise, returns /// false. /// /// This does a breadth-first search; immediate dependers will be unlocked /// first, followed by transitive dependers. bool tryUnlockDepender(Dependency dependency, [Set seen]) { if (seen == null) seen = new Set(); // Avoid an infinite loop if there are circular dependencies. if (seen.contains(dependency.name)) return false; seen.add(dependency.name); for (var dependerName in dependency.dependers) { var depender = getDependency(dependerName); var locked = lockFile.packages[dependerName]; if (locked != null && depender.version == locked.version) { enqueue(new UnlockPackage(depender)); return true; } } return dependency.dependers.map(getDependency).any((subdependency) => tryUnlockDepender(subdependency, seen)); } List buildResults() { return _packages.values.where((dep) => dep.isDependedOn).map((dep) { var description = dep.description; // If the lockfile contains a fully-resolved description for the package, // use that. This allows e.g. Git to ensure that the same commit is used. var lockedPackage = lockFile.packages[dep.name]; if (lockedPackage != null && lockedPackage.version == dep.version && lockedPackage.source.name == dep.source.name && dep.source.descriptionsEqual( description, lockedPackage.description)) { description = lockedPackage.description; } return new PackageId(dep.name, dep.source, dep.version, description); }) .toList(); } } /// The constraint solver works by iteratively processing a queue of work items. /// Each item is a single atomic change to the dependency graph. Handling them /// in a queue lets us handle asynchrony (resolving versions requires /// information from servers) as well as avoid deeply nested recursion. abstract class WorkItem { /// Processes this work item. Returns a future that completes when the work is /// done. If `null` is returned, that means the work has completed /// synchronously and the next item can be started immediately. Future process(VersionSolver solver); } /// The best selected version for a package has changed to [version]. If the /// previous version of the package is `null`, that means the package is being /// added to the graph. If [version] is `null`, it is being removed. class ChangeVersion implements WorkItem { /// The name of the package whose version is being changed. final String package; /// The source of the package whose version is changing. final Source source; /// The description identifying the package whose version is changing. final description; /// The new selected version. final Version version; ChangeVersion(this.package, this.source, this.description, this.version); Future process(VersionSolver solver) { log.fine("Changing $package to version $version."); var dependency = solver.getDependency(package); var oldVersion = dependency.version; solver.setVersion(package, version); // The dependencies between the old and new version may be different. Walk // them both and update any constraints that differ between the two. return Future.wait([ getDependencyRefs(solver, oldVersion), getDependencyRefs(solver, version)]).then((list) { var oldDependencyRefs = list[0]; var newDependencyRefs = list[1]; for (var oldRef in oldDependencyRefs.values) { if (newDependencyRefs.containsKey(oldRef.name)) { // The dependency is in both versions of this package, but its // constraint may have changed. var newRef = newDependencyRefs.remove(oldRef.name); solver.enqueue(new AddConstraint(package, newRef)); } else { // The dependency is not in the new version of the package, so just // remove its constraint. solver.enqueue(new RemoveConstraint(package, oldRef.name)); } } // Everything that's left is a depdendency that's only in the new // version of the package. for (var newRef in newDependencyRefs.values) { solver.enqueue(new AddConstraint(package, newRef)); } }); } /// Get the dependencies at [version] of the package being changed. Future> getDependencyRefs(VersionSolver solver, Version version) { // If there is no version, it means no package, so no dependencies. if (version == null) { return new Future>.immediate({}); } var id = new PackageId(package, source, version, description); return solver._pubspecs.load(id).then((pubspec) { var dependencies = {}; for (var dependency in pubspec.dependencies) { dependencies[dependency.name] = dependency; } return dependencies; }); } } /// A constraint that a depending package places on a dependent package has /// changed. /// /// This is an abstract class that contains logic for updating the dependency /// graph once a dependency has changed. Changing the dependency is the /// responsibility of subclasses. abstract class ChangeConstraint implements WorkItem { Future process(VersionSolver solver); void undo(VersionSolver solver); Future _processChange(VersionSolver solver, Dependency oldDependency, Dependency newDependency) { var name = newDependency.name; var source = oldDependency.source != null ? oldDependency.source : newDependency.source; var description = oldDependency.description != null ? oldDependency.description : newDependency.description; var oldConstraint = oldDependency.constraint; var newConstraint = newDependency.constraint; // If the package is over-constrained, i.e. the packages depending have // disjoint constraints, then try unlocking a depender that's locked by the // lockfile. If there are no remaining locked dependencies, throw an error. if (newConstraint != null && newConstraint.isEmpty) { if (solver.tryUnlockDepender(newDependency)) { undo(solver); return null; } throw new DisjointConstraintException(name, newDependency._refs); } // If this constraint change didn't cause the overall constraint on the // package to change, then we don't need to do any further work. if (oldConstraint == newConstraint) return null; // If the dependency has been cut free from the graph, just remove it. if (!newDependency.isDependedOn) { solver.enqueue(new ChangeVersion(name, source, description, null)); return null; } // If the dependency is on the root package, then we don't need to do // anything since it's already at the best version. if (name == solver._root.name) { solver.enqueue(new ChangeVersion( name, source, description, solver._root.version)); return null; } // If the dependency is on a package in the lockfile, use the lockfile's // version for that package if it's valid given the other constraints. var lockedPackage = solver.lockFile.packages[name]; if (lockedPackage != null) { var lockedVersion = lockedPackage.version; if (newConstraint.allows(lockedVersion)) { solver.enqueue( new ChangeVersion(name, source, description, lockedVersion)); return null; } } // The constraint has changed, so see what the best version of the package // that meets the new constraint is. return solver.getBestVersion(newDependency).then((best) { if (best == null) { undo(solver); } else if (newDependency.version != best) { solver.enqueue(new ChangeVersion(name, source, description, best)); } }); } } /// The constraint given by [ref] is being placed by [depender]. class AddConstraint extends ChangeConstraint { /// The package that has the dependency. final String depender; /// The package being depended on and the constraints being placed on it. The /// source, version, and description in this ref are all considered /// constraints on the dependent package. final PackageRef ref; AddConstraint(this.depender, this.ref); Future process(VersionSolver solver) { log.fine("Adding $depender's constraint $ref."); var dependency = solver.getDependency(ref.name); var oldDependency = dependency.clone(); dependency.placeConstraint(depender, ref); return _processChange(solver, oldDependency, dependency); } void undo(VersionSolver solver) { solver.getDependency(ref.name).removeConstraint(depender); } } /// [depender] is no longer placing a constraint on [dependent]. class RemoveConstraint extends ChangeConstraint { /// The package that was placing a constraint on [dependent]. String depender; /// The package that was being depended on. String dependent; /// The constraint that was removed. PackageRef _removed; RemoveConstraint(this.depender, this.dependent); Future process(VersionSolver solver) { log.fine("Removing $depender's constraint ($_removed) on $dependent."); var dependency = solver.getDependency(dependent); var oldDependency = dependency.clone(); _removed = dependency.removeConstraint(depender); return _processChange(solver, oldDependency, dependency); } void undo(VersionSolver solver) { solver.getDependency(dependent).placeConstraint(depender, _removed); } } /// [package]'s version is no longer constrained by the lockfile. class UnlockPackage implements WorkItem { /// The package being unlocked. Dependency package; UnlockPackage(this.package); Future process(VersionSolver solver) { log.fine("Unlocking ${package.name}."); solver.lockFile.packages.remove(package.name); return solver.getBestVersion(package).then((best) { if (best == null) return null; solver.enqueue(new ChangeVersion( package.name, package.source, package.description, best)); }); } } // TODO(rnystrom): Instead of always pulling from the source (which will mean // hitting a server), we should consider caching pubspecs of uninstalled // packages in the system cache. /// Maintains a cache of previously-loaded pubspecs. Used to avoid requesting /// the same pubspec from the server repeatedly. class PubspecCache { final SourceRegistry _sources; final Map _pubspecs; PubspecCache(this._sources) : _pubspecs = new Map(); /// Caches [pubspec] as the [Pubspec] for the package identified by [id]. void cache(PackageId id, Pubspec pubspec) { _pubspecs[id] = pubspec; } /// Loads the pubspec for the package identified by [id]. Future load(PackageId id) { // Complete immediately if it's already cached. if (_pubspecs.containsKey(id)) { return new Future.immediate(_pubspecs[id]); } return id.describe().then((pubspec) { // Cache it. _pubspecs[id] = pubspec; return pubspec; }); } } /// Describes one [Package] in the [DependencyGraph] and keeps track of which /// packages depend on it and what constraints they place on it. class Dependency { /// The name of the this dependency's package. final String name; /// The [PackageRefs] that represent constraints that depending packages have /// placed on this one. final Map _refs; /// The currently-selected best version for this dependency. Version version; /// Whether this dependency should always select the latest version. bool useLatestVersion = false; /// Gets whether or not any other packages are currently depending on this /// one. If `false`, then it means this package is not part of the dependency /// graph and should be omitted. bool get isDependedOn => !_refs.isEmpty; /// The names of all the packages that depend on this dependency. Iterable get dependers => _refs.keys; /// Gets the overall constraint that all packages are placing on this one. /// If no packages have a constraint on this one (which can happen when this /// package is in the process of being added to the graph), returns `null`. VersionConstraint get constraint { if (_refs.isEmpty) return null; return new VersionConstraint.intersection( _refs.values.map((ref) => ref.constraint)); } /// The source of this dependency's package. Source get source { var canonical = _canonicalRef(); if (canonical == null) return null; return canonical.source; } /// The description of this dependency's package. get description { var canonical = _canonicalRef(); if (canonical == null) return null; return canonical.description; } /// Return the PackageRef that has the canonical source and description for /// this package. If any dependency is on the root package, that will be used; /// otherwise, it will be the source and description that all dependencies /// agree upon. PackageRef _canonicalRef() { if (_refs.isEmpty) return null; var refs = _refs.values; for (var ref in refs) { if (ref.isRoot) return ref; } return refs.first; } Dependency(this.name) : _refs = {}; Dependency._clone(Dependency other) : name = other.name, version = other.version, _refs = new Map.from(other._refs); /// Creates a copy of this dependency. Dependency clone() => new Dependency._clone(this); /// Return a list of available versions for this dependency. Future> getVersions() => source.getVersions(name, description); /// Places [ref] as a constraint from [package] onto this. void placeConstraint(String package, PackageRef ref) { var requiredDepender = _requiredDepender(); if (requiredDepender != null) { var required = _refs[requiredDepender]; if (required.source.name != ref.source.name) { throw new SourceMismatchException(name, requiredDepender, required.source, package, ref.source); } else if (!required.source.descriptionsEqual( required.description, ref.description)) { throw new DescriptionMismatchException(name, requiredDepender, required.description, package, ref.description); } } _refs[package] = ref; } /// Returns the name of a package whose constraint source and description /// all other constraints must match. Returns null if there are no /// requirements on new constraints. String _requiredDepender() { if (_refs.isEmpty) return null; var dependers = _refs.keys.toList(); if (dependers.length == 1) { var depender = dependers[0]; if (_refs[depender].isRoot) return null; return depender; } return dependers[1]; } /// Removes the constraint from [package] onto this. PackageRef removeConstraint(String package) => _refs.remove(package); } /// Exception thrown when the [VersionConstraint] used to match a package is /// valid (i.e. non-empty), but there are no released versions of the package /// that fit that constraint. class NoVersionException implements Exception { final String package; final VersionConstraint constraint; final Map _dependencies; NoVersionException(this.package, this.constraint, this._dependencies); String toString() { var buffer = new StringBuffer(); buffer.add("Package '$package' has no versions that match $constraint " "derived from:\n"); var keys = new List.from(_dependencies.keys); keys.sort(); for (var key in keys) { buffer.add("- '$key' depends on version " "${_dependencies[key].constraint}\n"); } return buffer.toString(); } } // TODO(rnystrom): Report the list of depending packages and their constraints. /// Exception thrown when the most recent version of [package] must be selected, /// but doesn't match the [VersionConstraint] imposed on the package. class CouldNotUpdateException implements Exception { final String package; final VersionConstraint constraint; final Version best; CouldNotUpdateException(this.package, this.constraint, this.best); String toString() => "The latest version of '$package', $best, does not match $constraint."; } /// Exception thrown when the [VersionConstraint] used to match a package is /// the empty set: in other words, multiple packages depend on it and have /// conflicting constraints that have no overlap. class DisjointConstraintException implements Exception { final String package; final Map _dependencies; DisjointConstraintException(this.package, this._dependencies); String toString() { var buffer = new StringBuffer(); buffer.add("Incompatible version constraints on '$package':\n"); var keys = new List.from(_dependencies.keys); keys.sort(); for (var key in keys) { buffer.add("- '$key' depends on version " "${_dependencies[key].constraint}\n"); } return buffer.toString(); } } /// Exception thrown when the [VersionSolver] fails to find a solution after a /// certain number of iterations. class CouldNotSolveException implements Exception { CouldNotSolveException(); String toString() => "Could not find a solution that met all version constraints."; } /// Exception thrown when two packages with the same name but different sources /// are depended upon. class SourceMismatchException implements Exception { final String package; final String depender1; final Source source1; final String depender2; final Source source2; SourceMismatchException(this.package, this.depender1, this.source1, this.depender2, this.source2); String toString() { return "Incompatible dependencies on '$package':\n" "- '$depender1' depends on it from source '$source1'\n" "- '$depender2' depends on it from source '$source2'"; } } /// Exception thrown when two packages with the same name and source but /// different descriptions are depended upon. class DescriptionMismatchException implements Exception { final String package; final String depender1; final description1; final String depender2; final description2; DescriptionMismatchException(this.package, this.depender1, this.description1, this.depender2, this.description2); String toString() { // TODO(nweiz): Dump descriptions to YAML when that's supported. return "Incompatible dependencies on '$package':\n" "- '$depender1' depends on it with description " "${json.stringify(description1)}\n" "- '$depender2' depends on it with description " "${json.stringify(description2)}"; } }