summaryrefslogtreecommitdiff
path: root/lib/bundler/lockfile_parser.rb
blob: 1e11621e55ed24c482a0ca3df34bf1b2ee9f6874 (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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# frozen_string_literal: true

module Bundler
  class LockfileParser
    class Position
      attr_reader :line, :column
      def initialize(line, column)
        @line = line
        @column = column
      end

      def advance!(string)
        lines = string.count("\n")
        if lines > 0
          @line += lines
          @column = string.length - string.rindex("\n")
        else
          @column += string.length
        end
      end

      def to_s
        "#{line}:#{column}"
      end
    end

    attr_reader(
      :sources,
      :dependencies,
      :specs,
      :platforms,
      :bundler_version,
      :ruby_version,
      :checksums,
    )

    BUNDLED      = "BUNDLED WITH"
    DEPENDENCIES = "DEPENDENCIES"
    CHECKSUMS    = "CHECKSUMS"
    PLATFORMS    = "PLATFORMS"
    RUBY         = "RUBY VERSION"
    GIT          = "GIT"
    GEM          = "GEM"
    PATH         = "PATH"
    PLUGIN       = "PLUGIN SOURCE"
    SPECS        = "  specs:"
    OPTIONS      = /^  ([a-z]+): (.*)$/i
    SOURCE       = [GIT, GEM, PATH, PLUGIN].freeze

    SECTIONS_BY_VERSION_INTRODUCED = {
      Gem::Version.create("1.0") => [DEPENDENCIES, PLATFORMS, GIT, GEM, PATH].freeze,
      Gem::Version.create("1.10") => [BUNDLED].freeze,
      Gem::Version.create("1.12") => [RUBY].freeze,
      Gem::Version.create("1.13") => [PLUGIN].freeze,
      Gem::Version.create("2.5.0") => [CHECKSUMS].freeze,
    }.freeze

    KNOWN_SECTIONS = SECTIONS_BY_VERSION_INTRODUCED.values.flatten!.freeze

    ENVIRONMENT_VERSION_SECTIONS = [BUNDLED, RUBY].freeze
    deprecate_constant(:ENVIRONMENT_VERSION_SECTIONS)

    def self.sections_in_lockfile(lockfile_contents)
      sections = lockfile_contents.scan(/^\w[\w ]*$/)
      sections.uniq!
      sections
    end

    def self.unknown_sections_in_lockfile(lockfile_contents)
      sections_in_lockfile(lockfile_contents) - KNOWN_SECTIONS
    end

    def self.sections_to_ignore(base_version = nil)
      base_version &&= base_version.release
      base_version ||= Gem::Version.create("1.0")
      attributes = []
      SECTIONS_BY_VERSION_INTRODUCED.each do |version, introduced|
        next if version <= base_version
        attributes += introduced
      end
      attributes
    end

    def self.bundled_with
      lockfile = Bundler.default_lockfile
      return unless lockfile.file?

      lockfile_contents = Bundler.read_file(lockfile)
      return unless lockfile_contents.include?(BUNDLED)

      lockfile_contents.split(BUNDLED).last.strip
    end

    def initialize(lockfile)
      @platforms    = []
      @sources      = []
      @dependencies = {}
      @parse_method = nil
      @specs        = {}
      @lockfile_path = begin
        SharedHelpers.relative_lockfile_path
      rescue GemfileNotFound
        "Gemfile.lock"
      end
      @pos = Position.new(1, 1)

      if lockfile.match?(/<<<<<<<|=======|>>>>>>>|\|\|\|\|\|\|\|/)
        raise LockfileError, "Your #{@lockfile_path} contains merge conflicts.\n" \
          "Run `git checkout HEAD -- #{@lockfile_path}` first to get a clean lock."
      end

      lockfile.split(/((?:\r?\n)+)/) do |line|
        # split alternates between the line and the following whitespace
        next @pos.advance!(line) if line.match?(/^\s*$/)

        if SOURCE.include?(line)
          @parse_method = :parse_source
          parse_source(line)
        elsif line == DEPENDENCIES
          @parse_method = :parse_dependency
        elsif line == CHECKSUMS
          # This is a temporary solution to make this feature disabled by default
          # for all gemfiles that don't already explicitly include the feature.
          @checksums = true
          @parse_method = :parse_checksum
        elsif line == PLATFORMS
          @parse_method = :parse_platform
        elsif line == RUBY
          @parse_method = :parse_ruby
        elsif line == BUNDLED
          @parse_method = :parse_bundled_with
        elsif /^[^\s]/.match?(line)
          @parse_method = nil
        elsif @parse_method
          send(@parse_method, line)
        end
        @pos.advance!(line)
      end
      @specs = @specs.values.sort_by!(&:full_name)
    rescue ArgumentError => e
      Bundler.ui.debug(e)
      raise LockfileError, "Your lockfile is unreadable. Run `rm #{@lockfile_path}` " \
        "and then `bundle install` to generate a new lockfile. The error occurred while " \
        "evaluating #{@lockfile_path}:#{@pos}"
    end

    def may_include_redundant_platform_specific_gems?
      bundler_version.nil? || bundler_version < Gem::Version.new("1.16.2")
    end

    private

    TYPES = {
      GIT => Bundler::Source::Git,
      GEM => Bundler::Source::Rubygems,
      PATH => Bundler::Source::Path,
      PLUGIN => Bundler::Plugin,
    }.freeze

    def parse_source(line)
      case line
      when SPECS
        return unless TYPES.key?(@type)
        @current_source = TYPES[@type].from_lock(@opts)
        @sources << @current_source
      when OPTIONS
        value = $2
        value = true if value == "true"
        value = false if value == "false"

        key = $1

        if @opts[key]
          @opts[key] = Array(@opts[key])
          @opts[key] << value
        else
          @opts[key] = value
        end
      when *SOURCE
        @current_source = nil
        @opts = {}
        @type = line
      else
        parse_spec(line)
      end
    end

    space = / /
    NAME_VERSION = /
      ^(#{space}{2}|#{space}{4}|#{space}{6})(?!#{space}) # Exactly 2, 4, or 6 spaces at the start of the line
      (.*?)                                              # Name
      (?:#{space}\(([^-]*)                               # Space, followed by version
      (?:-(.*))?\))?                                     # Optional platform
      (!)?                                               # Optional pinned marker
      (?:#{space}([^ ]+))?                               # Optional checksum
      $                                                  # Line end
    /xo

    def parse_dependency(line)
      return unless line =~ NAME_VERSION
      spaces = $1
      return unless spaces.size == 2
      name = -$2
      version = $3
      pinned = $5

      version = version.split(",").each(&:strip!) if version

      dep = Bundler::Dependency.new(name, version)

      if pinned && dep.name != "bundler"
        spec = @specs.find {|_, v| v.name == dep.name }
        dep.source = spec.last.source if spec

        # Path sources need to know what the default name / version
        # to use in the case that there are no gemspecs present. A fake
        # gemspec is created based on the version set on the dependency
        # TODO: Use the version from the spec instead of from the dependency
        if version && version.size == 1 && version.first =~ /^\s*= (.+)\s*$/ && dep.source.is_a?(Bundler::Source::Path)
          dep.source.name    = name
          dep.source.version = $1
        end
      end

      @dependencies[dep.name] = dep
    end

    def parse_checksum(line)
      return unless line =~ NAME_VERSION

      spaces = $1
      return unless spaces.size == 2
      checksums = $6
      return unless checksums
      name = $2
      version = $3
      platform = $4

      version = Gem::Version.new(version)
      platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY
      full_name = Gem::NameTuple.new(name, version, platform).full_name
      return unless spec = @specs[full_name]

      checksums.split(",") do |lock_checksum|
        column = line.index(lock_checksum) + 1
        checksum = Checksum.from_lock(lock_checksum, "#{@lockfile_path}:#{@pos.line}:#{column}")
        spec.source.checksum_store.register(spec, checksum)
      end
    end

    def parse_spec(line)
      return unless line =~ NAME_VERSION
      spaces = $1
      name = -$2
      version = $3

      if spaces.size == 4
        # only load platform for non-dependency (spec) line
        platform = $4

        version = Gem::Version.new(version)
        platform = platform ? Gem::Platform.new(platform) : Gem::Platform::RUBY
        @current_spec = LazySpecification.new(name, version, platform, @current_source)
        @current_source.add_dependency_names(name)

        @specs[@current_spec.full_name] = @current_spec
      elsif spaces.size == 6
        version = version.split(",").each(&:strip!) if version
        dep = Gem::Dependency.new(name, version)
        @current_spec.dependencies << dep
      end
    end

    def parse_platform(line)
      @platforms << Gem::Platform.new($1) if line =~ /^  (.*)$/
    end

    def parse_bundled_with(line)
      line.strip!
      return unless Gem::Version.correct?(line)
      @bundler_version = Gem::Version.create(line)
    end

    def parse_ruby(line)
      line.strip!
      @ruby_version = line
    end
  end
end