zeitwerk 2.4.0 → 2.5.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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