bootsnap 1.4.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +68 -13
  4. data/exe/bootsnap +5 -0
  5. data/ext/bootsnap/bootsnap.c +285 -87
  6. data/ext/bootsnap/extconf.rb +20 -14
  7. data/lib/bootsnap/bundler.rb +1 -0
  8. data/lib/bootsnap/cli/worker_pool.rb +135 -0
  9. data/lib/bootsnap/cli.rb +281 -0
  10. data/lib/bootsnap/compile_cache/iseq.rb +24 -7
  11. data/lib/bootsnap/compile_cache/json.rb +79 -0
  12. data/lib/bootsnap/compile_cache/yaml.rb +145 -39
  13. data/lib/bootsnap/compile_cache.rb +25 -3
  14. data/lib/bootsnap/explicit_require.rb +1 -0
  15. data/lib/bootsnap/load_path_cache/cache.rb +44 -9
  16. data/lib/bootsnap/load_path_cache/change_observer.rb +5 -1
  17. data/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb +30 -6
  18. data/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb +11 -0
  19. data/lib/bootsnap/load_path_cache/loaded_features_index.rb +43 -11
  20. data/lib/bootsnap/load_path_cache/path.rb +3 -2
  21. data/lib/bootsnap/load_path_cache/path_scanner.rb +53 -27
  22. data/lib/bootsnap/load_path_cache/realpath_cache.rb +5 -5
  23. data/lib/bootsnap/load_path_cache/store.rb +28 -14
  24. data/lib/bootsnap/load_path_cache.rb +10 -16
  25. data/lib/bootsnap/setup.rb +2 -33
  26. data/lib/bootsnap/version.rb +2 -1
  27. data/lib/bootsnap.rb +96 -17
  28. metadata +18 -29
  29. data/.gitignore +0 -17
  30. data/.rubocop.yml +0 -20
  31. data/.travis.yml +0 -24
  32. data/CODE_OF_CONDUCT.md +0 -74
  33. data/CONTRIBUTING.md +0 -21
  34. data/Gemfile +0 -8
  35. data/README.jp.md +0 -231
  36. data/Rakefile +0 -12
  37. data/bin/ci +0 -10
  38. data/bin/console +0 -14
  39. data/bin/setup +0 -8
  40. data/bin/test-minimal-support +0 -7
  41. data/bin/testunit +0 -8
  42. data/bootsnap.gemspec +0 -45
  43. data/dev.yml +0 -10
  44. data/lib/bootsnap/load_path_cache/core_ext/active_support.rb +0 -100
  45. data/shipit.rubygems.yml +0 -4
@@ -1,57 +1,163 @@
1
+ # frozen_string_literal: true
1
2
  require('bootsnap/bootsnap')
2
3
 
3
4
  module Bootsnap
4
5
  module CompileCache
5
6
  module YAML
6
7
  class << self
7
- attr_accessor(:msgpack_factory)
8
- end
9
-
10
- def self.input_to_storage(contents, _)
11
- raise(Uncompilable) if contents.index("!ruby/object")
12
- obj = ::YAML.load(contents)
13
- msgpack_factory.packer.write(obj).to_s
14
- rescue NoMethodError, RangeError
15
- # if the object included things that we can't serialize, fall back to
16
- # Marshal. It's a bit slower, but can encode anything yaml can.
17
- # NoMethodError is unexpected types; RangeError is Bignums
18
- Marshal.dump(obj)
19
- end
8
+ attr_accessor(:msgpack_factory, :cache_dir, :supported_options)
20
9
 
21
- def self.storage_to_output(data)
22
- # This could have a meaning in messagepack, and we're being a little lazy
23
- # about it. -- but a leading 0x04 would indicate the contents of the YAML
24
- # is a positive integer, which is rare, to say the least.
25
- if data[0] == 0x04.chr && data[1] == 0x08.chr
26
- Marshal.load(data)
27
- else
28
- msgpack_factory.unpacker.feed(data).read
10
+ def input_to_storage(contents, _)
11
+ obj = strict_load(contents)
12
+ msgpack_factory.dump(obj)
13
+ rescue NoMethodError, RangeError
14
+ # The object included things that we can't serialize
15
+ raise(Uncompilable)
29
16
  end
30
- end
31
17
 
32
- def self.input_to_output(data)
33
- ::YAML.load(data)
34
- end
18
+ def storage_to_output(data, kwargs)
19
+ if kwargs && kwargs.key?(:symbolize_names)
20
+ kwargs[:symbolize_keys] = kwargs.delete(:symbolize_names)
21
+ end
22
+ msgpack_factory.load(data, kwargs)
23
+ end
35
24
 
36
- def self.install!(cache_dir)
37
- require('yaml')
38
- require('msgpack')
25
+ def input_to_output(data, kwargs)
26
+ if ::YAML.respond_to?(:unsafe_load)
27
+ ::YAML.unsafe_load(data, **(kwargs || {}))
28
+ else
29
+ ::YAML.load(data, **(kwargs || {}))
30
+ end
31
+ end
39
32
 
40
- # MessagePack serializes symbols as strings by default.
41
- # We want them to roundtrip cleanly, so we use a custom factory.
42
- # see: https://github.com/msgpack/msgpack-ruby/pull/122
43
- factory = MessagePack::Factory.new
44
- factory.register_type(0x00, Symbol)
45
- Bootsnap::CompileCache::YAML.msgpack_factory = factory
33
+ def strict_load(payload, *args)
34
+ ast = ::YAML.parse(payload)
35
+ return ast unless ast
36
+ strict_visitor.create(*args).visit(ast)
37
+ end
38
+ ruby2_keywords :strict_load if respond_to?(:ruby2_keywords, true)
46
39
 
47
- klass = class << ::YAML; self; end
48
- klass.send(:define_method, :load_file) do |path|
49
- Bootsnap::CompileCache::Native.fetch(
40
+ def precompile(path, cache_dir: YAML.cache_dir)
41
+ Bootsnap::CompileCache::Native.precompile(
50
42
  cache_dir,
51
- path,
52
- Bootsnap::CompileCache::YAML
43
+ path.to_s,
44
+ Bootsnap::CompileCache::YAML,
53
45
  )
54
46
  end
47
+
48
+ def install!(cache_dir)
49
+ self.cache_dir = cache_dir
50
+ init!
51
+ ::YAML.singleton_class.prepend(Patch)
52
+ end
53
+
54
+ def init!
55
+ require('yaml')
56
+ require('msgpack')
57
+ require('date')
58
+
59
+ if Patch.method_defined?(:unsafe_load_file) && !::YAML.respond_to?(:unsafe_load_file)
60
+ Patch.send(:remove_method, :unsafe_load_file)
61
+ end
62
+ if Patch.method_defined?(:load_file) && ::YAML::VERSION >= '4'
63
+ Patch.send(:remove_method, :load_file)
64
+ end
65
+
66
+ # MessagePack serializes symbols as strings by default.
67
+ # We want them to roundtrip cleanly, so we use a custom factory.
68
+ # see: https://github.com/msgpack/msgpack-ruby/pull/122
69
+ factory = MessagePack::Factory.new
70
+ factory.register_type(0x00, Symbol)
71
+
72
+ if defined? MessagePack::Timestamp
73
+ factory.register_type(
74
+ MessagePack::Timestamp::TYPE, # or just -1
75
+ Time,
76
+ packer: MessagePack::Time::Packer,
77
+ unpacker: MessagePack::Time::Unpacker
78
+ )
79
+
80
+ marshal_fallback = {
81
+ packer: ->(value) { Marshal.dump(value) },
82
+ unpacker: ->(payload) { Marshal.load(payload) },
83
+ }
84
+ {
85
+ Date => 0x01,
86
+ Regexp => 0x02,
87
+ }.each do |type, code|
88
+ factory.register_type(code, type, marshal_fallback)
89
+ end
90
+ end
91
+
92
+ self.msgpack_factory = factory
93
+
94
+ self.supported_options = []
95
+ params = ::YAML.method(:load).parameters
96
+ if params.include?([:key, :symbolize_names])
97
+ self.supported_options << :symbolize_names
98
+ end
99
+ if params.include?([:key, :freeze])
100
+ if factory.load(factory.dump('yaml'), freeze: true).frozen?
101
+ self.supported_options << :freeze
102
+ end
103
+ end
104
+ self.supported_options.freeze
105
+ end
106
+
107
+ def strict_visitor
108
+ self::NoTagsVisitor ||= Class.new(Psych::Visitors::ToRuby) do
109
+ def visit(target)
110
+ if target.tag
111
+ raise Uncompilable, "YAML tags are not supported: #{target.tag}"
112
+ end
113
+ super
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ module Patch
120
+ def load_file(path, *args)
121
+ return super if args.size > 1
122
+ if kwargs = args.first
123
+ return super unless kwargs.is_a?(Hash)
124
+ return super unless (kwargs.keys - ::Bootsnap::CompileCache::YAML.supported_options).empty?
125
+ end
126
+
127
+ begin
128
+ ::Bootsnap::CompileCache::Native.fetch(
129
+ Bootsnap::CompileCache::YAML.cache_dir,
130
+ File.realpath(path),
131
+ ::Bootsnap::CompileCache::YAML,
132
+ kwargs,
133
+ )
134
+ rescue Errno::EACCES
135
+ ::Bootsnap::CompileCache.permission_error(path)
136
+ end
137
+ end
138
+
139
+ ruby2_keywords :load_file if respond_to?(:ruby2_keywords, true)
140
+
141
+ def unsafe_load_file(path, *args)
142
+ return super if args.size > 1
143
+ if kwargs = args.first
144
+ return super unless kwargs.is_a?(Hash)
145
+ return super unless (kwargs.keys - ::Bootsnap::CompileCache::YAML.supported_options).empty?
146
+ end
147
+
148
+ begin
149
+ ::Bootsnap::CompileCache::Native.fetch(
150
+ Bootsnap::CompileCache::YAML.cache_dir,
151
+ File.realpath(path),
152
+ ::Bootsnap::CompileCache::YAML,
153
+ kwargs,
154
+ )
155
+ rescue Errno::EACCES
156
+ ::Bootsnap::CompileCache.permission_error(path)
157
+ end
158
+ end
159
+
160
+ ruby2_keywords :unsafe_load_file if respond_to?(:ruby2_keywords, true)
55
161
  end
56
162
  end
57
163
  end
@@ -1,6 +1,10 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module CompileCache
3
- def self.setup(cache_dir:, iseq:, yaml:)
4
+ Error = Class.new(StandardError)
5
+ PermissionError = Class.new(Error)
6
+
7
+ def self.setup(cache_dir:, iseq:, yaml:, json:)
4
8
  if iseq
5
9
  if supported?
6
10
  require_relative('compile_cache/iseq')
@@ -18,12 +22,30 @@ module Bootsnap
18
22
  warn("[bootsnap/setup] YAML parsing caching is not supported on this implementation of Ruby")
19
23
  end
20
24
  end
25
+
26
+ if json
27
+ if supported?
28
+ require_relative('compile_cache/json')
29
+ Bootsnap::CompileCache::JSON.install!(cache_dir)
30
+ elsif $VERBOSE
31
+ warn("[bootsnap/setup] JSON parsing caching is not supported on this implementation of Ruby")
32
+ end
33
+ end
34
+ end
35
+
36
+ def self.permission_error(path)
37
+ cpath = Bootsnap::CompileCache::ISeq.cache_dir
38
+ raise(
39
+ PermissionError,
40
+ "bootsnap doesn't have permission to write cache entries in '#{cpath}' " \
41
+ "(or, less likely, doesn't have permission to read '#{path}')",
42
+ )
21
43
  end
22
44
 
23
45
  def self.supported?
24
- # only enable on 'ruby' (MRI), POSIX (darwin, linux, *bsd), and >= 2.3.0
46
+ # only enable on 'ruby' (MRI), POSIX (darwin, linux, *bsd), Windows (RubyInstaller2) and >= 2.3.0
25
47
  RUBY_ENGINE == 'ruby' &&
26
- RUBY_PLATFORM =~ /darwin|linux|bsd/ &&
48
+ RUBY_PLATFORM =~ /darwin|linux|bsd|mswin|mingw|cygwin/ &&
27
49
  Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.3.0")
28
50
  end
29
51
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module ExplicitRequire
3
4
  ARCHDIR = RbConfig::CONFIG['archdir']
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative('../explicit_require')
2
4
 
3
5
  module Bootsnap
@@ -8,8 +10,8 @@ module Bootsnap
8
10
  def initialize(store, path_obj, development_mode: false)
9
11
  @development_mode = development_mode
10
12
  @store = store
11
- @mutex = defined?(::Mutex) ? ::Mutex.new : ::Thread::Mutex.new # TODO: Remove once Ruby 2.2 support is dropped.
12
- @path_obj = path_obj.map! { |f| File.exist?(f) ? File.realpath(f) : f }
13
+ @mutex = Mutex.new
14
+ @path_obj = path_obj.map! { |f| PathScanner.os_path(File.exist?(f) ? File.realpath(f) : f.dup) }
13
15
  @has_relative_paths = nil
14
16
  reinitialize
15
17
  end
@@ -44,9 +46,9 @@ module Bootsnap
44
46
  # loadpath.
45
47
  def find(feature)
46
48
  reinitialize if (@has_relative_paths && dir_changed?) || stale?
47
- feature = feature.to_s
49
+ feature = feature.to_s.freeze
48
50
  return feature if absolute_path?(feature)
49
- return File.expand_path(feature) if feature.start_with?('./')
51
+ return expand_path(feature) if feature.start_with?('./')
50
52
  @mutex.synchronize do
51
53
  x = search_index(feature)
52
54
  return x if x
@@ -65,7 +67,7 @@ module Bootsnap
65
67
  # native dynamic extension, e.g. .bundle or .so), we know it was a
66
68
  # failure and there's nothing more we can do to find the file.
67
69
  # no extension, .rb, (.bundle or .so)
68
- when '', *CACHED_EXTENSIONS # rubocop:disable Performance/CaseWhenSplat
70
+ when '', *CACHED_EXTENSIONS
69
71
  nil
70
72
  # Ruby allows specifying native extensions as '.so' even when DLEXT
71
73
  # is '.bundle'. This is where we handle that case.
@@ -142,7 +144,7 @@ module Bootsnap
142
144
  expanded_path = p.expanded_path
143
145
  entries, dirs = p.entries_and_dirs(@store)
144
146
  # push -> low precedence -> set only if unset
145
- dirs.each { |dir| @dirs[dir] ||= path }
147
+ dirs.each { |dir| @dirs[dir] ||= path }
146
148
  entries.each { |rel| @index[rel] ||= expanded_path }
147
149
  end
148
150
  end
@@ -162,6 +164,10 @@ module Bootsnap
162
164
  end
163
165
  end
164
166
 
167
+ def expand_path(feature)
168
+ maybe_append_extension(File.expand_path(feature))
169
+ end
170
+
165
171
  def stale?
166
172
  @development_mode && @generated_at + AGE_THRESHOLD < now
167
173
  end
@@ -174,17 +180,46 @@ module Bootsnap
174
180
  def search_index(f)
175
181
  try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f + DLEXT2) || try_index(f)
176
182
  end
183
+
184
+ def maybe_append_extension(f)
185
+ try_ext(f + DOT_RB) || try_ext(f + DLEXT) || try_ext(f + DLEXT2) || f
186
+ end
177
187
  else
178
188
  def search_index(f)
179
189
  try_index(f + DOT_RB) || try_index(f + DLEXT) || try_index(f)
180
190
  end
191
+
192
+ def maybe_append_extension(f)
193
+ try_ext(f + DOT_RB) || try_ext(f + DLEXT) || f
194
+ end
181
195
  end
182
196
 
183
- def try_index(f)
184
- if (p = @index[f])
185
- p + '/' + f
197
+ s = rand.to_s.force_encoding(Encoding::US_ASCII).freeze
198
+ if s.respond_to?(:-@)
199
+ if (-s).equal?(s) && (-s.dup).equal?(s) || RUBY_VERSION >= '2.7'
200
+ def try_index(f)
201
+ if (p = @index[f])
202
+ -(File.join(p, f).freeze)
203
+ end
204
+ end
205
+ else
206
+ def try_index(f)
207
+ if (p = @index[f])
208
+ -File.join(p, f).untaint
209
+ end
210
+ end
211
+ end
212
+ else
213
+ def try_index(f)
214
+ if (p = @index[f])
215
+ File.join(p, f)
216
+ end
186
217
  end
187
218
  end
219
+
220
+ def try_ext(f)
221
+ f if File.exist?(f)
222
+ end
188
223
  end
189
224
  end
190
225
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module LoadPathCache
3
4
  module ChangeObserver
@@ -14,18 +15,20 @@ module Bootsnap
14
15
  @lpc_observer.push_paths(self, *entries.map(&:to_s))
15
16
  super
16
17
  end
18
+ alias_method :append, :push
17
19
 
18
20
  def unshift(*entries)
19
21
  @lpc_observer.unshift_paths(self, *entries.map(&:to_s))
20
22
  super
21
23
  end
24
+ alias_method :prepend, :unshift
22
25
 
23
26
  def concat(entries)
24
27
  @lpc_observer.push_paths(self, *entries.map(&:to_s))
25
28
  super
26
29
  end
27
30
 
28
- # uniq! keeps the first occurance of each path, otherwise preserving
31
+ # uniq! keeps the first occurrence of each path, otherwise preserving
29
32
  # order, preserving the effective load path
30
33
  def uniq!(*args)
31
34
  ret = super
@@ -53,6 +56,7 @@ module Bootsnap
53
56
  end
54
57
 
55
58
  def self.register(observer, arr)
59
+ return if arr.frozen? # can't register observer, but no need to.
56
60
  arr.instance_variable_set(:@lpc_observer, observer)
57
61
  arr.extend(ArrayMixin)
58
62
  end
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
1
2
  module Bootsnap
2
3
  module LoadPathCache
3
4
  module CoreExt
4
5
  def self.make_load_error(path)
5
- err = LoadError.new("cannot load such file -- #{path}")
6
+ err = LoadError.new(+"cannot load such file -- #{path}")
7
+ err.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
6
8
  err.define_singleton_method(:path) { path }
7
9
  err
8
10
  end
@@ -30,16 +32,24 @@ module Kernel
30
32
  end
31
33
 
32
34
  raise(Bootsnap::LoadPathCache::CoreExt.make_load_error(path))
35
+ rescue LoadError => e
36
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
37
+ raise(e)
33
38
  rescue Bootsnap::LoadPathCache::ReturnFalse
34
39
  false
35
40
  rescue Bootsnap::LoadPathCache::FallbackScan
36
- require_with_bootsnap_lfi(path)
41
+ fallback = true
42
+ ensure
43
+ if fallback
44
+ require_with_bootsnap_lfi(path)
45
+ end
37
46
  end
38
47
 
39
48
  alias_method(:require_relative_without_bootsnap, :require_relative)
40
49
  def require_relative(path)
50
+ location = caller_locations(1..1).first
41
51
  realpath = Bootsnap::LoadPathCache.realpath_cache.call(
42
- caller_locations(1..1).first.absolute_path, path
52
+ location.absolute_path || location.path, path
43
53
  )
44
54
  require(realpath)
45
55
  end
@@ -51,15 +61,22 @@ module Kernel
51
61
  end
52
62
 
53
63
  # load also allows relative paths from pwd even when not in $:
54
- if File.exist?(relative = File.expand_path(path))
64
+ if File.exist?(relative = File.expand_path(path).freeze)
55
65
  return load_without_bootsnap(relative, wrap)
56
66
  end
57
67
 
58
68
  raise(Bootsnap::LoadPathCache::CoreExt.make_load_error(path))
69
+ rescue LoadError => e
70
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
71
+ raise(e)
59
72
  rescue Bootsnap::LoadPathCache::ReturnFalse
60
73
  false
61
74
  rescue Bootsnap::LoadPathCache::FallbackScan
62
- load_without_bootsnap(path, wrap)
75
+ fallback = true
76
+ ensure
77
+ if fallback
78
+ load_without_bootsnap(path, wrap)
79
+ end
63
80
  end
64
81
  end
65
82
 
@@ -74,9 +91,16 @@ class Module
74
91
  # added to $LOADED_FEATURES and won't be able to hook that modification
75
92
  # since it's done in C-land.
76
93
  autoload_without_bootsnap(const, Bootsnap::LoadPathCache.load_path_cache.find(path) || path)
94
+ rescue LoadError => e
95
+ e.instance_variable_set(Bootsnap::LoadPathCache::ERROR_TAG_IVAR, true)
96
+ raise(e)
77
97
  rescue Bootsnap::LoadPathCache::ReturnFalse
78
98
  false
79
99
  rescue Bootsnap::LoadPathCache::FallbackScan
80
- autoload_without_bootsnap(const, path)
100
+ fallback = true
101
+ ensure
102
+ if fallback
103
+ autoload_without_bootsnap(const, path)
104
+ end
81
105
  end
82
106
  end
@@ -1,7 +1,18 @@
1
+ # frozen_string_literal: true
1
2
  class << $LOADED_FEATURES
2
3
  alias_method(:delete_without_bootsnap, :delete)
3
4
  def delete(key)
4
5
  Bootsnap::LoadPathCache.loaded_features_index.purge(key)
5
6
  delete_without_bootsnap(key)
6
7
  end
8
+
9
+ alias_method(:reject_without_bootsnap!, :reject!)
10
+ def reject!(&block)
11
+ backup = dup
12
+
13
+ # FIXME: if no block is passed we'd need to return a decorated iterator
14
+ reject_without_bootsnap!(&block)
15
+
16
+ Bootsnap::LoadPathCache.loaded_features_index.purge_multi(backup - self)
17
+ end
7
18
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Bootsnap
2
4
  module LoadPathCache
3
5
  # LoadedFeaturesIndex partially mirrors an internal structure in ruby that
@@ -24,7 +26,7 @@ module Bootsnap
24
26
  class LoadedFeaturesIndex
25
27
  def initialize
26
28
  @lfi = {}
27
- @mutex = defined?(::Mutex) ? ::Mutex.new : ::Thread::Mutex.new # TODO: Remove once Ruby 2.2 support is dropped.
29
+ @mutex = Mutex.new
28
30
 
29
31
  # In theory the user could mutate $LOADED_FEATURES and invalidate our
30
32
  # cache. If this ever comes up in practice — or if you, the
@@ -38,7 +40,7 @@ module Bootsnap
38
40
  # /a/b/lib/my/foo.rb
39
41
  # ^^^^^^^^^
40
42
  short = feat[(lpe.length + 1)..-1]
41
- stripped = strip_extension(short)
43
+ stripped = strip_extension_if_elidable(short)
42
44
  @lfi[short] = hash
43
45
  @lfi[stripped] = hash
44
46
  end
@@ -55,6 +57,13 @@ module Bootsnap
55
57
  end
56
58
  end
57
59
 
60
+ def purge_multi(features)
61
+ rejected_hashes = features.each_with_object({}) { |f, h| h[f.hash] = true }
62
+ @mutex.synchronize do
63
+ @lfi.reject! { |_, hash| rejected_hashes.key?(hash) }
64
+ end
65
+ end
66
+
58
67
  def key?(feature)
59
68
  @mutex.synchronize { @lfi.key?(feature) }
60
69
  end
@@ -85,13 +94,14 @@ module Bootsnap
85
94
 
86
95
  hash = long.hash
87
96
 
88
- # do we have 'bundler' or 'bundler.rb'?
89
- altname = if File.extname(short) != ''
90
- # strip the path from 'bundler.rb' -> 'bundler'
91
- strip_extension(short)
92
- elsif long && (ext = File.extname(long))
93
- # get the extension from the expanded path if given
94
- # 'bundler' + '.rb'
97
+ # Do we have a filename with an elidable extension, e.g.,
98
+ # 'bundler.rb', or 'libgit2.so'?
99
+ altname = if extension_elidable?(short)
100
+ # Strip the extension off, e.g. 'bundler.rb' -> 'bundler'.
101
+ strip_extension_if_elidable(short)
102
+ elsif long && (ext = File.extname(long.freeze))
103
+ # We already know the extension of the actual file this
104
+ # resolves to, so put that back on.
95
105
  short + ext
96
106
  end
97
107
 
@@ -108,8 +118,30 @@ module Bootsnap
108
118
  STRIP_EXTENSION = /\.[^.]*?$/
109
119
  private_constant(:STRIP_EXTENSION)
110
120
 
111
- def strip_extension(f)
112
- f.sub(STRIP_EXTENSION, '')
121
+ # Might Ruby automatically search for this extension if
122
+ # someone tries to 'require' the file without it? E.g. Ruby
123
+ # will implicitly try 'x.rb' if you ask for 'x'.
124
+ #
125
+ # This is complex and platform-dependent, and the Ruby docs are a little
126
+ # handwavy about what will be tried when and in what order.
127
+ # So optimistically pretend that all known elidable extensions
128
+ # will be tried on all platforms, and that people are unlikely
129
+ # to name files in a way that assumes otherwise.
130
+ # (E.g. It's unlikely that someone will know that their code
131
+ # will _never_ run on MacOS, and therefore think they can get away
132
+ # with calling a Ruby file 'x.dylib.rb' and then requiring it as 'x.dylib'.)
133
+ #
134
+ # See <https://ruby-doc.org/core-2.6.4/Kernel.html#method-i-require>.
135
+ def extension_elidable?(f)
136
+ f.to_s.end_with?('.rb', '.so', '.o', '.dll', '.dylib')
137
+ end
138
+
139
+ def strip_extension_if_elidable(f)
140
+ if extension_elidable?(f)
141
+ f.sub(STRIP_EXTENSION, '')
142
+ else
143
+ f
144
+ end
113
145
  end
114
146
  end
115
147
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require_relative('path_scanner')
2
3
 
3
4
  module Bootsnap
@@ -20,7 +21,7 @@ module Bootsnap
20
21
  attr_reader(:path)
21
22
 
22
23
  def initialize(path)
23
- @path = path.to_s
24
+ @path = path.to_s.freeze
24
25
  end
25
26
 
26
27
  # True if the path exists, but represents a non-directory object
@@ -59,7 +60,7 @@ module Bootsnap
59
60
  end
60
61
 
61
62
  def expanded_path
62
- File.expand_path(path)
63
+ File.expand_path(path).freeze
63
64
  end
64
65
 
65
66
  private