diff options
Diffstat (limited to 'lib/rubygems/resolver.rb')
| -rw-r--r-- | lib/rubygems/resolver.rb | 607 |
1 files changed, 412 insertions, 195 deletions
diff --git a/lib/rubygems/resolver.rb b/lib/rubygems/resolver.rb index 13ee035e4c..788206c056 100644 --- a/lib/rubygems/resolver.rb +++ b/lib/rubygems/resolver.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -require 'rubygems/dependency' -require 'rubygems/exceptions' -require 'rubygems/util' -require 'rubygems/util/list' + +require_relative "dependency" +require_relative "exceptions" ## # Given a set of Gem::Dependency objects as +needed+ and a way to query the @@ -11,14 +10,14 @@ require 'rubygems/util/list' # all the requirements. class Gem::Resolver - require 'rubygems/resolver/molinillo' + require_relative "vendored_pub_grub" ## # If the DEBUG_RESOLVER environment variable is set then debugging mode is # enabled for the resolver. This will display information about the state # of the resolver while a set of dependencies is being resolved. - DEBUG_RESOLVER = !ENV['DEBUG_RESOLVER'].nil? + DEBUG_RESOLVER = !ENV["DEBUG_RESOLVER"].nil? ## # Set to true if all development dependencies should be considered. @@ -36,21 +35,13 @@ class Gem::Resolver attr_accessor :ignore_dependencies ## - # List of dependencies that could not be found in the configured sources. - - attr_reader :missing - - attr_reader :stats - - ## # Hash of gems to skip resolution. Keyed by gem name, with arrays of # gem specifications as values. attr_accessor :skip_gems ## - # When a missing dependency, don't stop. Just go on and record what was - # missing. + # attr_accessor :soft_missing @@ -59,10 +50,10 @@ class Gem::Resolver # uniform manner. If one of the +sets+ is itself a ComposedSet its sets are # flattened into the result ComposedSet. - def self.compose_sets *sets + def self.compose_sets(*sets) sets.compact! - sets = sets.map do |set| + sets = sets.flat_map do |set| case set when Gem::Resolver::BestSet then set @@ -71,11 +62,11 @@ class Gem::Resolver else set end - end.flatten + end case sets.length when 0 then - raise ArgumentError, 'one set in the composition must be non-nil' + raise ArgumentError, "one set in the composition must be non-nil" when 1 then sets.first else @@ -87,7 +78,7 @@ class Gem::Resolver # Creates a Resolver that queries only against the already installed gems # for the +needed+ dependencies. - def self.for_current_gems needed + def self.for_current_gems(needed) new needed, Gem::Resolver::CurrentSet.new end @@ -99,250 +90,476 @@ class Gem::Resolver # satisfy the Dependencies. This defaults to IndexSet, which will query # rubygems.org. - def initialize needed, set = nil + def initialize(needed, set = nil) @set = set || Gem::Resolver::IndexSet.new @needed = needed @development = false @development_shallow = false @ignore_dependencies = false - @missing = [] @skip_gems = {} @soft_missing = false - @stats = Gem::Resolver::Stats.new - end - def explain stage, *data # :nodoc: - return unless DEBUG_RESOLVER + @root_package = RootPackage.new + @root_version = Gem::PubGrub::Package.root_version + + @packages = {} - d = data.map { |x| x.pretty_inspect }.join(", ") - $stderr.printf "%10s %s\n", stage.to_s.upcase, d + @unfiltered_specs = Hash.new {|h, name| h[name] = find_unfiltered_specs_for(name) } + @all_specs = Hash.new {|h, name| h[name] = filter_specs(@unfiltered_specs[name]) } + @all_versions = Hash.new {|h, pkg| h[pkg] = @all_specs[pkg.to_s].map(&:version).uniq.sort } + @sorted_versions = Hash.new do |h, pkg| + h[pkg] = Gem::PubGrub::Package.root?(pkg) ? [@root_version] : @all_versions[pkg] + end + @cached_dependencies = Hash.new do |h, pkg| + h[pkg] = if Gem::PubGrub::Package.root?(pkg) + { @root_version => root_dependencies } + else + Hash.new {|v, ver| v[ver] = compute_dependencies(pkg, ver) } + end + end + @version_to_index = Hash.new {|h, pkg| h[pkg] = @sorted_versions[pkg].each_with_index.to_h } + @versions_for_cache = Hash.new {|h, pkg| h[pkg] = {} } + @spec_for_cache = Hash.new {|h, name| h[name] = build_spec_for_cache(name) } end - def explain_list stage # :nodoc: - return unless DEBUG_RESOLVER + ## + # Proceed with resolution! Returns an array of ActivationRequest objects. + + def resolve + # Pre-check: raise UnsatisfiableDependencyError for root deps with no + # platform match. We filter by platform ONLY here (not required_ruby_version + # / required_rubygems_version): a foreign-platform gem is genuinely "not + # found", but a gem that exists yet is incompatible with the running Ruby + # should flow through the solver to a DependencyResolutionError that names + # the Ruby requirement. That matches Bundler (which models Ruby as a + # synthetic dependency, so this surfaces as a solve failure) and gives a + # clearer message than the platform-oriented UnsatisfiableDependencyError. + @needed.each do |dep| + next if @soft_missing + dep_request = DependencyRequest.new(dep, nil) + all = @set.find_all(dep_request) + matching = select_local_platforms(all) + + next unless matching.empty? + + exc = Gem::UnsatisfiableDependencyError.new(dep_request, all) + exc.errors = @set.errors + raise exc + end - data = yield - $stderr.printf "%10s (%d entries)\n", stage.to_s.upcase, data.size - PP.pp data, $stderr unless data.empty? + solver = Gem::PubGrub::VersionSolver.new( + source: self, + root: @root_package, + strategy: Gem::Resolver::Strategy.new(self), + logger: make_logger + ) + result = solver.solve + + # Convert to Array<ActivationRequest> + needed_by_name = @needed.group_by(&:name) + result.filter_map do |package, version| + next if Gem::PubGrub::Package.root?(package) + spec = spec_for(package.to_s, version) + dep = needed_by_name[package.to_s]&.first || Gem::Dependency.new(package.to_s) + dep_request = DependencyRequest.new(dep, nil) + ActivationRequest.new(spec, dep_request) + end + rescue Gem::PubGrub::SolveFailure => e + extended = extract_extended_explanation(e.incompatibility) + if extended + message = "#{e.explanation}\n\n#{extended}" + raise Gem::DependencyResolutionError, Struct.new(:explanation).new(message) + else + raise Gem::DependencyResolutionError, e + end end - ## - # Creates an ActivationRequest for the given +dep+ and the last +possible+ - # specification. - # - # Returns the Specification and the ActivationRequest + # PubGrub source interface methods + + def all_versions_for(package) + versions = @sorted_versions[package].reverse # highest first + name = package.to_s + + if (skip_dep_gems = skip_gems[name]) && !skip_dep_gems.empty? + # Conservative mode: float the already-installed (skip) versions to the + # front so the solver prefers them. This sets *preference* only (it feeds + # the strategy's version-index map); it does not restrict availability, so + # every version stays selectable via versions_for. When an installed + # version is made impossible by a downstream conflict, the solver + # backtracks to a newer version instead of failing. Molinillo instead + # hard-restricted the candidate set to skip versions and raised. + # + # This reaches the same outcome as Bundler (upgrade-over-raise) for the + # common single-blocked-gem case, though the mechanism differs: Bundler + # hard-pins locked gems and selectively unlocks + re-solves on conflict, + # whereas we float as a preference and let PubGrub backtrack in one solve. + # The float can therefore over-upgrade when several installed gems are + # jointly involved in a conflict; that outcome-level divergence is + # accepted (see test_conservative_upgrades_when_installed_blocked). + skip_versions = skip_dep_gems.map(&:version) + preferred, rest = versions.partition {|v| skip_versions.include?(v) } + preferred + rest + else + # Prefer already-installed versions to avoid unnecessary upgrades + installed_versions = @all_specs[name]. + select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) }. + map(&:version) + if installed_versions.any? + preferred, rest = versions.partition {|v| installed_versions.include?(v) } + preferred + rest + else + versions + end + end + end + + def versions_for(package, range = Gem::PubGrub::VersionRange.any) + @versions_for_cache[package][range] ||= begin + candidates = range.select_versions(@sorted_versions[package]) - def activation_request dep, possible # :nodoc: - spec = possible.pop + if Gem::PubGrub::Package.root?(package) || + (@set.respond_to?(:prerelease) && @set.prerelease) || + range_admits_prerelease?(range) + candidates + elsif @all_versions[package].any? {|v| !v.prerelease? } + candidates.reject(&:prerelease?) + else + # Only prereleases exist for this gem; fall back to them so + # dependencies like `>= 1.0` can still be satisfied. + candidates + end + end + end + + def no_versions_incompatibility_for(_package, unsatisfied_term) + cause = Gem::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term) - explain :activate, [spec.full_name, possible.size] - explain :possible, possible + name = unsatisfied_term.package.to_s + constraint = unsatisfied_term.constraint + extended_explanation = build_extended_explanation(name, constraint) - activation_request = - Gem::Resolver::ActivationRequest.new spec, dep, possible + custom_explanation = if extended_explanation + "#{constraint} could not be found in any repository" + end - return spec, activation_request + Gem::Resolver::Incompatibility.new( + [unsatisfied_term], + cause: cause, + custom_explanation: custom_explanation, + extended_explanation: extended_explanation + ) end - def requests s, act, reqs=[] # :nodoc: - return reqs if @ignore_dependencies + def incompatibilities_for(package, version) + package_deps = @cached_dependencies[package] + sorted_versions = @sorted_versions[package] + package_deps[version].filter_map do |dep_package_name, dep_constraint| + dep_package = dep_constraint.package - s.fetch_development_dependencies if @development + low = high = @version_to_index[package][version] - s.dependencies.reverse_each do |d| - next if d.type == :development and not @development - next if d.type == :development and @development_shallow and - act.development? - next if d.type == :development and @development_shallow and - act.parent + # find version low such that all >= low share the same dep + while low > 0 && + package_deps[sorted_versions[low - 1]][dep_package_name] == dep_constraint + low -= 1 + end + low = + if low == 0 + nil + else + sorted_versions[low] + end + + # find version high such that all < high share the same dep + while high < sorted_versions.length && + package_deps[sorted_versions[high]][dep_package_name] == dep_constraint + high += 1 + end + high = + if high == sorted_versions.length + nil + else + sorted_versions[high] + end + + range = Gem::PubGrub::VersionRange.new(min: low, max: high, include_min: !low.nil?) + self_constraint = Gem::PubGrub::VersionConstraint.new(package, range: range) + + # No specs anywhere means an unknown package. Check @unfiltered_specs, not + # the filtered set, so a dep filtered out by platform/Ruby/prerelease falls + # through to NoVersions for proper hints instead. The band-scoped + # self_constraint lets clean sibling versions still resolve via backtracking. + if @unfiltered_specs[dep_package_name].empty? + cause = Gem::PubGrub::Incompatibility::InvalidDependency.new(dep_package, dep_constraint) + self_term = Gem::PubGrub::Term.new(self_constraint, true) + # PubGrub's default InvalidDependency rendering drops the version + # requirement ("depends on unknown package bar"). Supply a custom + # explanation so the missing dependency's constraint is preserved + # ("depends on bar = 0.5 which could not be found in any repository"), + # matching Molinillo's diagnostics. + return [Gem::PubGrub::Incompatibility.new( + [self_term], + cause: cause, + custom_explanation: "#{self_term.to_s(allow_every: true)} depends on #{dep_constraint} which could not be found in any repository" + )] + end - reqs << Gem::Resolver::DependencyRequest.new(d, act) - @stats.requirement! + # An empty range means the requirement is self-contradictory (e.g. `> 2, < 1`). + if dep_constraint.range.empty? + return [Gem::Resolver::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true)], + cause: Gem::PubGrub::Incompatibility::NoVersions.new(dep_constraint), + custom_explanation: "#{dep_package_name} cannot satisfy contradictory requirements #{dep_constraint.constraint_string}" + )] + end + + Gem::PubGrub::Incompatibility.new( + [Gem::PubGrub::Term.new(self_constraint, true), Gem::PubGrub::Term.new(dep_constraint, false)], + cause: :dependency + ) end + end - @set.prefetch reqs + ## + # Returns the gems in +specs+ that match the local platform. + + def select_local_platforms(specs) # :nodoc: + specs.select do |spec| + Gem::Platform.installable? spec + end + end - @stats.record_requirements reqs + private - reqs + def package_for(name) + @packages[name] ||= Gem::PubGrub::Package.new(name) end - include Molinillo::UI + def root_dependencies + deps = {} + @needed.each do |dep| + constraint = Gem::PubGrub::RubyGems.requirement_to_constraint(package_for(dep.name), dep.requirement) + deps[dep.name] = deps.key?(dep.name) ? deps[dep.name].intersect(constraint) : constraint + end + deps + end - def output - @output ||= debug? ? $stdout : File.open(Gem::Util::NULL_DEVICE, 'w') + # Only the min bound is inspected: `~>` synthesises a max like `X.A` + # whose suffix looks prerelease to Gem::Version but is not the user's + # intent, so checking max would mis-admit prereleases for every `~>`. + def range_admits_prerelease?(range) + range.ranges.any? do |r| + next false if r.empty? + r.min&.prerelease? + end end - def debug? - DEBUG_RESOLVER + def find_unfiltered_specs_for(name) + dep = Gem::Dependency.new(name, ">= 0.a") + dep_request = DependencyRequest.new(dep, nil) + @set.find_all(dep_request) end - include Molinillo::SpecificationProvider + def filter_specs(specs) + filtered = select_local_platforms(specs) - ## - # Proceed with resolution! Returns an array of ActivationRequest objects. + unless @soft_missing + filtered = filtered.select do |s| + s.required_ruby_version.satisfied_by?(Gem.ruby_version) && + s.required_rubygems_version.satisfied_by?(Gem.rubygems_version) + rescue StandardError + true + end + end - def resolve - locking_dg = Molinillo::DependencyGraph.new - Molinillo::Resolver.new(self, self).resolve(@needed.map { |d| DependencyRequest.new d, nil }, locking_dg).tsort.map(&:payload).compact - rescue Molinillo::VersionConflict => e - conflict = e.conflicts.values.first - raise Gem::DependencyResolutionError, Conflict.new(conflict.requirement_trees.first.first, conflict.existing, conflict.requirement) - ensure - @output.close if defined?(@output) and !debug? + filtered end - ## - # Extracts the specifications that may be able to fulfill +dependency+ and - # returns those that match the local platform and all those that match. + def spec_for(name, version) + @spec_for_cache[name][version] + end - def find_possible dependency # :nodoc: - all = @set.find_all dependency + def build_spec_for_cache(name) + # Rank sources by the order they were first supplied so that, when multiple + # sources offer the same version and platform, the earlier source wins. + source_rank = {} + @all_specs[name].each do |s| + source_rank[s.source] ||= source_rank.size + end - if (skip_dep_gems = skip_gems[dependency.name]) && !skip_dep_gems.empty? - matching = all.select do |api_spec| - skip_dep_gems.any? { |s| api_spec.version == s.version } - end + @all_specs[name].group_by(&:version).transform_values do |candidates| + next candidates.first if candidates.length == 1 - all = matching unless matching.empty? + # Prefer already-installed specs to avoid unnecessary downloads + installed = candidates.select {|s| s.is_a?(Gem::Resolver::InstalledSpecification) } + next installed.first if installed.length == 1 + candidates = installed if installed.any? + + # Among remaining candidates, prefer the most specific platform, then the + # earlier-supplied source. + candidates.min_by do |s| + [Gem::Platform.platform_specificity_match(s.platform, Gem::Platform.local), + source_rank[s.source]] + end end + end - matching_platform = select_local_platforms all + def compute_dependencies(package, version) + spec = spec_for(package.to_s, version) + return {} unless spec + return {} if @ignore_dependencies - return matching_platform, all - end + spec.fetch_development_dependencies if @development && spec.respond_to?(:fetch_development_dependencies) - ## - # Returns the gems in +specs+ that match the local platform. + deps = {} + root_names = @needed.map(&:name) - def select_local_platforms specs # :nodoc: - specs.select do |spec| - Gem::Platform.installable? spec + spec.dependencies.each do |d| + next if d.name == package.to_s + next if d.type == :development && !@development + next if d.type == :development && @development_shallow && !root_names.include?(package.to_s) + + dep_package = package_for(d.name) + + # In force mode, skip deps that can't be satisfied - either no + # specs at all, or no specs matching the version requirement. + if @soft_missing + dep_specs = @all_specs[d.name] + matching = dep_specs.select {|s| d.requirement.satisfied_by?(s.version) } + next if matching.empty? + end + + deps[d.name] = Gem::PubGrub::RubyGems.requirement_to_constraint(dep_package, d.requirement) end + + deps end - def search_for(dependency) - possibles, all = find_possible(dependency) - if !@soft_missing && possibles.empty? - @missing << dependency - exc = Gem::UnsatisfiableDependencyError.new dependency, all - exc.errors = @set.errors - raise exc - end + def build_extended_explanation(name, constraint) + unfiltered = @unfiltered_specs[name] + return if unfiltered.empty? - sources = [] + filtered = @all_specs[name] + pkg = package_for(name) - groups = Hash.new { |hash, key| hash[key] = [] } + # A prerelease hint applies when the source would strip prereleases for + # this constraint (global prerelease flag off and the constraint's range + # doesn't itself reach into prerelease territory) AND a prerelease of + # the gem exists somewhere. + prerelease_gated = !(@set.respond_to?(:prerelease) && @set.prerelease) && + !range_admits_prerelease?(constraint.range) + has_prerelease_candidate = prerelease_gated && + @all_versions[pkg].any?(&:prerelease?) - # create groups & sources in the same loop - sources = possibles.map { |spec| - source = spec.source - groups[source] << spec - source - }.uniq.reverse + return if filtered.length == unfiltered.length && !has_prerelease_candidate - activation_requests = [] + hints = [] - sources.each do |source| - groups[source]. - sort_by { |spec| [spec.version, Gem::Platform.local =~ spec.platform ? 1 : 0] }. - map { |spec| ActivationRequest.new spec, dependency, [] }. - each { |activation_request| activation_requests << activation_request } + # Check for specs that exist for other platforms + platform_specs = unfiltered.select do |s| + !Gem::Platform.installable?(s) && constraint.range.include?(s.version) + end + if platform_specs.any? + label = "#{name} (#{constraint.constraint_string})" + hints << "The source contains the following gems matching '#{label}':" + platform_specs.each do |s| + actual = s.respond_to?(:spec) ? s.spec : s + hints << " * #{actual.full_name}" + end end - activation_requests - end + # Check for specs filtered by Ruby version + installable = select_local_platforms(unfiltered) + ruby_specs = installable.select do |s| + actual = s.respond_to?(:spec) ? s.spec : s + constraint.range.include?(s.version) && + !actual.required_ruby_version.satisfied_by?(Gem.ruby_version) + rescue StandardError + false + end + if ruby_specs.any? + versions = ruby_specs.map(&:version).uniq.sort.reverse.first(3) + sample = ruby_specs.find {|s| s.version == versions.first } + actual = sample.respond_to?(:spec) ? sample.spec : sample + ruby_req = actual.required_ruby_version + hints << "#{name} #{versions.join(", ")} requires Ruby #{ruby_req} (you have #{Gem.ruby_version})" + end - def dependencies_for(specification) - return [] if @ignore_dependencies - spec = specification.spec - requests(spec, specification) - end + # Check for specs filtered by prerelease status + if prerelease_gated + prerelease_versions = @all_versions[pkg].select(&:prerelease?) + if prerelease_versions.any? + versions = prerelease_versions.sort.reverse.first(3) # limit to avoid cluttering error output + hints << "#{name} #{versions.join(", ")} are pre-release versions. Use --prerelease to allow pre-release gems." + end + end - def requirement_satisfied_by?(requirement, activated, spec) - requirement.matches_spec? spec + hints.empty? ? nil : hints.join("\n") end - def name_for(dependency) - dependency.name + def extract_extended_explanation(incompatibility) + while incompatibility.cause.is_a?(Gem::PubGrub::Incompatibility::ConflictCause) + cause = incompatibility.cause + + [cause.conflict, cause.other].each do |incompat| + if incompat.cause.is_a?(Gem::PubGrub::Incompatibility::NoVersions) && + incompat.respond_to?(:extended_explanation) && + incompat.extended_explanation + return incompat.extended_explanation + end + end + + incompatibility = cause.conflict + end + + nil end - def allow_missing?(dependency) - @missing << dependency - @soft_missing + def make_logger + DEBUG_RESOLVER ? Gem::PubGrub::StderrLogger.new : Gem::PubGrub::NullLogger.new end - def sort_dependencies(dependencies, activated, conflicts) - dependencies.sort_by.with_index do |dependency, i| - name = name_for(dependency) - [ - activated.vertex_named(name).payload ? 0 : 1, - amount_constrained(dependency), - conflicts[name] ? 0 : 1, - activated.vertex_named(name).payload ? 0 : search_for(dependency).count, - i # for stable sort - ] + # Custom root package so error messages say "your request depends on..." + # instead of PubGrub's default "root depends on...". + class RootPackage < Gem::PubGrub::Package + def initialize + super(:root) end - end - SINGLE_POSSIBILITY_CONSTRAINT_PENALTY = 1_000_000 - private_constant :SINGLE_POSSIBILITY_CONSTRAINT_PENALTY if defined?(private_constant) + def root? + true + end - # returns an integer \in (-\infty, 0] - # a number closer to 0 means the dependency is less constraining - # - # dependencies w/ 0 or 1 possibilities (ignoring version requirements) - # are given very negative values, so they _always_ sort first, - # before dependencies that are unconstrained - def amount_constrained(dependency) - @amount_constrained ||= {} - @amount_constrained[dependency.name] ||= begin - name_dependency = Gem::Dependency.new(dependency.name) - dependency_request_for_name = Gem::Resolver::DependencyRequest.new(name_dependency, dependency.requester) - all = @set.find_all(dependency_request_for_name).size - - if all <= 1 - all - SINGLE_POSSIBILITY_CONSTRAINT_PENALTY - else - search = search_for(dependency).size - search - all - end + def to_s + "your request" end end - private :amount_constrained - end -## -# TODO remove in RubyGems 3 - -Gem::DependencyResolver = Gem::Resolver # :nodoc: - -require 'rubygems/resolver/activation_request' -require 'rubygems/resolver/conflict' -require 'rubygems/resolver/dependency_request' -require 'rubygems/resolver/requirement_list' -require 'rubygems/resolver/stats' - -require 'rubygems/resolver/set' -require 'rubygems/resolver/api_set' -require 'rubygems/resolver/composed_set' -require 'rubygems/resolver/best_set' -require 'rubygems/resolver/current_set' -require 'rubygems/resolver/git_set' -require 'rubygems/resolver/index_set' -require 'rubygems/resolver/installer_set' -require 'rubygems/resolver/lock_set' -require 'rubygems/resolver/vendor_set' -require 'rubygems/resolver/source_set' - -require 'rubygems/resolver/specification' -require 'rubygems/resolver/spec_specification' -require 'rubygems/resolver/api_specification' -require 'rubygems/resolver/git_specification' -require 'rubygems/resolver/index_specification' -require 'rubygems/resolver/installed_specification' -require 'rubygems/resolver/local_specification' -require 'rubygems/resolver/lock_specification' -require 'rubygems/resolver/vendor_specification' +require_relative "resolver/activation_request" +require_relative "resolver/dependency_request" +require_relative "resolver/incompatibility" +require_relative "resolver/strategy" +require_relative "resolver/requirement_list" +require_relative "resolver/set" +require_relative "resolver/api_set" +require_relative "resolver/composed_set" +require_relative "resolver/best_set" +require_relative "resolver/current_set" +require_relative "resolver/git_set" +require_relative "resolver/index_set" +require_relative "resolver/installer_set" +require_relative "resolver/lock_set" +require_relative "resolver/vendor_set" +require_relative "resolver/source_set" + +require_relative "resolver/specification" +require_relative "resolver/spec_specification" +require_relative "resolver/api_specification" +require_relative "resolver/git_specification" +require_relative "resolver/index_specification" +require_relative "resolver/installed_specification" +require_relative "resolver/local_specification" +require_relative "resolver/lock_specification" +require_relative "resolver/vendor_specification" |
