zeitwerk 2.2.0 → 2.8.2

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