zeitwerk 2.6.0 → 2.6.15

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.
@@ -12,4 +12,10 @@ module Zeitwerk
12
12
 
13
13
  class NameError < ::NameError
14
14
  end
15
+
16
+ class SetupRequired < Error
17
+ def initialize
18
+ super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
19
+ end
20
+ end
15
21
  end
@@ -11,28 +11,28 @@ module Zeitwerk
11
11
  module ExplicitNamespace # :nodoc: all
12
12
  class << self
13
13
  include RealModName
14
+ extend Internal
14
15
 
15
16
  # Maps constant paths that correspond to explicit namespaces according to
16
17
  # the file system, to the loader responsible for them.
17
18
  #
18
- # @private
19
19
  # @sig Hash[String, Zeitwerk::Loader]
20
20
  attr_reader :cpaths
21
+ private :cpaths
21
22
 
22
- # @private
23
23
  # @sig Mutex
24
24
  attr_reader :mutex
25
+ private :mutex
25
26
 
26
- # @private
27
27
  # @sig TracePoint
28
28
  attr_reader :tracer
29
+ private :tracer
29
30
 
30
31
  # Asserts `cpath` corresponds to an explicit namespace for which `loader`
31
32
  # is responsible.
32
33
  #
33
- # @private
34
34
  # @sig (String, Zeitwerk::Loader) -> void
35
- def register(cpath, loader)
35
+ internal def register(cpath, loader)
36
36
  mutex.synchronize do
37
37
  cpaths[cpath] = loader
38
38
  # We check enabled? because, looking at the C source code, enabling an
@@ -41,24 +41,28 @@ module Zeitwerk
41
41
  end
42
42
  end
43
43
 
44
- # @private
45
44
  # @sig (Zeitwerk::Loader) -> void
46
- def unregister_loader(loader)
45
+ internal def unregister_loader(loader)
47
46
  cpaths.delete_if { |_cpath, l| l == loader }
48
47
  disable_tracer_if_unneeded
49
48
  end
50
49
 
51
- private
50
+ # This is an internal method only used by the test suite.
51
+ #
52
+ # @sig (String) -> bool
53
+ internal def registered?(cpath)
54
+ cpaths.key?(cpath)
55
+ end
52
56
 
53
57
  # @sig () -> void
54
- def disable_tracer_if_unneeded
58
+ private def disable_tracer_if_unneeded
55
59
  mutex.synchronize do
56
60
  tracer.disable if cpaths.empty?
57
61
  end
58
62
  end
59
63
 
60
64
  # @sig (TracePoint) -> void
61
- def tracepoint_class_callback(event)
65
+ private def tracepoint_class_callback(event)
62
66
  # If the class is a singleton class, we won't do anything with it so we
63
67
  # can bail out immediately. This is several orders of magnitude faster
64
68
  # than accessing its name.
@@ -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
- cname = inflector.camelize(basename_without_ext, abspath)
47
- ftype = dir?(abspath) ? "directory" : "file"
50
+ cname = inflector.camelize(basename_without_ext, abspath).to_sym
48
51
 
49
52
  warn(<<~EOS)
50
53
  WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is a private module.
4
+ module Zeitwerk::Internal
5
+ def internal(method_name)
6
+ private method_name
7
+
8
+ mangled = "__#{method_name}"
9
+ alias_method mangled, method_name
10
+ public mangled
11
+ end
12
+ end
@@ -14,21 +14,20 @@ module Kernel
14
14
  # should not require anything. But if someone has legacy require calls around,
15
15
  # they will work as expected, and in a compatible way. This feature is by now
16
16
  # EXPERIMENTAL and UNDOCUMENTED.
17
- #
18
- # We cannot decorate with prepend + super because Kernel has already been
19
- # included in Object, and changes in ancestors don't get propagated into
20
- # already existing ancestor chains on Ruby < 3.0.
21
17
  alias_method :zeitwerk_original_require, :require
18
+ class << self
19
+ alias_method :zeitwerk_original_require, :require
20
+ end
22
21
 
23
22
  # @sig (String) -> true | false
24
23
  def require(path)
25
24
  if loader = Zeitwerk::Registry.loader_for(path)
26
25
  if path.end_with?(".rb")
27
26
  required = zeitwerk_original_require(path)
28
- loader.on_file_autoloaded(path) if required
27
+ loader.__on_file_autoloaded(path) if required
29
28
  required
30
29
  else
31
- loader.on_dir_autoloaded(path)
30
+ loader.__on_dir_autoloaded(path)
32
31
  true
33
32
  end
34
33
  else
@@ -36,7 +35,7 @@ module Kernel
36
35
  if required
37
36
  abspath = $LOADED_FEATURES.last
38
37
  if loader = Zeitwerk::Registry.loader_for(abspath)
39
- loader.on_file_autoloaded(abspath)
38
+ loader.__on_file_autoloaded(abspath)
40
39
  end
41
40
  end
42
41
  required
@@ -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
9
  # @sig (String) -> void
10
- def on_file_autoloaded(file)
10
+ internal def on_file_autoloaded(file)
11
11
  cref = autoloads.delete(file)
12
12
  cpath = cpath(*cref)
13
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?
19
14
  Zeitwerk::Registry.unregister_autoload(file)
20
15
 
21
16
  if cdef?(*cref)
22
17
  log("constant #{cpath} loaded from file #{file}") if logger
18
+ to_unload[cpath] = [file, cref] if reloading_enabled?
23
19
  run_on_load_callbacks(cpath, cget(*cref), 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 #{cpath}, 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
+ crem(*cref)
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[cpath] = [file, cref] if reloading_enabled?
32
+
33
+ raise Zeitwerk::NameError.new(msg, cref.last)
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
@@ -42,8 +50,8 @@ module Zeitwerk::Loader::Callbacks
42
50
  # Without the mutex and subsequent delete call, t2 would reset the module.
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
- # children, since t1 would have correctly deleted its lazy_subdirs entry.
46
- mutex2.synchronize do
53
+ # children, since t1 would have correctly deleted its namespace_dirs entry.
54
+ dirs_autoload_monitor.synchronize do
47
55
  if cref = autoloads.delete(dir)
48
56
  autovivified_module = cref[0].const_set(cref[1], Module.new)
49
57
  cpath = autovivified_module.name
@@ -71,9 +79,9 @@ module Zeitwerk::Loader::Callbacks
71
79
  # @private
72
80
  # @sig (Module) -> void
73
81
  def on_namespace_loaded(namespace)
74
- if subdirs = lazy_subdirs.delete(real_mod_name(namespace))
75
- subdirs.each do |subdir|
76
- set_autoloads_in_dir(subdir, namespace)
82
+ if dirs = namespace_dirs.delete(real_mod_name(namespace))
83
+ dirs.each do |dir|
84
+ define_autoloads_for_dir(dir, namespace)
77
85
  end
78
86
  end
79
87
  end
@@ -4,85 +4,91 @@ require "set"
4
4
  require "securerandom"
5
5
 
6
6
  module Zeitwerk::Loader::Config
7
- # Absolute paths of the root directories. Stored in a hash to preserve
8
- # order, easily handle duplicates, and also be able to have a fast lookup,
9
- # needed for detecting nested paths.
7
+ extend Zeitwerk::Internal
8
+ include Zeitwerk::RealModName
9
+
10
+ # @sig #camelize
11
+ attr_accessor :inflector
12
+
13
+ # @sig #call | #debug | nil
14
+ attr_accessor :logger
15
+
16
+ # Absolute paths of the root directories, mapped to their respective root namespaces:
10
17
  #
11
- # "/Users/fxn/blog/app/assets" => true,
12
- # "/Users/fxn/blog/app/channels" => true,
18
+ # "/Users/fxn/blog/app/channels" => Object,
19
+ # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
13
20
  # ...
14
21
  #
22
+ # Stored in a hash to preserve order, easily handle duplicates, and have a
23
+ # fast lookup by directory.
24
+ #
15
25
  # This is a private collection maintained by the loader. The public
16
26
  # interface for it is `push_dir` and `dirs`.
17
27
  #
18
- # @private
19
- # @sig Hash[String, true]
20
- attr_reader :root_dirs
21
-
22
- # @sig #camelize
23
- attr_accessor :inflector
28
+ # @sig Hash[String, Module]
29
+ attr_reader :roots
30
+ internal :roots
24
31
 
25
32
  # Absolute paths of files, directories, or glob patterns to be totally
26
33
  # ignored.
27
34
  #
28
- # @private
29
35
  # @sig Set[String]
30
36
  attr_reader :ignored_glob_patterns
37
+ private :ignored_glob_patterns
31
38
 
32
39
  # The actual collection of absolute file and directory names at the time the
33
40
  # ignored glob patterns were expanded. Computed on setup, and recomputed on
34
41
  # reload.
35
42
  #
36
- # @private
37
43
  # @sig Set[String]
38
44
  attr_reader :ignored_paths
45
+ private :ignored_paths
39
46
 
40
47
  # Absolute paths of directories or glob patterns to be collapsed.
41
48
  #
42
- # @private
43
49
  # @sig Set[String]
44
50
  attr_reader :collapse_glob_patterns
51
+ private :collapse_glob_patterns
45
52
 
46
53
  # The actual collection of absolute directory names at the time the collapse
47
54
  # glob patterns were expanded. Computed on setup, and recomputed on reload.
48
55
  #
49
- # @private
50
56
  # @sig Set[String]
51
57
  attr_reader :collapse_dirs
58
+ private :collapse_dirs
52
59
 
53
60
  # Absolute paths of files or directories not to be eager loaded.
54
61
  #
55
- # @private
56
62
  # @sig Set[String]
57
63
  attr_reader :eager_load_exclusions
64
+ private :eager_load_exclusions
58
65
 
59
66
  # User-oriented callbacks to be fired on setup and on reload.
60
67
  #
61
- # @private
62
68
  # @sig Array[{ () -> void }]
63
69
  attr_reader :on_setup_callbacks
70
+ private :on_setup_callbacks
64
71
 
65
72
  # User-oriented callbacks to be fired when a constant is loaded.
66
73
  #
67
- # @private
68
74
  # @sig Hash[String, Array[{ (Object, String) -> void }]]
69
75
  # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
70
76
  attr_reader :on_load_callbacks
77
+ private :on_load_callbacks
71
78
 
72
79
  # User-oriented callbacks to be fired before constants are removed.
73
80
  #
74
- # @private
75
81
  # @sig Hash[String, Array[{ (Object, String) -> void }]]
76
82
  # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
77
83
  attr_reader :on_unload_callbacks
78
-
79
- # @sig #call | #debug | nil
80
- attr_accessor :logger
84
+ private :on_unload_callbacks
81
85
 
82
86
  def initialize
83
- @initialized_at = Time.now
84
- @root_dirs = {}
85
87
  @inflector = Zeitwerk::Inflector.new
88
+ @logger = self.class.default_logger
89
+ @tag = SecureRandom.hex(3)
90
+ @initialized_at = Time.now
91
+ @roots = {}
86
92
  @ignored_glob_patterns = Set.new
87
93
  @ignored_paths = Set.new
88
94
  @collapse_glob_patterns = Set.new
@@ -92,8 +98,6 @@ module Zeitwerk::Loader::Config
92
98
  @on_setup_callbacks = []
93
99
  @on_load_callbacks = {}
94
100
  @on_unload_callbacks = {}
95
- @logger = self.class.default_logger
96
- @tag = SecureRandom.hex(3)
97
101
  end
98
102
 
99
103
  # Pushes `path` to the list of root directories.
@@ -105,15 +109,18 @@ module Zeitwerk::Loader::Config
105
109
  # @raise [Zeitwerk::Error]
106
110
  # @sig (String | Pathname, Module) -> void
107
111
  def push_dir(path, namespace: Object)
108
- # Note that Class < Module.
109
- unless namespace.is_a?(Module)
112
+ unless namespace.is_a?(Module) # Note that Class < Module.
110
113
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
111
114
  end
112
115
 
116
+ unless real_mod_name(namespace)
117
+ raise Zeitwerk::Error, "root namespaces cannot be anonymous"
118
+ end
119
+
113
120
  abspath = File.expand_path(path)
114
121
  if dir?(abspath)
115
122
  raise_if_conflicting_directory(abspath)
116
- root_dirs[abspath] = namespace
123
+ roots[abspath] = namespace
117
124
  else
118
125
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
119
126
  end
@@ -136,12 +143,30 @@ module Zeitwerk::Loader::Config
136
143
  @tag = tag.to_s
137
144
  end
138
145
 
139
- # Absolute paths of the root directories. This is a read-only collection,
140
- # please push here via `push_dir`.
146
+ # If `namespaces` is falsey (default), returns an array with the absolute
147
+ # paths of the root directories as strings. If truthy, returns a hash table
148
+ # instead. Keys are the absolute paths of the root directories as strings,
149
+ # values are their corresponding namespaces, class or module objects.
141
150
  #
142
- # @sig () -> Array[String]
143
- def dirs
144
- root_dirs.keys.freeze
151
+ # If `ignored` is falsey (default), ignored root directories are filtered out.
152
+ #
153
+ # These are read-only collections, please add to them with `push_dir`.
154
+ #
155
+ # @sig () -> Array[String] | Hash[String, Module]
156
+ def dirs(namespaces: false, ignored: false)
157
+ if namespaces
158
+ if ignored || ignored_paths.empty?
159
+ roots.clone
160
+ else
161
+ roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
162
+ end
163
+ else
164
+ if ignored || ignored_paths.empty?
165
+ roots.keys
166
+ else
167
+ roots.keys.reject { |root_dir| ignored_path?(root_dir) }
168
+ end
169
+ end.freeze
145
170
  end
146
171
 
147
172
  # You need to call this method before setup in order to be able to reload.
@@ -264,57 +289,76 @@ module Zeitwerk::Loader::Config
264
289
  @logger = ->(msg) { puts msg }
265
290
  end
266
291
 
267
- # @private
292
+ # Returns true if the argument has been configured to be ignored, or is a
293
+ # descendant of an ignored directory.
294
+ #
268
295
  # @sig (String) -> bool
269
- def ignores?(abspath)
270
- ignored_paths.any? do |ignored_path|
271
- ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
296
+ internal def ignores?(abspath)
297
+ # Common use case.
298
+ return false if ignored_paths.empty?
299
+
300
+ walk_up(abspath) do |path|
301
+ return true if ignored_path?(path)
302
+ return false if roots.key?(path)
272
303
  end
304
+
305
+ false
273
306
  end
274
307
 
275
- private
308
+ # @sig (String) -> bool
309
+ private def ignored_path?(abspath)
310
+ ignored_paths.member?(abspath)
311
+ end
276
312
 
277
313
  # @sig () -> Array[String]
278
- def actual_root_dirs
279
- root_dirs.reject do |root_dir, _namespace|
280
- !dir?(root_dir) || ignored_paths.member?(root_dir)
314
+ private def actual_roots
315
+ roots.reject do |root_dir, _root_namespace|
316
+ !dir?(root_dir) || ignored_path?(root_dir)
281
317
  end
282
318
  end
283
319
 
284
320
  # @sig (String) -> bool
285
- def root_dir?(dir)
286
- root_dirs.key?(dir)
321
+ private def root_dir?(dir)
322
+ roots.key?(dir)
287
323
  end
288
324
 
289
325
  # @sig (String) -> bool
290
- def excluded_from_eager_load?(abspath)
291
- eager_load_exclusions.member?(abspath)
326
+ private def excluded_from_eager_load?(abspath)
327
+ # Optimize this common use case.
328
+ return false if eager_load_exclusions.empty?
329
+
330
+ walk_up(abspath) do |path|
331
+ return true if eager_load_exclusions.member?(path)
332
+ return false if roots.key?(path)
333
+ end
334
+
335
+ false
292
336
  end
293
337
 
294
338
  # @sig (String) -> bool
295
- def collapse?(dir)
339
+ private def collapse?(dir)
296
340
  collapse_dirs.member?(dir)
297
341
  end
298
342
 
299
343
  # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
300
- def expand_paths(paths)
344
+ private def expand_paths(paths)
301
345
  paths.flatten.map! { |path| File.expand_path(path) }
302
346
  end
303
347
 
304
348
  # @sig (Array[String]) -> Array[String]
305
- def expand_glob_patterns(glob_patterns)
349
+ private def expand_glob_patterns(glob_patterns)
306
350
  # Note that Dir.glob works with regular file names just fine. That is,
307
351
  # glob patterns technically need no wildcards.
308
352
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
309
353
  end
310
354
 
311
355
  # @sig () -> void
312
- def recompute_ignored_paths
356
+ private def recompute_ignored_paths
313
357
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
314
358
  end
315
359
 
316
360
  # @sig () -> void
317
- def recompute_collapse_dirs
361
+ private def recompute_collapse_dirs
318
362
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
319
363
  end
320
364
  end