summaryrefslogtreecommitdiff
path: root/lib/bundler/gem_version_promoter.rb
blob: c7eacd193049a37438d0a4f0b6a67ff74f465587 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# frozen_string_literal: true

module Bundler
  # This class contains all of the logic for determining the next version of a
  # Gem to update to based on the requested level (patch, minor, major).
  # Primarily designed to work with Resolver which will provide it the list of
  # available dependency versions as found in its index, before returning it to
  # to the resolution engine to select the best version.
  class GemVersionPromoter
    attr_reader :level
    attr_accessor :pre

    # By default, strict is false, meaning every available version of a gem
    # is returned from sort_versions. The order gives preference to the
    # requested level (:patch, :minor, :major) but in complicated requirement
    # cases some gems will by necessity be promoted past the requested level,
    # or even reverted to older versions.
    #
    # If strict is set to true, the results from sort_versions will be
    # truncated, eliminating any version outside the current level scope.
    # This can lead to unexpected outcomes or even VersionConflict exceptions
    # that report a version of a gem not existing for versions that indeed do
    # existing in the referenced source.
    attr_accessor :strict

    # Creates a GemVersionPromoter instance.
    #
    # @return [GemVersionPromoter]
    def initialize
      @level = :major
      @strict = false
      @pre = false
    end

    # @param value [Symbol] One of three Symbols: :major, :minor or :patch.
    def level=(value)
      v = case value
          when String, Symbol
            value.to_sym
      end

      raise ArgumentError, "Unexpected level #{v}. Must be :major, :minor or :patch" unless [:major, :minor, :patch].include?(v)
      @level = v
    end

    # 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(package, specs)
      specs = filter_dep_specs(specs, package) if strict

      sort_dep_specs(specs, package)
    end

    # @return [bool] Convenience method for testing value of level variable.
    def major?
      level == :major
    end

    # @return [bool] Convenience method for testing value of level variable.
    def minor?
      level == :minor
    end

    # @return [bool] Convenience method for testing value of pre variable.
    def pre?
      pre == true
    end

    private

    def filter_dep_specs(specs, package)
      locked_version = package.locked_version
      return specs if locked_version.nil? || major?

      specs.select do |spec|
        gsv = spec.version

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

        all_match = must_match.all? {|idx| gsv.segments[idx] == locked_version.segments[idx] }
        all_match && gsv >= locked_version
      end
    end

    def sort_dep_specs(specs, package)
      locked_version = package.locked_version

      result = specs.sort do |a, b|
        unless package.prerelease_specified? || pre?
          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? || locked_version.nil?
          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 <=> b
        end
      end
      post_sort(result, package.unlock?, locked_version)
    end

    def either_version_older_than_locked?(a, b, locked_version)
      a.version < locked_version || b.version < locked_version
    end

    def segments_do_not_match?(a, b, level)
      index = [:major, :minor].index(level)
      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, unlock, locked_version)
      # default :major behavior in Bundler does not do this
      return result if major?
      if unlock || locked_version.nil?
        result
      else
        move_version_to_end(result, locked_version)
      end
    end

    def move_version_to_end(result, version)
      move, keep = result.partition {|s| s.version.to_s == version.to_s }
      keep.concat(move)
    end
  end
end