zeitwerk 2.5.0 → 2.6.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,8 +5,17 @@ module Zeitwerk
5
5
  end
6
6
 
7
7
  class ReloadingDisabledError < Error
8
+ def initialize
9
+ super("can't reload, please call loader.enable_reloading before setup")
10
+ end
8
11
  end
9
12
 
10
13
  class NameError < ::NameError
11
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
12
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
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zeitwerk
4
+ # @private
5
+ class GemLoader < Loader
6
+ include RealModName
7
+
8
+ # Users should not create instances directly, the public interface is
9
+ # `Zeitwerk::Loader.for_gem`.
10
+ private_class_method :new
11
+
12
+ # @private
13
+ # @sig (String, bool) -> Zeitwerk::GemLoader
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)
16
+ end
17
+
18
+ # @sig (String, bool) -> void
19
+ def initialize(root_file, namespace:, warn_on_extra_files:)
20
+ super()
21
+
22
+ @tag = File.basename(root_file, ".rb")
23
+ @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
24
+
25
+ @inflector = GemInflector.new(root_file)
26
+ @root_file = File.expand_path(root_file)
27
+ @root_dir = File.dirname(root_file)
28
+ @warn_on_extra_files = warn_on_extra_files
29
+
30
+ push_dir(@root_dir, namespace: namespace)
31
+ end
32
+
33
+ # @sig () -> void
34
+ def setup
35
+ warn_on_extra_files if @warn_on_extra_files
36
+ super
37
+ end
38
+
39
+ private
40
+
41
+ # @sig () -> void
42
+ def warn_on_extra_files
43
+ expected_namespace_dir = @root_file.delete_suffix(".rb")
44
+
45
+ ls(@root_dir) do |basename, abspath|
46
+ next if abspath == @root_file
47
+ next if abspath == expected_namespace_dir
48
+
49
+ basename_without_ext = basename.delete_suffix(".rb")
50
+ cname = inflector.camelize(basename_without_ext, abspath).to_sym
51
+ ftype = dir?(abspath) ? "directory" : "file"
52
+
53
+ warn(<<~EOS)
54
+ WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
55
+
56
+ #{abspath}
57
+
58
+ To prevent that, please configure the loader to ignore it:
59
+
60
+ loader.ignore("\#{__dir__}/#{basename}")
61
+
62
+ Otherwise, there is a flag to silence this warning:
63
+
64
+ Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
65
+ EOS
66
+ end
67
+ end
68
+ end
69
+ end
@@ -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
@@ -3,11 +3,11 @@
3
3
  module Kernel
4
4
  module_function
5
5
 
6
- # We are going to decorate Kernel#require with two goals.
6
+ # Zeitwerk's main idea is to define autoloads for project constants, and then
7
+ # intercept them when triggered in this thin `Kernel#require` wrapper.
7
8
  #
8
- # First, by intercepting Kernel#require calls, we are able to autovivify
9
- # modules on required directories, and also do internal housekeeping when
10
- # managed files are loaded.
9
+ # That allows us to complete the circle, invoke callbacks, autovivify modules,
10
+ # define autoloads for just autoloaded namespaces, update internal state, etc.
11
11
  #
12
12
  # On the other hand, if you publish a new version of a gem that is now managed
13
13
  # by Zeitwerk, client code can reference directly your classes and modules and
@@ -17,29 +17,32 @@ module Kernel
17
17
  #
18
18
  # We cannot decorate with prepend + super because Kernel has already been
19
19
  # included in Object, and changes in ancestors don't get propagated into
20
- # already existing ancestor chains.
20
+ # already existing ancestor chains on Ruby < 3.0.
21
21
  alias_method :zeitwerk_original_require, :require
22
+ class << self
23
+ alias_method :zeitwerk_original_require, :require
24
+ end
22
25
 
23
26
  # @sig (String) -> true | false
24
27
  def require(path)
25
28
  if loader = Zeitwerk::Registry.loader_for(path)
26
29
  if path.end_with?(".rb")
27
- zeitwerk_original_require(path).tap do |required|
28
- loader.on_file_autoloaded(path) if required
29
- end
30
+ required = zeitwerk_original_require(path)
31
+ loader.__on_file_autoloaded(path) if required
32
+ required
30
33
  else
31
- loader.on_dir_autoloaded(path)
34
+ loader.__on_dir_autoloaded(path)
32
35
  true
33
36
  end
34
37
  else
35
- zeitwerk_original_require(path).tap do |required|
36
- if required
37
- abspath = $LOADED_FEATURES.last
38
- if loader = Zeitwerk::Registry.loader_for(abspath)
39
- loader.on_file_autoloaded(abspath)
40
- end
38
+ required = zeitwerk_original_require(path)
39
+ if required
40
+ abspath = $LOADED_FEATURES.last
41
+ if loader = Zeitwerk::Registry.loader_for(abspath)
42
+ loader.__on_file_autoloaded(abspath)
41
43
  end
42
44
  end
45
+ required
43
46
  end
44
47
  end
45
48
 
@@ -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
@@ -131,18 +138,35 @@ module Zeitwerk::Loader::Config
131
138
 
132
139
  # Sets a tag for the loader, useful for logging.
133
140
  #
134
- # @param tag [#to_s]
135
141
  # @sig (#to_s) -> void
136
142
  def tag=(tag)
137
143
  @tag = tag.to_s
138
144
  end
139
145
 
140
- # Absolute paths of the root directories. This is a read-only collection,
141
- # 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.
142
150
  #
143
- # @sig () -> Array[String]
144
- def dirs
145
- 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
146
170
  end
147
171
 
148
172
  # You need to call this method before setup in order to be able to reload.
@@ -265,57 +289,76 @@ module Zeitwerk::Loader::Config
265
289
  @logger = ->(msg) { puts msg }
266
290
  end
267
291
 
268
- # @private
292
+ # Returns true if the argument has been configured to be ignored, or is a
293
+ # descendant of an ignored directory.
294
+ #
269
295
  # @sig (String) -> bool
270
- def ignores?(abspath)
271
- ignored_paths.any? do |ignored_path|
272
- 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)
273
303
  end
304
+
305
+ false
274
306
  end
275
307
 
276
- private
308
+ # @sig (String) -> bool
309
+ private def ignored_path?(abspath)
310
+ ignored_paths.member?(abspath)
311
+ end
277
312
 
278
313
  # @sig () -> Array[String]
279
- def actual_root_dirs
280
- root_dirs.reject do |root_dir, _namespace|
281
- !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)
282
317
  end
283
318
  end
284
319
 
285
320
  # @sig (String) -> bool
286
- def root_dir?(dir)
287
- root_dirs.key?(dir)
321
+ private def root_dir?(dir)
322
+ roots.key?(dir)
288
323
  end
289
324
 
290
325
  # @sig (String) -> bool
291
- def excluded_from_eager_load?(abspath)
292
- 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
293
336
  end
294
337
 
295
338
  # @sig (String) -> bool
296
- def collapse?(dir)
339
+ private def collapse?(dir)
297
340
  collapse_dirs.member?(dir)
298
341
  end
299
342
 
300
343
  # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
301
- def expand_paths(paths)
344
+ private def expand_paths(paths)
302
345
  paths.flatten.map! { |path| File.expand_path(path) }
303
346
  end
304
347
 
305
348
  # @sig (Array[String]) -> Array[String]
306
- def expand_glob_patterns(glob_patterns)
349
+ private def expand_glob_patterns(glob_patterns)
307
350
  # Note that Dir.glob works with regular file names just fine. That is,
308
351
  # glob patterns technically need no wildcards.
309
352
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
310
353
  end
311
354
 
312
355
  # @sig () -> void
313
- def recompute_ignored_paths
356
+ private def recompute_ignored_paths
314
357
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
315
358
  end
316
359
 
317
360
  # @sig () -> void
318
- def recompute_collapse_dirs
361
+ private def recompute_collapse_dirs
319
362
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
320
363
  end
321
364
  end