zeitwerk 2.4.0 → 2.5.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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