Skip to content

Commit

Permalink
Migrate our resolver engine to PubGrub
Browse files Browse the repository at this point in the history
  rubygems/rubygems#5960

  Co-authored-by: David Rodríguez <deivid.rodriguez@riseup.net>
  • Loading branch information
hsbt committed Nov 11, 2022
1 parent 14a1394 commit 0a9d51e
Show file tree
Hide file tree
Showing 75 changed files with 2,710 additions and 3,152 deletions.
1 change: 0 additions & 1 deletion lib/bundler.rb
Expand Up @@ -75,7 +75,6 @@ module Bundler
autoload :StubSpecification, File.expand_path("bundler/stub_specification", __dir__)
autoload :UI, File.expand_path("bundler/ui", __dir__)
autoload :URICredentialsFilter, File.expand_path("bundler/uri_credentials_filter", __dir__)
autoload :VersionRanges, File.expand_path("bundler/version_ranges", __dir__)

class << self
def configure
Expand Down
2 changes: 1 addition & 1 deletion lib/bundler/cli/check.rb
Expand Up @@ -17,7 +17,7 @@ def run
begin
definition.resolve_only_locally!
not_installed = definition.missing_specs
rescue GemNotFound, VersionConflict
rescue GemNotFound, SolveFailure
Bundler.ui.error "Bundler can't satisfy your Gemfile's dependencies."
Bundler.ui.warn "Install missing gems with `bundle install`."
exit 1
Expand Down
6 changes: 3 additions & 3 deletions lib/bundler/cli/lock.rb
Expand Up @@ -15,8 +15,8 @@ def run
end

print = options[:print]
ui = Bundler.ui
Bundler.ui = UI::Silent.new if print
previous_ui_level = Bundler.ui.level
Bundler.ui.level = "silent" if print

Bundler::Fetcher.disable_endpoint = options["full-index"]

Expand Down Expand Up @@ -61,7 +61,7 @@ def run
definition.lock(file)
end

Bundler.ui = ui
Bundler.ui.level = previous_ui_level
end
end
end
35 changes: 29 additions & 6 deletions lib/bundler/definition.rb
Expand Up @@ -150,7 +150,7 @@ def initialize(lockfile, dependencies, sources, unlock, ruby_version = nil, opti
end

def gem_version_promoter
@gem_version_promoter ||= GemVersionPromoter.new(@originally_locked_specs, @unlock[:gems])
@gem_version_promoter ||= GemVersionPromoter.new
end

def resolve_only_locally!
Expand Down Expand Up @@ -276,7 +276,7 @@ def resolve
end
else
Bundler.ui.debug("Found changes from the lockfile, re-resolving dependencies because #{change_reason}")
resolver.start(expanded_dependencies)
start_resolution
end
end

Expand All @@ -299,7 +299,7 @@ def lock(file, preserve_unknown_sections = false)

if @locked_bundler_version
locked_major = @locked_bundler_version.segments.first
current_major = Gem::Version.create(Bundler::VERSION).segments.first
current_major = Bundler.gem_version.segments.first

updating_major = locked_major < current_major
end
Expand Down Expand Up @@ -474,14 +474,31 @@ def resolver
@resolver ||= begin
last_resolve = converge_locked_specs
remove_ruby_from_platforms_if_necessary!(current_dependencies)
Resolver.new(source_requirements, last_resolve, gem_version_promoter, additional_base_requirements_for_resolve(last_resolve), platforms)
Resolver.new(source_requirements, last_resolve, gem_version_promoter, additional_base_requirements_for_resolve(last_resolve))
end
end

def expanded_dependencies
@expanded_dependencies ||= dependencies + metadata_dependencies
end

def resolution_packages
@resolution_packages ||= begin
packages = Hash.new do |h, k|
h[k] = Resolver::Package.new(k, @platforms, @originally_locked_specs, @unlock[:gems])
end

expanded_dependencies.each do |dep|
name = dep.name
platforms = dep.gem_platforms(@platforms)

packages[name] = Resolver::Package.new(name, platforms, @originally_locked_specs, @unlock[:gems], :dependency => dep)
end

packages
end
end

def filter_specs(specs, deps)
SpecSet.new(specs).for(deps, false, platforms)
end
Expand Down Expand Up @@ -512,16 +529,22 @@ def materialize(dependencies)
break if incomplete_specs.empty?

Bundler.ui.debug("The lockfile does not have all gems needed for the current platform though, Bundler will still re-resolve dependencies")
@resolve = resolver.start(expanded_dependencies, :exclude_specs => incomplete_specs)
@resolve = start_resolution(:exclude_specs => incomplete_specs)
specs = resolve.materialize(dependencies)
end

bundler = sources.metadata_source.specs.search(Gem::Dependency.new("bundler", VERSION)).last
bundler = sources.metadata_source.specs.search(["bundler", Bundler.gem_version]).last
specs["bundler"] = bundler

specs
end

def start_resolution(exclude_specs: [])
result = resolver.start(expanded_dependencies, resolution_packages, :exclude_specs => exclude_specs)

SpecSet.new(SpecSet.new(result).for(dependencies, false, @platforms))
end

def precompute_source_requirements_for_indirect_dependencies?
@remote && sources.non_global_rubygems_sources.all?(&:dependency_api_available?) && !sources.aggregate_global_source?
end
Expand Down
1 change: 1 addition & 0 deletions lib/bundler/dependency.rb
Expand Up @@ -50,6 +50,7 @@ def initialize(name, version, options = {}, &blk)
# Returns the platforms this dependency is valid for, in the same order as
# passed in the `valid_platforms` parameter
def gem_platforms(valid_platforms)
return [Gem::Platform::RUBY] if @force_ruby_platform
return valid_platforms if @platforms.empty?

valid_platforms.select {|p| expanded_platforms.include?(GemHelpers.generic(p)) }
Expand Down
11 changes: 1 addition & 10 deletions lib/bundler/errors.rb
Expand Up @@ -21,16 +21,7 @@ class GemfileError < BundlerError; status_code(4); end
class InstallError < BundlerError; status_code(5); end

# Internal error, should be rescued
class VersionConflict < BundlerError
attr_reader :conflicts

def initialize(conflicts, msg = nil)
super(msg)
@conflicts = conflicts
end

status_code(6)
end
class SolveFailure < BundlerError; status_code(6); end

class GemNotFound < BundlerError; status_code(7); end
class InstallHookError < BundlerError; status_code(8); end
Expand Down
121 changes: 42 additions & 79 deletions lib/bundler/gem_version_promoter.rb
Expand Up @@ -7,9 +7,7 @@ module Bundler
# available dependency versions as found in its index, before returning it to
# to the resolution engine to select the best version.
class GemVersionPromoter
DEBUG = ENV["BUNDLER_DEBUG_RESOLVER"] || ENV["DEBUG_RESOLVER"]

attr_reader :level, :locked_specs, :unlock_gems
attr_reader :level

# By default, strict is false, meaning every available version of a gem
# is returned from sort_versions. The order gives preference to the
Expand All @@ -24,24 +22,12 @@ class GemVersionPromoter
# existing in the referenced source.
attr_accessor :strict

attr_accessor :prerelease_specified

# Given a list of locked_specs and a list of gems to unlock creates a
# GemVersionPromoter instance.
# Creates a GemVersionPromoter instance.
#
# @param locked_specs [SpecSet] All current locked specs. Unlike Definition
# where this list is empty if all gems are being updated, this should
# always be populated for all gems so this class can properly function.
# @param unlock_gems [String] List of gem names being unlocked. If empty,
# all gems will be considered unlocked.
# @return [GemVersionPromoter]
def initialize(locked_specs = SpecSet.new([]), unlock_gems = [])
def initialize
@level = :major
@strict = false
@locked_specs = locked_specs
@unlock_gems = unlock_gems
@sort_versions = {}
@prerelease_specified = {}
end

# @param value [Symbol] One of three Symbols: :major, :minor or :patch.
Expand All @@ -55,34 +41,19 @@ def level=(value)
@level = v
end

# Given a Dependency and an Array of Specifications of available versions for a
# gem, this method will return the Array of Specifications sorted (and possibly
# truncated if strict is true) in an order to give preference to the current
# level (:major, :minor or :patch) when resolution is deciding what versions
# best resolve all dependencies in the bundle.
# @param dep [Dependency] The Dependency of the gem.
# @param spec_groups [Specification] An array of Specifications for the same gem
# named in the @dep param.
# Given a Resolver::Package and an Array of Specifications of available
# versions for a gem, this method will return the Array of Specifications
# sorted (and possibly truncated if strict is true) in an order to give
# preference to the current level (:major, :minor or :patch) when resolution
# is deciding what versions best resolve all dependencies in the bundle.
# @param package [Resolver::Package] The package being resolved.
# @param specs [Specification] An array of Specifications for the package.
# @return [Specification] A new instance of the Specification Array sorted and
# possibly filtered.
def sort_versions(dep, spec_groups)
@sort_versions[dep] ||= begin
gem_name = dep.name
def sort_versions(package, specs)
specs = filter_dep_specs(specs, package) if strict

# An Array per version returned, different entries for different platforms.
# We only need the version here so it's ok to hard code this to the first instance.
locked_spec = locked_specs[gem_name].first

if strict
filter_dep_specs(spec_groups, locked_spec)
else
sort_dep_specs(spec_groups, locked_spec)
end
end
end

def reset
@sort_versions = {}
sort_dep_specs(specs, package)
end

# @return [bool] Convenience method for testing value of level variable.
Expand All @@ -97,11 +68,13 @@ def minor?

private

def filter_dep_specs(spec_groups, locked_spec)
res = spec_groups.select do |spec_group|
if locked_spec && !major?
gsv = spec_group.version
lsv = locked_spec.version
def filter_dep_specs(specs, package)
locked_version = package.locked_version

specs.select do |spec|
if locked_version && !major?
gsv = spec.version
lsv = locked_version

must_match = minor? ? [0] : [0, 1]

Expand All @@ -111,63 +84,53 @@ def filter_dep_specs(spec_groups, locked_spec)
true
end
end

sort_dep_specs(res, locked_spec)
end

def sort_dep_specs(spec_groups, locked_spec)
@locked_version = locked_spec&.version
@gem_name = locked_spec&.name
def sort_dep_specs(specs, package)
locked_version = package.locked_version

result = spec_groups.sort do |a, b|
@a_ver = a.version
@b_ver = b.version

unless @gem_name && @prerelease_specified[@gem_name]
a_pre = @a_ver.prerelease?
b_pre = @b_ver.prerelease?
result = specs.sort do |a, b|
unless locked_version && package.prerelease_specified?
a_pre = a.prerelease?
b_pre = b.prerelease?

next -1 if a_pre && !b_pre
next 1 if b_pre && !a_pre
end

if major?
@a_ver <=> @b_ver
elsif either_version_older_than_locked
@a_ver <=> @b_ver
elsif segments_do_not_match(:major)
@b_ver <=> @a_ver
elsif !minor? && segments_do_not_match(:minor)
@b_ver <=> @a_ver
a <=> b
elsif either_version_older_than_locked(a, b, locked_version)
a <=> b
elsif segments_do_not_match(a, b, :major)
b <=> a
elsif !minor? && segments_do_not_match(a, b, :minor)
b <=> a
else
@a_ver <=> @b_ver
a <=> b
end
end
post_sort(result)
post_sort(result, package.unlock?, locked_version)
end

def either_version_older_than_locked
@locked_version && (@a_ver < @locked_version || @b_ver < @locked_version)
def either_version_older_than_locked(a, b, locked_version)
locked_version && (a.version < locked_version || b.version < locked_version)
end

def segments_do_not_match(level)
def segments_do_not_match(a, b, level)
index = [:major, :minor].index(level)
@a_ver.segments[index] != @b_ver.segments[index]
end

def unlocking_gem?
unlock_gems.empty? || (@gem_name && unlock_gems.include?(@gem_name))
a.segments[index] != b.segments[index]
end

# Specific version moves can't always reliably be done during sorting
# as not all elements are compared against each other.
def post_sort(result)
def post_sort(result, unlock, locked_version)
# default :major behavior in Bundler does not do this
return result if major?
if unlocking_gem? || @locked_version.nil?
if unlock || locked_version.nil?
result
else
move_version_to_end(result, @locked_version)
move_version_to_end(result, locked_version)
end
end

Expand Down
18 changes: 5 additions & 13 deletions lib/bundler/index.rb
Expand Up @@ -70,7 +70,7 @@ def local_search(query)
case query
when Gem::Specification, RemoteSpecification, LazySpecification, EndpointSpecification then search_by_spec(query)
when String then specs_by_name(query)
when Gem::Dependency then search_by_dependency(query)
when Array then specs_by_name_and_version(*query)
else
raise "You can't search for a #{query.inspect}."
end
Expand Down Expand Up @@ -157,20 +157,12 @@ def add_source(index)

private

def specs_by_name(name)
@specs[name].values
def specs_by_name_and_version(name, version)
specs_by_name(name).select {|spec| spec.version == version }
end

def search_by_dependency(dependency)
@cache[dependency] ||= begin
specs = specs_by_name(dependency.name)
found = specs.select do |spec|
next true if spec.source.is_a?(Source::Gemspec)
dependency.matches_spec?(spec)
end

found
end
def specs_by_name(name)
@specs[name].values
end

EMPTY_SEARCH = [].freeze
Expand Down
4 changes: 2 additions & 2 deletions lib/bundler/inline.rb
Expand Up @@ -34,7 +34,8 @@ def gemfile(install = false, options = {}, &gemfile)

opts = options.dup
ui = opts.delete(:ui) { Bundler::UI::Shell.new }
ui.level = "silent" if opts.delete(:quiet)
ui.level = "silent" if opts.delete(:quiet) || !install
Bundler.ui = ui
raise ArgumentError, "Unknown options: #{opts.keys.join(", ")}" unless opts.empty?

begin
Expand All @@ -52,7 +53,6 @@ def gemfile(install = false, options = {}, &gemfile)
def definition.lock(*); end
definition.validate_runtime!

Bundler.ui = install ? ui : Bundler::UI::Silent.new
if install || definition.missing_specs?
Bundler.settings.temporary(:inline => true, :no_install => false) do
installer = Bundler::Installer.install(Bundler.root, definition, :system => true)
Expand Down
2 changes: 1 addition & 1 deletion lib/bundler/lazy_specification.rb
Expand Up @@ -79,7 +79,7 @@ def materialize_for_installation
candidates = if source.is_a?(Source::Path) || !ruby_platform_materializes_to_ruby_platform?
target_platform = ruby_platform_materializes_to_ruby_platform? ? platform : local_platform

GemHelpers.select_best_platform_match(source.specs.search(Dependency.new(name, version)), target_platform)
GemHelpers.select_best_platform_match(source.specs.search([name, version]), target_platform)
else
source.specs.search(self)
end
Expand Down

0 comments on commit 0a9d51e

Please sign in to comment.