zeitwerk 2.4.2 → 2.5.4

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