zeitwerk 2.5.4 → 2.6.18

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,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
@@ -0,0 +1,232 @@
1
+ module Zeitwerk::Loader::EagerLoad
2
+ # Eager loads all files in the root directories, recursively. Files do not
3
+ # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
4
+ # shadowed files are not eager loaded. You can opt-out specifically in
5
+ # specific files and directories with `do_not_eager_load`, and that can be
6
+ # overridden passing `force: true`.
7
+ #
8
+ # @sig (true | false) -> void
9
+ def eager_load(force: false)
10
+ mutex.synchronize do
11
+ break if @eager_loaded
12
+ raise Zeitwerk::SetupRequired unless @setup
13
+
14
+ log("eager load start") if logger
15
+
16
+ actual_roots.each do |root_dir, root_namespace|
17
+ actual_eager_load_dir(root_dir, root_namespace, force: force)
18
+ end
19
+
20
+ autoloaded_dirs.each do |autoloaded_dir|
21
+ Zeitwerk::Registry.unregister_autoload(autoloaded_dir)
22
+ end
23
+ autoloaded_dirs.clear
24
+
25
+ @eager_loaded = true
26
+
27
+ log("eager load end") if logger
28
+ end
29
+ end
30
+
31
+ # @sig (String | Pathname) -> void
32
+ def eager_load_dir(path)
33
+ raise Zeitwerk::SetupRequired unless @setup
34
+
35
+ abspath = File.expand_path(path)
36
+
37
+ raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
38
+
39
+ cnames = []
40
+
41
+ root_namespace = nil
42
+ walk_up(abspath) do |dir|
43
+ return if ignored_path?(dir)
44
+ return if eager_load_exclusions.member?(dir)
45
+
46
+ break if root_namespace = roots[dir]
47
+
48
+ basename = File.basename(dir)
49
+ return if hidden?(basename)
50
+
51
+ unless collapse?(dir)
52
+ cnames << inflector.camelize(basename, dir).to_sym
53
+ end
54
+ end
55
+
56
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
57
+
58
+ return if @eager_loaded
59
+
60
+ namespace = root_namespace
61
+ cnames.reverse_each do |cname|
62
+ # Can happen if there are no Ruby files. This is not an error condition,
63
+ # the directory is actually managed. Could have Ruby files later.
64
+ return unless namespace.const_defined?(cname, false)
65
+ namespace = namespace.const_get(cname, false)
66
+ end
67
+
68
+ # A shortcircuiting test depends on the invocation of this method. Please
69
+ # keep them in sync if refactored.
70
+ actual_eager_load_dir(abspath, namespace)
71
+ end
72
+
73
+ # @sig (Module) -> void
74
+ def eager_load_namespace(mod)
75
+ raise Zeitwerk::SetupRequired unless @setup
76
+
77
+ unless mod.is_a?(Module)
78
+ raise Zeitwerk::Error, "#{mod.inspect} is not a class or module object"
79
+ end
80
+
81
+ return if @eager_loaded
82
+
83
+ mod_name = real_mod_name(mod)
84
+ return unless mod_name
85
+
86
+ actual_roots.each do |root_dir, root_namespace|
87
+ if mod.equal?(Object)
88
+ # A shortcircuiting test depends on the invocation of this method.
89
+ # Please keep them in sync if refactored.
90
+ actual_eager_load_dir(root_dir, root_namespace)
91
+ elsif root_namespace.equal?(Object)
92
+ eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
93
+ else
94
+ root_namespace_name = real_mod_name(root_namespace)
95
+ if root_namespace_name.start_with?(mod_name + "::")
96
+ actual_eager_load_dir(root_dir, root_namespace)
97
+ elsif mod_name == root_namespace_name
98
+ actual_eager_load_dir(root_dir, root_namespace)
99
+ elsif mod_name.start_with?(root_namespace_name + "::")
100
+ eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
101
+ else
102
+ # Unrelated constant hierarchies, do nothing.
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Loads the given Ruby file.
109
+ #
110
+ # Raises if the argument is ignored, shadowed, or not managed by the receiver.
111
+ #
112
+ # The method is implemented as `constantize` for files, in a sense, to be able
113
+ # to descend orderly and make sure the file is loadable.
114
+ #
115
+ # @sig (String | Pathname) -> void
116
+ def load_file(path)
117
+ abspath = File.expand_path(path)
118
+
119
+ raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
120
+ raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
121
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
122
+
123
+ basename = File.basename(abspath, ".rb")
124
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
125
+
126
+ base_cname = inflector.camelize(basename, abspath).to_sym
127
+
128
+ root_namespace = nil
129
+ cnames = []
130
+
131
+ walk_up(File.dirname(abspath)) do |dir|
132
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
133
+
134
+ break if root_namespace = roots[dir]
135
+
136
+ basename = File.basename(dir)
137
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
138
+
139
+ unless collapse?(dir)
140
+ cnames << inflector.camelize(basename, dir).to_sym
141
+ end
142
+ end
143
+
144
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
145
+
146
+ namespace = root_namespace
147
+ cnames.reverse_each do |cname|
148
+ namespace = namespace.const_get(cname, false)
149
+ end
150
+
151
+ raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
152
+
153
+ namespace.const_get(base_cname, false)
154
+ end
155
+
156
+ # The caller is responsible for making sure `namespace` is the namespace that
157
+ # corresponds to `dir`.
158
+ #
159
+ # @sig (String, Module, Boolean) -> void
160
+ private def actual_eager_load_dir(dir, namespace, force: false)
161
+ honour_exclusions = !force
162
+ return if honour_exclusions && excluded_from_eager_load?(dir)
163
+
164
+ log("eager load directory #{dir} start") if logger
165
+
166
+ queue = [[dir, namespace]]
167
+ while (current_dir, namespace = queue.shift)
168
+ ls(current_dir) do |basename, abspath, ftype|
169
+ next if honour_exclusions && eager_load_exclusions.member?(abspath)
170
+
171
+ if ftype == :file
172
+ if (cref = autoloads[abspath])
173
+ cref.get
174
+ end
175
+ else
176
+ if collapse?(abspath)
177
+ queue << [abspath, namespace]
178
+ else
179
+ cname = inflector.camelize(basename, abspath).to_sym
180
+ queue << [abspath, namespace.const_get(cname, false)]
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ log("eager load directory #{dir} end") if logger
187
+ end
188
+
189
+ # In order to invoke this method, the caller has to ensure `child` is a
190
+ # strict namespace descendant of `root_namespace`.
191
+ #
192
+ # @sig (Module, String, Module, Boolean) -> void
193
+ private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
194
+ suffix = child_name
195
+ unless root_namespace.equal?(Object)
196
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
197
+ end
198
+
199
+ # These directories are at the same namespace level, there may be more if
200
+ # we find collapsed ones. As we scan, we look for matches for the first
201
+ # segment, and store them in `next_dirs`. If there are any, we look for
202
+ # the next segments in those matches. Repeat.
203
+ #
204
+ # If we exhaust the search locating directories that match all segments,
205
+ # we just need to eager load those ones.
206
+ dirs = [root_dir]
207
+ next_dirs = []
208
+
209
+ suffix.split("::").each do |segment|
210
+ while (dir = dirs.shift)
211
+ ls(dir) do |basename, abspath, ftype|
212
+ next unless ftype == :directory
213
+
214
+ if collapse?(abspath)
215
+ dirs << abspath
216
+ elsif segment == inflector.camelize(basename, abspath)
217
+ next_dirs << abspath
218
+ end
219
+ end
220
+ end
221
+
222
+ return if next_dirs.empty?
223
+
224
+ dirs.replace(next_dirs)
225
+ next_dirs.clear
226
+ end
227
+
228
+ dirs.each do |dir|
229
+ actual_eager_load_dir(dir, child)
230
+ end
231
+ end
232
+ end