zeitwerk 2.2.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.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk
4
+ require_relative "zeitwerk/real_mod_name"
5
+ require_relative "zeitwerk/loader"
6
+ require_relative "zeitwerk/registry"
7
+ require_relative "zeitwerk/explicit_namespace"
8
+ require_relative "zeitwerk/inflector"
9
+ require_relative "zeitwerk/gem_inflector"
10
+ require_relative "zeitwerk/kernel"
11
+ require_relative "zeitwerk/error"
12
+ end
@@ -0,0 +1,10 @@
1
+ module Zeitwerk
2
+ class Error < StandardError
3
+ end
4
+
5
+ class ReloadingDisabledError < Error
6
+ end
7
+
8
+ class NameError < ::NameError
9
+ end
10
+ end
@@ -0,0 +1,80 @@
1
+ module Zeitwerk
2
+ # Centralizes the logic for the trace point used to detect the creation of
3
+ # explicit namespaces, needed to descend into matching subdirectories right
4
+ # after the constant has been defined.
5
+ #
6
+ # The implementation assumes an explicit namespace is managed by one loader.
7
+ # Loaders that reopen namespaces owned by other projects are responsible for
8
+ # loading their constant before setup. This is documented.
9
+ module ExplicitNamespace # :nodoc: all
10
+ class << self
11
+ include RealModName
12
+
13
+ # Maps constant paths that correspond to explicit namespaces according to
14
+ # the file system, to the loader responsible for them.
15
+ #
16
+ # @private
17
+ # @return [{String => Zeitwerk::Loader}]
18
+ attr_reader :cpaths
19
+
20
+ # @private
21
+ # @return [Mutex]
22
+ attr_reader :mutex
23
+
24
+ # @private
25
+ # @return [TracePoint]
26
+ attr_reader :tracer
27
+
28
+ # Asserts `cpath` corresponds to an explicit namespace for which `loader`
29
+ # is responsible.
30
+ #
31
+ # @private
32
+ # @param cpath [String]
33
+ # @param loader [Zeitwerk::Loader]
34
+ # @return [void]
35
+ def register(cpath, loader)
36
+ mutex.synchronize do
37
+ cpaths[cpath] = loader
38
+ # We check enabled? because, looking at the C source code, enabling an
39
+ # enabled tracer does not seem to be a simple no-op.
40
+ tracer.enable unless tracer.enabled?
41
+ end
42
+ end
43
+
44
+ # @private
45
+ # @param loader [Zeitwerk::Loader]
46
+ # @return [void]
47
+ def unregister(loader)
48
+ cpaths.delete_if { |_cpath, l| l == loader }
49
+ disable_tracer_if_unneeded
50
+ end
51
+
52
+ def disable_tracer_if_unneeded
53
+ mutex.synchronize do
54
+ tracer.disable if cpaths.empty?
55
+ end
56
+ end
57
+
58
+ def tracepoint_class_callback(event)
59
+ # If the class is a singleton class, we won't do anything with it so we
60
+ # can bail out immediately. This is several orders of magnitude faster
61
+ # than accessing its name.
62
+ return if event.self.singleton_class?
63
+
64
+ # Note that it makes sense to compute the hash code unconditionally,
65
+ # because the trace point is disabled if cpaths is empty.
66
+ if loader = cpaths.delete(real_mod_name(event.self))
67
+ loader.on_namespace_loaded(event.self)
68
+ disable_tracer_if_unneeded
69
+ end
70
+ end
71
+ end
72
+
73
+ @cpaths = {}
74
+ @mutex = Mutex.new
75
+
76
+ # We go through a method instead of defining a block mainly to have a better
77
+ # label when profiling.
78
+ @tracer = TracePoint.new(:class, &method(:tracepoint_class_callback))
79
+ end
80
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk
4
+ class GemInflector < Inflector
5
+ # @param root_file [String]
6
+ def initialize(root_file)
7
+ namespace = File.basename(root_file, ".rb")
8
+ lib_dir = File.dirname(root_file)
9
+ @version_file = File.join(lib_dir, namespace, "version.rb")
10
+ end
11
+
12
+ # @param basename [String]
13
+ # @param abspath [String]
14
+ # @return [String]
15
+ def camelize(basename, abspath)
16
+ abspath == @version_file ? "VERSION" : super
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk
4
+ class Inflector
5
+ # Very basic snake case -> camel case conversion.
6
+ #
7
+ # inflector = Zeitwerk::Inflector.new
8
+ # inflector.camelize("post", ...) # => "Post"
9
+ # inflector.camelize("users_controller", ...) # => "UsersController"
10
+ # inflector.camelize("api", ...) # => "Api"
11
+ #
12
+ # Takes into account hard-coded mappings configured with `inflect`.
13
+ #
14
+ # @param basename [String]
15
+ # @param _abspath [String]
16
+ # @return [String]
17
+ def camelize(basename, _abspath)
18
+ overrides[basename] || basename.split('_').map!(&:capitalize).join
19
+ end
20
+
21
+ # Configures hard-coded inflections:
22
+ #
23
+ # inflector = Zeitwerk::Inflector.new
24
+ # inflector.inflect(
25
+ # "html_parser" => "HTMLParser",
26
+ # "mysql_adapter" => "MySQLAdapter"
27
+ # )
28
+ #
29
+ # inflector.camelize("html_parser", abspath) # => "HTMLParser"
30
+ # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
31
+ # inflector.camelize("users_controller", abspath) # => "UsersController"
32
+ #
33
+ # @param inflections [{String => String}]
34
+ # @return [void]
35
+ def inflect(inflections)
36
+ overrides.merge!(inflections)
37
+ end
38
+
39
+ private
40
+
41
+ # Hard-coded basename to constant name user maps that override the default
42
+ # inflection logic.
43
+ #
44
+ # @return [{String => String}]
45
+ def overrides
46
+ @overrides ||= {}
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ module_function
5
+
6
+ # We cannot decorate with prepend + super because Kernel has already been
7
+ # included in Object, and changes in ancestors don't get propagated into
8
+ # already existing ancestor chains.
9
+ alias_method :zeitwerk_original_require, :require
10
+
11
+ # @param path [String]
12
+ # @return [Boolean]
13
+ def require(path)
14
+ if loader = Zeitwerk::Registry.loader_for(path)
15
+ if path.end_with?(".rb")
16
+ zeitwerk_original_require(path).tap do |required|
17
+ loader.on_file_autoloaded(path) if required
18
+ end
19
+ else
20
+ loader.on_dir_autoloaded(path)
21
+ end
22
+ else
23
+ zeitwerk_original_require(path).tap do |required|
24
+ if required
25
+ realpath = $LOADED_FEATURES.last
26
+ if loader = Zeitwerk::Registry.loader_for(realpath)
27
+ loader.on_file_autoloaded(realpath)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,765 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "securerandom"
5
+
6
+ module Zeitwerk
7
+ class Loader
8
+ 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
35
+
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, of glob patterns to be totally
43
+ # ignored.
44
+ #
45
+ # @private
46
+ # @return [Set<String>]
47
+ attr_reader :ignored_glob_patterns
48
+
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.
52
+ #
53
+ # @private
54
+ # @return [Set<String>]
55
+ attr_reader :ignored_paths
56
+
57
+ # Maps real absolute paths for which an autoload has been set ---and not
58
+ # executed--- to their corresponding parent class or module and constant
59
+ # name.
60
+ #
61
+ # "/Users/fxn/blog/app/models/user.rb" => [Object, :User],
62
+ # "/Users/fxn/blog/app/models/hotel/pricing.rb" => [Hotel, :Pricing]
63
+ # ...
64
+ #
65
+ # @private
66
+ # @return [{String => (Module, Symbol)}]
67
+ attr_reader :autoloads
68
+
69
+ # We keep track of autoloaded directories to remove them from the registry
70
+ # at the end of eager loading.
71
+ #
72
+ # Files are removed as they are autoloaded, but directories need to wait due
73
+ # to concurrency (see why in Zeitwerk::Loader::Callbacks#on_dir_autoloaded).
74
+ #
75
+ # @private
76
+ # @return [<String>]
77
+ attr_reader :autoloaded_dirs
78
+
79
+ # Stores metadata needed for unloading. Its entries look like this:
80
+ #
81
+ # "Admin::Role" => [".../admin/role.rb", [Admin, :Role]]
82
+ #
83
+ # The cpath as key helps implementing unloadable_cpath? The real file name
84
+ # is stored in order to be able to delete it from $LOADED_FEATURES, and the
85
+ # pair [Module, Symbol] is used to remove_const the constant from the class
86
+ # or module object.
87
+ #
88
+ # If reloading is enabled, this hash is filled as constants are autoloaded
89
+ # or eager loaded. Otherwise, the collection remains empty.
90
+ #
91
+ # @private
92
+ # @return [{String => (String, (Module, Symbol))}]
93
+ attr_reader :to_unload
94
+
95
+ # Maps constant paths of namespaces to arrays of corresponding directories.
96
+ #
97
+ # For example, given this mapping:
98
+ #
99
+ # "Admin" => [
100
+ # "/Users/fxn/blog/app/controllers/admin",
101
+ # "/Users/fxn/blog/app/models/admin",
102
+ # ...
103
+ # ]
104
+ #
105
+ # when `Admin` gets defined we know that it plays the role of a namespace and
106
+ # that its children are spread over those directories. We'll visit them to set
107
+ # up the corresponding autoloads.
108
+ #
109
+ # @private
110
+ # @return [{String => <String>}]
111
+ attr_reader :lazy_subdirs
112
+
113
+ # Absolute paths of files or directories not to be eager loaded.
114
+ #
115
+ # @private
116
+ # @return [Set<String>]
117
+ attr_reader :eager_load_exclusions
118
+
119
+ # @private
120
+ # @return [Mutex]
121
+ attr_reader :mutex
122
+
123
+ # @private
124
+ # @return [Mutex]
125
+ attr_reader :mutex2
126
+
127
+ def initialize
128
+ @initialized_at = Time.now
129
+
130
+ @tag = SecureRandom.hex(3)
131
+ @inflector = Inflector.new
132
+ @logger = self.class.default_logger
133
+
134
+ @root_dirs = {}
135
+ @preloads = []
136
+ @ignored_glob_patterns = Set.new
137
+ @ignored_paths = Set.new
138
+ @autoloads = {}
139
+ @autoloaded_dirs = []
140
+ @to_unload = {}
141
+ @lazy_subdirs = {}
142
+ @eager_load_exclusions = Set.new
143
+
144
+ # TODO: find a better name for these mutexes.
145
+ @mutex = Mutex.new
146
+ @mutex2 = Mutex.new
147
+ @setup = false
148
+ @eager_loaded = false
149
+
150
+ @reloading_enabled = false
151
+
152
+ Registry.register_loader(self)
153
+ end
154
+
155
+ # Sets a tag for the loader, useful for logging.
156
+ #
157
+ # @return [void]
158
+ def tag=(tag)
159
+ @tag = tag.to_s
160
+ end
161
+
162
+ # Absolute paths of the root directories. This is a read-only collection,
163
+ # please push here via `push_dir`.
164
+ #
165
+ # @return [<String>]
166
+ def dirs
167
+ root_dirs.keys.freeze
168
+ end
169
+
170
+ # Pushes `path` to the list of root directories.
171
+ #
172
+ # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
173
+ # the same process already manages that directory or one of its ascendants
174
+ # or descendants.
175
+ #
176
+ # @param path [<String, Pathname>]
177
+ # @raise [Zeitwerk::Error]
178
+ # @return [void]
179
+ def push_dir(path)
180
+ abspath = File.expand_path(path)
181
+ if dir?(abspath)
182
+ raise_if_conflicting_directory(abspath)
183
+ root_dirs[abspath] = true
184
+ else
185
+ raise Error, "the root directory #{abspath} does not exist"
186
+ end
187
+ end
188
+
189
+ # You need to call this method before setup in order to be able to reload.
190
+ # There is no way to undo this, either you want to reload or you don't.
191
+ #
192
+ # @raise [Zeitwerk::Error]
193
+ # @return [void]
194
+ def enable_reloading
195
+ mutex.synchronize do
196
+ break if @reloading_enabled
197
+
198
+ if @setup
199
+ raise Error, "cannot enable reloading after setup"
200
+ else
201
+ @reloading_enabled = true
202
+ end
203
+ end
204
+ end
205
+
206
+ # @return [Boolean]
207
+ def reloading_enabled?
208
+ @reloading_enabled
209
+ end
210
+
211
+ # Files or directories to be preloaded instead of lazy loaded.
212
+ #
213
+ # @param paths [<String, Pathname, <String, Pathname>>]
214
+ # @return [void]
215
+ def preload(*paths)
216
+ mutex.synchronize do
217
+ expand_paths(paths).each do |abspath|
218
+ preloads << abspath
219
+ do_preload_abspath(abspath) if @setup
220
+ end
221
+ end
222
+ end
223
+
224
+ # Configure files, directories, or glob patterns to be totally ignored.
225
+ #
226
+ # @param paths [<String, Pathname, <String, Pathname>>]
227
+ # @return [void]
228
+ def ignore(*glob_patterns)
229
+ glob_patterns = expand_paths(glob_patterns)
230
+ mutex.synchronize do
231
+ ignored_glob_patterns.merge(glob_patterns)
232
+ ignored_paths.merge(expand_glob_patterns(glob_patterns))
233
+ end
234
+ end
235
+
236
+ # Sets autoloads in the root namespace and preloads files, if any.
237
+ #
238
+ # @return [void]
239
+ def setup
240
+ mutex.synchronize do
241
+ break if @setup
242
+
243
+ actual_root_dirs.each { |root_dir| set_autoloads_in_dir(root_dir, Object) }
244
+ do_preload
245
+
246
+ @setup = true
247
+ end
248
+ end
249
+
250
+ # Removes loaded constants and configured autoloads.
251
+ #
252
+ # The objects the constants stored are no longer reachable through them. In
253
+ # addition, since said objects are normally not referenced from anywhere
254
+ # else, they are eligible for garbage collection, which would effectively
255
+ # unload them.
256
+ #
257
+ # @private
258
+ # @return [void]
259
+ def unload
260
+ mutex.synchronize do
261
+ # We are going to keep track of the files that were required by our
262
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
263
+ # loadable by Kernel#require again.
264
+ #
265
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
266
+ # is enough.
267
+ unloaded_files = Set.new
268
+
269
+ autoloads.each do |realpath, (parent, cname)|
270
+ if parent.autoload?(cname)
271
+ unload_autoload(parent, cname)
272
+ else
273
+ # Could happen if loaded with require_relative. That is unsupported,
274
+ # and the constant path would escape unloadable_cpath? This is just
275
+ # defensive code to clean things up as much as we are able to.
276
+ unload_cref(parent, cname) if cdef?(parent, cname)
277
+ unloaded_files.add(realpath) if ruby?(realpath)
278
+ end
279
+ end
280
+
281
+ to_unload.each_value do |(realpath, (parent, cname))|
282
+ unload_cref(parent, cname) if cdef?(parent, cname)
283
+ unloaded_files.add(realpath) if ruby?(realpath)
284
+ end
285
+
286
+ unless unloaded_files.empty?
287
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
288
+ # this optimization does not check if $LOADED_FEATURES has the file.
289
+ #
290
+ # To make it aware of changes, the gem defines singleton methods in
291
+ # $LOADED_FEATURES:
292
+ #
293
+ # https://github.com/Shopify/bootsnap/blob/master/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
294
+ #
295
+ # Rails applications may depend on bootsnap, so for unloading to work
296
+ # in that setting it is preferable that we restrict our API choice to
297
+ # one of those methods.
298
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
299
+ end
300
+
301
+ autoloads.clear
302
+ autoloaded_dirs.clear
303
+ to_unload.clear
304
+ lazy_subdirs.clear
305
+
306
+ Registry.on_unload(self)
307
+ ExplicitNamespace.unregister(self)
308
+
309
+ @setup = false
310
+ end
311
+ end
312
+
313
+ # Unloads all loaded code, and calls setup again so that the loader is able
314
+ # to pick any changes in the file system.
315
+ #
316
+ # This method is not thread-safe, please see how this can be achieved by
317
+ # client code in the README of the project.
318
+ #
319
+ # @raise [Zeitwerk::Error]
320
+ # @return [void]
321
+ def reload
322
+ if reloading_enabled?
323
+ unload
324
+ recompute_ignored_paths
325
+ setup
326
+ else
327
+ raise ReloadingDisabledError, "can't reload, please call loader.enable_reloading before setup"
328
+ end
329
+ end
330
+
331
+ # Eager loads all files in the root directories, recursively. Files do not
332
+ # need to be in `$LOAD_PATH`, absolute file names are used. Ignored files
333
+ # are not eager loaded. You can opt-out specifically in specific files and
334
+ # directories with `do_not_eager_load`.
335
+ #
336
+ # @return [void]
337
+ def eager_load
338
+ mutex.synchronize do
339
+ break if @eager_loaded
340
+
341
+ queue = actual_root_dirs.reject { |dir| eager_load_exclusions.member?(dir) }
342
+ queue.map! { |dir| [Object, dir] }
343
+ while to_eager_load = queue.shift
344
+ namespace, dir = to_eager_load
345
+
346
+ ls(dir) do |basename, abspath|
347
+ next if eager_load_exclusions.member?(abspath)
348
+
349
+ if ruby?(abspath)
350
+ if cref = autoloads[File.realpath(abspath)]
351
+ cref[0].const_get(cref[1], false)
352
+ end
353
+ elsif dir?(abspath) && !root_dirs.key?(abspath)
354
+ cname = inflector.camelize(basename, abspath)
355
+ queue << [namespace.const_get(cname, false), abspath]
356
+ end
357
+ end
358
+ end
359
+
360
+ autoloaded_dirs.each do |autoloaded_dir|
361
+ Registry.unregister_autoload(autoloaded_dir)
362
+ end
363
+ autoloaded_dirs.clear
364
+
365
+ @eager_loaded = true
366
+ end
367
+ end
368
+
369
+ # Let eager load ignore the given files or directories. The constants
370
+ # defined in those files are still autoloadable.
371
+ #
372
+ # @param paths [<String, Pathname, <String, Pathname>>]
373
+ # @return [void]
374
+ def do_not_eager_load(*paths)
375
+ mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
376
+ end
377
+
378
+ # Says if the given constant path would be unloaded on reload. This
379
+ # predicate returns `false` if reloading is disabled.
380
+ #
381
+ # @param cpath [String]
382
+ # @return [Boolean]
383
+ def unloadable_cpath?(cpath)
384
+ to_unload.key?(cpath)
385
+ end
386
+
387
+ # Returns an array with the constant paths that would be unloaded on reload.
388
+ # This predicate returns an empty array if reloading is disabled.
389
+ #
390
+ # @return [<String>]
391
+ def unloadable_cpaths
392
+ to_unload.keys.freeze
393
+ end
394
+
395
+ # Logs to `$stdout`, handy shortcut for debugging.
396
+ #
397
+ # @return [void]
398
+ def log!
399
+ @logger = ->(msg) { puts msg }
400
+ end
401
+
402
+ # @private
403
+ # @param dir [String]
404
+ # @return [Boolean]
405
+ def manages?(dir)
406
+ dir = dir + "/"
407
+ ignored_paths.each do |ignored_path|
408
+ return false if dir.start_with?(ignored_path + "/")
409
+ end
410
+
411
+ root_dirs.each_key do |root_dir|
412
+ return true if root_dir.start_with?(dir) || dir.start_with?(root_dir + "/")
413
+ end
414
+
415
+ false
416
+ end
417
+
418
+ # --- Class methods ---------------------------------------------------------------------------
419
+
420
+ class << self
421
+ # @return [#call, #debug, nil]
422
+ attr_accessor :default_logger
423
+
424
+ # @private
425
+ # @return [Mutex]
426
+ attr_accessor :mutex
427
+
428
+ # This is a shortcut for
429
+ #
430
+ # require "zeitwerk"
431
+ # loader = Zeitwerk::Loader.new
432
+ # loader.tag = File.basename(__FILE__, ".rb")
433
+ # loader.inflector = Zeitwerk::GemInflector.new
434
+ # loader.push_dir(__dir__)
435
+ #
436
+ # except that this method returns the same object in subsequent calls from
437
+ # the same file, in the unlikely case the gem wants to be able to reload.
438
+ #
439
+ # @return [Zeitwerk::Loader]
440
+ def for_gem
441
+ called_from = caller_locations(1, 1).first.path
442
+ Registry.loader_for_gem(called_from)
443
+ end
444
+
445
+ # Broadcasts `eager_load` to all loaders.
446
+ #
447
+ # @return [void]
448
+ def eager_load_all
449
+ Registry.loaders.each(&:eager_load)
450
+ end
451
+
452
+ # Returns an array with the absolute paths of the root directories of all
453
+ # registered loaders. This is a read-only collection.
454
+ #
455
+ # @return [<String>]
456
+ def all_dirs
457
+ Registry.loaders.flat_map(&:dirs).freeze
458
+ end
459
+ end
460
+
461
+ self.mutex = Mutex.new
462
+
463
+ private # -------------------------------------------------------------------------------------
464
+
465
+ # @return [<String>]
466
+ def actual_root_dirs
467
+ root_dirs.keys.delete_if do |root_dir|
468
+ !dir?(root_dir) || ignored_paths.member?(root_dir)
469
+ end
470
+ end
471
+
472
+ # @param dir [String]
473
+ # @param parent [Module]
474
+ # @return [void]
475
+ def set_autoloads_in_dir(dir, parent)
476
+ ls(dir) do |basename, abspath|
477
+ begin
478
+ if ruby?(basename)
479
+ basename.slice!(-3, 3)
480
+ cname = inflector.camelize(basename, abspath).to_sym
481
+ autoload_file(parent, cname, abspath)
482
+ elsif dir?(abspath)
483
+ # In a Rails application, `app/models/concerns` is a subdirectory of
484
+ # `app/models`, but both of them are root directories.
485
+ #
486
+ # To resolve the ambiguity file name -> constant path this introduces,
487
+ # the `app/models/concerns` directory is totally ignored as a namespace,
488
+ # it counts only as root. The guard checks that.
489
+ unless root_dirs.key?(abspath)
490
+ cname = inflector.camelize(basename, abspath).to_sym
491
+ autoload_subdir(parent, cname, abspath)
492
+ end
493
+ end
494
+ rescue ::NameError => error
495
+ path_type = ruby?(abspath) ? "file" : "directory"
496
+
497
+ raise NameError, <<~MESSAGE
498
+ #{error.message} inferred by #{inflector.class} from #{path_type}
499
+
500
+ #{abspath}
501
+
502
+ Possible ways to address this:
503
+
504
+ * Tell Zeitwerk to ignore this particular #{path_type}.
505
+ * Tell Zeitwerk to ignore one of its parent directories.
506
+ * Rename the #{path_type} to comply with the naming conventions.
507
+ * Modify the inflector to handle this case.
508
+ MESSAGE
509
+ end
510
+ end
511
+ end
512
+
513
+ # @param parent [Module]
514
+ # @param cname [Symbol]
515
+ # @param subdir [String]
516
+ # @return [void]
517
+ def autoload_subdir(parent, cname, subdir)
518
+ if autoload_path = autoload_for?(parent, cname)
519
+ cpath = cpath(parent, cname)
520
+ register_explicit_namespace(cpath) if ruby?(autoload_path)
521
+ # We do not need to issue another autoload, the existing one is enough
522
+ # no matter if it is for a file or a directory. Just remember the
523
+ # subdirectory has to be visited if the namespace is used.
524
+ (lazy_subdirs[cpath] ||= []) << subdir
525
+ elsif !cdef?(parent, cname)
526
+ # First time we find this namespace, set an autoload for it.
527
+ (lazy_subdirs[cpath(parent, cname)] ||= []) << subdir
528
+ set_autoload(parent, cname, subdir)
529
+ else
530
+ # For whatever reason the constant that corresponds to this namespace has
531
+ # already been defined, we have to recurse.
532
+ set_autoloads_in_dir(subdir, parent.const_get(cname))
533
+ end
534
+ end
535
+
536
+ # @param parent [Module]
537
+ # @param cname [Symbol]
538
+ # @param file [String]
539
+ # @return [void]
540
+ def autoload_file(parent, cname, file)
541
+ if autoload_path = autoload_for?(parent, cname)
542
+ # First autoload for a Ruby file wins, just ignore subsequent ones.
543
+ if ruby?(autoload_path)
544
+ log("file #{file} is ignored because #{autoload_path} has precedence") if logger
545
+ else
546
+ promote_namespace_from_implicit_to_explicit(
547
+ dir: autoload_path,
548
+ file: file,
549
+ parent: parent,
550
+ cname: cname
551
+ )
552
+ end
553
+ elsif cdef?(parent, cname)
554
+ log("file #{file} is ignored because #{cpath(parent, cname)} is already defined") if logger
555
+ else
556
+ set_autoload(parent, cname, file)
557
+ end
558
+ end
559
+
560
+ # @param dir [String] directory that would have autovivified a module
561
+ # @param file [String] the file where the namespace is explictly defined
562
+ # @param parent [Module]
563
+ # @param cname [Symbol]
564
+ # @return [void]
565
+ def promote_namespace_from_implicit_to_explicit(dir:, file:, parent:, cname:)
566
+ autoloads.delete(dir)
567
+ Registry.unregister_autoload(dir)
568
+
569
+ set_autoload(parent, cname, file)
570
+ register_explicit_namespace(cpath(parent, cname))
571
+ end
572
+
573
+ # @param parent [Module]
574
+ # @param cname [Symbol]
575
+ # @param abspath [String]
576
+ # @return [void]
577
+ def set_autoload(parent, cname, abspath)
578
+ # $LOADED_FEATURES stores real paths since Ruby 2.4.4. We set and save the
579
+ # real path to be able to delete it from $LOADED_FEATURES on unload, and to
580
+ # be able to do a lookup later in Kernel#require for manual require calls.
581
+ realpath = File.realpath(abspath)
582
+ parent.autoload(cname, realpath)
583
+ if logger
584
+ if ruby?(realpath)
585
+ log("autoload set for #{cpath(parent, cname)}, to be loaded from #{realpath}")
586
+ else
587
+ log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{realpath}")
588
+ end
589
+ end
590
+
591
+ autoloads[realpath] = [parent, cname]
592
+ Registry.register_autoload(self, realpath)
593
+
594
+ # See why in the documentation of Zeitwerk::Registry.inceptions.
595
+ unless parent.autoload?(cname)
596
+ Registry.register_inception(cpath(parent, cname), realpath, self)
597
+ end
598
+ end
599
+
600
+ # @param parent [Module]
601
+ # @param cname [Symbol]
602
+ # @return [String, nil]
603
+ def autoload_for?(parent, cname)
604
+ strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
605
+ end
606
+
607
+ # The autoload? predicate takes into account the ancestor chain of the
608
+ # receiver, like const_defined? and other methods in the constants API do.
609
+ #
610
+ # For example, given
611
+ #
612
+ # class A
613
+ # autoload :X, "x.rb"
614
+ # end
615
+ #
616
+ # class B < A
617
+ # end
618
+ #
619
+ # B.autoload?(:X) returns "x.rb".
620
+ #
621
+ # We need a way to strictly check in parent ignoring ancestors.
622
+ #
623
+ # @param parent [Module]
624
+ # @param cname [Symbol]
625
+ # @return [String, nil]
626
+ if method(:autoload?).arity == 1
627
+ def strict_autoload_path(parent, cname)
628
+ parent.autoload?(cname) if cdef?(parent, cname)
629
+ end
630
+ else
631
+ def strict_autoload_path(parent, cname)
632
+ parent.autoload?(cname, false)
633
+ end
634
+ end
635
+
636
+ # This method is called this way because I prefer `preload` to be the method
637
+ # name to configure preloads in the public interface.
638
+ #
639
+ # @return [void]
640
+ def do_preload
641
+ preloads.each do |abspath|
642
+ do_preload_abspath(abspath)
643
+ end
644
+ end
645
+
646
+ # @param abspath [String]
647
+ # @return [void]
648
+ def do_preload_abspath(abspath)
649
+ if ruby?(abspath)
650
+ do_preload_file(abspath)
651
+ elsif dir?(abspath)
652
+ do_preload_dir(abspath)
653
+ end
654
+ end
655
+
656
+ # @param dir [String]
657
+ # @return [void]
658
+ def do_preload_dir(dir)
659
+ ls(dir) do |_basename, abspath|
660
+ do_preload_abspath(abspath)
661
+ end
662
+ end
663
+
664
+ # @param file [String]
665
+ # @return [Boolean]
666
+ def do_preload_file(file)
667
+ log("preloading #{file}") if logger
668
+ require file
669
+ end
670
+
671
+ # @param parent [Module]
672
+ # @param cname [Symbol]
673
+ # @return [String]
674
+ def cpath(parent, cname)
675
+ parent.equal?(Object) ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
676
+ end
677
+
678
+ # @param dir [String]
679
+ # @yieldparam path [String, String]
680
+ # @return [void]
681
+ def ls(dir)
682
+ Dir.foreach(dir) do |basename|
683
+ next if basename.start_with?(".")
684
+ abspath = File.join(dir, basename)
685
+ yield basename, abspath unless ignored_paths.member?(abspath)
686
+ end
687
+ end
688
+
689
+ # @param path [String]
690
+ # @return [Boolean]
691
+ def ruby?(path)
692
+ path.end_with?(".rb")
693
+ end
694
+
695
+ # @param path [String]
696
+ # @return [Boolean]
697
+ def dir?(path)
698
+ File.directory?(path)
699
+ end
700
+
701
+ # @param paths [<String, Pathname, <String, Pathname>>]
702
+ # @return [<String>]
703
+ def expand_paths(paths)
704
+ paths.flatten.map! { |path| File.expand_path(path) }
705
+ end
706
+
707
+ # @param glob_patterns [<String>]
708
+ # @return [<String>]
709
+ def expand_glob_patterns(glob_patterns)
710
+ # Note that Dir.glob works with regular file names just fine. That is,
711
+ # glob patterns technically need no wildcards.
712
+ glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
713
+ end
714
+
715
+ # @return [void]
716
+ def recompute_ignored_paths
717
+ ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
718
+ end
719
+
720
+ # @param message [String]
721
+ # @return [void]
722
+ def log(message)
723
+ method_name = logger.respond_to?(:debug) ? :debug : :call
724
+ logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
725
+ end
726
+
727
+ def cdef?(parent, cname)
728
+ parent.const_defined?(cname, false)
729
+ end
730
+
731
+ def register_explicit_namespace(cpath)
732
+ ExplicitNamespace.register(cpath, self)
733
+ end
734
+
735
+ def raise_if_conflicting_directory(dir)
736
+ self.class.mutex.synchronize do
737
+ Registry.loaders.each do |loader|
738
+ if loader != self && loader.manages?(dir)
739
+ require "pp"
740
+ raise Error,
741
+ "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
742
+ " which is already managed by\n\n#{loader.pretty_inspect}\n"
743
+ EOS
744
+ end
745
+ end
746
+ end
747
+ end
748
+
749
+ # @param parent [Module]
750
+ # @param cname [Symbol]
751
+ # @return [void]
752
+ def unload_autoload(parent, cname)
753
+ parent.send(:remove_const, cname)
754
+ log("autoload for #{cpath(parent, cname)} removed") if logger
755
+ end
756
+
757
+ # @param parent [Module]
758
+ # @param cname [Symbol]
759
+ # @return [void]
760
+ def unload_cref(parent, cname)
761
+ parent.send(:remove_const, cname)
762
+ log("#{cpath(parent, cname)} unloaded") if logger
763
+ end
764
+ end
765
+ end