zeitwerk 2.6.1 → 2.6.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -106,15 +109,18 @@ module Zeitwerk::Loader::Config
106
109
  # @raise [Zeitwerk::Error]
107
110
  # @sig (String | Pathname, Module) -> void
108
111
  def push_dir(path, namespace: Object)
109
- # Note that Class < Module.
110
- unless namespace.is_a?(Module)
112
+ unless namespace.is_a?(Module) # Note that Class < Module.
111
113
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
112
114
  end
113
115
 
116
+ unless real_mod_name(namespace)
117
+ raise Zeitwerk::Error, "root namespaces cannot be anonymous"
118
+ end
119
+
114
120
  abspath = File.expand_path(path)
115
121
  if dir?(abspath)
116
122
  raise_if_conflicting_directory(abspath)
117
- root_dirs[abspath] = namespace
123
+ roots[abspath] = namespace
118
124
  else
119
125
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
120
126
  end
@@ -142,14 +148,24 @@ module Zeitwerk::Loader::Config
142
148
  # instead. Keys are the absolute paths of the root directories as strings,
143
149
  # values are their corresponding namespaces, class or module objects.
144
150
  #
151
+ # If `ignored` is falsey (default), ignored root directories are filtered out.
152
+ #
145
153
  # These are read-only collections, please add to them with `push_dir`.
146
154
  #
147
155
  # @sig () -> Array[String] | Hash[String, Module]
148
- def dirs(namespaces: false)
156
+ def dirs(namespaces: false, ignored: false)
149
157
  if namespaces
150
- root_dirs.clone
158
+ if ignored || ignored_paths.empty?
159
+ roots.clone
160
+ else
161
+ roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
162
+ end
151
163
  else
152
- root_dirs.keys
164
+ if ignored || ignored_paths.empty?
165
+ roots.keys
166
+ else
167
+ roots.keys.reject { |root_dir| ignored_path?(root_dir) }
168
+ end
153
169
  end.freeze
154
170
  end
155
171
 
@@ -273,57 +289,76 @@ module Zeitwerk::Loader::Config
273
289
  @logger = ->(msg) { puts msg }
274
290
  end
275
291
 
276
- # @private
292
+ # Returns true if the argument has been configured to be ignored, or is a
293
+ # descendant of an ignored directory.
294
+ #
277
295
  # @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 + "/"))
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)
281
303
  end
304
+
305
+ false
282
306
  end
283
307
 
284
- private
308
+ # @sig (String) -> bool
309
+ private def ignored_path?(abspath)
310
+ ignored_paths.member?(abspath)
311
+ end
285
312
 
286
313
  # @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)
314
+ private def actual_roots
315
+ roots.reject do |root_dir, _root_namespace|
316
+ !dir?(root_dir) || ignored_path?(root_dir)
290
317
  end
291
318
  end
292
319
 
293
320
  # @sig (String) -> bool
294
- def root_dir?(dir)
295
- root_dirs.key?(dir)
321
+ private def root_dir?(dir)
322
+ roots.key?(dir)
296
323
  end
297
324
 
298
325
  # @sig (String) -> bool
299
- def excluded_from_eager_load?(abspath)
300
- 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
301
336
  end
302
337
 
303
338
  # @sig (String) -> bool
304
- def collapse?(dir)
339
+ private def collapse?(dir)
305
340
  collapse_dirs.member?(dir)
306
341
  end
307
342
 
308
343
  # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
309
- def expand_paths(paths)
344
+ private def expand_paths(paths)
310
345
  paths.flatten.map! { |path| File.expand_path(path) }
311
346
  end
312
347
 
313
348
  # @sig (Array[String]) -> Array[String]
314
- def expand_glob_patterns(glob_patterns)
349
+ private def expand_glob_patterns(glob_patterns)
315
350
  # Note that Dir.glob works with regular file names just fine. That is,
316
351
  # glob patterns technically need no wildcards.
317
352
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
318
353
  end
319
354
 
320
355
  # @sig () -> void
321
- def recompute_ignored_paths
356
+ private def recompute_ignored_paths
322
357
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
323
358
  end
324
359
 
325
360
  # @sig () -> void
326
- def recompute_collapse_dirs
361
+ private def recompute_collapse_dirs
327
362
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
328
363
  end
329
364
  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