summaryrefslogtreecommitdiff
path: root/lib/bundled_gems.rb
blob: c5893a241efae710f54b1e7afb81181b7bd14ad5 (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
# -*- frozen-string-literal: true -*-

# :stopdoc:
module Gem
end
# :startdoc:

module Gem::BUNDLED_GEMS # :nodoc:
  SINCE = {
    "racc" => "3.3.0",
    "abbrev" => "3.4.0",
    "base64" => "3.4.0",
    "bigdecimal" => "3.4.0",
    "csv" => "3.4.0",
    "drb" => "3.4.0",
    "getoptlong" => "3.4.0",
    "mutex_m" => "3.4.0",
    "nkf" => "3.4.0",
    "observer" => "3.4.0",
    "resolv-replace" => "3.4.0",
    "rinda" => "3.4.0",
    "syslog" => "3.4.0",
    "ostruct" => "4.0.0",
    "pstore" => "4.0.0",
    "rdoc" => "4.0.0",
    "win32ole" => "4.0.0",
    "fiddle" => "4.0.0",
    "logger" => "4.0.0",
    "benchmark" => "4.0.0",
    "irb" => "4.0.0",
    "reline" => "4.0.0",
    # "readline" => "4.0.0", # This is wrapper for reline. We don't warn for this.
    "tsort" => "4.1.0",
  }.freeze

  EXACT = {
    "kconv" => "nkf",
  }.freeze

  WARNED = {}                   # unfrozen

  conf = ::RbConfig::CONFIG
  LIBDIR = (conf["rubylibdir"] + "/").freeze
  ARCHDIR = (conf["rubyarchdir"] + "/").freeze
  dlext = [conf["DLEXT"], "so"].uniq
  DLEXT = /\.#{Regexp.union(dlext)}\z/
  LIBEXT = /\.#{Regexp.union("rb", *dlext)}\z/

  def self.replace_require(specs)
    return if [::Kernel.singleton_class, ::Kernel].any? {|klass| klass.respond_to?(:no_warning_require) }

    spec_names = specs.to_a.each_with_object({}) {|spec, h| h[spec.name] = true }

    [::Kernel.singleton_class, ::Kernel].each do |kernel_class|
      kernel_class.send(:alias_method, :no_warning_require, :require)
      kernel_class.send(:define_method, :require) do |name|
        if message = ::Gem::BUNDLED_GEMS.warning?(name, specs: spec_names)
          Kernel.warn message, uplevel: ::Gem::BUNDLED_GEMS.uplevel
        end
        kernel_class.send(:no_warning_require, name)
      end
      if kernel_class == ::Kernel
        kernel_class.send(:private, :require)
      else
        kernel_class.send(:public, :require)
      end
    end
  end

  def self.uplevel
    frame_count = 0
    require_labels = ["replace_require", "require"]
    uplevel = 0
    require_found = false
    Thread.each_caller_location do |cl|
      frame_count += 1

      if require_found
        unless require_labels.include?(cl.base_label)
          return uplevel
        end
      else
        if require_labels.include?(cl.base_label)
          require_found = true
        end
      end
      uplevel += 1
      # Don't show script name when bundle exec and call ruby script directly.
      if cl.path.end_with?("bundle")
        return
      end
    end
    require_found ? 1 : (frame_count - 1).nonzero?
  end

  def self.warning?(name, specs: nil)
    # name can be a feature name or a file path with String or Pathname
    feature = File.path(name).sub(LIBEXT, "")

    # The actual checks needed to properly identify the gem being required
    # are costly (see [Bug #20641]), so we first do a much cheaper check
    # to exclude the vast majority of candidates.
    subfeature = if feature.include?("/")
      # bootsnap expands `require "csv"` to `require "#{LIBDIR}/csv.rb"`,
      # and `require "syslog"` to `require "#{ARCHDIR}/syslog.so"`.
      feature.delete_prefix!(ARCHDIR)
      feature.delete_prefix!(LIBDIR)
      # 1. A segment for the EXACT mapping and SINCE check
      # 2. A segment for the SINCE check for dashed names
      # 3. A segment to check if there's a subfeature
      segments = feature.split("/", 3)
      name = segments.shift
      name = EXACT[name] || name
      if !SINCE[name]
        name = "#{name}-#{segments.shift}"
        return unless SINCE[name]
      end
      segments.any?
    else
      name = EXACT[feature] || feature
      return unless SINCE[name]
      false
    end

    if suppress_list = Thread.current[:__bundled_gems_warning_suppression]
      return if suppress_list.include?(name) || suppress_list.include?(feature)
    end

    return if specs.include?(name)

    # Don't warn if a hyphenated gem provides this feature
    # (e.g., benchmark-ips provides benchmark/ips, benchmark/timing, etc.)
    if subfeature
      prefix = feature.split("/").first + "-"
      return if specs.any? { |spec, _| spec.start_with?(prefix) }

      # Don't warn if the feature is found outside the standard library
      # (e.g., benchmark-ips's lib dir is on $LOAD_PATH but not in specs)
      resolved = $LOAD_PATH.resolve_feature_path(feature) rescue nil
      if resolved
        return unless resolved[1].start_with?(LIBDIR) || resolved[1].start_with?(ARCHDIR)
      end
    end

    return if WARNED[name]
    WARNED[name] = true

    level = RUBY_VERSION < SINCE[name] ? :warning : :error

    if subfeature
      "#{feature} is found in #{name}, which"
    else
      "#{feature} #{level == :warning ? "was loaded" : "used to be loaded"} from the standard library, but"
    end + build_message(name, level)
  end

  def self.build_message(name, level)
    msg = if level == :warning
      " will no longer be part of the default gems starting from Ruby #{SINCE[name]}"
    else
      " is not part of the default gems since Ruby #{SINCE[name]}."
    end

    if defined?(Bundler)
      motivation = level == :warning ? "silence this warning" : "fix this error"
      msg += "\nYou can add #{name} to your Gemfile or gemspec to #{motivation}."

      # We detect the gem name from caller_locations. First we walk until we find `require`
      # then take the first frame that's not from `require`.
      #
      # Additionally, we need to skip Bootsnap and Zeitwerk if present, these
      # gems decorate Kernel#require, so they are not really the ones issuing
      # the require call users should be warned about. Those are upwards.
      frames_to_skip = 3
      location = nil
      require_found = false
      Thread.each_caller_location do |cl|
        if frames_to_skip >= 1
          frames_to_skip -= 1
          next
        end

        if require_found
          if cl.base_label != "require"
            location = cl.path
            break
          end
        else
          if cl.base_label == "require"
            require_found = true
          end
        end
      end

      if location && File.file?(location) && !location.start_with?(Gem::BUNDLED_GEMS::LIBDIR)
        caller_gem = nil
        Gem.path.each do |path|
          if location =~ %r{#{path}/gems/([\w\-\.]+)}
            caller_gem = $1
            break
          end
        end
        if caller_gem
          msg += "\nAlso please contact the author of #{caller_gem} to request adding #{name} into its gemspec."
        end
      end
    else
      msg += " Install #{name} from RubyGems."
    end

    msg
  end

  def self.force_activate(gem)
    require "bundler"
    Bundler.reset!

    # Build and activate a temporary definition containing the original gems + the requested gem
    builder = Bundler::Dsl.new

    lockfile = nil
    if Bundler::SharedHelpers.in_bundle? && Bundler.definition.gemfiles.size > 0
      Bundler.definition.gemfiles.each {|gemfile| builder.eval_gemfile(gemfile) }
      lockfile = begin
        Bundler.default_lockfile
      rescue Bundler::GemfileNotFound
        nil
      end
    else
      # Fake BUNDLE_GEMFILE and BUNDLE_LOCKFILE to let checks pass
      orig_gemfile = ENV["BUNDLE_GEMFILE"]
      orig_lockfile = ENV["BUNDLE_LOCKFILE"]
      Bundler::SharedHelpers.set_env "BUNDLE_GEMFILE", "Gemfile"
      Bundler::SharedHelpers.set_env "BUNDLE_LOCKFILE", "Gemfile.lock"
    end

    builder.gem gem

    definition = builder.to_definition(lockfile, nil)
    definition.validate_runtime!

    begin
      orig_ui = Bundler.ui
      orig_no_lock = Bundler::Definition.no_lock

      ui = Bundler::UI::Shell.new
      ui.level = "silent"
      Bundler.ui = ui
      Bundler::Definition.no_lock = true

      Bundler::Runtime.new(nil, definition).setup
    rescue Bundler::GemNotFound
      warn "Failed to activate #{gem}, please install it with 'gem install #{gem}'"
    ensure
      ENV['BUNDLE_GEMFILE'] = orig_gemfile if orig_gemfile
      ENV['BUNDLE_LOCKFILE'] = orig_lockfile if orig_lockfile
      Bundler.ui = orig_ui
      Bundler::Definition.no_lock = orig_no_lock
    end
  end
end

# for RubyGems without Bundler environment.
# If loading library is not part of the default gems and the bundled gems, warn it.
class LoadError
  def message # :nodoc:
    return super unless path

    name = path.tr("/", "-")
    if !defined?(Bundler) && Gem::BUNDLED_GEMS::SINCE[name] && !Gem::BUNDLED_GEMS::WARNED[name]
      warn name + Gem::BUNDLED_GEMS.build_message(name, :error), uplevel: Gem::BUNDLED_GEMS.uplevel
    end
    super
  end
end