zeitwerk 2.2.0 → 2.8.2

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.
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'securerandom'
5
+
6
+ module Zeitwerk::Loader::Config
7
+ extend Zeitwerk::Internal
8
+ include Zeitwerk::RealModName
9
+
10
+ UNDEFINED = Object.new.freeze
11
+ private_constant :UNDEFINED
12
+
13
+ #: camelize(String, String) -> String
14
+ attr_accessor :inflector
15
+
16
+ #: call(String) -> void | debug(String) -> void | nil
17
+ attr_accessor :logger
18
+
19
+ # Absolute paths of the root directories, mapped to their respective root namespaces:
20
+ #
21
+ # '/Users/fxn/blog/app/channels' => Object,
22
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
23
+ # ...
24
+ #
25
+ # Stored in a hash to preserve order, easily handle duplicates, and have a
26
+ # fast lookup by directory.
27
+ #
28
+ # This is a private collection maintained by the loader. The public
29
+ # interface for it is `push_dir` and `dirs`.
30
+ #
31
+ #: Hash[String, Module]
32
+ attr_reader :roots
33
+ internal :roots
34
+
35
+ # Basename of files that define namespaces. For example, if `nsfile` is
36
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
37
+ #
38
+ #: String?
39
+ attr_reader :nsfile
40
+
41
+ # Absolute paths of files, directories, or glob patterns to be ignored.
42
+ #
43
+ #: Set[String]
44
+ attr_reader :ignored_glob_patterns
45
+ private :ignored_glob_patterns
46
+
47
+ # The actual collection of absolute file and directory names at the time the
48
+ # ignored glob patterns were expanded. Computed on setup, and recomputed on
49
+ # reload.
50
+ #
51
+ #: Set[String]
52
+ attr_reader :ignored_paths
53
+ private :ignored_paths
54
+
55
+ # Absolute paths of directories or glob patterns to be collapsed.
56
+ #
57
+ #: Set[String]
58
+ attr_reader :collapse_glob_patterns
59
+ private :collapse_glob_patterns
60
+
61
+ # The actual collection of absolute directory names at the time the collapse
62
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
63
+ #
64
+ #: Set[String]
65
+ attr_reader :collapse_dirs
66
+ private :collapse_dirs
67
+
68
+ # Absolute paths of directories that are parents of collapsed directories.
69
+ # This is a cache to optimize some tree walks. Computed on setup and
70
+ # recomputed on reload.
71
+ #
72
+ #: Set[String]
73
+ attr_reader :collapse_parents
74
+ private :collapse_parents
75
+
76
+ # Absolute paths of files or directories not to be eager loaded.
77
+ #
78
+ #: Set[String]
79
+ attr_reader :eager_load_exclusions
80
+ private :eager_load_exclusions
81
+
82
+ # User-oriented callbacks to be fired on setup and on reload.
83
+ #
84
+ #: Array[{ () -> void }]
85
+ attr_reader :on_setup_callbacks
86
+ private :on_setup_callbacks
87
+
88
+ # User-oriented callbacks to be fired when a constant is loaded.
89
+ #
90
+ #: Hash[String, Array[{ (top, String) -> void }]]
91
+ #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
92
+ attr_reader :on_load_callbacks
93
+ private :on_load_callbacks
94
+
95
+ # User-oriented callbacks to be fired before constants are removed.
96
+ #
97
+ #: Hash[String, Array[{ (top, String) -> void }]]
98
+ #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
99
+ attr_reader :on_unload_callbacks
100
+ private :on_unload_callbacks
101
+
102
+ #: () -> void
103
+ def initialize
104
+ @inflector = Zeitwerk::Inflector.new
105
+ @logger = self.class.default_logger
106
+ @tag = SecureRandom.hex(3)
107
+ @initialized_at = Time.now
108
+ @roots = {}
109
+ @nsfile = nil
110
+ @ignored_glob_patterns = Set.new
111
+ @ignored_paths = Set.new
112
+ @collapse_glob_patterns = Set.new
113
+ @collapse_dirs = Set.new
114
+ @collapse_parents = Set.new
115
+ @eager_load_exclusions = Set.new
116
+ @reloading_enabled = false
117
+ @on_setup_callbacks = []
118
+ @on_load_callbacks = {}
119
+ @on_unload_callbacks = {}
120
+ end
121
+
122
+ # Pushes `path` to the list of root directories.
123
+ #
124
+ # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
125
+ # the same process already manages that directory or one of its ascendants or
126
+ # descendants.
127
+ #
128
+ #: (String | Pathname, namespace: Module) -> void ! Zeitwerk::Error
129
+ def push_dir(path, namespace: Object)
130
+ unless namespace.is_a?(Module) # Note that Class < Module.
131
+ raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
132
+ end
133
+
134
+ unless real_mod_name(namespace)
135
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
136
+ end
137
+
138
+ abspath = File.expand_path(path)
139
+ if @fs.dir?(abspath)
140
+ raise_if_conflicting_root_dir(abspath)
141
+ roots[abspath] = namespace
142
+ else
143
+ raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
144
+ end
145
+ end
146
+
147
+ # Returns the loader's tag.
148
+ #
149
+ # Implemented as a method instead of via attr_reader for symmetry with the
150
+ # writer below.
151
+ #
152
+ #: () -> String
153
+ def tag
154
+ @tag
155
+ end
156
+
157
+ # Sets a tag for the loader, useful for logging.
158
+ #
159
+ #: (to_s() -> String) -> void
160
+ def tag=(tag)
161
+ @tag = tag.to_s
162
+ end
163
+
164
+ #: (String?) -> void ! TypeError, ArgumentError
165
+ def nsfile=(nsfile)
166
+ unless nsfile.nil?
167
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
168
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
169
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
170
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
171
+ end
172
+
173
+ @nsfile = nsfile
174
+ end
175
+
176
+ # If `namespaces` is falsey (default), returns an array with the absolute
177
+ # paths of the root directories as strings. If truthy, returns a hash table
178
+ # instead. Keys are the absolute paths of the root directories as strings,
179
+ # values are their corresponding namespaces, class or module objects.
180
+ #
181
+ # If `ignored` is falsey (default), ignored root directories are filtered out.
182
+ #
183
+ # These are read-only collections, please add to them with `push_dir`.
184
+ #
185
+ #: (?namespaces: boolish, ?ignored: boolish) -> Array[String] | Hash[String, Module]
186
+ def dirs(namespaces: false, ignored: false)
187
+ if namespaces
188
+ if ignored || ignored_paths.empty?
189
+ roots.clone
190
+ else
191
+ roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
192
+ end
193
+ else
194
+ if ignored || ignored_paths.empty?
195
+ roots.keys
196
+ else
197
+ roots.keys.reject { |root_dir| ignored_path?(root_dir) }
198
+ end
199
+ end.freeze
200
+ end
201
+
202
+ # You need to call this method before setup in order to be able to reload.
203
+ # There is no way to undo this, either you want to reload or you don't.
204
+ #
205
+ #: () -> void ! Zeitwerk::Error
206
+ def enable_reloading
207
+ mutex.synchronize do
208
+ break if @reloading_enabled
209
+
210
+ if @setup
211
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
212
+ else
213
+ @reloading_enabled = true
214
+ end
215
+ end
216
+ end
217
+
218
+ #: () -> bool
219
+ def reloading_enabled?
220
+ @reloading_enabled
221
+ end
222
+
223
+ # Let eager load ignore the given files or directories. The constants defined
224
+ # in those files are still autoloadable.
225
+ #
226
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
227
+ def do_not_eager_load(*paths)
228
+ mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
229
+ end
230
+
231
+ # Configure files, directories, or glob patterns to be totally ignored.
232
+ #
233
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
234
+ def ignore(*glob_patterns)
235
+ glob_patterns = expand_paths(glob_patterns)
236
+ mutex.synchronize do
237
+ ignored_glob_patterns.merge(glob_patterns)
238
+ ignored_paths.merge(expand_glob_patterns(glob_patterns))
239
+ end
240
+ end
241
+
242
+ # Configure directories or glob patterns to be collapsed.
243
+ #
244
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
245
+ def collapse(*glob_patterns)
246
+ glob_patterns = expand_paths(glob_patterns)
247
+ mutex.synchronize do
248
+ collapse_glob_patterns.merge(glob_patterns)
249
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
250
+ collapse_dirs.merge(new_collapse_dirs)
251
+ new_collapse_dirs.each do |dir|
252
+ collapse_parents << File.dirname(dir)
253
+ end
254
+ end
255
+ end
256
+
257
+ # Configure a block to be called after setup and on each reload.
258
+ # If setup was already done, the block runs immediately.
259
+ #
260
+ #: () { () -> void } -> void
261
+ def on_setup(&block)
262
+ mutex.synchronize do
263
+ on_setup_callbacks << block
264
+ block.call if @setup
265
+ end
266
+ end
267
+
268
+ # Configure a block to be invoked once a certain constant path is loaded.
269
+ # Supports multiple callbacks, and if there are many, they are executed in
270
+ # the order in which they were defined.
271
+ #
272
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
273
+ # klass.endpoint = 'https://api.dev'
274
+ # end
275
+ #
276
+ # Can also be configured for any constant loaded:
277
+ #
278
+ # loader.on_load do |cpath, value, abspath|
279
+ # # ...
280
+ # end
281
+ #
282
+ #: (String) { (top, String) -> void } -> void ! TypeError | NameError
283
+ #| { (String, top, String) -> void } -> void
284
+ def on_load(cpath = UNDEFINED, &block)
285
+ key = if cpath.equal?(UNDEFINED)
286
+ :ANY
287
+ elsif !cpath.is_a?(String)
288
+ raise TypeError, 'on_load only accepts strings'
289
+ else
290
+ @cpv.validate!(cpath)
291
+ end
292
+
293
+ mutex.synchronize do
294
+ (on_load_callbacks[key] ||= []) << block
295
+ end
296
+ end
297
+
298
+ # Configure a block to be invoked right before a certain constant is removed.
299
+ # Supports multiple callbacks, and if there are many, they are executed in the
300
+ # order in which they were defined.
301
+ #
302
+ # loader.on_unload('Country') do |klass, _abspath|
303
+ # klass.clear_cache
304
+ # end
305
+ #
306
+ # Can also be configured for any removed constant:
307
+ #
308
+ # loader.on_unload do |cpath, value, abspath|
309
+ # # ...
310
+ # end
311
+ #
312
+ #: (String) { (top, String) -> void } -> void ! TypeError | NameError
313
+ #| { (String, top, String) -> void } -> void
314
+ def on_unload(cpath = UNDEFINED, &block)
315
+ key = if cpath.equal?(UNDEFINED)
316
+ :ANY
317
+ elsif !cpath.is_a?(String)
318
+ raise TypeError, 'on_unload only accepts strings'
319
+ else
320
+ @cpv.validate!(cpath)
321
+ end
322
+
323
+ mutex.synchronize do
324
+ (on_unload_callbacks[key] ||= []) << block
325
+ end
326
+ end
327
+
328
+ # Logs to `$stdout`, handy shortcut for debugging.
329
+ #
330
+ #: () -> void
331
+ def log!
332
+ @logger = ->(msg) { puts msg }
333
+ end
334
+
335
+ # Returns true if the argument has been configured to be ignored, or is a
336
+ # descendant of an ignored directory.
337
+ #
338
+ #: (String) -> bool
339
+ internal def ignores?(abspath)
340
+ # Common use case.
341
+ return false if ignored_paths.empty?
342
+
343
+ @fs.walk_up(abspath) do |path|
344
+ return true if ignored_path?(path)
345
+ return false if root_dir?(path)
346
+ end
347
+
348
+ false
349
+ end
350
+
351
+ #: (String) -> bool
352
+ internal def ignored_path?(abspath)
353
+ ignored_paths.member?(abspath)
354
+ end
355
+
356
+ #: () -> Array[String]
357
+ private def actual_roots
358
+ roots.reject do |root_dir, _root_namespace|
359
+ !@fs.dir?(root_dir) || ignored_path?(root_dir)
360
+ end
361
+ end
362
+
363
+ #: (String) -> bool
364
+ internal def root_dir?(dir)
365
+ roots.key?(dir)
366
+ end
367
+
368
+ #: (String) -> bool
369
+ internal def collapse?(dir)
370
+ collapse_dirs.member?(dir)
371
+ end
372
+
373
+ #: (String) -> bool
374
+ internal def collapse_parent?(dir)
375
+ collapse_parents.member?(dir)
376
+ end
377
+
378
+ #: (String) -> bool
379
+ private def excluded_from_eager_load?(abspath)
380
+ # Optimize this common use case.
381
+ return false if eager_load_exclusions.empty?
382
+
383
+ @fs.walk_up(abspath) do |path|
384
+ return true if eager_load_exclusions.member?(path)
385
+ return false if root_dir?(path)
386
+ end
387
+
388
+ false
389
+ end
390
+
391
+ #: (String | Pathname | Array[String | Pathname]) -> Array[String]
392
+ private def expand_paths(paths)
393
+ paths.flatten.map! { |path| File.expand_path(path) }
394
+ end
395
+
396
+ #: (Array[String]) -> Array[String]
397
+ private def expand_glob_patterns(glob_patterns)
398
+ # Note that Dir.glob works with regular file names just fine. That is,
399
+ # glob patterns technically need no wildcards.
400
+ glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
401
+ end
402
+
403
+ #: () -> void
404
+ private def recompute_ignored_paths
405
+ ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
406
+ end
407
+
408
+ #: () -> void
409
+ private def recompute_collapse_dirs
410
+ collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
411
+ end
412
+
413
+ #: () -> void
414
+ private def recompute_collapse_parents
415
+ collapse_parents.clear
416
+ collapse_dirs.each do |dir|
417
+ collapse_parents << File.dirname(dir)
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,17 @@
1
+ # @private
2
+ class Zeitwerk::Loader::ConstantPathValidator # :nodoc
3
+ CNAME_VALIDATOR = Module.new.freeze #: Module
4
+ private_constant :CNAME_VALIDATOR
5
+
6
+ # Technically, this validation works with symbols, but API boundary restricts
7
+ # input to strings, so we assume strings, and we test strings.
8
+ #
9
+ #: (String) -> String ! NameError
10
+ def validate!(possible_cpath)
11
+ # We do this before validating because as of this writing, TruffleRuby
12
+ # raises TypeError if the argument has leading colons.
13
+ possible_cpath = possible_cpath.delete_prefix('::')
14
+ CNAME_VALIDATOR.const_defined?(possible_cpath, false)
15
+ possible_cpath
16
+ end
17
+ end
@@ -0,0 +1,226 @@
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.
4
+ #
5
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
6
+ # files and directories with `do_not_eager_load`, and that can be overridden
7
+ # passing `force: true`.
8
+ #
9
+ #: (?force: boolish) -> void
10
+ def eager_load(force: false)
11
+ mutex.synchronize do
12
+ break if @eager_loaded
13
+ raise Zeitwerk::SetupRequired unless @setup
14
+
15
+ log { 'eager load start' }
16
+
17
+ actual_roots.each do |root_dir, root_namespace|
18
+ actual_eager_load_dir(root_dir, root_namespace, force: force)
19
+ end
20
+
21
+ autoloaded_dirs.each do |autoloaded_dir|
22
+ Zeitwerk::Registry.autoloads.unregister(autoloaded_dir)
23
+ end
24
+ autoloaded_dirs.clear
25
+
26
+ @eager_loaded = true
27
+
28
+ log { 'eager load end' }
29
+ end
30
+ end
31
+
32
+ #: (String | Pathname) -> void
33
+ def eager_load_dir(path)
34
+ raise Zeitwerk::SetupRequired unless @setup
35
+
36
+ abspath = File.expand_path(path)
37
+
38
+ raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
39
+
40
+ paths = []
41
+
42
+ root_namespace = nil
43
+ @fs.walk_up(abspath) do |dir|
44
+ return if ignored_path?(dir)
45
+ return if eager_load_exclusions.member?(dir)
46
+
47
+ break if root_namespace = roots[dir]
48
+
49
+ basename = File.basename(dir)
50
+ return if @fs.hidden?(basename)
51
+
52
+ paths << [basename, dir] unless collapse?(dir)
53
+ end
54
+
55
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
56
+
57
+ return if @eager_loaded
58
+
59
+ namespace = root_namespace
60
+ paths.reverse_each do |basename, dir|
61
+ cname = cname_for(basename, dir)
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
+ #: (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 Object.equal?(mod)
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
+ #: (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 !@fs.rb_extension?(abspath)
121
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
122
+
123
+ file_basename = File.basename(abspath)
124
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
125
+
126
+ root_namespace = nil
127
+ paths = []
128
+
129
+ @fs.walk_up(File.dirname(abspath)) do |dir|
130
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
131
+
132
+ break if root_namespace = roots[dir]
133
+
134
+ basename = File.basename(dir)
135
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
136
+
137
+ paths << [basename, dir] unless collapse?(dir)
138
+ end
139
+
140
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
141
+
142
+ namespace = root_namespace
143
+ paths.reverse_each do |basename, dir|
144
+ cname = cname_for(basename, dir)
145
+ namespace = namespace.const_get(cname, false)
146
+ end
147
+
148
+ if file_basename == @nsfile
149
+ namespace
150
+ elsif shadowed_file?(abspath)
151
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
152
+ else
153
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
154
+ namespace.const_get(cname, false)
155
+ end
156
+ end
157
+
158
+ # The caller is responsible for making sure `namespace` is the namespace that
159
+ # corresponds to `dir`.
160
+ #
161
+ #: (String, Module, ?force: boolish) -> void
162
+ private def actual_eager_load_dir(dir, namespace, force: false)
163
+ honour_exclusions = !force
164
+ return if honour_exclusions && excluded_from_eager_load?(dir)
165
+
166
+ log { "eager load directory #{dir} start" }
167
+
168
+ queue = [[dir, namespace]]
169
+ while (current_dir, namespace = queue.shift)
170
+ @fs.ls(current_dir) do |basename, abspath, ftype|
171
+ next if honour_exclusions && eager_load_exclusions.member?(abspath)
172
+
173
+ if ftype == :file
174
+ if (cref = autoloads[abspath])
175
+ cref.get
176
+ end
177
+ else
178
+ cname = cname_for(basename, abspath)
179
+ queue << [abspath, namespace.const_get(cname, false)]
180
+ end
181
+ end
182
+ end
183
+
184
+ log { "eager load directory #{dir} end" }
185
+ end
186
+
187
+ # In order to invoke this method, the caller has to ensure `child` is a
188
+ # strict namespace descendant of `root_namespace`.
189
+ #
190
+ #: (Module, String, String, Module) -> void
191
+ private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
192
+ suffix = child_name
193
+ unless root_namespace.equal?(Object)
194
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
195
+ end
196
+
197
+ # These directories are at the same namespace level, there may be more if
198
+ # we find collapsed ones. As we scan, we look for matches for the first
199
+ # segment, and store them in `next_dirs`. If there are any, we look for
200
+ # the next segments in those matches. Repeat.
201
+ #
202
+ # If we exhaust the search locating directories that match all segments,
203
+ # we just need to eager load those ones.
204
+ dirs = [root_dir]
205
+ next_dirs = []
206
+
207
+ suffix.split('::').each do |segment|
208
+ while (dir = dirs.shift)
209
+ @fs.ls(dir) do |basename, abspath, ftype|
210
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
211
+ next_dirs << abspath
212
+ end
213
+ end
214
+ end
215
+
216
+ return if next_dirs.empty?
217
+
218
+ dirs.replace(next_dirs)
219
+ next_dirs.clear
220
+ end
221
+
222
+ dirs.each do |dir|
223
+ actual_eager_load_dir(dir, child)
224
+ end
225
+ end
226
+ end