zeitwerk 2.6.1 → 2.6.7

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,86 +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, have a fast lookup needed for detecting
9
- # nested paths, and store custom namespaces as values.
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" => Object,
12
18
  # "/Users/fxn/blog/app/channels" => Object,
13
- # "/Users/fxn/blog/adapters" => ActiveJob::QueueAdapters,
19
+ # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
14
20
  # ...
15
21
  #
22
+ # Stored in a hash to preserve order, easily handle duplicates, and have a
23
+ # fast lookup by directory.
24
+ #
16
25
  # This is a private collection maintained by the loader. The public
17
26
  # interface for it is `push_dir` and `dirs`.
18
27
  #
19
- # @private
20
28
  # @sig Hash[String, Module]
21
- attr_reader :root_dirs
22
-
23
- # @sig #camelize
24
- attr_accessor :inflector
29
+ attr_reader :roots
30
+ internal :roots
25
31
 
26
32
  # Absolute paths of files, directories, or glob patterns to be totally
27
33
  # ignored.
28
34
  #
29
- # @private
30
35
  # @sig Set[String]
31
36
  attr_reader :ignored_glob_patterns
37
+ private :ignored_glob_patterns
32
38
 
33
39
  # The actual collection of absolute file and directory names at the time the
34
40
  # ignored glob patterns were expanded. Computed on setup, and recomputed on
35
41
  # reload.
36
42
  #
37
- # @private
38
43
  # @sig Set[String]
39
44
  attr_reader :ignored_paths
45
+ private :ignored_paths
40
46
 
41
47
  # Absolute paths of directories or glob patterns to be collapsed.
42
48
  #
43
- # @private
44
49
  # @sig Set[String]
45
50
  attr_reader :collapse_glob_patterns
51
+ private :collapse_glob_patterns
46
52
 
47
53
  # The actual collection of absolute directory names at the time the collapse
48
54
  # glob patterns were expanded. Computed on setup, and recomputed on reload.
49
55
  #
50
- # @private
51
56
  # @sig Set[String]
52
57
  attr_reader :collapse_dirs
58
+ private :collapse_dirs
53
59
 
54
60
  # Absolute paths of files or directories not to be eager loaded.
55
61
  #
56
- # @private
57
62
  # @sig Set[String]
58
63
  attr_reader :eager_load_exclusions
64
+ private :eager_load_exclusions
59
65
 
60
66
  # User-oriented callbacks to be fired on setup and on reload.
61
67
  #
62
- # @private
63
68
  # @sig Array[{ () -> void }]
64
69
  attr_reader :on_setup_callbacks
70
+ private :on_setup_callbacks
65
71
 
66
72
  # User-oriented callbacks to be fired when a constant is loaded.
67
73
  #
68
- # @private
69
74
  # @sig Hash[String, Array[{ (Object, String) -> void }]]
70
75
  # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
71
76
  attr_reader :on_load_callbacks
77
+ private :on_load_callbacks
72
78
 
73
79
  # User-oriented callbacks to be fired before constants are removed.
74
80
  #
75
- # @private
76
81
  # @sig Hash[String, Array[{ (Object, String) -> void }]]
77
82
  # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
78
83
  attr_reader :on_unload_callbacks
79
-
80
- # @sig #call | #debug | nil
81
- attr_accessor :logger
84
+ private :on_unload_callbacks
82
85
 
83
86
  def initialize
84
- @initialized_at = Time.now
85
- @root_dirs = {}
86
87
  @inflector = Zeitwerk::Inflector.new
88
+ @logger = self.class.default_logger
89
+ @tag = SecureRandom.hex(3)
90
+ @initialized_at = Time.now
91
+ @roots = {}
87
92
  @ignored_glob_patterns = Set.new
88
93
  @ignored_paths = Set.new
89
94
  @collapse_glob_patterns = Set.new
@@ -93,8 +98,6 @@ module Zeitwerk::Loader::Config
93
98
  @on_setup_callbacks = []
94
99
  @on_load_callbacks = {}
95
100
  @on_unload_callbacks = {}
96
- @logger = self.class.default_logger
97
- @tag = SecureRandom.hex(3)
98
101
  end
99
102
 
100
103
  # Pushes `path` to the list of root directories.
@@ -111,10 +114,14 @@ module Zeitwerk::Loader::Config
111
114
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
112
115
  end
113
116
 
117
+ unless real_mod_name(namespace)
118
+ raise Zeitwerk::Error, "root namespaces cannot be anonymous"
119
+ end
120
+
114
121
  abspath = File.expand_path(path)
115
122
  if dir?(abspath)
116
123
  raise_if_conflicting_directory(abspath)
117
- root_dirs[abspath] = namespace
124
+ roots[abspath] = namespace
118
125
  else
119
126
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
120
127
  end
@@ -142,14 +149,24 @@ module Zeitwerk::Loader::Config
142
149
  # instead. Keys are the absolute paths of the root directories as strings,
143
150
  # values are their corresponding namespaces, class or module objects.
144
151
  #
152
+ # If `ignored` is falsey (default), ignored root directories are filtered out.
153
+ #
145
154
  # These are read-only collections, please add to them with `push_dir`.
146
155
  #
147
156
  # @sig () -> Array[String] | Hash[String, Module]
148
- def dirs(namespaces: false)
157
+ def dirs(namespaces: false, ignored: false)
149
158
  if namespaces
150
- root_dirs.clone
159
+ if ignored || ignored_paths.empty?
160
+ roots.clone
161
+ else
162
+ roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
163
+ end
151
164
  else
152
- root_dirs.keys
165
+ if ignored || ignored_paths.empty?
166
+ roots.keys
167
+ else
168
+ roots.keys.reject { |root_dir| ignored_path?(root_dir) }
169
+ end
153
170
  end.freeze
154
171
  end
155
172
 
@@ -273,57 +290,76 @@ module Zeitwerk::Loader::Config
273
290
  @logger = ->(msg) { puts msg }
274
291
  end
275
292
 
276
- # @private
293
+ # Returns true if the argument has been configured to be ignored, or is a
294
+ # descendant of an ignored directory.
295
+ #
277
296
  # @sig (String) -> bool
278
- def ignores?(abspath)
279
- ignored_paths.any? do |ignored_path|
280
- ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
297
+ internal def ignores?(abspath)
298
+ # Common use case.
299
+ return false if ignored_paths.empty?
300
+
301
+ walk_up(abspath) do |abspath|
302
+ return true if ignored_path?(abspath)
303
+ return false if roots.key?(abspath)
281
304
  end
305
+
306
+ false
282
307
  end
283
308
 
284
- private
309
+ # @sig (String) -> bool
310
+ private def ignored_path?(abspath)
311
+ ignored_paths.member?(abspath)
312
+ end
285
313
 
286
314
  # @sig () -> Array[String]
287
- def actual_root_dirs
288
- root_dirs.reject do |root_dir, _namespace|
289
- !dir?(root_dir) || ignored_paths.member?(root_dir)
315
+ private def actual_roots
316
+ roots.reject do |root_dir, _root_namespace|
317
+ !dir?(root_dir) || ignored_path?(root_dir)
290
318
  end
291
319
  end
292
320
 
293
321
  # @sig (String) -> bool
294
- def root_dir?(dir)
295
- root_dirs.key?(dir)
322
+ private def root_dir?(dir)
323
+ roots.key?(dir)
296
324
  end
297
325
 
298
326
  # @sig (String) -> bool
299
- def excluded_from_eager_load?(abspath)
300
- eager_load_exclusions.member?(abspath)
327
+ private def excluded_from_eager_load?(abspath)
328
+ # Optimize this common use case.
329
+ return false if eager_load_exclusions.empty?
330
+
331
+ walk_up(abspath) do |abspath|
332
+ return true if eager_load_exclusions.member?(abspath)
333
+ return false if roots.key?(abspath)
334
+ end
335
+
336
+ false
301
337
  end
302
338
 
303
339
  # @sig (String) -> bool
304
- def collapse?(dir)
340
+ private def collapse?(dir)
305
341
  collapse_dirs.member?(dir)
306
342
  end
307
343
 
308
344
  # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
309
- def expand_paths(paths)
345
+ private def expand_paths(paths)
310
346
  paths.flatten.map! { |path| File.expand_path(path) }
311
347
  end
312
348
 
313
349
  # @sig (Array[String]) -> Array[String]
314
- def expand_glob_patterns(glob_patterns)
350
+ private def expand_glob_patterns(glob_patterns)
315
351
  # Note that Dir.glob works with regular file names just fine. That is,
316
352
  # glob patterns technically need no wildcards.
317
353
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
318
354
  end
319
355
 
320
356
  # @sig () -> void
321
- def recompute_ignored_paths
357
+ private def recompute_ignored_paths
322
358
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
323
359
  end
324
360
 
325
361
  # @sig () -> void
326
- def recompute_collapse_dirs
362
+ private def recompute_collapse_dirs
327
363
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
328
364
  end
329
365
  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 descendant 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,29 @@ 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
137
+
138
+ # @raise [NameError]
139
+ # @sig (Module, Symbol) -> Object
140
+ private def crem(parent, cname)
141
+ parent.__send__(:remove_const, cname)
142
+ end
129
143
  end