zeitwerk 2.6.18 → 2.8.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.
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
4
- require "set"
3
+ require 'monitor'
4
+ require 'set'
5
5
 
6
6
  module Zeitwerk
7
7
  class Loader
8
- require_relative "loader/helpers"
9
- require_relative "loader/callbacks"
10
- require_relative "loader/config"
11
- require_relative "loader/eager_load"
8
+ require_relative 'loader/helpers'
9
+ require_relative 'loader/callbacks'
10
+ require_relative 'loader/config'
11
+ require_relative 'loader/eager_load'
12
+ require_relative 'loader/file_system'
12
13
 
13
14
  extend Internal
14
15
 
@@ -18,111 +19,118 @@ module Zeitwerk
18
19
  include Config
19
20
  include EagerLoad
20
21
 
21
- MUTEX = Mutex.new
22
- private_constant :MUTEX
23
-
24
22
  # Maps absolute paths for which an autoload has been set ---and not
25
23
  # executed--- to their corresponding Zeitwerk::Cref object.
26
24
  #
27
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
28
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
25
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
26
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
29
27
  # ...
30
28
  #
31
- # @sig Hash[String, Zeitwerk::Cref]
29
+ #: Hash[String, Zeitwerk::Cref]
32
30
  attr_reader :autoloads
33
31
  internal :autoloads
34
32
 
33
+ # When the path passed to Module#autoload is in the stack of features being
34
+ # loaded at the moment, Ruby passes. For example, Module#autoload? returns
35
+ # `nil` even if the autoload has not been attempted. See
36
+ #
37
+ # https://bugs.ruby-lang.org/issues/21035
38
+ #
39
+ # We call these "inceptions".
40
+ #
41
+ # A common case is the entry point of gems managed by Zeitwerk. Their main
42
+ # file is normally required and, while doing so, the loader sets an autoload
43
+ # on the gem namespace. That autoload hits this edge case.
44
+ #
45
+ # There is some logic that needs to know if an autoload for a given constant
46
+ # already exists. We check Module#autoload? first, and fallback to the
47
+ # inceptions just in case.
48
+ #
49
+ # This map keeps track of pairs (cref, autoload_path) found by the loader.
50
+ # The object Zeitwerk::Registry.inceptions, on the other hand, acts as a
51
+ # global registry for them.
52
+ #
53
+ #: Zeitwerk::Cref::Map[String]
54
+ attr_reader :inceptions
55
+ internal :inceptions
56
+
35
57
  # We keep track of autoloaded directories to remove them from the registry
36
58
  # at the end of eager loading.
37
59
  #
38
60
  # Files are removed as they are autoloaded, but directories need to wait due
39
61
  # to concurrency (see why in Zeitwerk::Loader::Callbacks#on_dir_autoloaded).
40
62
  #
41
- # @sig Array[String]
63
+ #: Array[String]
42
64
  attr_reader :autoloaded_dirs
43
65
  internal :autoloaded_dirs
44
66
 
45
- # Stores metadata needed for unloading. Its entries look like this:
46
- #
47
- # "Admin::Role" => [
48
- # ".../admin/role.rb",
49
- # #<Zeitwerk::Cref:... @mod=Admin, @cname=:Role, ...>
50
- # ]
67
+ # If reloading is enabled, this collection maps autoload paths to their
68
+ # autoloaded crefs.
51
69
  #
52
- # The cpath as key helps implementing unloadable_cpath? The file name is
53
- # stored in order to be able to delete it from $LOADED_FEATURES, and the
54
- # cref is used to remove the constant from the parent class or module.
70
+ # On unload, the autoload paths are passed to callbacks, files deleted from
71
+ # $LOADED_FEATURES, and the crefs are deleted.
55
72
  #
56
- # If reloading is enabled, this hash is filled as constants are autoloaded
57
- # or eager loaded. Otherwise, the collection remains empty.
58
- #
59
- # @sig Hash[String, [String, Zeitwerk::Cref]]
73
+ #: Hash[String, Zeitwerk::Cref]
60
74
  attr_reader :to_unload
61
75
  internal :to_unload
62
76
 
63
- # Maps namespace constant paths to their respective directories.
64
- #
65
- # For example, given this mapping:
66
- #
67
- # "Admin" => [
68
- # "/Users/fxn/blog/app/controllers/admin",
69
- # "/Users/fxn/blog/app/models/admin",
70
- # ...
71
- # ]
77
+ # Maps namespace crefs to the directories that conform the namespace.
72
78
  #
73
- # when `Admin` gets defined we know that it plays the role of a namespace
74
- # and that its children are spread over those directories. We'll visit them
75
- # to set up the corresponding autoloads.
79
+ # When these crefs get defined we know their children are spread over those
80
+ # directories. We'll visit them to set up the corresponding autoloads.
76
81
  #
77
- # @sig Hash[String, Array[String]]
82
+ #: Zeitwerk::Cref::Map[String]
78
83
  attr_reader :namespace_dirs
79
84
  internal :namespace_dirs
80
85
 
81
86
  # A shadowed file is a file managed by this loader that is ignored when
82
87
  # setting autoloads because its matching constant is already taken.
83
88
  #
84
- # This private set is populated as we descend. For example, if the loader
85
- # has only scanned the top-level, `shadowed_files` does not have shadowed
86
- # files that may exist deep in the project tree yet.
89
+ # This private set is populated lazily, as we descend. For example, if the
90
+ # loader has only scanned the top-level, `shadowed_files` does not have the
91
+ # shadowed files that may exist deep in the project tree.
87
92
  #
88
- # @sig Set[String]
93
+ #: Set[String]
89
94
  attr_reader :shadowed_files
90
95
  internal :shadowed_files
91
96
 
92
- # @sig Mutex
97
+ #: Mutex
93
98
  attr_reader :mutex
94
99
  private :mutex
95
100
 
96
- # @sig Monitor
101
+ #: Monitor
97
102
  attr_reader :dirs_autoload_monitor
98
103
  private :dirs_autoload_monitor
99
104
 
105
+ #: () -> void
100
106
  def initialize
101
107
  super
102
108
 
103
109
  @autoloads = {}
110
+ @inceptions = Zeitwerk::Cref::Map.new
104
111
  @autoloaded_dirs = []
105
112
  @to_unload = {}
106
- @namespace_dirs = Hash.new { |h, cpath| h[cpath] = [] }
113
+ @namespace_dirs = Zeitwerk::Cref::Map.new
107
114
  @shadowed_files = Set.new
108
115
  @setup = false
109
116
  @eager_loaded = false
117
+ @fs = FileSystem.new(self)
110
118
 
111
119
  @mutex = Mutex.new
112
120
  @dirs_autoload_monitor = Monitor.new
113
121
 
114
- Registry.register_loader(self)
122
+ Registry.loaders.register(self)
115
123
  end
116
124
 
117
125
  # Sets autoloads in the root namespaces.
118
126
  #
119
- # @sig () -> void
127
+ #: () -> void
120
128
  def setup
121
129
  mutex.synchronize do
122
130
  break if @setup
123
131
 
124
132
  actual_roots.each do |root_dir, root_namespace|
125
- define_autoloads_for_dir(root_dir, root_namespace)
133
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
126
134
  end
127
135
 
128
136
  on_setup_callbacks.each(&:call)
@@ -142,75 +150,83 @@ module Zeitwerk
142
150
  # means `unload` + `setup`. This one is available to be used together with
143
151
  # `unregister`, which is undocumented too.
144
152
  #
145
- # @sig () -> void
153
+ #: () -> void
146
154
  def unload
147
155
  mutex.synchronize do
148
156
  raise SetupRequired unless @setup
157
+ __unload
158
+ end
159
+ end
149
160
 
150
- # We are going to keep track of the files that were required by our
151
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
152
- # loadable by Kernel#require again.
153
- #
154
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
155
- # is enough.
156
- unloaded_files = Set.new
161
+ # This is an internal method.
162
+ #
163
+ #: () -> void
164
+ def __unload
165
+ # We are going to keep track of the files that were required by our
166
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
167
+ # loadable by Kernel#require again.
168
+ #
169
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
170
+ # is enough.
171
+ unloaded_files = Set.new
172
+
173
+ autoloads.each do |abspath, cref|
174
+ if cref.autoload?
175
+ unload_autoload(cref)
176
+ else
177
+ # Could happen if loaded with require_relative. That is unsupported,
178
+ # and the constant path would escape unloadable_cpath? This is just
179
+ # defensive code to clean things up as much as we are able to.
180
+ unload_cref(cref)
181
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
182
+ end
183
+ end
157
184
 
158
- autoloads.each do |abspath, cref|
159
- if cref.autoload?
160
- unload_autoload(cref)
185
+ to_unload.each do |abspath, cref|
186
+ unless on_unload_callbacks.empty?
187
+ begin
188
+ value = cref.get
189
+ rescue ::NameError
190
+ # Perhaps the user deleted the constant by hand, or perhaps an
191
+ # autoload failed to define the expected constant but the user
192
+ # rescued the exception.
161
193
  else
162
- # Could happen if loaded with require_relative. That is unsupported,
163
- # and the constant path would escape unloadable_cpath? This is just
164
- # defensive code to clean things up as much as we are able to.
165
- unload_cref(cref)
166
- unloaded_files.add(abspath) if ruby?(abspath)
194
+ run_on_unload_callbacks(cref, value, abspath)
167
195
  end
168
196
  end
169
197
 
170
- to_unload.each do |cpath, (abspath, cref)|
171
- unless on_unload_callbacks.empty?
172
- begin
173
- value = cref.get
174
- rescue ::NameError
175
- # Perhaps the user deleted the constant by hand, or perhaps an
176
- # autoload failed to define the expected constant but the user
177
- # rescued the exception.
178
- else
179
- run_on_unload_callbacks(cpath, value, abspath)
180
- end
181
- end
198
+ unload_cref(cref)
199
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
200
+ end
182
201
 
183
- unload_cref(cref)
184
- unloaded_files.add(abspath) if ruby?(abspath)
185
- end
202
+ unless unloaded_files.empty?
203
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
204
+ # this optimization does not check if $LOADED_FEATURES has the file.
205
+ #
206
+ # To make it aware of changes, the gem defines singleton methods in
207
+ # $LOADED_FEATURES:
208
+ #
209
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
210
+ #
211
+ # Rails applications may depend on bootsnap, so for unloading to work
212
+ # in that setting it is preferable that we restrict our API choice to
213
+ # one of those methods.
214
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
215
+ end
186
216
 
187
- unless unloaded_files.empty?
188
- # Bootsnap decorates Kernel#require to speed it up using a cache and
189
- # this optimization does not check if $LOADED_FEATURES has the file.
190
- #
191
- # To make it aware of changes, the gem defines singleton methods in
192
- # $LOADED_FEATURES:
193
- #
194
- # https://github.com/Shopify/bootsnap/blob/master/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
195
- #
196
- # Rails applications may depend on bootsnap, so for unloading to work
197
- # in that setting it is preferable that we restrict our API choice to
198
- # one of those methods.
199
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
200
- end
217
+ autoloads.clear
218
+ autoloaded_dirs.clear
219
+ to_unload.clear
220
+ namespace_dirs.clear
221
+ shadowed_files.clear
201
222
 
202
- autoloads.clear
203
- autoloaded_dirs.clear
204
- to_unload.clear
205
- namespace_dirs.clear
206
- shadowed_files.clear
223
+ unregister_inceptions
224
+ unregister_explicit_namespaces
207
225
 
208
- Registry.on_unload(self)
209
- ExplicitNamespace.__unregister_loader(self)
226
+ Registry.autoloads.unregister_loader(self)
210
227
 
211
- @setup = false
212
- @eager_loaded = false
213
- end
228
+ @setup = false
229
+ @eager_loaded = false
214
230
  end
215
231
 
216
232
  # Unloads all loaded code, and calls setup again so that the loader is able
@@ -219,22 +235,24 @@ module Zeitwerk
219
235
  # This method is not thread-safe, please see how this can be achieved by
220
236
  # client code in the README of the project.
221
237
  #
222
- # @raise [Zeitwerk::Error]
223
- # @sig () -> void
238
+ #: () -> void ! Zeitwerk::Error
224
239
  def reload
225
240
  raise ReloadingDisabledError unless reloading_enabled?
226
241
  raise SetupRequired unless @setup
227
242
 
228
243
  unload
244
+
229
245
  recompute_ignored_paths
230
246
  recompute_collapse_dirs
247
+ recompute_collapse_parents
248
+
231
249
  setup
232
250
  end
233
251
 
234
252
  # Returns a hash that maps the absolute paths of the managed files and
235
253
  # directories to their respective expected constant paths.
236
254
  #
237
- # @sig () -> Hash[String, String]
255
+ #: () -> Hash[String, String]
238
256
  def all_expected_cpaths
239
257
  result = {}
240
258
 
@@ -244,18 +262,20 @@ module Zeitwerk
244
262
  while (dir, cpath = queue.shift)
245
263
  result[dir] = cpath
246
264
 
247
- prefix = cpath == "Object" ? "" : cpath + "::"
265
+ prefix = cpath == 'Object' ? '' : cpath + '::'
248
266
 
249
- ls(dir) do |basename, abspath, ftype|
267
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
250
268
  if ftype == :file
251
- basename.delete_suffix!(".rb")
252
- result[abspath] = prefix + inflector.camelize(basename, abspath)
253
- else
254
- if collapse?(abspath)
255
- queue << [abspath, cpath]
269
+ if basename == @nsfile
270
+ result[abspath] = cpath
256
271
  else
257
- queue << [abspath, prefix + inflector.camelize(basename, abspath)]
272
+ basename.delete_suffix!('.rb')
273
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
258
274
  end
275
+ elsif collapse?(abspath)
276
+ queue.unshift([abspath, cpath])
277
+ else
278
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
259
279
  end
260
280
  end
261
281
  end
@@ -264,22 +284,24 @@ module Zeitwerk
264
284
  result
265
285
  end
266
286
 
267
- # @sig (String | Pathname) -> String?
287
+ #: (String | Pathname) -> String?
268
288
  def cpath_expected_at(path)
269
289
  abspath = File.expand_path(path)
270
290
 
271
291
  raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
272
292
 
273
- return unless dir?(abspath) || ruby?(abspath)
293
+ ftype = @fs.supported_ftype?(abspath)
294
+ return unless ftype
295
+
274
296
  return if ignored_path?(abspath)
275
297
 
276
298
  paths = []
277
299
 
278
- if ruby?(abspath)
279
- basename = File.basename(abspath, ".rb")
280
- return if hidden?(basename)
300
+ if ftype == :file
301
+ basename = File.basename(abspath)
302
+ return if @fs.hidden?(basename)
281
303
 
282
- paths << [basename, abspath]
304
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
283
305
  walk_up_from = File.dirname(abspath)
284
306
  else
285
307
  walk_up_from = abspath
@@ -287,14 +309,14 @@ module Zeitwerk
287
309
 
288
310
  root_namespace = nil
289
311
 
290
- walk_up(walk_up_from) do |dir|
312
+ @fs.walk_up(walk_up_from) do |dir|
291
313
  break if root_namespace = roots[dir]
292
314
  return if ignored_path?(dir)
293
315
 
294
316
  basename = File.basename(dir)
295
- return if hidden?(basename)
317
+ return if @fs.hidden?(basename)
296
318
 
297
- paths << [basename, abspath] unless collapse?(dir)
319
+ paths << [basename, dir] unless collapse?(dir)
298
320
  end
299
321
 
300
322
  return unless root_namespace
@@ -302,12 +324,12 @@ module Zeitwerk
302
324
  if paths.empty?
303
325
  real_mod_name(root_namespace)
304
326
  else
305
- cnames = paths.reverse_each.map { |b, a| cname_for(b, a) }
327
+ cnames = paths.reverse_each.map { cname_for(_1, _2) }
306
328
 
307
329
  if root_namespace == Object
308
- cnames.join("::")
330
+ cnames.join('::')
309
331
  else
310
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
332
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
311
333
  end
312
334
  end
313
335
  end
@@ -315,50 +337,69 @@ module Zeitwerk
315
337
  # Says if the given constant path would be unloaded on reload. This
316
338
  # predicate returns `false` if reloading is disabled.
317
339
  #
318
- # @sig (String) -> bool
340
+ # This is an undocumented method that I wrote to help transition from the
341
+ # classic autoloader in Rails. Its usage was removed from Rails in 7.0.
342
+ #
343
+ #: (String) -> bool
319
344
  def unloadable_cpath?(cpath)
320
- to_unload.key?(cpath)
345
+ unloadable_cpaths.include?(cpath)
321
346
  end
322
347
 
323
348
  # Returns an array with the constant paths that would be unloaded on reload.
324
349
  # This predicate returns an empty array if reloading is disabled.
325
350
  #
326
- # @sig () -> Array[String]
351
+ # This is an undocumented method that I wrote to help transition from the
352
+ # classic autoloader in Rails. Its usage was removed from Rails in 7.0.
353
+ #
354
+ #: () -> Array[String]
327
355
  def unloadable_cpaths
328
- to_unload.keys.freeze
356
+ to_unload.values.map(&:path)
329
357
  end
330
358
 
331
359
  # This is a dangerous method.
332
360
  #
333
361
  # @experimental
334
- # @sig () -> void
362
+ #: () -> void
335
363
  def unregister
364
+ unregister_inceptions
365
+ unregister_explicit_namespaces
366
+ Registry.loaders.unregister(self)
367
+ Registry.autoloads.unregister_loader(self)
336
368
  Registry.unregister_loader(self)
337
- ExplicitNamespace.__unregister_loader(self)
338
369
  end
339
370
 
340
371
  # The return value of this predicate is only meaningful if the loader has
341
372
  # scanned the file. This is the case in the spots where we use it.
342
373
  #
343
- # @sig (String) -> Boolean
374
+ #: (String) -> bool
344
375
  internal def shadowed_file?(file)
345
376
  shadowed_files.member?(file)
346
377
  end
347
378
 
379
+ #: { () -> String } -> void
380
+ internal def log
381
+ return unless logger
382
+
383
+ message = yield
384
+ method_name = logger.respond_to?(:debug) ? :debug : :call
385
+ logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
386
+ end
387
+
388
+
348
389
  # --- Class methods ---------------------------------------------------------------------------
349
390
 
350
391
  class << self
351
392
  include RealModName
352
393
 
353
- # @sig #call | #debug | nil
394
+ #: call(String) -> void | debug(String) -> void | nil
354
395
  attr_accessor :default_logger
355
396
 
356
397
  # This is a shortcut for
357
398
  #
358
- # require "zeitwerk"
399
+ # require 'zeitwerk'
359
400
  #
360
401
  # loader = Zeitwerk::Loader.new
361
- # loader.tag = File.basename(__FILE__, ".rb")
402
+ # loader.tag = File.basename(__FILE__, '.rb')
362
403
  # loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
363
404
  # loader.push_dir(__dir__)
364
405
  #
@@ -368,7 +409,7 @@ module Zeitwerk
368
409
  # This method returns a subclass of Zeitwerk::Loader, but the exact type
369
410
  # is private, client code can only rely on the interface.
370
411
  #
371
- # @sig (bool) -> Zeitwerk::GemLoader
412
+ #: (?warn_on_extra_files: boolish) -> Zeitwerk::GemLoader
372
413
  def for_gem(warn_on_extra_files: true)
373
414
  called_from = caller_locations(1, 1).first.path
374
415
  Registry.loader_for_gem(called_from, namespace: Object, warn_on_extra_files: warn_on_extra_files)
@@ -376,10 +417,10 @@ module Zeitwerk
376
417
 
377
418
  # This is a shortcut for
378
419
  #
379
- # require "zeitwerk"
420
+ # require 'zeitwerk'
380
421
  #
381
422
  # loader = Zeitwerk::Loader.new
382
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
423
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
383
424
  # loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
384
425
  # loader.push_dir(__dir__, namespace: namespace)
385
426
  #
@@ -389,14 +430,14 @@ module Zeitwerk
389
430
  # This method returns a subclass of Zeitwerk::Loader, but the exact type
390
431
  # is private, client code can only rely on the interface.
391
432
  #
392
- # @sig (bool) -> Zeitwerk::GemLoader
433
+ #: (Module) -> Zeitwerk::GemLoader
393
434
  def for_gem_extension(namespace)
394
435
  unless namespace.is_a?(Module) # Note that Class < Module.
395
436
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
396
437
  end
397
438
 
398
439
  unless real_mod_name(namespace)
399
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
440
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
400
441
  end
401
442
 
402
443
  called_from = caller_locations(1, 1).first.path
@@ -406,7 +447,7 @@ module Zeitwerk
406
447
  # Broadcasts `eager_load` to all loaders. Those that have not been setup
407
448
  # are skipped.
408
449
  #
409
- # @sig () -> void
450
+ #: () -> void
410
451
  def eager_load_all
411
452
  Registry.loaders.each do |loader|
412
453
  begin
@@ -420,7 +461,7 @@ module Zeitwerk
420
461
  # Broadcasts `eager_load_namespace` to all loaders. Those that have not
421
462
  # been setup are skipped.
422
463
  #
423
- # @sig (Module) -> void
464
+ #: (Module) -> void
424
465
  def eager_load_namespace(mod)
425
466
  Registry.loaders.each do |loader|
426
467
  begin
@@ -434,164 +475,198 @@ module Zeitwerk
434
475
  # Returns an array with the absolute paths of the root directories of all
435
476
  # registered loaders. This is a read-only collection.
436
477
  #
437
- # @sig () -> Array[String]
478
+ #: () -> Array[String]
438
479
  def all_dirs
439
- Registry.loaders.flat_map(&:dirs).freeze
480
+ dirs = []
481
+ Registry.loaders.each do |loader|
482
+ dirs.concat(loader.dirs)
483
+ end
484
+ dirs.freeze
440
485
  end
441
486
  end
442
487
 
443
- # @sig (String, Module) -> void
444
- private def define_autoloads_for_dir(dir, parent)
445
- ls(dir) do |basename, abspath, ftype|
488
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
489
+ # expected to define.
490
+ #
491
+ # The `external` flag indicates whether `mod` has been externally defined,
492
+ # as is the case with root namespaces or reopened third-party namespaces.
493
+ #
494
+ #: (String, Module, external: boolish) -> void
495
+ private def define_autoloads_for_dir(dir, mod, external:)
496
+ @fs.ls(dir) do |basename, abspath, ftype|
446
497
  if ftype == :file
447
- basename.delete_suffix!(".rb")
448
- cref = Cref.new(parent, cname_for(basename, abspath))
449
- autoload_file(cref, abspath)
450
- else
451
- if collapse?(abspath)
452
- define_autoloads_for_dir(abspath, parent)
453
- else
454
- cref = Cref.new(parent, cname_for(basename, abspath))
455
- autoload_subdir(cref, abspath)
498
+ if basename == @nsfile
499
+ if external
500
+ cpath = real_mod_name(mod)
501
+ location = Object.const_source_location(cpath)&.join(':')
502
+ location = nil if location&.empty?
503
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
504
+ end
505
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
456
506
  end
457
- end
458
- end
459
- end
460
507
 
461
- # @sig (Module, Symbol, String) -> void
462
- private def autoload_subdir(cref, subdir)
463
- if autoload_path = autoload_path_set_by_me_for?(cref)
464
- if ruby?(autoload_path)
465
- # Scanning visited a Ruby file first, and now a directory for the same
466
- # constant has been found. This means we are dealing with an explicit
467
- # namespace whose definition was seen first.
468
- #
469
- # Registering is idempotent, and we have to keep the autoload pointing
470
- # to the file. This may run again if more directories are found later
471
- # on, no big deal.
472
- register_explicit_namespace(cref.path)
508
+ basename.delete_suffix!('.rb')
509
+ cref = Cref.new(mod, cname_for(basename, abspath))
510
+ visit_file(cref, abspath)
511
+ else
512
+ cref = Cref.new(mod, cname_for(basename, abspath))
513
+ visit_subdir(cref, abspath, external:)
473
514
  end
474
- # If the existing autoload points to a file, it has to be preserved, if
475
- # not, it is fine as it is. In either case, we do not need to override.
476
- # Just remember the subdirectory conforms this namespace.
477
- namespace_dirs[cref.path] << subdir
478
- elsif !cref.defined?
479
- # First time we find this namespace, set an autoload for it.
480
- namespace_dirs[cref.path] << subdir
481
- define_autoload(cref, subdir)
482
- else
483
- # For whatever reason the constant that corresponds to this namespace has
484
- # already been defined, we have to recurse.
485
- log("the namespace #{cref.path} already exists, descending into #{subdir}") if logger
486
- define_autoloads_for_dir(subdir, cref.get)
487
515
  end
488
516
  end
489
517
 
490
- # @sig (Module, Symbol, String) -> void
491
- private def autoload_file(cref, file)
492
- if autoload_path = cref.autoload? || Registry.inception?(cref.path)
493
- # First autoload for a Ruby file wins, just ignore subsequent ones.
494
- if ruby?(autoload_path)
518
+ #: (Zeitwerk::Cref, String) -> void
519
+ private def visit_file(cref, file)
520
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
521
+ if @fs.rb_extension?(autoload_path)
522
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
523
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
524
+ end
495
525
  shadowed_files << file
496
- log("file #{file} is ignored because #{autoload_path} has precedence") if logger
526
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
497
527
  else
498
528
  promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
499
529
  end
500
530
  elsif cref.defined?
501
531
  shadowed_files << file
502
- log("file #{file} is ignored because #{cref.path} is already defined") if logger
532
+ if location = cref.location
533
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
534
+ else
535
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
536
+ end
503
537
  else
504
538
  define_autoload(cref, file)
505
539
  end
506
540
  end
507
541
 
542
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
543
+ private def visit_subdir(cref, subdir, external:)
544
+ if autoload_path = autoload_path_set_by_me_for?(cref)
545
+ if @fs.rb_extension?(autoload_path)
546
+ # The namespace that corresponds to this subdirectory is defined in a
547
+ # file, either regular or nsfile. Therefore, a nsfile would be a
548
+ # duplication.
549
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
550
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
551
+ end
552
+ # Scanning visited a Ruby file first, and now a directory for the same
553
+ # constant has been found. This is an explicit namespace.
554
+ #
555
+ # The namespace may be spread over multiple directories and perhaps it
556
+ # was already registered, but registering is idempotent, just do it.
557
+ register_explicit_namespace(cref)
558
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
559
+ # Scanning found a matching directory first, and now we saw a nsfile.
560
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
561
+ end
562
+ namespace_dirs.get_or_set(cref) { [] } << subdir
563
+ elsif !cref.defined?
564
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
565
+ define_autoload(cref, nsfile_abspath)
566
+ register_explicit_namespace(cref)
567
+ else
568
+ define_autoload(cref, subdir)
569
+ end
570
+ namespace_dirs.get_or_set(cref) { [] } << subdir
571
+ else
572
+ # For whatever reason the constant that corresponds to this namespace has
573
+ # already been defined, we have to recurse.
574
+ log { "the namespace #{cref} already exists, descending into #{subdir}" }
575
+ define_autoloads_for_dir(subdir, cref.get, external:)
576
+ end
577
+ end
578
+
508
579
  # `dir` is the directory that would have autovivified a namespace. `file` is
509
580
  # the file where we've found the namespace is explicitly defined.
510
581
  #
511
- # @sig (dir: String, file: String, cref: Zeitwerk::Cref) -> void
582
+ #: (dir: String, file: String, cref: Zeitwerk::Cref) -> void
512
583
  private def promote_namespace_from_implicit_to_explicit(dir:, file:, cref:)
513
584
  autoloads.delete(dir)
514
- Registry.unregister_autoload(dir)
585
+ Registry.autoloads.unregister(dir)
515
586
 
516
- log("earlier autoload for #{cref.path} discarded, it is actually an explicit namespace defined in #{file}") if logger
587
+ log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
517
588
 
589
+ # Order matters: When Module#const_added is triggered by the autoload, we
590
+ # don't want the namespace to be registered yet.
518
591
  define_autoload(cref, file)
519
- register_explicit_namespace(cref.path)
592
+ register_explicit_namespace(cref)
520
593
  end
521
594
 
522
- # @sig (Module, Symbol, String) -> void
595
+ #: (Zeitwerk::Cref, String) -> void
523
596
  private def define_autoload(cref, abspath)
524
597
  cref.autoload(abspath)
525
598
 
526
599
  if logger
527
- if ruby?(abspath)
528
- log("autoload set for #{cref.path}, to be loaded from #{abspath}")
600
+ if @fs.rb_extension?(abspath)
601
+ log { "autoload set for #{cref}, to be loaded from #{abspath}" }
529
602
  else
530
- log("autoload set for #{cref.path}, to be autovivified from #{abspath}")
603
+ log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
531
604
  end
532
605
  end
533
606
 
534
607
  autoloads[abspath] = cref
535
- Registry.register_autoload(self, abspath)
608
+ Registry.autoloads.register(abspath, self)
536
609
 
537
- # See why in the documentation of Zeitwerk::Registry.inceptions.
538
- unless cref.autoload?
539
- Registry.register_inception(cref.path, abspath, self)
540
- end
610
+ register_inception(cref, abspath) unless cref.autoload?
541
611
  end
542
612
 
543
- # @sig (Module, Symbol) -> String?
613
+ #: (Zeitwerk::Cref) -> String?
544
614
  private def autoload_path_set_by_me_for?(cref)
545
615
  if autoload_path = cref.autoload?
546
616
  autoload_path if autoloads.key?(autoload_path)
547
617
  else
548
- Registry.inception?(cref.path, self)
618
+ inceptions[cref]
549
619
  end
550
620
  end
551
621
 
552
- # @sig (String) -> void
553
- private def register_explicit_namespace(cpath)
554
- ExplicitNamespace.__register(cpath, self)
622
+ #: (Zeitwerk::Cref) -> void
623
+ private def register_explicit_namespace(cref)
624
+ Registry.explicit_namespaces.register(cref, self)
555
625
  end
556
626
 
557
- # @sig (String) -> void
558
- private def raise_if_conflicting_directory(dir)
559
- MUTEX.synchronize do
560
- dir_slash = dir + "/"
627
+ #: () -> void
628
+ private def unregister_explicit_namespaces
629
+ Registry.explicit_namespaces.unregister_loader(self)
630
+ end
561
631
 
562
- Registry.loaders.each do |loader|
563
- next if loader == self
564
- next if loader.__ignores?(dir)
565
-
566
- loader.__roots.each_key do |root_dir|
567
- next if ignores?(root_dir)
568
-
569
- root_dir_slash = root_dir + "/"
570
- if dir_slash.start_with?(root_dir_slash) || root_dir_slash.start_with?(dir_slash)
571
- require "pp" # Needed for pretty_inspect, even in Ruby 2.5.
572
- raise Error,
573
- "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
574
- " which is already managed by\n\n#{loader.pretty_inspect}\n"
575
- end
576
- end
577
- end
632
+ #: (Zeitwerk::Cref, String) -> void
633
+ private def register_inception(cref, abspath)
634
+ inceptions[cref] = abspath
635
+ Registry.inceptions.register(cref, abspath)
636
+ end
637
+
638
+ #: () -> void
639
+ private def unregister_inceptions
640
+ inceptions.each_key do |cref|
641
+ Registry.inceptions.unregister(cref)
642
+ end
643
+ inceptions.clear
644
+ end
645
+
646
+ #: (String) -> void
647
+ private def raise_if_conflicting_root_dir(root_dir)
648
+ if loader = Registry.conflicting_root_dir?(self, root_dir)
649
+ require 'pp' # Needed to have pretty_inspect available.
650
+ raise Error,
651
+ "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
652
+ " which is already managed by\n\n#{loader.pretty_inspect}\n"
578
653
  end
579
654
  end
580
655
 
581
- # @sig (String, Object, String) -> void
582
- private def run_on_unload_callbacks(cpath, value, abspath)
656
+ #: (String, top, String) -> void
657
+ private def run_on_unload_callbacks(cref, value, abspath)
583
658
  # Order matters. If present, run the most specific one.
584
- on_unload_callbacks[cpath]&.each { |c| c.call(value, abspath) }
585
- on_unload_callbacks[:ANY]&.each { |c| c.call(cpath, value, abspath) }
659
+ on_unload_callbacks[cref.path]&.each { |c| c.call(value, abspath) }
660
+ on_unload_callbacks[:ANY]&.each { |c| c.call(cref.path, value, abspath) }
586
661
  end
587
662
 
588
- # @sig (Module, Symbol) -> void
663
+ #: (Zeitwerk::Cref) -> void
589
664
  private def unload_autoload(cref)
590
665
  cref.remove
591
- log("autoload for #{cref.path} removed") if logger
666
+ log { "autoload for #{cref} removed" }
592
667
  end
593
668
 
594
- # @sig (Module, Symbol) -> void
669
+ #: (Zeitwerk::Cref) -> void
595
670
  private def unload_cref(cref)
596
671
  # Let's optimistically remove_const. The way we use it, this is going to
597
672
  # succeed always if all is good.
@@ -600,7 +675,7 @@ module Zeitwerk
600
675
  # There are a few edge scenarios in which this may happen. If the constant
601
676
  # is gone, that is OK, anyway.
602
677
  else
603
- log("#{cref.path} unloaded") if logger
678
+ log { "#{cref} unloaded" }
604
679
  end
605
680
  end
606
681
  end