zeitwerk 2.4.0 → 2.5.0.beta2

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.
@@ -4,26 +4,28 @@ module Zeitwerk::Loader::Callbacks
4
4
  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
5
5
  #
6
6
  # @private
7
- # @param file [String]
8
- # @return [void]
7
+ # @sig (String) -> void
9
8
  def on_file_autoloaded(file)
10
- cref = autoloads.delete(file)
11
- 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?
12
13
  Zeitwerk::Registry.unregister_autoload(file)
13
14
 
14
15
  if logger && cdef?(*cref)
15
- log("constant #{cpath(*cref)} loaded from file #{file}")
16
+ log("constant #{cpath} loaded from file #{file}")
16
17
  elsif !cdef?(*cref)
17
- 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)
18
19
  end
20
+
21
+ run_on_load_callbacks(cpath, cget(*cref), file) unless on_load_callbacks.empty?
19
22
  end
20
23
 
21
24
  # Invoked from our decorated Kernel#require when a managed directory is
22
25
  # autoloaded.
23
26
  #
24
27
  # @private
25
- # @param dir [String]
26
- # @return [void]
28
+ # @sig (String) -> void
27
29
  def on_dir_autoloaded(dir)
28
30
  # Module#autoload does not serialize concurrent requires, and we handle
29
31
  # directories ourselves, so the callback needs to account for concurrency.
@@ -39,9 +41,10 @@ module Zeitwerk::Loader::Callbacks
39
41
  mutex2.synchronize do
40
42
  if cref = autoloads.delete(dir)
41
43
  autovivified_module = cref[0].const_set(cref[1], Module.new)
42
- 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
43
46
 
44
- to_unload[autovivified_module.name] = [dir, cref] if reloading_enabled?
47
+ to_unload[cpath] = [dir, cref] if reloading_enabled?
45
48
 
46
49
  # We don't unregister `dir` in the registry because concurrent threads
47
50
  # wouldn't find a loader associated to it in Kernel#require and would
@@ -50,6 +53,8 @@ module Zeitwerk::Loader::Callbacks
50
53
  autoloaded_dirs << dir
51
54
 
52
55
  on_namespace_loaded(autovivified_module)
56
+
57
+ run_on_load_callbacks(cpath, autovivified_module, dir) unless on_load_callbacks.empty?
53
58
  end
54
59
  end
55
60
  end
@@ -59,8 +64,7 @@ module Zeitwerk::Loader::Callbacks
59
64
  # subdirectories, we descend into them now.
60
65
  #
61
66
  # @private
62
- # @param namespace [Module]
63
- # @return [void]
67
+ # @sig (Module) -> void
64
68
  def on_namespace_loaded(namespace)
65
69
  if subdirs = lazy_subdirs.delete(real_mod_name(namespace))
66
70
  subdirs.each do |subdir|
@@ -68,4 +72,16 @@ module Zeitwerk::Loader::Callbacks
68
72
  end
69
73
  end
70
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
71
87
  end
@@ -0,0 +1,308 @@
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 manages?(dir)
251
+ dir = dir + "/"
252
+ ignored_paths.each do |ignored_path|
253
+ return false if dir.start_with?(ignored_path + "/")
254
+ end
255
+
256
+ root_dirs.each_key do |root_dir|
257
+ return true if root_dir.start_with?(dir) || dir.start_with?(root_dir + "/")
258
+ end
259
+
260
+ false
261
+ end
262
+
263
+ private
264
+
265
+ # @sig () -> Array[String]
266
+ def actual_root_dirs
267
+ root_dirs.reject do |root_dir, _namespace|
268
+ !dir?(root_dir) || ignored_paths.member?(root_dir)
269
+ end
270
+ end
271
+
272
+ # @sig (String) -> bool
273
+ def root_dir?(dir)
274
+ root_dirs.key?(dir)
275
+ end
276
+
277
+ # @sig (String) -> bool
278
+ def excluded_from_eager_load?(abspath)
279
+ eager_load_exclusions.member?(abspath)
280
+ end
281
+
282
+ # @sig (String) -> bool
283
+ def collapse?(dir)
284
+ collapse_dirs.member?(dir)
285
+ end
286
+
287
+ # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
288
+ def expand_paths(paths)
289
+ paths.flatten.map! { |path| File.expand_path(path) }
290
+ end
291
+
292
+ # @sig (Array[String]) -> Array[String]
293
+ def expand_glob_patterns(glob_patterns)
294
+ # Note that Dir.glob works with regular file names just fine. That is,
295
+ # glob patterns technically need no wildcards.
296
+ glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
297
+ end
298
+
299
+ # @sig () -> void
300
+ def recompute_ignored_paths
301
+ ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
302
+ end
303
+
304
+ # @sig () -> void
305
+ def recompute_collapse_dirs
306
+ collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
307
+ end
308
+ 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