zeitwerk 2.6.0 → 2.6.6

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.
@@ -110,10 +114,14 @@ module Zeitwerk::Loader::Config
110
114
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
111
115
  end
112
116
 
117
+ unless real_mod_name(namespace)
118
+ raise Zeitwerk::Error, "root namespaces cannot be anonymous"
119
+ end
120
+
113
121
  abspath = File.expand_path(path)
114
122
  if dir?(abspath)
115
123
  raise_if_conflicting_directory(abspath)
116
- root_dirs[abspath] = namespace
124
+ roots[abspath] = namespace
117
125
  else
118
126
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
119
127
  end
@@ -136,12 +144,20 @@ module Zeitwerk::Loader::Config
136
144
  @tag = tag.to_s
137
145
  end
138
146
 
139
- # Absolute paths of the root directories. This is a read-only collection,
140
- # please push here via `push_dir`.
147
+ # If `namespaces` is falsey (default), returns an array with the absolute
148
+ # paths of the root directories as strings. If truthy, returns a hash table
149
+ # instead. Keys are the absolute paths of the root directories as strings,
150
+ # values are their corresponding namespaces, class or module objects.
141
151
  #
142
- # @sig () -> Array[String]
143
- def dirs
144
- root_dirs.keys.freeze
152
+ # These are read-only collections, please add to them with `push_dir`.
153
+ #
154
+ # @sig () -> Array[String] | Hash[String, Module]
155
+ def dirs(namespaces: false)
156
+ if namespaces
157
+ roots.clone
158
+ else
159
+ roots.keys
160
+ end.freeze
145
161
  end
146
162
 
147
163
  # You need to call this method before setup in order to be able to reload.
@@ -264,57 +280,76 @@ module Zeitwerk::Loader::Config
264
280
  @logger = ->(msg) { puts msg }
265
281
  end
266
282
 
267
- # @private
283
+ # Returns true if the argument has been configured to be ignored, or is a
284
+ # descendant of an ignored directory.
285
+ #
268
286
  # @sig (String) -> bool
269
- def ignores?(abspath)
270
- ignored_paths.any? do |ignored_path|
271
- ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
287
+ internal def ignores?(abspath)
288
+ # Common use case.
289
+ return false if ignored_paths.empty?
290
+
291
+ walk_up(abspath) do |abspath|
292
+ return true if ignored_path?(abspath)
293
+ return false if roots.key?(abspath)
272
294
  end
295
+
296
+ false
273
297
  end
274
298
 
275
- private
299
+ # @sig (String) -> bool
300
+ private def ignored_path?(abspath)
301
+ ignored_paths.member?(abspath)
302
+ end
276
303
 
277
304
  # @sig () -> Array[String]
278
- def actual_root_dirs
279
- root_dirs.reject do |root_dir, _namespace|
280
- !dir?(root_dir) || ignored_paths.member?(root_dir)
305
+ private def actual_roots
306
+ roots.reject do |root_dir, _root_namespace|
307
+ !dir?(root_dir) || ignored_path?(root_dir)
281
308
  end
282
309
  end
283
310
 
284
311
  # @sig (String) -> bool
285
- def root_dir?(dir)
286
- root_dirs.key?(dir)
312
+ private def root_dir?(dir)
313
+ roots.key?(dir)
287
314
  end
288
315
 
289
316
  # @sig (String) -> bool
290
- def excluded_from_eager_load?(abspath)
291
- eager_load_exclusions.member?(abspath)
317
+ private def excluded_from_eager_load?(abspath)
318
+ # Optimize this common use case.
319
+ return false if eager_load_exclusions.empty?
320
+
321
+ walk_up(abspath) do |abspath|
322
+ return true if eager_load_exclusions.member?(abspath)
323
+ return false if roots.key?(abspath)
324
+ end
325
+
326
+ false
292
327
  end
293
328
 
294
329
  # @sig (String) -> bool
295
- def collapse?(dir)
330
+ private def collapse?(dir)
296
331
  collapse_dirs.member?(dir)
297
332
  end
298
333
 
299
334
  # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
300
- def expand_paths(paths)
335
+ private def expand_paths(paths)
301
336
  paths.flatten.map! { |path| File.expand_path(path) }
302
337
  end
303
338
 
304
339
  # @sig (Array[String]) -> Array[String]
305
- def expand_glob_patterns(glob_patterns)
340
+ private def expand_glob_patterns(glob_patterns)
306
341
  # Note that Dir.glob works with regular file names just fine. That is,
307
342
  # glob patterns technically need no wildcards.
308
343
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
309
344
  end
310
345
 
311
346
  # @sig () -> void
312
- def recompute_ignored_paths
347
+ private def recompute_ignored_paths
313
348
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
314
349
  end
315
350
 
316
351
  # @sig () -> void
317
- def recompute_collapse_dirs
352
+ private def recompute_collapse_dirs
318
353
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
319
354
  end
320
355
  end
@@ -0,0 +1,228 @@
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
+ unless collapse?(dir)
49
+ basename = File.basename(dir)
50
+ cnames << inflector.camelize(basename, dir).to_sym
51
+ end
52
+ end
53
+
54
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
55
+
56
+ return if @eager_loaded
57
+
58
+ namespace = root_namespace
59
+ cnames.reverse_each do |cname|
60
+ # Can happen if there are no Ruby files. This is not an error condition,
61
+ # the directory is actually managed. Could have Ruby files later.
62
+ return unless cdef?(namespace, cname)
63
+ namespace = cget(namespace, cname)
64
+ end
65
+
66
+ # A shortcircuiting test depends on the invocation of this method. Please
67
+ # keep them in sync if refactored.
68
+ actual_eager_load_dir(abspath, namespace)
69
+ end
70
+
71
+ # @sig (Module) -> void
72
+ def eager_load_namespace(mod)
73
+ raise Zeitwerk::SetupRequired unless @setup
74
+
75
+ unless mod.is_a?(Module)
76
+ raise Zeitwerk::Error, "#{mod.inspect} is not a class or module object"
77
+ end
78
+
79
+ return if @eager_loaded
80
+
81
+ mod_name = real_mod_name(mod)
82
+ return unless mod_name
83
+
84
+ actual_roots.each do |root_dir, root_namespace|
85
+ if mod.equal?(Object)
86
+ # A shortcircuiting test depends on the invocation of this method.
87
+ # Please keep them in sync if refactored.
88
+ actual_eager_load_dir(root_dir, root_namespace)
89
+ elsif root_namespace.equal?(Object)
90
+ eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
91
+ else
92
+ root_namespace_name = real_mod_name(root_namespace)
93
+ if root_namespace_name.start_with?(mod_name + "::")
94
+ actual_eager_load_dir(root_dir, root_namespace)
95
+ elsif mod_name == root_namespace_name
96
+ actual_eager_load_dir(root_dir, root_namespace)
97
+ elsif mod_name.start_with?(root_namespace_name + "::")
98
+ eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
99
+ else
100
+ # Unrelated constant hierarchies, do nothing.
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # Loads the given Ruby file.
107
+ #
108
+ # Raises if the argument is ignored, shadowed, or not managed by the receiver.
109
+ #
110
+ # The method is implemented as `constantize` for files, in a sense, to be able
111
+ # to descend orderly and make sure the file is loadable.
112
+ #
113
+ # @sig (String | Pathname) -> void
114
+ def load_file(path)
115
+ abspath = File.expand_path(path)
116
+
117
+ raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
118
+ raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
119
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
120
+
121
+ basename = File.basename(abspath, ".rb")
122
+ base_cname = inflector.camelize(basename, abspath).to_sym
123
+
124
+ root_namespace = nil
125
+ cnames = []
126
+
127
+ walk_up(File.dirname(abspath)) do |dir|
128
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
129
+
130
+ break if root_namespace = roots[dir]
131
+
132
+ unless collapse?(dir)
133
+ basename = File.basename(dir)
134
+ cnames << inflector.camelize(basename, dir).to_sym
135
+ end
136
+ end
137
+
138
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
139
+
140
+ namespace = root_namespace
141
+ cnames.reverse_each do |cname|
142
+ namespace = cget(namespace, cname)
143
+ end
144
+
145
+ raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
146
+
147
+ cget(namespace, base_cname)
148
+ end
149
+
150
+ # The caller is responsible for making sure `namespace` is the namespace that
151
+ # corresponds to `dir`.
152
+ #
153
+ # @sig (String, Module, Boolean) -> void
154
+ private def actual_eager_load_dir(dir, namespace, force: false)
155
+ honour_exclusions = !force
156
+ return if honour_exclusions && excluded_from_eager_load?(dir)
157
+
158
+ log("eager load directory #{dir} start") if logger
159
+
160
+ queue = [[dir, namespace]]
161
+ while to_eager_load = queue.shift
162
+ dir, namespace = to_eager_load
163
+
164
+ ls(dir) do |basename, abspath|
165
+ next if honour_exclusions && eager_load_exclusions.member?(abspath)
166
+
167
+ if ruby?(abspath)
168
+ if (cref = autoloads[abspath]) && !shadowed_file?(abspath)
169
+ cget(*cref)
170
+ end
171
+ else
172
+ if collapse?(abspath)
173
+ queue << [abspath, namespace]
174
+ else
175
+ cname = inflector.camelize(basename, abspath).to_sym
176
+ queue << [abspath, cget(namespace, cname)]
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ log("eager load directory #{dir} end") if logger
183
+ end
184
+
185
+ # In order to invoke this method, the caller has to ensure `child` is a
186
+ # strict namespace descendendant of `root_namespace`.
187
+ #
188
+ # @sig (Module, String, Module, Boolean) -> void
189
+ private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
190
+ suffix = child_name
191
+ unless root_namespace.equal?(Object)
192
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
193
+ end
194
+
195
+ # These directories are at the same namespace level, there may be more if
196
+ # we find collapsed ones. As we scan, we look for matches for the first
197
+ # segment, and store them in `next_dirs`. If there are any, we look for
198
+ # the next segments in those matches. Repeat.
199
+ #
200
+ # If we exhaust the search locating directories that match all segments,
201
+ # we just need to eager load those ones.
202
+ dirs = [root_dir]
203
+ next_dirs = []
204
+
205
+ suffix.split("::").each do |segment|
206
+ while dir = dirs.shift
207
+ ls(dir) do |basename, abspath|
208
+ next unless dir?(abspath)
209
+
210
+ if collapse?(abspath)
211
+ dirs << abspath
212
+ elsif segment == inflector.camelize(basename, abspath)
213
+ next_dirs << abspath
214
+ end
215
+ end
216
+ end
217
+
218
+ return if next_dirs.empty?
219
+
220
+ dirs.replace(next_dirs)
221
+ next_dirs.clear
222
+ end
223
+
224
+ dirs.each do |dir|
225
+ actual_eager_load_dir(dir, child)
226
+ end
227
+ end
228
+ end
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zeitwerk::Loader::Helpers
4
- private
5
-
6
4
  # --- Logging -----------------------------------------------------------------------------------
7
5
 
8
6
  # @sig (String) -> void
9
- def log(message)
7
+ private def log(message)
10
8
  method_name = logger.respond_to?(:debug) ? :debug : :call
11
9
  logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
12
10
  end
@@ -14,7 +12,7 @@ module Zeitwerk::Loader::Helpers
14
12
  # --- Files and directories ---------------------------------------------------------------------
15
13
 
16
14
  # @sig (String) { (String, String) -> void } -> void
17
- def ls(dir)
15
+ private def ls(dir)
18
16
  children = Dir.children(dir)
19
17
 
20
18
  # The order in which a directory is listed depends on the file system.
@@ -28,10 +26,11 @@ module Zeitwerk::Loader::Helpers
28
26
  next if hidden?(basename)
29
27
 
30
28
  abspath = File.join(dir, basename)
31
- next if ignored_paths.member?(abspath)
29
+ next if ignored_path?(abspath)
32
30
 
33
31
  if dir?(abspath)
34
- next unless has_at_least_one_ruby_file?(abspath)
32
+ next if roots.key?(abspath)
33
+ next if !has_at_least_one_ruby_file?(abspath)
35
34
  else
36
35
  next unless ruby?(abspath)
37
36
  end
@@ -43,7 +42,7 @@ module Zeitwerk::Loader::Helpers
43
42
  end
44
43
 
45
44
  # @sig (String) -> bool
46
- def has_at_least_one_ruby_file?(dir)
45
+ private def has_at_least_one_ruby_file?(dir)
47
46
  to_visit = [dir]
48
47
 
49
48
  while dir = to_visit.shift
@@ -60,20 +59,29 @@ module Zeitwerk::Loader::Helpers
60
59
  end
61
60
 
62
61
  # @sig (String) -> bool
63
- def ruby?(path)
62
+ private def ruby?(path)
64
63
  path.end_with?(".rb")
65
64
  end
66
65
 
67
66
  # @sig (String) -> bool
68
- def dir?(path)
67
+ private def dir?(path)
69
68
  File.directory?(path)
70
69
  end
71
70
 
72
71
  # @sig (String) -> bool
73
- def hidden?(basename)
72
+ private def hidden?(basename)
74
73
  basename.start_with?(".")
75
74
  end
76
75
 
76
+ # @sig (String) { (String) -> void } -> void
77
+ private def walk_up(abspath)
78
+ loop do
79
+ yield abspath
80
+ abspath, basename = File.split(abspath)
81
+ break if basename == "/"
82
+ end
83
+ end
84
+
77
85
  # --- Constants ---------------------------------------------------------------------------------
78
86
 
79
87
  # The autoload? predicate takes into account the ancestor chain of the
@@ -94,11 +102,11 @@ module Zeitwerk::Loader::Helpers
94
102
  #
95
103
  # @sig (Module, Symbol) -> String?
96
104
  if method(:autoload?).arity == 1
97
- def strict_autoload_path(parent, cname)
105
+ private def strict_autoload_path(parent, cname)
98
106
  parent.autoload?(cname) if cdef?(parent, cname)
99
107
  end
100
108
  else
101
- def strict_autoload_path(parent, cname)
109
+ private def strict_autoload_path(parent, cname)
102
110
  parent.autoload?(cname, false)
103
111
  end
104
112
  end
@@ -107,23 +115,23 @@ module Zeitwerk::Loader::Helpers
107
115
  if Symbol.method_defined?(:name)
108
116
  # Symbol#name was introduced in Ruby 3.0. It returns always the same
109
117
  # frozen object, so we may save a few string allocations.
110
- def cpath(parent, cname)
118
+ private def cpath(parent, cname)
111
119
  Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
112
120
  end
113
121
  else
114
- def cpath(parent, cname)
122
+ private def cpath(parent, cname)
115
123
  Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
116
124
  end
117
125
  end
118
126
 
119
127
  # @sig (Module, Symbol) -> bool
120
- def cdef?(parent, cname)
128
+ private def cdef?(parent, cname)
121
129
  parent.const_defined?(cname, false)
122
130
  end
123
131
 
124
132
  # @raise [NameError]
125
133
  # @sig (Module, Symbol) -> Object
126
- def cget(parent, cname)
134
+ private def cget(parent, cname)
127
135
  parent.const_get(cname, false)
128
136
  end
129
137
  end