zeitwerk 2.4.1 → 2.5.0.beta3

Sign up to get free protection for your applications and to get access to all the features.
@@ -41,7 +41,7 @@ module Zeitwerk
41
41
 
42
42
  # @private
43
43
  # @sig (Zeitwerk::Loader) -> void
44
- def unregister(loader)
44
+ def unregister_loader(loader)
45
45
  cpaths.delete_if { |_cpath, l| l == loader }
46
46
  disable_tracer_if_unneeded
47
47
  end
@@ -62,8 +62,14 @@ module Zeitwerk
62
62
  # than accessing its name.
63
63
  return if event.self.singleton_class?
64
64
 
65
- # Note that it makes sense to compute the hash code unconditionally,
66
- # because the trace point is disabled if cpaths is empty.
65
+ # It might be tempting to return if name.nil?, to avoid the computation
66
+ # of a hash code and delete call. But Ruby does not trigger the :class
67
+ # event on Class.new or Module.new, so that would incur in an extra call
68
+ # for nothing.
69
+ #
70
+ # On the other hand, if we were called, cpaths is not empty. Otherwise
71
+ # the tracer is disabled. So we do need to go ahead with the hash code
72
+ # computation and delete call.
67
73
  if loader = cpaths.delete(real_mod_name(event.self))
68
74
  loader.on_namespace_loaded(event.self)
69
75
  disable_tracer_if_unneeded
@@ -12,7 +12,8 @@ module Kernel
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
14
14
  # should not require anything. But if someone has legacy require calls around,
15
- # they will work as expected, and in a compatible way.
15
+ # they will work as expected, and in a compatible way. This feature is by now
16
+ # EXPERIMENTAL and UNDOCUMENTED.
16
17
  #
17
18
  # We cannot decorate with prepend + super because Kernel has already been
18
19
  # included in Object, and changes in ancestors don't get propagated into
@@ -28,13 +29,14 @@ module Kernel
28
29
  end
29
30
  else
30
31
  loader.on_dir_autoloaded(path)
32
+ true
31
33
  end
32
34
  else
33
35
  zeitwerk_original_require(path).tap do |required|
34
36
  if required
35
- realpath = $LOADED_FEATURES.last
36
- if loader = Zeitwerk::Registry.loader_for(realpath)
37
- loader.on_file_autoloaded(realpath)
37
+ abspath = $LOADED_FEATURES.last
38
+ if loader = Zeitwerk::Registry.loader_for(abspath)
39
+ loader.on_file_autoloaded(abspath)
38
40
  end
39
41
  end
40
42
  end
@@ -6,15 +6,19 @@ module Zeitwerk::Loader::Callbacks
6
6
  # @private
7
7
  # @sig (String) -> void
8
8
  def on_file_autoloaded(file)
9
- cref = autoloads.delete(file)
10
- to_unload[cpath(*cref)] = [file, cref] if reloading_enabled?
9
+ cref = autoloads.delete(file)
10
+ cpath = cpath(*cref)
11
+
12
+ to_unload[cpath] = [file, cref] if reloading_enabled?
11
13
  Zeitwerk::Registry.unregister_autoload(file)
12
14
 
13
15
  if logger && cdef?(*cref)
14
- log("constant #{cpath(*cref)} loaded from file #{file}")
16
+ log("constant #{cpath} loaded from file #{file}")
15
17
  elsif !cdef?(*cref)
16
- raise Zeitwerk::NameError.new("expected file #{file} to define constant #{cpath(*cref)}, but didn't", cref.last)
18
+ raise Zeitwerk::NameError.new("expected file #{file} to define constant #{cpath}, but didn't", cref.last)
17
19
  end
20
+
21
+ run_on_load_callbacks(cpath, cget(*cref), file) unless on_load_callbacks.empty?
18
22
  end
19
23
 
20
24
  # Invoked from our decorated Kernel#require when a managed directory is
@@ -37,9 +41,10 @@ module Zeitwerk::Loader::Callbacks
37
41
  mutex2.synchronize do
38
42
  if cref = autoloads.delete(dir)
39
43
  autovivified_module = cref[0].const_set(cref[1], Module.new)
40
- log("module #{autovivified_module.name} autovivified from directory #{dir}") if logger
44
+ cpath = autovivified_module.name
45
+ log("module #{cpath} autovivified from directory #{dir}") if logger
41
46
 
42
- to_unload[autovivified_module.name] = [dir, cref] if reloading_enabled?
47
+ to_unload[cpath] = [dir, cref] if reloading_enabled?
43
48
 
44
49
  # We don't unregister `dir` in the registry because concurrent threads
45
50
  # wouldn't find a loader associated to it in Kernel#require and would
@@ -48,6 +53,8 @@ module Zeitwerk::Loader::Callbacks
48
53
  autoloaded_dirs << dir
49
54
 
50
55
  on_namespace_loaded(autovivified_module)
56
+
57
+ run_on_load_callbacks(cpath, autovivified_module, dir) unless on_load_callbacks.empty?
51
58
  end
52
59
  end
53
60
  end
@@ -65,4 +72,16 @@ module Zeitwerk::Loader::Callbacks
65
72
  end
66
73
  end
67
74
  end
75
+
76
+ private
77
+
78
+ # @sig (String, Object) -> void
79
+ def run_on_load_callbacks(cpath, value, abspath)
80
+ # Order matters. If present, run the most specific one.
81
+ callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)
82
+ callbacks&.each { |c| c.call(value, abspath) }
83
+
84
+ callbacks = on_load_callbacks[:ANY]
85
+ callbacks&.each { |c| c.call(cpath, value, abspath) }
86
+ end
68
87
  end
@@ -0,0 +1,301 @@
1
+ require "set"
2
+ require "securerandom"
3
+
4
+ module Zeitwerk::Loader::Config
5
+ # Absolute paths of the root directories. Stored in a hash to preserve
6
+ # order, easily handle duplicates, and also be able to have a fast lookup,
7
+ # needed for detecting nested paths.
8
+ #
9
+ # "/Users/fxn/blog/app/assets" => true,
10
+ # "/Users/fxn/blog/app/channels" => true,
11
+ # ...
12
+ #
13
+ # This is a private collection maintained by the loader. The public
14
+ # interface for it is `push_dir` and `dirs`.
15
+ #
16
+ # @private
17
+ # @sig Hash[String, true]
18
+ attr_reader :root_dirs
19
+
20
+ # @sig #camelize
21
+ attr_accessor :inflector
22
+
23
+ # Absolute paths of files, directories, or glob patterns to be totally
24
+ # ignored.
25
+ #
26
+ # @private
27
+ # @sig Set[String]
28
+ attr_reader :ignored_glob_patterns
29
+
30
+ # The actual collection of absolute file and directory names at the time the
31
+ # ignored glob patterns were expanded. Computed on setup, and recomputed on
32
+ # reload.
33
+ #
34
+ # @private
35
+ # @sig Set[String]
36
+ attr_reader :ignored_paths
37
+
38
+ # Absolute paths of directories or glob patterns to be collapsed.
39
+ #
40
+ # @private
41
+ # @sig Set[String]
42
+ attr_reader :collapse_glob_patterns
43
+
44
+ # The actual collection of absolute directory names at the time the collapse
45
+ # glob patterns were expanded. Computed on setup, and recomputed on reload.
46
+ #
47
+ # @private
48
+ # @sig Set[String]
49
+ attr_reader :collapse_dirs
50
+
51
+ # Absolute paths of files or directories not to be eager loaded.
52
+ #
53
+ # @private
54
+ # @sig Set[String]
55
+ attr_reader :eager_load_exclusions
56
+
57
+ # User-oriented callbacks to be fired when a constant is loaded.
58
+ #
59
+ # @private
60
+ # @sig Hash[String, Array[{ (Object, String) -> void }]]
61
+ # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
62
+ attr_reader :on_load_callbacks
63
+
64
+ # User-oriented callbacks to be fired before constants are removed.
65
+ #
66
+ # @private
67
+ # @sig Hash[String, Array[{ (Object, String) -> void }]]
68
+ # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
69
+ attr_reader :on_unload_callbacks
70
+
71
+ # @sig #call | #debug | nil
72
+ attr_accessor :logger
73
+
74
+ def initialize
75
+ @initialized_at = Time.now
76
+ @root_dirs = {}
77
+ @inflector = Zeitwerk::Inflector.new
78
+ @ignored_glob_patterns = Set.new
79
+ @ignored_paths = Set.new
80
+ @collapse_glob_patterns = Set.new
81
+ @collapse_dirs = Set.new
82
+ @eager_load_exclusions = Set.new
83
+ @reloading_enabled = false
84
+ @on_load_callbacks = {}
85
+ @on_unload_callbacks = {}
86
+ @logger = self.class.default_logger
87
+ @tag = SecureRandom.hex(3)
88
+ end
89
+
90
+ # Pushes `path` to the list of root directories.
91
+ #
92
+ # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
93
+ # the same process already manages that directory or one of its ascendants or
94
+ # descendants.
95
+ #
96
+ # @raise [Zeitwerk::Error]
97
+ # @sig (String | Pathname, Module) -> void
98
+ def push_dir(path, namespace: Object)
99
+ # Note that Class < Module.
100
+ unless namespace.is_a?(Module)
101
+ raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
102
+ end
103
+
104
+ abspath = File.expand_path(path)
105
+ if dir?(abspath)
106
+ raise_if_conflicting_directory(abspath)
107
+ root_dirs[abspath] = namespace
108
+ else
109
+ raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
110
+ end
111
+ end
112
+
113
+ # Returns the loader's tag.
114
+ #
115
+ # Implemented as a method instead of via attr_reader for symmetry with the
116
+ # writer below.
117
+ #
118
+ # @sig () -> String
119
+ def tag
120
+ @tag
121
+ end
122
+
123
+ # Sets a tag for the loader, useful for logging.
124
+ #
125
+ # @param tag [#to_s]
126
+ # @sig (#to_s) -> void
127
+ def tag=(tag)
128
+ @tag = tag.to_s
129
+ end
130
+
131
+ # Absolute paths of the root directories. This is a read-only collection,
132
+ # please push here via `push_dir`.
133
+ #
134
+ # @sig () -> Array[String]
135
+ def dirs
136
+ root_dirs.keys.freeze
137
+ end
138
+
139
+ # You need to call this method before setup in order to be able to reload.
140
+ # There is no way to undo this, either you want to reload or you don't.
141
+ #
142
+ # @raise [Zeitwerk::Error]
143
+ # @sig () -> void
144
+ def enable_reloading
145
+ mutex.synchronize do
146
+ break if @reloading_enabled
147
+
148
+ if @setup
149
+ raise Zeitwerk::Error, "cannot enable reloading after setup"
150
+ else
151
+ @reloading_enabled = true
152
+ end
153
+ end
154
+ end
155
+
156
+ # @sig () -> bool
157
+ def reloading_enabled?
158
+ @reloading_enabled
159
+ end
160
+
161
+ # Let eager load ignore the given files or directories. The constants defined
162
+ # in those files are still autoloadable.
163
+ #
164
+ # @sig (*(String | Pathname | Array[String | Pathname])) -> void
165
+ def do_not_eager_load(*paths)
166
+ mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
167
+ end
168
+
169
+ # Configure files, directories, or glob patterns to be totally ignored.
170
+ #
171
+ # @sig (*(String | Pathname | Array[String | Pathname])) -> void
172
+ def ignore(*glob_patterns)
173
+ glob_patterns = expand_paths(glob_patterns)
174
+ mutex.synchronize do
175
+ ignored_glob_patterns.merge(glob_patterns)
176
+ ignored_paths.merge(expand_glob_patterns(glob_patterns))
177
+ end
178
+ end
179
+
180
+ # Configure directories or glob patterns to be collapsed.
181
+ #
182
+ # @sig (*(String | Pathname | Array[String | Pathname])) -> void
183
+ def collapse(*glob_patterns)
184
+ glob_patterns = expand_paths(glob_patterns)
185
+ mutex.synchronize do
186
+ collapse_glob_patterns.merge(glob_patterns)
187
+ collapse_dirs.merge(expand_glob_patterns(glob_patterns))
188
+ end
189
+ end
190
+
191
+ # Configure a block to be invoked once a certain constant path is loaded.
192
+ # Supports multiple callbacks, and if there are many, they are executed in
193
+ # the order in which they were defined.
194
+ #
195
+ # loader.on_load("SomeApiClient") do |klass, _abspath|
196
+ # klass.endpoint = "https://api.dev"
197
+ # end
198
+ #
199
+ # Can also be configured for any constant loaded:
200
+ #
201
+ # loader.on_load do |cpath, value, abspath|
202
+ # # ...
203
+ # end
204
+ #
205
+ # @raise [TypeError]
206
+ # @sig (String) { (Object, String) -> void } -> void
207
+ # (:ANY) { (String, Object, String) -> void } -> void
208
+ def on_load(cpath = :ANY, &block)
209
+ raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
210
+
211
+ mutex.synchronize do
212
+ (on_load_callbacks[cpath] ||= []) << block
213
+ end
214
+ end
215
+
216
+ # Configure a block to be invoked right before a certain constant is removed.
217
+ # Supports multiple callbacks, and if there are many, they are executed in the
218
+ # order in which they were defined.
219
+ #
220
+ # loader.on_unload("Country") do |klass, _abspath|
221
+ # klass.clear_cache
222
+ # end
223
+ #
224
+ # Can also be configured for any removed constant:
225
+ #
226
+ # loader.on_unload do |cpath, value, abspath|
227
+ # # ...
228
+ # end
229
+ #
230
+ # @raise [TypeError]
231
+ # @sig (String) { (Object) -> void } -> void
232
+ # (:ANY) { (String, Object) -> void } -> void
233
+ def on_unload(cpath = :ANY, &block)
234
+ raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
235
+
236
+ mutex.synchronize do
237
+ (on_unload_callbacks[cpath] ||= []) << block
238
+ end
239
+ end
240
+
241
+ # Logs to `$stdout`, handy shortcut for debugging.
242
+ #
243
+ # @sig () -> void
244
+ def log!
245
+ @logger = ->(msg) { puts msg }
246
+ end
247
+
248
+ # @private
249
+ # @sig (String) -> bool
250
+ def ignores?(abspath)
251
+ ignored_paths.any? do |ignored_path|
252
+ ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
253
+ end
254
+ end
255
+
256
+ private
257
+
258
+ # @sig () -> Array[String]
259
+ def actual_root_dirs
260
+ root_dirs.reject do |root_dir, _namespace|
261
+ !dir?(root_dir) || ignored_paths.member?(root_dir)
262
+ end
263
+ end
264
+
265
+ # @sig (String) -> bool
266
+ def root_dir?(dir)
267
+ root_dirs.key?(dir)
268
+ end
269
+
270
+ # @sig (String) -> bool
271
+ def excluded_from_eager_load?(abspath)
272
+ eager_load_exclusions.member?(abspath)
273
+ end
274
+
275
+ # @sig (String) -> bool
276
+ def collapse?(dir)
277
+ collapse_dirs.member?(dir)
278
+ end
279
+
280
+ # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
281
+ def expand_paths(paths)
282
+ paths.flatten.map! { |path| File.expand_path(path) }
283
+ end
284
+
285
+ # @sig (Array[String]) -> Array[String]
286
+ def expand_glob_patterns(glob_patterns)
287
+ # Note that Dir.glob works with regular file names just fine. That is,
288
+ # glob patterns technically need no wildcards.
289
+ glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
290
+ end
291
+
292
+ # @sig () -> void
293
+ def recompute_ignored_paths
294
+ ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
295
+ end
296
+
297
+ # @sig () -> void
298
+ def recompute_collapse_dirs
299
+ collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
300
+ end
301
+ end
@@ -0,0 +1,95 @@
1
+ module Zeitwerk::Loader::Helpers
2
+ private
3
+
4
+ # --- Logging -----------------------------------------------------------------------------------
5
+
6
+ # @sig (String) -> void
7
+ def log(message)
8
+ method_name = logger.respond_to?(:debug) ? :debug : :call
9
+ logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
10
+ end
11
+
12
+ # --- Files and directories ---------------------------------------------------------------------
13
+
14
+ # @sig (String) { (String, String) -> void } -> void
15
+ def ls(dir)
16
+ Dir.each_child(dir) do |basename|
17
+ next if hidden?(basename)
18
+
19
+ abspath = File.join(dir, basename)
20
+ next if ignored_paths.member?(abspath)
21
+
22
+ # We freeze abspath because that saves allocations when passed later to
23
+ # File methods. See #125.
24
+ yield basename, abspath.freeze
25
+ end
26
+ end
27
+
28
+ # @sig (String) -> bool
29
+ def ruby?(path)
30
+ path.end_with?(".rb")
31
+ end
32
+
33
+ # @sig (String) -> bool
34
+ def dir?(path)
35
+ File.directory?(path)
36
+ end
37
+
38
+ # @sig String -> bool
39
+ def hidden?(basename)
40
+ basename.start_with?(".")
41
+ end
42
+
43
+ # --- Constants ---------------------------------------------------------------------------------
44
+
45
+ # The autoload? predicate takes into account the ancestor chain of the
46
+ # receiver, like const_defined? and other methods in the constants API do.
47
+ #
48
+ # For example, given
49
+ #
50
+ # class A
51
+ # autoload :X, "x.rb"
52
+ # end
53
+ #
54
+ # class B < A
55
+ # end
56
+ #
57
+ # B.autoload?(:X) returns "x.rb".
58
+ #
59
+ # We need a way to strictly check in parent ignoring ancestors.
60
+ #
61
+ # @sig (Module, Symbol) -> String?
62
+ if method(:autoload?).arity == 1
63
+ def strict_autoload_path(parent, cname)
64
+ parent.autoload?(cname) if cdef?(parent, cname)
65
+ end
66
+ else
67
+ def strict_autoload_path(parent, cname)
68
+ parent.autoload?(cname, false)
69
+ end
70
+ end
71
+
72
+ # @sig (Module, Symbol) -> String
73
+ if Symbol.method_defined?(:name)
74
+ # Symbol#name was introduced in Ruby 3.0. It returns always the same
75
+ # frozen object, so we may save a few string allocations.
76
+ def cpath(parent, cname)
77
+ Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
78
+ end
79
+ else
80
+ def cpath(parent, cname)
81
+ Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
82
+ end
83
+ end
84
+
85
+ # @sig (Module, Symbol) -> bool
86
+ def cdef?(parent, cname)
87
+ parent.const_defined?(cname, false)
88
+ end
89
+
90
+ # @raise [NameError]
91
+ # @sig (Module, Symbol) -> Object
92
+ def cget(parent, cname)
93
+ parent.const_get(cname, false)
94
+ end
95
+ end