zeitwerk 2.4.0 → 2.5.0.beta2

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.
@@ -14,24 +14,22 @@ module Zeitwerk
14
14
  # the file system, to the loader responsible for them.
15
15
  #
16
16
  # @private
17
- # @return [{String => Zeitwerk::Loader}]
17
+ # @sig Hash[String, Zeitwerk::Loader]
18
18
  attr_reader :cpaths
19
19
 
20
20
  # @private
21
- # @return [Mutex]
21
+ # @sig Mutex
22
22
  attr_reader :mutex
23
23
 
24
24
  # @private
25
- # @return [TracePoint]
25
+ # @sig TracePoint
26
26
  attr_reader :tracer
27
27
 
28
28
  # Asserts `cpath` corresponds to an explicit namespace for which `loader`
29
29
  # is responsible.
30
30
  #
31
31
  # @private
32
- # @param cpath [String]
33
- # @param loader [Zeitwerk::Loader]
34
- # @return [void]
32
+ # @sig (String, Zeitwerk::Loader) -> void
35
33
  def register(cpath, loader)
36
34
  mutex.synchronize do
37
35
  cpaths[cpath] = loader
@@ -42,27 +40,36 @@ module Zeitwerk
42
40
  end
43
41
 
44
42
  # @private
45
- # @param loader [Zeitwerk::Loader]
46
- # @return [void]
47
- def unregister(loader)
43
+ # @sig (Zeitwerk::Loader) -> void
44
+ def unregister_loader(loader)
48
45
  cpaths.delete_if { |_cpath, l| l == loader }
49
46
  disable_tracer_if_unneeded
50
47
  end
51
48
 
49
+ private
50
+
51
+ # @sig () -> void
52
52
  def disable_tracer_if_unneeded
53
53
  mutex.synchronize do
54
54
  tracer.disable if cpaths.empty?
55
55
  end
56
56
  end
57
57
 
58
+ # @sig (TracePoint) -> void
58
59
  def tracepoint_class_callback(event)
59
60
  # If the class is a singleton class, we won't do anything with it so we
60
61
  # can bail out immediately. This is several orders of magnitude faster
61
62
  # than accessing its name.
62
63
  return if event.self.singleton_class?
63
64
 
64
- # Note that it makes sense to compute the hash code unconditionally,
65
- # because the trace point is disabled if cpaths is empty.
65
+ # It might be tempting to return if name.nil?, to avoid the computation
66
+ # of a hash code and delete call. But Ruby does not trigger the :class
67
+ # event on Class.new or Module.new, so that would incur in an extra call
68
+ # for nothing.
69
+ #
70
+ # On the other hand, if we were called, cpaths is not empty. Otherwise
71
+ # the tracer is disabled. So we do need to go ahead with the hash code
72
+ # computation and delete call.
66
73
  if loader = cpaths.delete(real_mod_name(event.self))
67
74
  loader.on_namespace_loaded(event.self)
68
75
  disable_tracer_if_unneeded
@@ -2,16 +2,14 @@
2
2
 
3
3
  module Zeitwerk
4
4
  class GemInflector < Inflector
5
- # @param root_file [String]
5
+ # @sig (String) -> void
6
6
  def initialize(root_file)
7
7
  namespace = File.basename(root_file, ".rb")
8
8
  lib_dir = File.dirname(root_file)
9
9
  @version_file = File.join(lib_dir, namespace, "version.rb")
10
10
  end
11
11
 
12
- # @param basename [String]
13
- # @param abspath [String]
14
- # @return [String]
12
+ # @sig (String, String) -> String
15
13
  def camelize(basename, abspath)
16
14
  abspath == @version_file ? "VERSION" : super
17
15
  end
@@ -11,9 +11,7 @@ module Zeitwerk
11
11
  #
12
12
  # Takes into account hard-coded mappings configured with `inflect`.
13
13
  #
14
- # @param basename [String]
15
- # @param _abspath [String]
16
- # @return [String]
14
+ # @sig (String, String) -> String
17
15
  def camelize(basename, _abspath)
18
16
  overrides[basename] || basename.split('_').each(&:capitalize!).join
19
17
  end
@@ -30,8 +28,7 @@ module Zeitwerk
30
28
  # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
31
29
  # inflector.camelize("users_controller", abspath) # => "UsersController"
32
30
  #
33
- # @param inflections [{String => String}]
34
- # @return [void]
31
+ # @sig (Hash[String, String]) -> void
35
32
  def inflect(inflections)
36
33
  overrides.merge!(inflections)
37
34
  end
@@ -41,7 +38,7 @@ module Zeitwerk
41
38
  # Hard-coded basename to constant name user maps that override the default
42
39
  # inflection logic.
43
40
  #
44
- # @return [{String => String}]
41
+ # @sig () -> Hash[String, String]
45
42
  def overrides
46
43
  @overrides ||= {}
47
44
  end
@@ -12,15 +12,15 @@ module Kernel
12
12
  # On the other hand, if you publish a new version of a gem that is now managed
13
13
  # by Zeitwerk, client code can reference directly your classes and modules and
14
14
  # should not require anything. But if someone has legacy require calls around,
15
- # they will work as expected, and in a compatible way.
15
+ # they will work as expected, and in a compatible way. This feature is by now
16
+ # EXPERIMENTAL and UNDOCUMENTED.
16
17
  #
17
18
  # We cannot decorate with prepend + super because Kernel has already been
18
19
  # included in Object, and changes in ancestors don't get propagated into
19
20
  # already existing ancestor chains.
20
21
  alias_method :zeitwerk_original_require, :require
21
22
 
22
- # @param path [String]
23
- # @return [Boolean]
23
+ # @sig (String) -> true | false
24
24
  def require(path)
25
25
  if loader = Zeitwerk::Registry.loader_for(path)
26
26
  if path.end_with?(".rb")
@@ -29,13 +29,14 @@ module Kernel
29
29
  end
30
30
  else
31
31
  loader.on_dir_autoloaded(path)
32
+ true
32
33
  end
33
34
  else
34
35
  zeitwerk_original_require(path).tap do |required|
35
36
  if required
36
- realpath = $LOADED_FEATURES.last
37
- if loader = Zeitwerk::Registry.loader_for(realpath)
38
- loader.on_file_autoloaded(realpath)
37
+ abspath = $LOADED_FEATURES.last
38
+ if loader = Zeitwerk::Registry.loader_for(abspath)
39
+ loader.on_file_autoloaded(abspath)
39
40
  end
40
41
  end
41
42
  end
@@ -5,78 +5,31 @@ require "securerandom"
5
5
 
6
6
  module Zeitwerk
7
7
  class Loader
8
+ require_relative "loader/helpers"
8
9
  require_relative "loader/callbacks"
9
- include Callbacks
10
- include RealModName
11
-
12
- # @return [String]
13
- attr_reader :tag
14
-
15
- # @return [#camelize]
16
- attr_accessor :inflector
17
-
18
- # @return [#call, #debug, nil]
19
- attr_accessor :logger
20
-
21
- # Absolute paths of the root directories. Stored in a hash to preserve
22
- # order, easily handle duplicates, and also be able to have a fast lookup,
23
- # needed for detecting nested paths.
24
- #
25
- # "/Users/fxn/blog/app/assets" => true,
26
- # "/Users/fxn/blog/app/channels" => true,
27
- # ...
28
- #
29
- # This is a private collection maintained by the loader. The public
30
- # interface for it is `push_dir` and `dirs`.
31
- #
32
- # @private
33
- # @return [{String => true}]
34
- attr_reader :root_dirs
10
+ require_relative "loader/config"
35
11
 
36
- # Absolute paths of files or directories that have to be preloaded.
37
- #
38
- # @private
39
- # @return [<String>]
40
- attr_reader :preloads
41
-
42
- # Absolute paths of files, directories, or glob patterns to be totally
43
- # ignored.
44
- #
45
- # @private
46
- # @return [Set<String>]
47
- attr_reader :ignored_glob_patterns
12
+ include RealModName
13
+ include Callbacks
14
+ include Helpers
15
+ include Config
48
16
 
49
- # The actual collection of absolute file and directory names at the time the
50
- # ignored glob patterns were expanded. Computed on setup, and recomputed on
51
- # reload.
17
+ # Keeps track of autoloads defined by the loader which have not been
18
+ # executed so far.
52
19
  #
53
- # @private
54
- # @return [Set<String>]
55
- attr_reader :ignored_paths
56
-
57
- # Absolute paths of directories or glob patterns to be collapsed.
20
+ # This metadata helps us implement a few things:
58
21
  #
59
- # @private
60
- # @return [Set<String>]
61
- attr_reader :collapse_glob_patterns
62
-
63
- # The actual collection of absolute directory names at the time the collapse
64
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
22
+ # 1. When autoloads are triggered, ensure they define the expected constant
23
+ # and invoke user callbacks. If reloading is enabled, remember cref and
24
+ # abspath for later unloading logic.
65
25
  #
66
- # @private
67
- # @return [Set<String>]
68
- attr_reader :collapse_dirs
69
-
70
- # Maps real absolute paths for which an autoload has been set ---and not
71
- # executed--- to their corresponding parent class or module and constant
72
- # name.
26
+ # 2. When unloading, remove autoloads that have not been executed.
73
27
  #
74
- # "/Users/fxn/blog/app/models/user.rb" => [Object, :User],
75
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => [Hotel, :Pricing]
76
- # ...
28
+ # 3. Eager load with a recursive const_get, rather than a recursive require,
29
+ # for consistency with lazy loading.
77
30
  #
78
31
  # @private
79
- # @return [{String => (Module, Symbol)}]
32
+ # @sig Zeitwerk::Autoloads
80
33
  attr_reader :autoloads
81
34
 
82
35
  # We keep track of autoloaded directories to remove them from the registry
@@ -86,15 +39,15 @@ module Zeitwerk
86
39
  # to concurrency (see why in Zeitwerk::Loader::Callbacks#on_dir_autoloaded).
87
40
  #
88
41
  # @private
89
- # @return [<String>]
42
+ # @sig Array[String]
90
43
  attr_reader :autoloaded_dirs
91
44
 
92
45
  # Stores metadata needed for unloading. Its entries look like this:
93
46
  #
94
47
  # "Admin::Role" => [".../admin/role.rb", [Admin, :Role]]
95
48
  #
96
- # The cpath as key helps implementing unloadable_cpath? The real file name
97
- # is stored in order to be able to delete it from $LOADED_FEATURES, and the
49
+ # The cpath as key helps implementing unloadable_cpath? The file name is
50
+ # stored in order to be able to delete it from $LOADED_FEATURES, and the
98
51
  # pair [Module, Symbol] is used to remove_const the constant from the class
99
52
  # or module object.
100
53
  #
@@ -102,7 +55,7 @@ module Zeitwerk
102
55
  # or eager loaded. Otherwise, the collection remains empty.
103
56
  #
104
57
  # @private
105
- # @return [{String => (String, (Module, Symbol))}]
58
+ # @sig Hash[String, [String, [Module, Symbol]]]
106
59
  attr_reader :to_unload
107
60
 
108
61
  # Maps constant paths of namespaces to arrays of corresponding directories.
@@ -120,156 +73,35 @@ module Zeitwerk
120
73
  # up the corresponding autoloads.
121
74
  #
122
75
  # @private
123
- # @return [{String => <String>}]
76
+ # @sig Hash[String, Array[String]]
124
77
  attr_reader :lazy_subdirs
125
78
 
126
- # Absolute paths of files or directories not to be eager loaded.
127
- #
128
- # @private
129
- # @return [Set<String>]
130
- attr_reader :eager_load_exclusions
131
-
132
79
  # @private
133
- # @return [Mutex]
80
+ # @sig Mutex
134
81
  attr_reader :mutex
135
82
 
136
83
  # @private
137
- # @return [Mutex]
84
+ # @sig Mutex
138
85
  attr_reader :mutex2
139
86
 
140
87
  def initialize
141
- @initialized_at = Time.now
142
-
143
- @tag = SecureRandom.hex(3)
144
- @inflector = Inflector.new
145
- @logger = self.class.default_logger
146
-
147
- @root_dirs = {}
148
- @preloads = []
149
- @ignored_glob_patterns = Set.new
150
- @ignored_paths = Set.new
151
- @collapse_glob_patterns = Set.new
152
- @collapse_dirs = Set.new
153
- @autoloads = {}
154
- @autoloaded_dirs = []
155
- @to_unload = {}
156
- @lazy_subdirs = {}
157
- @eager_load_exclusions = Set.new
158
-
159
- # TODO: find a better name for these mutexes.
160
- @mutex = Mutex.new
161
- @mutex2 = Mutex.new
162
- @setup = false
163
- @eager_loaded = false
164
-
165
- @reloading_enabled = false
166
-
167
- Registry.register_loader(self)
168
- end
88
+ super
169
89
 
170
- # Sets a tag for the loader, useful for logging.
171
- #
172
- # @param tag [#to_s]
173
- # @return [void]
174
- def tag=(tag)
175
- @tag = tag.to_s
176
- end
90
+ @autoloads = Autoloads.new
91
+ @autoloaded_dirs = []
92
+ @to_unload = {}
93
+ @lazy_subdirs = Hash.new { |h, cpath| h[cpath] = [] }
94
+ @mutex = Mutex.new
95
+ @mutex2 = Mutex.new
96
+ @setup = false
97
+ @eager_loaded = false
177
98
 
178
- # Absolute paths of the root directories. This is a read-only collection,
179
- # please push here via `push_dir`.
180
- #
181
- # @return [<String>]
182
- def dirs
183
- root_dirs.keys.freeze
184
- end
185
-
186
- # Pushes `path` to the list of root directories.
187
- #
188
- # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
189
- # the same process already manages that directory or one of its ascendants
190
- # or descendants.
191
- #
192
- # @param path [<String, Pathname>]
193
- # @param namespace [Class, Module]
194
- # @raise [Zeitwerk::Error]
195
- # @return [void]
196
- def push_dir(path, namespace: Object)
197
- # Note that Class < Module.
198
- unless namespace.is_a?(Module)
199
- raise Error, "#{namespace.inspect} is not a class or module object, should be"
200
- end
201
-
202
- abspath = File.expand_path(path)
203
- if dir?(abspath)
204
- raise_if_conflicting_directory(abspath)
205
- root_dirs[abspath] = namespace
206
- else
207
- raise Error, "the root directory #{abspath} does not exist"
208
- end
209
- end
210
-
211
- # You need to call this method before setup in order to be able to reload.
212
- # There is no way to undo this, either you want to reload or you don't.
213
- #
214
- # @raise [Zeitwerk::Error]
215
- # @return [void]
216
- def enable_reloading
217
- mutex.synchronize do
218
- break if @reloading_enabled
219
-
220
- if @setup
221
- raise Error, "cannot enable reloading after setup"
222
- else
223
- @reloading_enabled = true
224
- end
225
- end
226
- end
227
-
228
- # @return [Boolean]
229
- def reloading_enabled?
230
- @reloading_enabled
231
- end
232
-
233
- # Files or directories to be preloaded instead of lazy loaded.
234
- #
235
- # @param paths [<String, Pathname, <String, Pathname>>]
236
- # @return [void]
237
- def preload(*paths)
238
- mutex.synchronize do
239
- expand_paths(paths).each do |abspath|
240
- preloads << abspath
241
- do_preload_abspath(abspath) if @setup
242
- end
243
- end
244
- end
245
-
246
- # Configure files, directories, or glob patterns to be totally ignored.
247
- #
248
- # @param paths [<String, Pathname, <String, Pathname>>]
249
- # @return [void]
250
- def ignore(*glob_patterns)
251
- glob_patterns = expand_paths(glob_patterns)
252
- mutex.synchronize do
253
- ignored_glob_patterns.merge(glob_patterns)
254
- ignored_paths.merge(expand_glob_patterns(glob_patterns))
255
- end
256
- end
257
-
258
- # Configure directories or glob patterns to be collapsed.
259
- #
260
- # @param paths [<String, Pathname, <String, Pathname>>]
261
- # @return [void]
262
- def collapse(*glob_patterns)
263
- glob_patterns = expand_paths(glob_patterns)
264
- mutex.synchronize do
265
- collapse_glob_patterns.merge(glob_patterns)
266
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
267
- end
99
+ Registry.register_loader(self)
268
100
  end
269
101
 
270
- # Sets autoloads in the root namespace and preloads files, if any.
102
+ # Sets autoloads in the root namespace.
271
103
  #
272
- # @return [void]
104
+ # @sig () -> void
273
105
  def setup
274
106
  mutex.synchronize do
275
107
  break if @setup
@@ -277,7 +109,6 @@ module Zeitwerk
277
109
  actual_root_dirs.each do |root_dir, namespace|
278
110
  set_autoloads_in_dir(root_dir, namespace)
279
111
  end
280
- do_preload
281
112
 
282
113
  @setup = true
283
114
  end
@@ -290,8 +121,11 @@ module Zeitwerk
290
121
  # else, they are eligible for garbage collection, which would effectively
291
122
  # unload them.
292
123
  #
293
- # @private
294
- # @return [void]
124
+ # This method is public but undocumented. Main interface is `reload`, which
125
+ # means `unload` + `setup`. This one is avaiable to be used together with
126
+ # `unregister`, which is undocumented too.
127
+ #
128
+ # @sig () -> void
295
129
  def unload
296
130
  mutex.synchronize do
297
131
  # We are going to keep track of the files that were required by our
@@ -302,21 +136,26 @@ module Zeitwerk
302
136
  # is enough.
303
137
  unloaded_files = Set.new
304
138
 
305
- autoloads.each do |realpath, (parent, cname)|
139
+ autoloads.each do |(parent, cname), abspath|
306
140
  if parent.autoload?(cname)
307
141
  unload_autoload(parent, cname)
308
142
  else
309
143
  # Could happen if loaded with require_relative. That is unsupported,
310
144
  # and the constant path would escape unloadable_cpath? This is just
311
145
  # defensive code to clean things up as much as we are able to.
312
- unload_cref(parent, cname) if cdef?(parent, cname)
313
- unloaded_files.add(realpath) if ruby?(realpath)
146
+ unload_cref(parent, cname) if cdef?(parent, cname)
147
+ unloaded_files.add(abspath) if ruby?(abspath)
314
148
  end
315
149
  end
316
150
 
317
- to_unload.each_value do |(realpath, (parent, cname))|
318
- unload_cref(parent, cname) if cdef?(parent, cname)
319
- unloaded_files.add(realpath) if ruby?(realpath)
151
+ to_unload.each do |cpath, (abspath, (parent, cname))|
152
+ unless on_unload_callbacks.empty?
153
+ value = parent.const_get(cname)
154
+ run_on_unload_callbacks(cpath, value, abspath)
155
+ end
156
+
157
+ unload_cref(parent, cname) if cdef?(parent, cname)
158
+ unloaded_files.add(abspath) if ruby?(abspath)
320
159
  end
321
160
 
322
161
  unless unloaded_files.empty?
@@ -340,7 +179,7 @@ module Zeitwerk
340
179
  lazy_subdirs.clear
341
180
 
342
181
  Registry.on_unload(self)
343
- ExplicitNamespace.unregister(self)
182
+ ExplicitNamespace.unregister_loader(self)
344
183
 
345
184
  @setup = false
346
185
  @eager_loaded = false
@@ -354,7 +193,7 @@ module Zeitwerk
354
193
  # client code in the README of the project.
355
194
  #
356
195
  # @raise [Zeitwerk::Error]
357
- # @return [void]
196
+ # @sig () -> void
358
197
  def reload
359
198
  if reloading_enabled?
360
199
  unload
@@ -371,32 +210,34 @@ module Zeitwerk
371
210
  # are not eager loaded. You can opt-out specifically in specific files and
372
211
  # directories with `do_not_eager_load`.
373
212
  #
374
- # @return [void]
213
+ # @sig () -> void
375
214
  def eager_load
376
215
  mutex.synchronize do
377
216
  break if @eager_loaded
378
217
 
218
+ log("eager load start") if logger
219
+
379
220
  queue = []
380
221
  actual_root_dirs.each do |root_dir, namespace|
381
- queue << [namespace, root_dir] unless eager_load_exclusions.member?(root_dir)
222
+ queue << [namespace, root_dir] unless excluded_from_eager_load?(root_dir)
382
223
  end
383
224
 
384
225
  while to_eager_load = queue.shift
385
226
  namespace, dir = to_eager_load
386
227
 
387
228
  ls(dir) do |basename, abspath|
388
- next if eager_load_exclusions.member?(abspath)
229
+ next if excluded_from_eager_load?(abspath)
389
230
 
390
231
  if ruby?(abspath)
391
- if cref = autoloads[File.realpath(abspath)]
392
- cref[0].const_get(cref[1], false)
232
+ if cref = autoloads.cref_for(abspath)
233
+ cget(*cref)
393
234
  end
394
235
  elsif dir?(abspath) && !root_dirs.key?(abspath)
395
- if collapse_dirs.member?(abspath)
236
+ if collapse?(abspath)
396
237
  queue << [namespace, abspath]
397
238
  else
398
239
  cname = inflector.camelize(basename, abspath)
399
- queue << [namespace.const_get(cname, false), abspath]
240
+ queue << [cget(namespace, cname), abspath]
400
241
  end
401
242
  end
402
243
  end
@@ -408,23 +249,15 @@ module Zeitwerk
408
249
  autoloaded_dirs.clear
409
250
 
410
251
  @eager_loaded = true
411
- end
412
- end
413
252
 
414
- # Let eager load ignore the given files or directories. The constants
415
- # defined in those files are still autoloadable.
416
- #
417
- # @param paths [<String, Pathname, <String, Pathname>>]
418
- # @return [void]
419
- def do_not_eager_load(*paths)
420
- mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
253
+ log("eager load end") if logger
254
+ end
421
255
  end
422
256
 
423
257
  # Says if the given constant path would be unloaded on reload. This
424
258
  # predicate returns `false` if reloading is disabled.
425
259
  #
426
- # @param cpath [String]
427
- # @return [Boolean]
260
+ # @sig (String) -> bool
428
261
  def unloadable_cpath?(cpath)
429
262
  to_unload.key?(cpath)
430
263
  end
@@ -432,42 +265,28 @@ module Zeitwerk
432
265
  # Returns an array with the constant paths that would be unloaded on reload.
433
266
  # This predicate returns an empty array if reloading is disabled.
434
267
  #
435
- # @return [<String>]
268
+ # @sig () -> Array[String]
436
269
  def unloadable_cpaths
437
270
  to_unload.keys.freeze
438
271
  end
439
272
 
440
- # Logs to `$stdout`, handy shortcut for debugging.
273
+ # This is a dangerous method.
441
274
  #
442
- # @return [void]
443
- def log!
444
- @logger = ->(msg) { puts msg }
445
- end
446
-
447
- # @private
448
- # @param dir [String]
449
- # @return [Boolean]
450
- def manages?(dir)
451
- dir = dir + "/"
452
- ignored_paths.each do |ignored_path|
453
- return false if dir.start_with?(ignored_path + "/")
454
- end
455
-
456
- root_dirs.each_key do |root_dir|
457
- return true if root_dir.start_with?(dir) || dir.start_with?(root_dir + "/")
458
- end
459
-
460
- false
275
+ # @experimental
276
+ # @sig () -> void
277
+ def unregister
278
+ Registry.unregister_loader(self)
279
+ ExplicitNamespace.unregister_loader(self)
461
280
  end
462
281
 
463
282
  # --- Class methods ---------------------------------------------------------------------------
464
283
 
465
284
  class << self
466
- # @return [#call, #debug, nil]
285
+ # @sig #call | #debug | nil
467
286
  attr_accessor :default_logger
468
287
 
469
288
  # @private
470
- # @return [Mutex]
289
+ # @sig Mutex
471
290
  attr_accessor :mutex
472
291
 
473
292
  # This is a shortcut for
@@ -481,7 +300,7 @@ module Zeitwerk
481
300
  # except that this method returns the same object in subsequent calls from
482
301
  # the same file, in the unlikely case the gem wants to be able to reload.
483
302
  #
484
- # @return [Zeitwerk::Loader]
303
+ # @sig () -> Zeitwerk::Loader
485
304
  def for_gem
486
305
  called_from = caller_locations(1, 1).first.path
487
306
  Registry.loader_for_gem(called_from)
@@ -489,7 +308,7 @@ module Zeitwerk
489
308
 
490
309
  # Broadcasts `eager_load` to all loaders.
491
310
  #
492
- # @return [void]
311
+ # @sig () -> void
493
312
  def eager_load_all
494
313
  Registry.loaders.each(&:eager_load)
495
314
  end
@@ -497,7 +316,7 @@ module Zeitwerk
497
316
  # Returns an array with the absolute paths of the root directories of all
498
317
  # registered loaders. This is a read-only collection.
499
318
  #
500
- # @return [<String>]
319
+ # @sig () -> Array[String]
501
320
  def all_dirs
502
321
  Registry.loaders.flat_map(&:dirs).freeze
503
322
  end
@@ -507,21 +326,12 @@ module Zeitwerk
507
326
 
508
327
  private # -------------------------------------------------------------------------------------
509
328
 
510
- # @return [<String>]
511
- def actual_root_dirs
512
- root_dirs.reject do |root_dir, _namespace|
513
- !dir?(root_dir) || ignored_paths.member?(root_dir)
514
- end
515
- end
516
-
517
- # @param dir [String]
518
- # @param parent [Module]
519
- # @return [void]
329
+ # @sig (String, Module) -> void
520
330
  def set_autoloads_in_dir(dir, parent)
521
331
  ls(dir) do |basename, abspath|
522
332
  begin
523
333
  if ruby?(basename)
524
- basename[-3..-1] = ''
334
+ basename.delete_suffix!(".rb")
525
335
  cname = inflector.camelize(basename, abspath).to_sym
526
336
  autoload_file(parent, cname, abspath)
527
337
  elsif dir?(abspath)
@@ -531,9 +341,9 @@ module Zeitwerk
531
341
  # To resolve the ambiguity file name -> constant path this introduces,
532
342
  # the `app/models/concerns` directory is totally ignored as a namespace,
533
343
  # it counts only as root. The guard checks that.
534
- unless root_dirs.key?(abspath)
344
+ unless root_dir?(abspath)
535
345
  cname = inflector.camelize(basename, abspath).to_sym
536
- if collapse_dirs.member?(abspath)
346
+ if collapse?(abspath)
537
347
  set_autoloads_in_dir(abspath, parent)
538
348
  else
539
349
  autoload_subdir(parent, cname, abspath)
@@ -559,35 +369,30 @@ module Zeitwerk
559
369
  end
560
370
  end
561
371
 
562
- # @param parent [Module]
563
- # @param cname [Symbol]
564
- # @param subdir [String]
565
- # @return [void]
372
+ # @sig (Module, Symbol, String) -> void
566
373
  def autoload_subdir(parent, cname, subdir)
567
- if autoload_path = autoload_for?(parent, cname)
374
+ if autoload_path = autoloads.abspath_for(parent, cname)
568
375
  cpath = cpath(parent, cname)
569
376
  register_explicit_namespace(cpath) if ruby?(autoload_path)
570
377
  # We do not need to issue another autoload, the existing one is enough
571
378
  # no matter if it is for a file or a directory. Just remember the
572
379
  # subdirectory has to be visited if the namespace is used.
573
- (lazy_subdirs[cpath] ||= []) << subdir
380
+ lazy_subdirs[cpath] << subdir
574
381
  elsif !cdef?(parent, cname)
575
382
  # First time we find this namespace, set an autoload for it.
576
- (lazy_subdirs[cpath(parent, cname)] ||= []) << subdir
383
+ lazy_subdirs[cpath(parent, cname)] << subdir
577
384
  set_autoload(parent, cname, subdir)
578
385
  else
579
386
  # For whatever reason the constant that corresponds to this namespace has
580
387
  # already been defined, we have to recurse.
581
- set_autoloads_in_dir(subdir, parent.const_get(cname))
388
+ log("the namespace #{cpath(parent, cname)} already exists, descending into #{subdir}") if logger
389
+ set_autoloads_in_dir(subdir, cget(parent, cname))
582
390
  end
583
391
  end
584
392
 
585
- # @param parent [Module]
586
- # @param cname [Symbol]
587
- # @param file [String]
588
- # @return [void]
393
+ # @sig (Module, Symbol, String) -> void
589
394
  def autoload_file(parent, cname, file)
590
- if autoload_path = autoload_for?(parent, cname)
395
+ if autoload_path = strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
591
396
  # First autoload for a Ruby file wins, just ignore subsequent ones.
592
397
  if ruby?(autoload_path)
593
398
  log("file #{file} is ignored because #{autoload_path} has precedence") if logger
@@ -606,194 +411,46 @@ module Zeitwerk
606
411
  end
607
412
  end
608
413
 
609
- # @param dir [String] directory that would have autovivified a module
610
- # @param file [String] the file where the namespace is explicitly defined
611
- # @param parent [Module]
612
- # @param cname [Symbol]
613
- # @return [void]
414
+ # `dir` is the directory that would have autovivified a namespace. `file` is
415
+ # the file where we've found the namespace is explicitly defined.
416
+ #
417
+ # @sig (dir: String, file: String, parent: Module, cname: Symbol) -> void
614
418
  def promote_namespace_from_implicit_to_explicit(dir:, file:, parent:, cname:)
615
419
  autoloads.delete(dir)
616
420
  Registry.unregister_autoload(dir)
617
421
 
422
+ log("earlier autoload for #{cpath(parent, cname)} discarded, it is actually an explicit namespace defined in #{file}") if logger
423
+
618
424
  set_autoload(parent, cname, file)
619
425
  register_explicit_namespace(cpath(parent, cname))
620
426
  end
621
427
 
622
- # @param parent [Module]
623
- # @param cname [Symbol]
624
- # @param abspath [String]
625
- # @return [void]
428
+ # @sig (Module, Symbol, String) -> void
626
429
  def set_autoload(parent, cname, abspath)
627
- # $LOADED_FEATURES stores real paths since Ruby 2.4.4. We set and save the
628
- # real path to be able to delete it from $LOADED_FEATURES on unload, and to
629
- # be able to do a lookup later in Kernel#require for manual require calls.
630
- #
631
- # We freeze realpath because that saves allocations in Module#autoload.
632
- # See #125.
633
- realpath = File.realpath(abspath).freeze
634
- parent.autoload(cname, realpath)
430
+ autoloads.define(parent, cname, abspath)
431
+
635
432
  if logger
636
- if ruby?(realpath)
637
- log("autoload set for #{cpath(parent, cname)}, to be loaded from #{realpath}")
433
+ if ruby?(abspath)
434
+ log("autoload set for #{cpath(parent, cname)}, to be loaded from #{abspath}")
638
435
  else
639
- log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{realpath}")
436
+ log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{abspath}")
640
437
  end
641
438
  end
642
439
 
643
- autoloads[realpath] = [parent, cname]
644
- Registry.register_autoload(self, realpath)
440
+ Registry.register_autoload(self, abspath)
645
441
 
646
442
  # See why in the documentation of Zeitwerk::Registry.inceptions.
647
443
  unless parent.autoload?(cname)
648
- Registry.register_inception(cpath(parent, cname), realpath, self)
649
- end
650
- end
651
-
652
- # @param parent [Module]
653
- # @param cname [Symbol]
654
- # @return [String, nil]
655
- def autoload_for?(parent, cname)
656
- strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
657
- end
658
-
659
- # The autoload? predicate takes into account the ancestor chain of the
660
- # receiver, like const_defined? and other methods in the constants API do.
661
- #
662
- # For example, given
663
- #
664
- # class A
665
- # autoload :X, "x.rb"
666
- # end
667
- #
668
- # class B < A
669
- # end
670
- #
671
- # B.autoload?(:X) returns "x.rb".
672
- #
673
- # We need a way to strictly check in parent ignoring ancestors.
674
- #
675
- # @param parent [Module]
676
- # @param cname [Symbol]
677
- # @return [String, nil]
678
- if method(:autoload?).arity == 1
679
- def strict_autoload_path(parent, cname)
680
- parent.autoload?(cname) if cdef?(parent, cname)
681
- end
682
- else
683
- def strict_autoload_path(parent, cname)
684
- parent.autoload?(cname, false)
685
- end
686
- end
687
-
688
- # This method is called this way because I prefer `preload` to be the method
689
- # name to configure preloads in the public interface.
690
- #
691
- # @return [void]
692
- def do_preload
693
- preloads.each do |abspath|
694
- do_preload_abspath(abspath)
695
- end
696
- end
697
-
698
- # @param abspath [String]
699
- # @return [void]
700
- def do_preload_abspath(abspath)
701
- if ruby?(abspath)
702
- do_preload_file(abspath)
703
- elsif dir?(abspath)
704
- do_preload_dir(abspath)
705
- end
706
- end
707
-
708
- # @param dir [String]
709
- # @return [void]
710
- def do_preload_dir(dir)
711
- ls(dir) do |_basename, abspath|
712
- do_preload_abspath(abspath)
713
- end
714
- end
715
-
716
- # @param file [String]
717
- # @return [Boolean]
718
- def do_preload_file(file)
719
- log("preloading #{file}") if logger
720
- require file
721
- end
722
-
723
- # @param parent [Module]
724
- # @param cname [Symbol]
725
- # @return [String]
726
- def cpath(parent, cname)
727
- parent.equal?(Object) ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
728
- end
729
-
730
- # @param dir [String]
731
- # @yieldparam path [String, String]
732
- # @return [void]
733
- def ls(dir)
734
- Dir.foreach(dir) do |basename|
735
- next if basename.start_with?(".")
736
-
737
- abspath = File.join(dir, basename)
738
- next if ignored_paths.member?(abspath)
739
-
740
- # We freeze abspath because that saves allocations when passed later to
741
- # File methods. See #125.
742
- yield basename, abspath.freeze
444
+ Registry.register_inception(cpath(parent, cname), abspath, self)
743
445
  end
744
446
  end
745
447
 
746
- # @param path [String]
747
- # @return [Boolean]
748
- def ruby?(path)
749
- path.end_with?(".rb")
750
- end
751
-
752
- # @param path [String]
753
- # @return [Boolean]
754
- def dir?(path)
755
- File.directory?(path)
756
- end
757
-
758
- # @param paths [<String, Pathname, <String, Pathname>>]
759
- # @return [<String>]
760
- def expand_paths(paths)
761
- paths.flatten.map! { |path| File.expand_path(path) }
762
- end
763
-
764
- # @param glob_patterns [<String>]
765
- # @return [<String>]
766
- def expand_glob_patterns(glob_patterns)
767
- # Note that Dir.glob works with regular file names just fine. That is,
768
- # glob patterns technically need no wildcards.
769
- glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
770
- end
771
-
772
- # @return [void]
773
- def recompute_ignored_paths
774
- ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
775
- end
776
-
777
- # @return [void]
778
- def recompute_collapse_dirs
779
- collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
780
- end
781
-
782
- # @param message [String]
783
- # @return [void]
784
- def log(message)
785
- method_name = logger.respond_to?(:debug) ? :debug : :call
786
- logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
787
- end
788
-
789
- def cdef?(parent, cname)
790
- parent.const_defined?(cname, false)
791
- end
792
-
448
+ # @sig (String) -> void
793
449
  def register_explicit_namespace(cpath)
794
450
  ExplicitNamespace.register(cpath, self)
795
451
  end
796
452
 
453
+ # @sig (String) -> void
797
454
  def raise_if_conflicting_directory(dir)
798
455
  self.class.mutex.synchronize do
799
456
  Registry.loaders.each do |loader|
@@ -808,19 +465,22 @@ module Zeitwerk
808
465
  end
809
466
  end
810
467
 
811
- # @param parent [Module]
812
- # @param cname [Symbol]
813
- # @return [void]
468
+ # @sig (String, Object, String) -> void
469
+ def run_on_unload_callbacks(cpath, value, abspath)
470
+ # Order matters. If present, run the most specific one.
471
+ on_unload_callbacks[cpath]&.each { |c| c.call(value, abspath) }
472
+ on_unload_callbacks[:ANY]&.each { |c| c.call(cpath, value, abspath) }
473
+ end
474
+
475
+ # @sig (Module, Symbol) -> void
814
476
  def unload_autoload(parent, cname)
815
- parent.send(:remove_const, cname)
477
+ parent.__send__(:remove_const, cname)
816
478
  log("autoload for #{cpath(parent, cname)} removed") if logger
817
479
  end
818
480
 
819
- # @param parent [Module]
820
- # @param cname [Symbol]
821
- # @return [void]
481
+ # @sig (Module, Symbol) -> void
822
482
  def unload_cref(parent, cname)
823
- parent.send(:remove_const, cname)
483
+ parent.__send__(:remove_const, cname)
824
484
  log("#{cpath(parent, cname)} unloaded") if logger
825
485
  end
826
486
  end