zeitwerk 2.6.8 → 2.7.5

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.
@@ -7,10 +7,10 @@ module Zeitwerk::Loader::Config
7
7
  extend Zeitwerk::Internal
8
8
  include Zeitwerk::RealModName
9
9
 
10
- # @sig #camelize
10
+ #: camelize(String, String) -> String
11
11
  attr_accessor :inflector
12
12
 
13
- # @sig #call | #debug | nil
13
+ #: call(String) -> void | debug(String) -> void | nil
14
14
  attr_accessor :logger
15
15
 
16
16
  # Absolute paths of the root directories, mapped to their respective root namespaces:
@@ -25,14 +25,13 @@ module Zeitwerk::Loader::Config
25
25
  # This is a private collection maintained by the loader. The public
26
26
  # interface for it is `push_dir` and `dirs`.
27
27
  #
28
- # @sig Hash[String, Module]
28
+ #: Hash[String, Module]
29
29
  attr_reader :roots
30
30
  internal :roots
31
31
 
32
- # Absolute paths of files, directories, or glob patterns to be totally
33
- # ignored.
32
+ # Absolute paths of files, directories, or glob patterns to be ignored.
34
33
  #
35
- # @sig Set[String]
34
+ #: Set[String]
36
35
  attr_reader :ignored_glob_patterns
37
36
  private :ignored_glob_patterns
38
37
 
@@ -40,46 +39,46 @@ module Zeitwerk::Loader::Config
40
39
  # ignored glob patterns were expanded. Computed on setup, and recomputed on
41
40
  # reload.
42
41
  #
43
- # @sig Set[String]
42
+ #: Set[String]
44
43
  attr_reader :ignored_paths
45
44
  private :ignored_paths
46
45
 
47
46
  # Absolute paths of directories or glob patterns to be collapsed.
48
47
  #
49
- # @sig Set[String]
48
+ #: Set[String]
50
49
  attr_reader :collapse_glob_patterns
51
50
  private :collapse_glob_patterns
52
51
 
53
52
  # The actual collection of absolute directory names at the time the collapse
54
53
  # glob patterns were expanded. Computed on setup, and recomputed on reload.
55
54
  #
56
- # @sig Set[String]
55
+ #: Set[String]
57
56
  attr_reader :collapse_dirs
58
57
  private :collapse_dirs
59
58
 
60
59
  # Absolute paths of files or directories not to be eager loaded.
61
60
  #
62
- # @sig Set[String]
61
+ #: Set[String]
63
62
  attr_reader :eager_load_exclusions
64
63
  private :eager_load_exclusions
65
64
 
66
65
  # User-oriented callbacks to be fired on setup and on reload.
67
66
  #
68
- # @sig Array[{ () -> void }]
67
+ #: Array[{ () -> void }]
69
68
  attr_reader :on_setup_callbacks
70
69
  private :on_setup_callbacks
71
70
 
72
71
  # User-oriented callbacks to be fired when a constant is loaded.
73
72
  #
74
- # @sig Hash[String, Array[{ (Object, String) -> void }]]
75
- # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
73
+ #: Hash[String, Array[{ (top, String) -> void }]]
74
+ #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
76
75
  attr_reader :on_load_callbacks
77
76
  private :on_load_callbacks
78
77
 
79
78
  # User-oriented callbacks to be fired before constants are removed.
80
79
  #
81
- # @sig Hash[String, Array[{ (Object, String) -> void }]]
82
- # Hash[Symbol, Array[{ (String, Object, String) -> void }]]
80
+ #: Hash[String, Array[{ (top, String) -> void }]]
81
+ #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
83
82
  attr_reader :on_unload_callbacks
84
83
  private :on_unload_callbacks
85
84
 
@@ -106,8 +105,7 @@ module Zeitwerk::Loader::Config
106
105
  # the same process already manages that directory or one of its ascendants or
107
106
  # descendants.
108
107
  #
109
- # @raise [Zeitwerk::Error]
110
- # @sig (String | Pathname, Module) -> void
108
+ #: (String | Pathname, namespace: Module) -> void ! Zeitwerk::Error
111
109
  def push_dir(path, namespace: Object)
112
110
  unless namespace.is_a?(Module) # Note that Class < Module.
113
111
  raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
@@ -118,8 +116,8 @@ module Zeitwerk::Loader::Config
118
116
  end
119
117
 
120
118
  abspath = File.expand_path(path)
121
- if dir?(abspath)
122
- raise_if_conflicting_directory(abspath)
119
+ if @fs.dir?(abspath)
120
+ raise_if_conflicting_root_dir(abspath)
123
121
  roots[abspath] = namespace
124
122
  else
125
123
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
@@ -131,14 +129,14 @@ module Zeitwerk::Loader::Config
131
129
  # Implemented as a method instead of via attr_reader for symmetry with the
132
130
  # writer below.
133
131
  #
134
- # @sig () -> String
132
+ #: () -> String
135
133
  def tag
136
134
  @tag
137
135
  end
138
136
 
139
137
  # Sets a tag for the loader, useful for logging.
140
138
  #
141
- # @sig (#to_s) -> void
139
+ #: (to_s() -> String) -> void
142
140
  def tag=(tag)
143
141
  @tag = tag.to_s
144
142
  end
@@ -152,7 +150,7 @@ module Zeitwerk::Loader::Config
152
150
  #
153
151
  # These are read-only collections, please add to them with `push_dir`.
154
152
  #
155
- # @sig () -> Array[String] | Hash[String, Module]
153
+ #: (?namespaces: boolish, ?ignored: boolish) -> Array[String] | Hash[String, Module]
156
154
  def dirs(namespaces: false, ignored: false)
157
155
  if namespaces
158
156
  if ignored || ignored_paths.empty?
@@ -172,8 +170,7 @@ module Zeitwerk::Loader::Config
172
170
  # You need to call this method before setup in order to be able to reload.
173
171
  # There is no way to undo this, either you want to reload or you don't.
174
172
  #
175
- # @raise [Zeitwerk::Error]
176
- # @sig () -> void
173
+ #: () -> void ! Zeitwerk::Error
177
174
  def enable_reloading
178
175
  mutex.synchronize do
179
176
  break if @reloading_enabled
@@ -186,7 +183,7 @@ module Zeitwerk::Loader::Config
186
183
  end
187
184
  end
188
185
 
189
- # @sig () -> bool
186
+ #: () -> bool
190
187
  def reloading_enabled?
191
188
  @reloading_enabled
192
189
  end
@@ -194,14 +191,14 @@ module Zeitwerk::Loader::Config
194
191
  # Let eager load ignore the given files or directories. The constants defined
195
192
  # in those files are still autoloadable.
196
193
  #
197
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
194
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
198
195
  def do_not_eager_load(*paths)
199
196
  mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
200
197
  end
201
198
 
202
199
  # Configure files, directories, or glob patterns to be totally ignored.
203
200
  #
204
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
201
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
205
202
  def ignore(*glob_patterns)
206
203
  glob_patterns = expand_paths(glob_patterns)
207
204
  mutex.synchronize do
@@ -212,7 +209,7 @@ module Zeitwerk::Loader::Config
212
209
 
213
210
  # Configure directories or glob patterns to be collapsed.
214
211
  #
215
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
212
+ #: (*(String | Pathname | Array[String | Pathname])) -> void
216
213
  def collapse(*glob_patterns)
217
214
  glob_patterns = expand_paths(glob_patterns)
218
215
  mutex.synchronize do
@@ -224,7 +221,7 @@ module Zeitwerk::Loader::Config
224
221
  # Configure a block to be called after setup and on each reload.
225
222
  # If setup was already done, the block runs immediately.
226
223
  #
227
- # @sig () { () -> void } -> void
224
+ #: () { () -> void } -> void
228
225
  def on_setup(&block)
229
226
  mutex.synchronize do
230
227
  on_setup_callbacks << block
@@ -246,9 +243,7 @@ module Zeitwerk::Loader::Config
246
243
  # # ...
247
244
  # end
248
245
  #
249
- # @raise [TypeError]
250
- # @sig (String) { (Object, String) -> void } -> void
251
- # (:ANY) { (String, Object, String) -> void } -> void
246
+ #: (String?) { (top, String) -> void } -> void ! TypeError
252
247
  def on_load(cpath = :ANY, &block)
253
248
  raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
254
249
 
@@ -271,9 +266,7 @@ module Zeitwerk::Loader::Config
271
266
  # # ...
272
267
  # end
273
268
  #
274
- # @raise [TypeError]
275
- # @sig (String) { (Object) -> void } -> void
276
- # (:ANY) { (String, Object) -> void } -> void
269
+ #: (String?) { (top, String) -> void } -> void ! TypeError
277
270
  def on_unload(cpath = :ANY, &block)
278
271
  raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
279
272
 
@@ -284,7 +277,7 @@ module Zeitwerk::Loader::Config
284
277
 
285
278
  # Logs to `$stdout`, handy shortcut for debugging.
286
279
  #
287
- # @sig () -> void
280
+ #: () -> void
288
281
  def log!
289
282
  @logger = ->(msg) { puts msg }
290
283
  end
@@ -292,72 +285,72 @@ module Zeitwerk::Loader::Config
292
285
  # Returns true if the argument has been configured to be ignored, or is a
293
286
  # descendant of an ignored directory.
294
287
  #
295
- # @sig (String) -> bool
288
+ #: (String) -> bool
296
289
  internal def ignores?(abspath)
297
290
  # Common use case.
298
291
  return false if ignored_paths.empty?
299
292
 
300
- walk_up(abspath) do |path|
293
+ @fs.walk_up(abspath) do |path|
301
294
  return true if ignored_path?(path)
302
- return false if roots.key?(path)
295
+ return false if root_dir?(path)
303
296
  end
304
297
 
305
298
  false
306
299
  end
307
300
 
308
- # @sig (String) -> bool
309
- private def ignored_path?(abspath)
301
+ #: (String) -> bool
302
+ internal def ignored_path?(abspath)
310
303
  ignored_paths.member?(abspath)
311
304
  end
312
305
 
313
- # @sig () -> Array[String]
306
+ #: () -> Array[String]
314
307
  private def actual_roots
315
308
  roots.reject do |root_dir, _root_namespace|
316
- !dir?(root_dir) || ignored_path?(root_dir)
309
+ !@fs.dir?(root_dir) || ignored_path?(root_dir)
317
310
  end
318
311
  end
319
312
 
320
- # @sig (String) -> bool
321
- private def root_dir?(dir)
313
+ #: (String) -> bool
314
+ internal def root_dir?(dir)
322
315
  roots.key?(dir)
323
316
  end
324
317
 
325
- # @sig (String) -> bool
318
+ #: (String) -> bool
326
319
  private def excluded_from_eager_load?(abspath)
327
320
  # Optimize this common use case.
328
321
  return false if eager_load_exclusions.empty?
329
322
 
330
- walk_up(abspath) do |path|
323
+ @fs.walk_up(abspath) do |path|
331
324
  return true if eager_load_exclusions.member?(path)
332
- return false if roots.key?(path)
325
+ return false if root_dir?(path)
333
326
  end
334
327
 
335
328
  false
336
329
  end
337
330
 
338
- # @sig (String) -> bool
331
+ #: (String) -> bool
339
332
  private def collapse?(dir)
340
333
  collapse_dirs.member?(dir)
341
334
  end
342
335
 
343
- # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
336
+ #: (String | Pathname | Array[String | Pathname]) -> Array[String]
344
337
  private def expand_paths(paths)
345
338
  paths.flatten.map! { |path| File.expand_path(path) }
346
339
  end
347
340
 
348
- # @sig (Array[String]) -> Array[String]
341
+ #: (Array[String]) -> Array[String]
349
342
  private def expand_glob_patterns(glob_patterns)
350
343
  # Note that Dir.glob works with regular file names just fine. That is,
351
344
  # glob patterns technically need no wildcards.
352
345
  glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
353
346
  end
354
347
 
355
- # @sig () -> void
348
+ #: () -> void
356
349
  private def recompute_ignored_paths
357
350
  ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
358
351
  end
359
352
 
360
- # @sig () -> void
353
+ #: () -> void
361
354
  private def recompute_collapse_dirs
362
355
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
363
356
  end
@@ -5,50 +5,50 @@ module Zeitwerk::Loader::EagerLoad
5
5
  # specific files and directories with `do_not_eager_load`, and that can be
6
6
  # overridden passing `force: true`.
7
7
  #
8
- # @sig (true | false) -> void
8
+ #: (?force: boolish) -> void
9
9
  def eager_load(force: false)
10
10
  mutex.synchronize do
11
11
  break if @eager_loaded
12
12
  raise Zeitwerk::SetupRequired unless @setup
13
13
 
14
- log("eager load start") if logger
14
+ log { "eager load start" }
15
15
 
16
16
  actual_roots.each do |root_dir, root_namespace|
17
17
  actual_eager_load_dir(root_dir, root_namespace, force: force)
18
18
  end
19
19
 
20
20
  autoloaded_dirs.each do |autoloaded_dir|
21
- Zeitwerk::Registry.unregister_autoload(autoloaded_dir)
21
+ Zeitwerk::Registry.autoloads.unregister(autoloaded_dir)
22
22
  end
23
23
  autoloaded_dirs.clear
24
24
 
25
25
  @eager_loaded = true
26
26
 
27
- log("eager load end") if logger
27
+ log { "eager load end" }
28
28
  end
29
29
  end
30
30
 
31
- # @sig (String | Pathname) -> void
31
+ #: (String | Pathname) -> void
32
32
  def eager_load_dir(path)
33
33
  raise Zeitwerk::SetupRequired unless @setup
34
34
 
35
35
  abspath = File.expand_path(path)
36
36
 
37
- raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
37
+ raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
38
38
 
39
- cnames = []
39
+ paths = []
40
40
 
41
41
  root_namespace = nil
42
- walk_up(abspath) do |dir|
42
+ @fs.walk_up(abspath) do |dir|
43
43
  return if ignored_path?(dir)
44
44
  return if eager_load_exclusions.member?(dir)
45
45
 
46
46
  break if root_namespace = roots[dir]
47
47
 
48
- unless collapse?(dir)
49
- basename = File.basename(dir)
50
- cnames << inflector.camelize(basename, dir).to_sym
51
- end
48
+ basename = File.basename(dir)
49
+ return if @fs.hidden?(basename)
50
+
51
+ paths << [basename, dir] unless collapse?(dir)
52
52
  end
53
53
 
54
54
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
@@ -56,11 +56,12 @@ module Zeitwerk::Loader::EagerLoad
56
56
  return if @eager_loaded
57
57
 
58
58
  namespace = root_namespace
59
- cnames.reverse_each do |cname|
59
+ paths.reverse_each do |basename, dir|
60
+ cname = cname_for(basename, dir)
60
61
  # Can happen if there are no Ruby files. This is not an error condition,
61
62
  # the directory is actually managed. Could have Ruby files later.
62
- return unless cdef?(namespace, cname)
63
- namespace = cget(namespace, cname)
63
+ return unless namespace.const_defined?(cname, false)
64
+ namespace = namespace.const_get(cname, false)
64
65
  end
65
66
 
66
67
  # A shortcircuiting test depends on the invocation of this method. Please
@@ -68,7 +69,7 @@ module Zeitwerk::Loader::EagerLoad
68
69
  actual_eager_load_dir(abspath, namespace)
69
70
  end
70
71
 
71
- # @sig (Module) -> void
72
+ #: (Module) -> void
72
73
  def eager_load_namespace(mod)
73
74
  raise Zeitwerk::SetupRequired unless @setup
74
75
 
@@ -82,7 +83,7 @@ module Zeitwerk::Loader::EagerLoad
82
83
  return unless mod_name
83
84
 
84
85
  actual_roots.each do |root_dir, root_namespace|
85
- if mod.equal?(Object)
86
+ if Object.equal?(mod)
86
87
  # A shortcircuiting test depends on the invocation of this method.
87
88
  # Please keep them in sync if refactored.
88
89
  actual_eager_load_dir(root_dir, root_namespace)
@@ -110,82 +111,83 @@ module Zeitwerk::Loader::EagerLoad
110
111
  # The method is implemented as `constantize` for files, in a sense, to be able
111
112
  # to descend orderly and make sure the file is loadable.
112
113
  #
113
- # @sig (String | Pathname) -> void
114
+ #: (String | Pathname) -> void
114
115
  def load_file(path)
115
116
  abspath = File.expand_path(path)
116
117
 
117
118
  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 not a Ruby file") if !@fs.rb_extension?(abspath)
119
120
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
120
121
 
121
- basename = File.basename(abspath, ".rb")
122
- base_cname = inflector.camelize(basename, abspath).to_sym
122
+ file_basename = File.basename(abspath, ".rb")
123
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
123
124
 
124
125
  root_namespace = nil
125
- cnames = []
126
+ paths = []
126
127
 
127
- walk_up(File.dirname(abspath)) do |dir|
128
+ @fs.walk_up(File.dirname(abspath)) do |dir|
128
129
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
129
130
 
130
131
  break if root_namespace = roots[dir]
131
132
 
132
- unless collapse?(dir)
133
- basename = File.basename(dir)
134
- cnames << inflector.camelize(basename, dir).to_sym
135
- end
133
+ basename = File.basename(dir)
134
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
135
+
136
+ paths << [basename, dir] unless collapse?(dir)
136
137
  end
137
138
 
138
139
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
139
140
 
141
+ base_cname = cname_for(file_basename, abspath)
142
+
140
143
  namespace = root_namespace
141
- cnames.reverse_each do |cname|
142
- namespace = cget(namespace, cname)
144
+ paths.reverse_each do |basename, dir|
145
+ cname = cname_for(basename, dir)
146
+ namespace = namespace.const_get(cname, false)
143
147
  end
144
148
 
145
149
  raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
146
150
 
147
- cget(namespace, base_cname)
151
+ namespace.const_get(base_cname, false)
148
152
  end
149
153
 
150
154
  # The caller is responsible for making sure `namespace` is the namespace that
151
155
  # corresponds to `dir`.
152
156
  #
153
- # @sig (String, Module, Boolean) -> void
157
+ #: (String, Module, ?force: boolish) -> void
154
158
  private def actual_eager_load_dir(dir, namespace, force: false)
155
159
  honour_exclusions = !force
156
160
  return if honour_exclusions && excluded_from_eager_load?(dir)
157
161
 
158
- log("eager load directory #{dir} start") if logger
162
+ log { "eager load directory #{dir} start" }
159
163
 
160
164
  queue = [[dir, namespace]]
161
- while to_eager_load = queue.shift
162
- dir, namespace = to_eager_load
163
-
164
- ls(dir) do |basename, abspath|
165
+ while (current_dir, namespace = queue.shift)
166
+ @fs.ls(current_dir) do |basename, abspath, ftype|
165
167
  next if honour_exclusions && eager_load_exclusions.member?(abspath)
166
168
 
167
- if ruby?(abspath)
168
- if (cref = autoloads[abspath]) && !shadowed_file?(abspath)
169
- cget(*cref)
169
+ if ftype == :file
170
+ if (cref = autoloads[abspath])
171
+ cref.get
170
172
  end
171
173
  else
172
174
  if collapse?(abspath)
173
175
  queue << [abspath, namespace]
174
176
  else
175
- cname = inflector.camelize(basename, abspath).to_sym
176
- queue << [abspath, cget(namespace, cname)]
177
+ cname = cname_for(basename, abspath)
178
+ queue << [abspath, namespace.const_get(cname, false)]
177
179
  end
178
180
  end
179
181
  end
180
182
  end
181
183
 
182
- log("eager load directory #{dir} end") if logger
184
+ log { "eager load directory #{dir} end" }
183
185
  end
184
186
 
185
187
  # In order to invoke this method, the caller has to ensure `child` is a
186
188
  # strict namespace descendant of `root_namespace`.
187
189
  #
188
- # @sig (Module, String, Module, Boolean) -> void
190
+ #: (Module, String, String, Module) -> void
189
191
  private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
190
192
  suffix = child_name
191
193
  unless root_namespace.equal?(Object)
@@ -203,13 +205,13 @@ module Zeitwerk::Loader::EagerLoad
203
205
  next_dirs = []
204
206
 
205
207
  suffix.split("::").each do |segment|
206
- while dir = dirs.shift
207
- ls(dir) do |basename, abspath|
208
- next unless dir?(abspath)
208
+ while (dir = dirs.shift)
209
+ @fs.ls(dir) do |basename, abspath, ftype|
210
+ next unless ftype == :directory
209
211
 
210
212
  if collapse?(abspath)
211
213
  dirs << abspath
212
- elsif segment == inflector.camelize(basename, abspath)
214
+ elsif segment == cname_for(basename, abspath).to_s
213
215
  next_dirs << abspath
214
216
  end
215
217
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This private class encapsulates interactions with the file system.
4
+ #
5
+ # It is used to list directories and check file types, and it encodes the
6
+ # conventions documented in the README.
7
+ #
8
+ # @private
9
+ class Zeitwerk::Loader::FileSystem # :nodoc:
10
+ #: (Zeitwerk::Loader) -> void
11
+ def initialize(loader)
12
+ @loader = loader
13
+ end
14
+
15
+ #: (String) { (String, String, Symbol) -> void } -> void
16
+ def ls(dir)
17
+ children = relevant_dir_entries(dir)
18
+
19
+ # The order in which a directory is listed depends on the file system.
20
+ #
21
+ # Since client code may run on different platforms, it seems convenient to
22
+ # sort directory entries. This provides more deterministic behavior, with
23
+ # consistent eager loading in particular.
24
+ children.sort_by!(&:first)
25
+
26
+ children.each do |basename, abspath, ftype|
27
+ if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
28
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
29
+ next
30
+ end
31
+
32
+ yield basename, abspath, ftype
33
+ end
34
+ end
35
+
36
+ #: (String) { (String) -> void } -> void
37
+ def walk_up(abspath)
38
+ loop do
39
+ yield abspath
40
+ abspath, basename = File.split(abspath)
41
+ break if basename == "/"
42
+ end
43
+ end
44
+
45
+ # Encodes the documented conventions.
46
+ #
47
+ #: (String) -> Symbol?
48
+ def supported_ftype?(abspath)
49
+ if rb_extension?(abspath)
50
+ :file # By convention, we can avoid a syscall here.
51
+ elsif dir?(abspath)
52
+ :directory
53
+ end
54
+ end
55
+
56
+ #: (String) -> bool
57
+ def rb_extension?(path)
58
+ path.end_with?(".rb")
59
+ end
60
+
61
+ #: (String) -> bool
62
+ def dir?(path)
63
+ File.directory?(path)
64
+ end
65
+
66
+ #: (String) -> bool
67
+ def hidden?(basename)
68
+ basename.start_with?(".")
69
+ end
70
+
71
+ private
72
+
73
+ # Looks for a Ruby file using breadth-first search. This type of search is
74
+ # important to list as less directories as possible and return fast in the
75
+ # common case in which there are Ruby files in the passed directory.
76
+ #
77
+ #: (String) -> bool
78
+ def has_at_least_one_ruby_file?(dir)
79
+ to_visit = [dir]
80
+
81
+ while (dir = to_visit.shift)
82
+ relevant_dir_entries(dir) do |_, abspath, ftype|
83
+ return true if :file == ftype
84
+ to_visit << abspath
85
+ end
86
+ end
87
+
88
+ false
89
+ end
90
+
91
+ #: (String) { (String, String, Symbol) -> void } -> void
92
+ #: (String) -> [[String, String, Symbol]]
93
+ def relevant_dir_entries(dir)
94
+ return enum_for(__method__, dir).to_a unless block_given?
95
+
96
+ each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
97
+ next if @loader.__ignored_path?(abspath)
98
+
99
+ if :link == ftype
100
+ begin
101
+ ftype = File.stat(abspath).ftype.to_sym
102
+ rescue Errno::ENOENT
103
+ warn "ignoring broken symlink #{abspath}"
104
+ next
105
+ end
106
+ end
107
+
108
+ if :file == ftype
109
+ yield basename, abspath, ftype if rb_extension?(basename)
110
+ elsif :directory == ftype
111
+ # Conceptually, root directories represent a separate project tree.
112
+ yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
113
+ end
114
+ end
115
+ end
116
+
117
+ # Dir.scan is more efficient in common platforms, but it is going to take a
118
+ # while for it to be available.
119
+ #
120
+ # The following compatibility methods have the same semantics but are written
121
+ # to favor the performance of the Ruby fallback, which can save syscalls.
122
+ #
123
+ # In particular, by convention, any directory entry with a .rb extension is
124
+ # assumed to be a file or a symlink to a file.
125
+ #
126
+ # These methods also freeze abspaths because that saves allocations when
127
+ # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
128
+
129
+ if Dir.respond_to?(:scan) # Available in Ruby 4.1.
130
+ #: (String) { (String, String, Symbol) -> void } -> void
131
+ def each_ruby_file_or_directory(dir)
132
+ Dir.scan(dir) do |basename, ftype|
133
+ next if hidden?(basename)
134
+
135
+ if rb_extension?(basename)
136
+ abspath = File.join(dir, basename).freeze
137
+ yield basename, abspath, :file # By convention.
138
+ elsif :directory == ftype
139
+ abspath = File.join(dir, basename).freeze
140
+ yield basename, abspath, :directory
141
+ elsif :link == ftype
142
+ abspath = File.join(dir, basename).freeze
143
+ yield basename, abspath, :directory if dir?(abspath)
144
+ end
145
+ end
146
+ end
147
+ else
148
+ #: (String) { (String, String, Symbol) -> void } -> void
149
+ def each_ruby_file_or_directory(dir)
150
+ Dir.each_child(dir) do |basename|
151
+ next if hidden?(basename)
152
+
153
+ if rb_extension?(basename)
154
+ abspath = File.join(dir, basename).freeze
155
+ yield basename, abspath, :file # By convention.
156
+ else
157
+ abspath = File.join(dir, basename).freeze
158
+ if dir?(abspath)
159
+ yield basename, abspath, :directory
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end