zeitwerk 2.6.6 → 2.7.0

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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This private class encapsulates pairs (mod, cname).
4
+ #
5
+ # Objects represent the constant cname in the class or module object mod, and
6
+ # have API to manage them that encapsulates the constants API. Examples:
7
+ #
8
+ # cref.path
9
+ # cref.set(value)
10
+ # cref.get
11
+ #
12
+ # The constant may or may not exist in mod.
13
+ class Zeitwerk::Cref
14
+ include Zeitwerk::RealModName
15
+
16
+ # @sig Symbol
17
+ attr_reader :cname
18
+
19
+ # The type of the first argument is Module because Class < Module, class
20
+ # objects are also valid.
21
+ #
22
+ # @sig (Module, Symbol) -> void
23
+ def initialize(mod, cname)
24
+ @mod = mod
25
+ @cname = cname
26
+ @path = nil
27
+ end
28
+
29
+ # @sig () -> String
30
+ def path
31
+ @path ||= Object.equal?(@mod) ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}".freeze
32
+ end
33
+
34
+ # @sig () -> String?
35
+ def autoload?
36
+ @mod.autoload?(@cname, false)
37
+ end
38
+
39
+ # @sig (String) -> bool
40
+ def autoload(abspath)
41
+ @mod.autoload(@cname, abspath)
42
+ end
43
+
44
+ # @sig () -> bool
45
+ def defined?
46
+ @mod.const_defined?(@cname, false)
47
+ end
48
+
49
+ # @sig (Object) -> Object
50
+ def set(value)
51
+ @mod.const_set(@cname, value)
52
+ end
53
+
54
+ # @raise [NameError]
55
+ # @sig () -> Object
56
+ def get
57
+ @mod.const_get(@cname, false)
58
+ end
59
+
60
+ # @raise [NameError]
61
+ # @sig () -> void
62
+ def remove
63
+ @mod.__send__(:remove_const, @cname)
64
+ end
65
+ end
@@ -1,86 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zeitwerk
4
- # Centralizes the logic for the trace point used to detect the creation of
5
- # explicit namespaces, needed to descend into matching subdirectories right
6
- # after the constant has been defined.
4
+ # Centralizes the logic needed to descend into matching subdirectories right
5
+ # after the constant for an explicit namespace has been defined.
7
6
  #
8
7
  # The implementation assumes an explicit namespace is managed by one loader.
9
8
  # Loaders that reopen namespaces owned by other projects are responsible for
10
9
  # loading their constant before setup. This is documented.
11
10
  module ExplicitNamespace # :nodoc: all
11
+ # Maps cpaths of explicit namespaces with their corresponding loader.
12
+ # Entries are added as the namespaces are found, and removed as they are
13
+ # autoloaded.
14
+ #
15
+ # @sig Hash[String => Zeitwerk::Loader]
16
+ @cpaths = {}
17
+
12
18
  class << self
13
19
  include RealModName
14
20
  extend Internal
15
21
 
16
- # Maps constant paths that correspond to explicit namespaces according to
17
- # the file system, to the loader responsible for them.
18
- #
19
- # @sig Hash[String, Zeitwerk::Loader]
20
- attr_reader :cpaths
21
- private :cpaths
22
-
23
- # @sig Mutex
24
- attr_reader :mutex
25
- private :mutex
26
-
27
- # @sig TracePoint
28
- attr_reader :tracer
29
- private :tracer
30
-
31
- # Asserts `cpath` corresponds to an explicit namespace for which `loader`
32
- # is responsible.
22
+ # Registers `cpath` as being the constant path of an explicit namespace
23
+ # managed by `loader`.
33
24
  #
34
25
  # @sig (String, Zeitwerk::Loader) -> void
35
26
  internal 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
27
+ @cpaths[cpath] = loader
28
+ end
29
+
30
+ # @sig (String) -> Zeitwerk::Loader?
31
+ internal def loader_for(mod, cname)
32
+ cpath = mod.equal?(Object) ? cname.name : "#{real_mod_name(mod)}::#{cname}"
33
+ @cpaths.delete(cpath)
42
34
  end
43
35
 
44
36
  # @sig (Zeitwerk::Loader) -> void
45
37
  internal def unregister_loader(loader)
46
- cpaths.delete_if { |_cpath, l| l == loader }
47
- disable_tracer_if_unneeded
38
+ @cpaths.delete_if { _2.equal?(loader) }
48
39
  end
49
40
 
41
+ # This is an internal method only used by the test suite.
42
+ #
43
+ # @sig (String) -> Zeitwerk::Loader?
44
+ internal def registered?(cpath)
45
+ @cpaths[cpath]
46
+ end
47
+
48
+ # This is an internal method only used by the test suite.
49
+ #
50
50
  # @sig () -> void
51
- private def disable_tracer_if_unneeded
52
- mutex.synchronize do
53
- tracer.disable if cpaths.empty?
54
- end
51
+ internal def clear
52
+ @cpaths.clear
55
53
  end
56
54
 
57
- # @sig (TracePoint) -> void
58
- private 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?
55
+ module Synchronized
56
+ extend Internal
63
57
 
64
- # It might be tempting to return if name.nil?, to avoid the computation
65
- # of a hash code and delete call. But Ruby does not trigger the :class
66
- # event on Class.new or Module.new, so that would incur in an extra call
67
- # for nothing.
68
- #
69
- # On the other hand, if we were called, cpaths is not empty. Otherwise
70
- # the tracer is disabled. So we do need to go ahead with the hash code
71
- # computation and delete call.
72
- if loader = cpaths.delete(real_mod_name(event.self))
73
- loader.on_namespace_loaded(event.self)
74
- disable_tracer_if_unneeded
58
+ MUTEX = Mutex.new
59
+
60
+ internal def register(...)
61
+ MUTEX.synchronize { super }
75
62
  end
76
- end
77
- end
78
63
 
79
- @cpaths = {}
80
- @mutex = Mutex.new
64
+ internal def loader_for(...)
65
+ MUTEX.synchronize { super }
66
+ end
67
+
68
+ internal def unregister_loader(...)
69
+ MUTEX.synchronize { super }
70
+ end
81
71
 
82
- # We go through a method instead of defining a block mainly to have a better
83
- # label when profiling.
84
- @tracer = TracePoint.new(:class, &method(:tracepoint_class_callback))
72
+ internal def registered?(...)
73
+ MUTEX.synchronize { super }
74
+ end
75
+
76
+ internal def clear
77
+ MUTEX.synchronize { super }
78
+ end
79
+ end
80
+
81
+ prepend Synchronized unless RUBY_ENGINE == "ruby"
82
+ end
85
83
  end
86
84
  end
@@ -5,8 +5,8 @@ module Zeitwerk
5
5
  # @sig (String) -> void
6
6
  def initialize(root_file)
7
7
  namespace = File.basename(root_file, ".rb")
8
- lib_dir = File.dirname(root_file)
9
- @version_file = File.join(lib_dir, namespace, "version.rb")
8
+ root_dir = File.dirname(root_file)
9
+ @version_file = File.join(root_dir, namespace, "version.rb")
10
10
  end
11
11
 
12
12
  # @sig (String, String) -> String
@@ -3,27 +3,31 @@
3
3
  module Zeitwerk
4
4
  # @private
5
5
  class GemLoader < Loader
6
+ include RealModName
7
+
6
8
  # Users should not create instances directly, the public interface is
7
9
  # `Zeitwerk::Loader.for_gem`.
8
10
  private_class_method :new
9
11
 
10
12
  # @private
11
13
  # @sig (String, bool) -> Zeitwerk::GemLoader
12
- def self._new(root_file, warn_on_extra_files:)
13
- new(root_file, warn_on_extra_files: warn_on_extra_files)
14
+ def self.__new(root_file, namespace:, warn_on_extra_files:)
15
+ new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
14
16
  end
15
17
 
16
18
  # @sig (String, bool) -> void
17
- def initialize(root_file, warn_on_extra_files:)
19
+ def initialize(root_file, namespace:, warn_on_extra_files:)
18
20
  super()
19
21
 
20
- @tag = File.basename(root_file, ".rb")
22
+ @tag = File.basename(root_file, ".rb")
23
+ @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
24
+
21
25
  @inflector = GemInflector.new(root_file)
22
26
  @root_file = File.expand_path(root_file)
23
- @lib = File.dirname(root_file)
27
+ @root_dir = File.dirname(root_file)
24
28
  @warn_on_extra_files = warn_on_extra_files
25
29
 
26
- push_dir(@lib)
30
+ push_dir(@root_dir, namespace: namespace)
27
31
  end
28
32
 
29
33
  # @sig () -> void
@@ -38,13 +42,12 @@ module Zeitwerk
38
42
  def warn_on_extra_files
39
43
  expected_namespace_dir = @root_file.delete_suffix(".rb")
40
44
 
41
- ls(@lib) do |basename, abspath|
45
+ ls(@root_dir) do |basename, abspath, ftype|
42
46
  next if abspath == @root_file
43
47
  next if abspath == expected_namespace_dir
44
48
 
45
49
  basename_without_ext = basename.delete_suffix(".rb")
46
50
  cname = inflector.camelize(basename_without_ext, abspath).to_sym
47
- ftype = dir?(abspath) ? "directory" : "file"
48
51
 
49
52
  warn(<<~EOS)
50
53
  WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
@@ -2,38 +2,46 @@
2
2
 
3
3
  module Zeitwerk::Loader::Callbacks
4
4
  include Zeitwerk::RealModName
5
+ extend Zeitwerk::Internal
5
6
 
6
7
  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
7
8
  #
8
- # @private
9
+ # @raise [Zeitwerk::NameError]
9
10
  # @sig (String) -> void
10
- def on_file_autoloaded(file)
11
- cref = autoloads.delete(file)
12
- cpath = cpath(*cref)
13
-
14
- # If reloading is enabled, we need to put this constant for unloading
15
- # regardless of what cdef? says. In Ruby < 3.1 the internal state is not
16
- # fully cleared. Module#constants still includes it, and you need to
17
- # remove_const. See https://github.com/ruby/ruby/pull/4715.
18
- to_unload[cpath] = [file, cref] if reloading_enabled?
11
+ internal def on_file_autoloaded(file)
12
+ cref = autoloads.delete(file)
13
+
19
14
  Zeitwerk::Registry.unregister_autoload(file)
20
15
 
21
- if cdef?(*cref)
22
- log("constant #{cpath} loaded from file #{file}") if logger
23
- run_on_load_callbacks(cpath, cget(*cref), file) unless on_load_callbacks.empty?
16
+ if cref.defined?
17
+ log("constant #{cref.path} loaded from file #{file}") if logger
18
+ to_unload[cref.path] = [file, cref] if reloading_enabled?
19
+ run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
24
20
  else
25
- raise Zeitwerk::NameError.new("expected file #{file} to define constant #{cpath}, but didn't", cref.last)
21
+ msg = "expected file #{file} to define constant #{cref.path}, but didn't"
22
+ log(msg) if logger
23
+
24
+ # Ruby still keeps the autoload defined, but we remove it because the
25
+ # contract in Zeitwerk is more strict.
26
+ cref.remove
27
+
28
+ # Since the expected constant was not defined, there is nothing to unload.
29
+ # However, if the exception is rescued and reloading is enabled, we still
30
+ # need to deleted the file from $LOADED_FEATURES.
31
+ to_unload[cref.path] = [file, cref] if reloading_enabled?
32
+
33
+ raise Zeitwerk::NameError.new(msg, cref.cname)
26
34
  end
27
35
  end
28
36
 
29
37
  # Invoked from our decorated Kernel#require when a managed directory is
30
38
  # autoloaded.
31
39
  #
32
- # @private
33
40
  # @sig (String) -> void
34
- def on_dir_autoloaded(dir)
35
- # Module#autoload does not serialize concurrent requires, and we handle
36
- # directories ourselves, so the callback needs to account for concurrency.
41
+ internal def on_dir_autoloaded(dir)
42
+ # Module#autoload does not serialize concurrent requires in CRuby < 3.2, and
43
+ # we handle directories ourselves without going through Kernel#require, so
44
+ # the callback needs to account for concurrency.
37
45
  #
38
46
  # Multi-threading would introduce a race condition here in which thread t1
39
47
  # autovivifies the module, and while autoloads for its children are being
@@ -43,10 +51,10 @@ module Zeitwerk::Loader::Callbacks
43
51
  # That not only would reassign the constant (undesirable per se) but, worse,
44
52
  # the module object created by t2 wouldn't have any of the autoloads for its
45
53
  # children, since t1 would have correctly deleted its namespace_dirs entry.
46
- mutex2.synchronize do
54
+ dirs_autoload_monitor.synchronize do
47
55
  if cref = autoloads.delete(dir)
48
- autovivified_module = cref[0].const_set(cref[1], Module.new)
49
- cpath = autovivified_module.name
56
+ implicit_namespace = cref.set(Module.new)
57
+ cpath = implicit_namespace.name
50
58
  log("module #{cpath} autovivified from directory #{dir}") if logger
51
59
 
52
60
  to_unload[cpath] = [dir, cref] if reloading_enabled?
@@ -57,15 +65,15 @@ module Zeitwerk::Loader::Callbacks
57
65
  # these to be able to unregister later if eager loading.
58
66
  autoloaded_dirs << dir
59
67
 
60
- on_namespace_loaded(autovivified_module)
68
+ on_namespace_loaded(implicit_namespace)
61
69
 
62
- run_on_load_callbacks(cpath, autovivified_module, dir) unless on_load_callbacks.empty?
70
+ run_on_load_callbacks(cpath, implicit_namespace, dir) unless on_load_callbacks.empty?
63
71
  end
64
72
  end
65
73
  end
66
74
 
67
75
  # Invoked when a class or module is created or reopened, either from the
68
- # tracer or from module autovivification. If the namespace has matching
76
+ # const_added or from module autovivification. If the namespace has matching
69
77
  # subdirectories, we descend into them now.
70
78
  #
71
79
  # @private
@@ -73,7 +81,7 @@ module Zeitwerk::Loader::Callbacks
73
81
  def on_namespace_loaded(namespace)
74
82
  if dirs = namespace_dirs.delete(real_mod_name(namespace))
75
83
  dirs.each do |dir|
76
- set_autoloads_in_dir(dir, namespace)
84
+ define_autoloads_for_dir(dir, namespace)
77
85
  end
78
86
  end
79
87
  end
@@ -109,8 +109,7 @@ module Zeitwerk::Loader::Config
109
109
  # @raise [Zeitwerk::Error]
110
110
  # @sig (String | Pathname, Module) -> void
111
111
  def push_dir(path, namespace: Object)
112
- # Note that Class < Module.
113
- unless namespace.is_a?(Module)
112
+ unless namespace.is_a?(Module) # Note that Class < Module.
114
113
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
115
114
  end
116
115
 
@@ -149,14 +148,24 @@ module Zeitwerk::Loader::Config
149
148
  # instead. Keys are the absolute paths of the root directories as strings,
150
149
  # values are their corresponding namespaces, class or module objects.
151
150
  #
151
+ # If `ignored` is falsey (default), ignored root directories are filtered out.
152
+ #
152
153
  # These are read-only collections, please add to them with `push_dir`.
153
154
  #
154
155
  # @sig () -> Array[String] | Hash[String, Module]
155
- def dirs(namespaces: false)
156
+ def dirs(namespaces: false, ignored: false)
156
157
  if namespaces
157
- roots.clone
158
+ if ignored || ignored_paths.empty?
159
+ roots.clone
160
+ else
161
+ roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
162
+ end
158
163
  else
159
- roots.keys
164
+ if ignored || ignored_paths.empty?
165
+ roots.keys
166
+ else
167
+ roots.keys.reject { |root_dir| ignored_path?(root_dir) }
168
+ end
160
169
  end.freeze
161
170
  end
162
171
 
@@ -288,9 +297,9 @@ module Zeitwerk::Loader::Config
288
297
  # Common use case.
289
298
  return false if ignored_paths.empty?
290
299
 
291
- walk_up(abspath) do |abspath|
292
- return true if ignored_path?(abspath)
293
- return false if roots.key?(abspath)
300
+ walk_up(abspath) do |path|
301
+ return true if ignored_path?(path)
302
+ return false if roots.key?(path)
294
303
  end
295
304
 
296
305
  false
@@ -318,9 +327,9 @@ module Zeitwerk::Loader::Config
318
327
  # Optimize this common use case.
319
328
  return false if eager_load_exclusions.empty?
320
329
 
321
- walk_up(abspath) do |abspath|
322
- return true if eager_load_exclusions.member?(abspath)
323
- return false if roots.key?(abspath)
330
+ walk_up(abspath) do |path|
331
+ return true if eager_load_exclusions.member?(path)
332
+ return false if roots.key?(path)
324
333
  end
325
334
 
326
335
  false
@@ -45,8 +45,10 @@ module Zeitwerk::Loader::EagerLoad
45
45
 
46
46
  break if root_namespace = roots[dir]
47
47
 
48
+ basename = File.basename(dir)
49
+ return if hidden?(basename)
50
+
48
51
  unless collapse?(dir)
49
- basename = File.basename(dir)
50
52
  cnames << inflector.camelize(basename, dir).to_sym
51
53
  end
52
54
  end
@@ -59,8 +61,8 @@ module Zeitwerk::Loader::EagerLoad
59
61
  cnames.reverse_each do |cname|
60
62
  # Can happen if there are no Ruby files. This is not an error condition,
61
63
  # the directory is actually managed. Could have Ruby files later.
62
- return unless cdef?(namespace, cname)
63
- namespace = cget(namespace, cname)
64
+ return unless namespace.const_defined?(cname, false)
65
+ namespace = namespace.const_get(cname, false)
64
66
  end
65
67
 
66
68
  # A shortcircuiting test depends on the invocation of this method. Please
@@ -119,6 +121,8 @@ module Zeitwerk::Loader::EagerLoad
119
121
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
120
122
 
121
123
  basename = File.basename(abspath, ".rb")
124
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
125
+
122
126
  base_cname = inflector.camelize(basename, abspath).to_sym
123
127
 
124
128
  root_namespace = nil
@@ -129,8 +133,10 @@ module Zeitwerk::Loader::EagerLoad
129
133
 
130
134
  break if root_namespace = roots[dir]
131
135
 
136
+ basename = File.basename(dir)
137
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
138
+
132
139
  unless collapse?(dir)
133
- basename = File.basename(dir)
134
140
  cnames << inflector.camelize(basename, dir).to_sym
135
141
  end
136
142
  end
@@ -139,12 +145,12 @@ module Zeitwerk::Loader::EagerLoad
139
145
 
140
146
  namespace = root_namespace
141
147
  cnames.reverse_each do |cname|
142
- namespace = cget(namespace, cname)
148
+ namespace = namespace.const_get(cname, false)
143
149
  end
144
150
 
145
151
  raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
146
152
 
147
- cget(namespace, base_cname)
153
+ namespace.const_get(base_cname, false)
148
154
  end
149
155
 
150
156
  # The caller is responsible for making sure `namespace` is the namespace that
@@ -158,22 +164,20 @@ module Zeitwerk::Loader::EagerLoad
158
164
  log("eager load directory #{dir} start") if logger
159
165
 
160
166
  queue = [[dir, namespace]]
161
- while to_eager_load = queue.shift
162
- dir, namespace = to_eager_load
163
-
164
- ls(dir) do |basename, abspath|
167
+ while (current_dir, namespace = queue.shift)
168
+ ls(current_dir) do |basename, abspath, ftype|
165
169
  next if honour_exclusions && eager_load_exclusions.member?(abspath)
166
170
 
167
- if ruby?(abspath)
168
- if (cref = autoloads[abspath]) && !shadowed_file?(abspath)
169
- cget(*cref)
171
+ if ftype == :file
172
+ if (cref = autoloads[abspath])
173
+ cref.get
170
174
  end
171
175
  else
172
176
  if collapse?(abspath)
173
177
  queue << [abspath, namespace]
174
178
  else
175
179
  cname = inflector.camelize(basename, abspath).to_sym
176
- queue << [abspath, cget(namespace, cname)]
180
+ queue << [abspath, namespace.const_get(cname, false)]
177
181
  end
178
182
  end
179
183
  end
@@ -183,7 +187,7 @@ module Zeitwerk::Loader::EagerLoad
183
187
  end
184
188
 
185
189
  # In order to invoke this method, the caller has to ensure `child` is a
186
- # strict namespace descendendant of `root_namespace`.
190
+ # strict namespace descendant of `root_namespace`.
187
191
  #
188
192
  # @sig (Module, String, Module, Boolean) -> void
189
193
  private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
@@ -203,9 +207,9 @@ module Zeitwerk::Loader::EagerLoad
203
207
  next_dirs = []
204
208
 
205
209
  suffix.split("::").each do |segment|
206
- while dir = dirs.shift
207
- ls(dir) do |basename, abspath|
208
- next unless dir?(abspath)
210
+ while (dir = dirs.shift)
211
+ ls(dir) do |basename, abspath, ftype|
212
+ next unless ftype == :directory
209
213
 
210
214
  if collapse?(abspath)
211
215
  dirs << abspath