zeitwerk 2.7.4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c652e42a3b3a3a92704f1924bac194dc9b9c2db94641e6a9a3daf192480e7c7e
4
- data.tar.gz: f24017f36733460e4c09ebc7d31de8ee95efda8c7e4f6aede34d4824f4922c3f
3
+ metadata.gz: '06683acd85eeb772fdb9757ae20bfb05e42ec465acfff3edcada546a399d9131'
4
+ data.tar.gz: 80e3cdede4e05607ef5c58c60a3f7824c6750dd1d19d8786352356025bde1d65
5
5
  SHA512:
6
- metadata.gz: d1a853bd6e7b638dc60203e6ba515646260913925ff8d30aa7edcb817253e61a534beadca02159ee7dffa78204d5c0dff652a056d2be2d688b9ec52fc1f9ac91
7
- data.tar.gz: 26824d767e2f18ef128b66b2b354a6fc7bc16754734eb27bc3c6bf20c8a3ed36a06e2d20c1cbed6b932a327cbb70f4aa58cedd88ed81bf3f8074ebdf90a52e27
6
+ metadata.gz: b37beb740e461d73a1bd1ae21ec4f2bf63bb5022f3a193e2718bb8e5d4f172bf9c1222ff2a33acc47fb390a34e50b8b4b65b5b762dc5f71c1971b25720081437
7
+ data.tar.gz: 02c85e17a3ab801c925157239386c9a5a105cc1b8b9d735f64c27c89d3a0371613f2cb3f27d49647d98bf3ad8220d3a3538c2bdae215a5890145136374eb1d3e
data/README.md CHANGED
@@ -58,6 +58,7 @@
58
58
  - [Reopening third-party namespaces](#reopening-third-party-namespaces)
59
59
  - [Introspection](#introspection)
60
60
  - [`Zeitwerk::Loader#dirs`](#zeitwerkloaderdirs)
61
+ - [Autoloaded Constants](#autoloaded-constants)
61
62
  - [`Zeitwerk::Loader#cpath_expected_at`](#zeitwerkloadercpath_expected_at)
62
63
  - [`Zeitwerk::Loader#all_expected_cpaths`](#zeitwerkloaderall_expected_cpaths)
63
64
  - [Encodings](#encodings)
@@ -1263,6 +1264,18 @@ By default, ignored root directories are filtered out. If you want them included
1263
1264
 
1264
1265
  These collections are read-only. Please add to them with `Zeitwerk::Loader#push_dir`.
1265
1266
 
1267
+ <a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
1268
+ #### Autoloaded Constants
1269
+
1270
+ Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
1271
+
1272
+ ```ruby
1273
+ autoloaded_cpaths = []
1274
+ loader.on_load do |cpath, _value, _abspath|
1275
+ autoloaded_cpaths << cpath
1276
+ end
1277
+ ```
1278
+
1266
1279
  <a id="markdown-zeitwerkloadercpath_expected_at" name="zeitwerkloadercpath_expected_at"></a>
1267
1280
  #### `Zeitwerk::Loader#cpath_expected_at`
1268
1281
 
@@ -1408,6 +1421,12 @@ To run one particular suite, pass its file name as an argument:
1408
1421
  bin/test test/lib/zeitwerk/test_eager_load.rb
1409
1422
  ```
1410
1423
 
1424
+ That also accepts a line number:
1425
+
1426
+ ```
1427
+ bin/test test/lib/zeitwerk/test_eager_load.rb:52
1428
+ ```
1429
+
1411
1430
  Furthermore, the project has a development dependency on [`minitest-focus`](https://github.com/seattlerb/minitest-focus). To run an individual test mark it with `focus`:
1412
1431
 
1413
1432
  ```ruby
@@ -42,12 +42,12 @@ module Zeitwerk
42
42
  def warn_on_extra_files
43
43
  expected_namespace_dir = @root_file.delete_suffix(".rb")
44
44
 
45
- ls(@root_dir) do |basename, abspath, ftype|
45
+ @fs.ls(@root_dir) do |basename, abspath, ftype|
46
46
  next if abspath == @root_file
47
47
  next if abspath == expected_namespace_dir
48
48
 
49
49
  basename_without_ext = basename.delete_suffix(".rb")
50
- cname = inflector.camelize(basename_without_ext, abspath).to_sym
50
+ cname = cname_for(basename_without_ext, abspath)
51
51
 
52
52
  warn(<<~EOS)
53
53
  WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
@@ -12,12 +12,12 @@ module Zeitwerk::Loader::Callbacks # :nodoc: all
12
12
  Zeitwerk::Registry.autoloads.unregister(file)
13
13
 
14
14
  if cref.defined?
15
- log("constant #{cref} loaded from file #{file}") if logger
15
+ log { "constant #{cref} loaded from file #{file}" }
16
16
  to_unload[file] = cref if reloading_enabled?
17
17
  run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
18
18
  else
19
19
  msg = "expected file #{file} to define constant #{cref}, but didn't"
20
- log(msg) if logger
20
+ log { msg }
21
21
 
22
22
  # Ruby still keeps the autoload defined, but we remove it because the
23
23
  # contract in Zeitwerk is more strict.
@@ -52,7 +52,7 @@ module Zeitwerk::Loader::Callbacks # :nodoc: all
52
52
  dirs_autoload_monitor.synchronize do
53
53
  if cref = autoloads.delete(dir)
54
54
  implicit_namespace = cref.set(Module.new)
55
- log("module #{cref} autovivified from directory #{dir}") if logger
55
+ log { "module #{cref} autovivified from directory #{dir}" }
56
56
 
57
57
  to_unload[dir] = cref if reloading_enabled?
58
58
 
@@ -116,8 +116,8 @@ module Zeitwerk::Loader::Config
116
116
  end
117
117
 
118
118
  abspath = File.expand_path(path)
119
- if dir?(abspath)
120
- raise_if_conflicting_directory(abspath)
119
+ if @fs.dir?(abspath)
120
+ raise_if_conflicting_root_dir(abspath)
121
121
  roots[abspath] = namespace
122
122
  else
123
123
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
@@ -290,28 +290,28 @@ module Zeitwerk::Loader::Config
290
290
  # Common use case.
291
291
  return false if ignored_paths.empty?
292
292
 
293
- walk_up(abspath) do |path|
293
+ @fs.walk_up(abspath) do |path|
294
294
  return true if ignored_path?(path)
295
- return false if roots.key?(path)
295
+ return false if root_dir?(path)
296
296
  end
297
297
 
298
298
  false
299
299
  end
300
300
 
301
301
  #: (String) -> bool
302
- private def ignored_path?(abspath)
302
+ internal def ignored_path?(abspath)
303
303
  ignored_paths.member?(abspath)
304
304
  end
305
305
 
306
306
  #: () -> Array[String]
307
307
  private def actual_roots
308
308
  roots.reject do |root_dir, _root_namespace|
309
- !dir?(root_dir) || ignored_path?(root_dir)
309
+ !@fs.dir?(root_dir) || ignored_path?(root_dir)
310
310
  end
311
311
  end
312
312
 
313
313
  #: (String) -> bool
314
- private def root_dir?(dir)
314
+ internal def root_dir?(dir)
315
315
  roots.key?(dir)
316
316
  end
317
317
 
@@ -320,9 +320,9 @@ module Zeitwerk::Loader::Config
320
320
  # Optimize this common use case.
321
321
  return false if eager_load_exclusions.empty?
322
322
 
323
- walk_up(abspath) do |path|
323
+ @fs.walk_up(abspath) do |path|
324
324
  return true if eager_load_exclusions.member?(path)
325
- return false if roots.key?(path)
325
+ return false if root_dir?(path)
326
326
  end
327
327
 
328
328
  false
@@ -11,7 +11,7 @@ module Zeitwerk::Loader::EagerLoad
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)
@@ -24,7 +24,7 @@ module Zeitwerk::Loader::EagerLoad
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
 
@@ -34,23 +34,21 @@ module Zeitwerk::Loader::EagerLoad
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
48
  basename = File.basename(dir)
49
- return if hidden?(basename)
49
+ return if @fs.hidden?(basename)
50
50
 
51
- unless collapse?(dir)
52
- cnames << inflector.camelize(basename, dir).to_sym
53
- end
51
+ paths << [basename, dir] unless collapse?(dir)
54
52
  end
55
53
 
56
54
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
@@ -58,7 +56,8 @@ module Zeitwerk::Loader::EagerLoad
58
56
  return if @eager_loaded
59
57
 
60
58
  namespace = root_namespace
61
- cnames.reverse_each do |cname|
59
+ paths.reverse_each do |basename, dir|
60
+ cname = cname_for(basename, dir)
62
61
  # Can happen if there are no Ruby files. This is not an error condition,
63
62
  # the directory is actually managed. Could have Ruby files later.
64
63
  return unless namespace.const_defined?(cname, false)
@@ -117,34 +116,33 @@ module Zeitwerk::Loader::EagerLoad
117
116
  abspath = File.expand_path(path)
118
117
 
119
118
  raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
120
- 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)
121
120
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
122
121
 
123
- basename = File.basename(abspath, ".rb")
124
- raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
125
-
126
- 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)
127
124
 
128
125
  root_namespace = nil
129
- cnames = []
126
+ paths = []
130
127
 
131
- walk_up(File.dirname(abspath)) do |dir|
128
+ @fs.walk_up(File.dirname(abspath)) do |dir|
132
129
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
133
130
 
134
131
  break if root_namespace = roots[dir]
135
132
 
136
133
  basename = File.basename(dir)
137
- raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
134
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
138
135
 
139
- unless collapse?(dir)
140
- cnames << inflector.camelize(basename, dir).to_sym
141
- end
136
+ paths << [basename, dir] unless collapse?(dir)
142
137
  end
143
138
 
144
139
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
145
140
 
141
+ base_cname = cname_for(file_basename, abspath)
142
+
146
143
  namespace = root_namespace
147
- cnames.reverse_each do |cname|
144
+ paths.reverse_each do |basename, dir|
145
+ cname = cname_for(basename, dir)
148
146
  namespace = namespace.const_get(cname, false)
149
147
  end
150
148
 
@@ -161,11 +159,11 @@ module Zeitwerk::Loader::EagerLoad
161
159
  honour_exclusions = !force
162
160
  return if honour_exclusions && excluded_from_eager_load?(dir)
163
161
 
164
- log("eager load directory #{dir} start") if logger
162
+ log { "eager load directory #{dir} start" }
165
163
 
166
164
  queue = [[dir, namespace]]
167
165
  while (current_dir, namespace = queue.shift)
168
- ls(current_dir) do |basename, abspath, ftype|
166
+ @fs.ls(current_dir) do |basename, abspath, ftype|
169
167
  next if honour_exclusions && eager_load_exclusions.member?(abspath)
170
168
 
171
169
  if ftype == :file
@@ -176,14 +174,14 @@ module Zeitwerk::Loader::EagerLoad
176
174
  if collapse?(abspath)
177
175
  queue << [abspath, namespace]
178
176
  else
179
- cname = inflector.camelize(basename, abspath).to_sym
177
+ cname = cname_for(basename, abspath)
180
178
  queue << [abspath, namespace.const_get(cname, false)]
181
179
  end
182
180
  end
183
181
  end
184
182
  end
185
183
 
186
- log("eager load directory #{dir} end") if logger
184
+ log { "eager load directory #{dir} end" }
187
185
  end
188
186
 
189
187
  # In order to invoke this method, the caller has to ensure `child` is a
@@ -208,12 +206,12 @@ module Zeitwerk::Loader::EagerLoad
208
206
 
209
207
  suffix.split("::").each do |segment|
210
208
  while (dir = dirs.shift)
211
- ls(dir) do |basename, abspath, ftype|
209
+ @fs.ls(dir) do |basename, abspath, ftype|
212
210
  next unless ftype == :directory
213
211
 
214
212
  if collapse?(abspath)
215
213
  dirs << abspath
216
- elsif segment == inflector.camelize(basename, abspath)
214
+ elsif segment == cname_for(basename, abspath).to_s
217
215
  next_dirs << abspath
218
216
  end
219
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
@@ -1,105 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zeitwerk::Loader::Helpers
4
- # --- Logging -----------------------------------------------------------------------------------
5
-
6
- #: (to_s() -> String) -> void
7
- private def log(message)
8
- method_name = logger.respond_to?(:debug) ? :debug : :call
9
- logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
10
- end
11
-
12
- # --- Files and directories ---------------------------------------------------------------------
13
-
14
- #: (String) { (String, String, Symbol) -> void } -> void
15
- private def ls(dir)
16
- children = Dir.children(dir)
17
-
18
- # The order in which a directory is listed depends on the file system.
19
- #
20
- # Since client code may run in different platforms, it seems convenient to
21
- # order directory entries. This provides consistent eager loading across
22
- # platforms, for example.
23
- children.sort!
24
-
25
- children.each do |basename|
26
- next if hidden?(basename)
27
-
28
- abspath = File.join(dir, basename)
29
- next if ignored_path?(abspath)
30
-
31
- if dir?(abspath)
32
- next if roots.key?(abspath)
33
-
34
- if !has_at_least_one_ruby_file?(abspath)
35
- log("directory #{abspath} is ignored because it has no Ruby files") if logger
36
- next
37
- end
38
-
39
- ftype = :directory
40
- else
41
- next unless ruby?(abspath)
42
- ftype = :file
43
- end
44
-
45
- # We freeze abspath because that saves allocations when passed later to
46
- # File methods. See #125.
47
- yield basename, abspath.freeze, ftype
48
- end
49
- end
50
-
51
- # Looks for a Ruby file using breadth-first search. This type of search is
52
- # important to list as less directories as possible and return fast in the
53
- # common case in which there are Ruby files.
54
- #
55
- #: (String) -> bool
56
- private def has_at_least_one_ruby_file?(dir)
57
- to_visit = [dir]
58
-
59
- while (dir = to_visit.shift)
60
- Dir.each_child(dir) do |basename|
61
- next if hidden?(basename)
62
-
63
- abspath = File.join(dir, basename)
64
- next if ignored_path?(abspath)
65
-
66
- if dir?(abspath)
67
- to_visit << abspath unless roots.key?(abspath)
68
- else
69
- return true if ruby?(abspath)
70
- end
71
- end
72
- end
73
-
74
- false
75
- end
76
-
77
- #: (String) -> bool
78
- private def ruby?(path)
79
- path.end_with?(".rb")
80
- end
81
-
82
- #: (String) -> bool
83
- private def dir?(path)
84
- File.directory?(path)
85
- end
86
-
87
- #: (String) -> bool
88
- private def hidden?(basename)
89
- basename.start_with?(".")
90
- end
91
-
92
- #: (String) { (String) -> void } -> void
93
- private def walk_up(abspath)
94
- loop do
95
- yield abspath
96
- abspath, basename = File.split(abspath)
97
- break if basename == "/"
98
- end
99
- end
100
-
101
- # --- Inflection --------------------------------------------------------------------------------
102
-
103
4
  CNAME_VALIDATOR = Module.new #: Module
104
5
  private_constant :CNAME_VALIDATOR
105
6
 
@@ -124,7 +25,7 @@ module Zeitwerk::Loader::Helpers
124
25
  begin
125
26
  CNAME_VALIDATOR.const_defined?(cname, false)
126
27
  rescue ::NameError => error
127
- path_type = ruby?(abspath) ? "file" : "directory"
28
+ path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
128
29
 
129
30
  raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
130
31
  #{error.message} inferred by #{inflector.class} from #{path_type}
@@ -9,6 +9,7 @@ module Zeitwerk
9
9
  require_relative "loader/callbacks"
10
10
  require_relative "loader/config"
11
11
  require_relative "loader/eager_load"
12
+ require_relative "loader/file_system"
12
13
 
13
14
  extend Internal
14
15
 
@@ -18,9 +19,6 @@ module Zeitwerk
18
19
  include Config
19
20
  include EagerLoad
20
21
 
21
- MUTEX = Mutex.new #: Mutex
22
- private_constant :MUTEX
23
-
24
22
  # Maps absolute paths for which an autoload has been set ---and not
25
23
  # executed--- to their corresponding Zeitwerk::Cref object.
26
24
  #
@@ -115,6 +113,7 @@ module Zeitwerk
115
113
  @shadowed_files = Set.new
116
114
  @setup = false
117
115
  @eager_loaded = false
116
+ @fs = FileSystem.new(self)
118
117
 
119
118
  @mutex = Mutex.new
120
119
  @dirs_autoload_monitor = Monitor.new
@@ -171,7 +170,7 @@ module Zeitwerk
171
170
  # and the constant path would escape unloadable_cpath? This is just
172
171
  # defensive code to clean things up as much as we are able to.
173
172
  unload_cref(cref)
174
- unloaded_files.add(abspath) if ruby?(abspath)
173
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
175
174
  end
176
175
  end
177
176
 
@@ -189,7 +188,7 @@ module Zeitwerk
189
188
  end
190
189
 
191
190
  unload_cref(cref)
192
- unloaded_files.add(abspath) if ruby?(abspath)
191
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
193
192
  end
194
193
 
195
194
  unless unloaded_files.empty?
@@ -199,7 +198,7 @@ module Zeitwerk
199
198
  # To make it aware of changes, the gem defines singleton methods in
200
199
  # $LOADED_FEATURES:
201
200
  #
202
- # https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
201
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
203
202
  #
204
203
  # Rails applications may depend on bootsnap, so for unloading to work
205
204
  # in that setting it is preferable that we restrict our API choice to
@@ -255,15 +254,15 @@ module Zeitwerk
255
254
 
256
255
  prefix = cpath == "Object" ? "" : cpath + "::"
257
256
 
258
- ls(dir) do |basename, abspath, ftype|
257
+ @fs.ls(dir) do |basename, abspath, ftype|
259
258
  if ftype == :file
260
259
  basename.delete_suffix!(".rb")
261
- result[abspath] = prefix + inflector.camelize(basename, abspath)
260
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
262
261
  else
263
262
  if collapse?(abspath)
264
263
  queue << [abspath, cpath]
265
264
  else
266
- queue << [abspath, prefix + inflector.camelize(basename, abspath)]
265
+ queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
267
266
  end
268
267
  end
269
268
  end
@@ -279,14 +278,16 @@ module Zeitwerk
279
278
 
280
279
  raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
281
280
 
282
- return unless dir?(abspath) || ruby?(abspath)
281
+ ftype = @fs.supported_ftype?(abspath)
282
+ return unless ftype
283
+
283
284
  return if ignored_path?(abspath)
284
285
 
285
286
  paths = []
286
287
 
287
- if ruby?(abspath)
288
+ if :file == ftype
288
289
  basename = File.basename(abspath, ".rb")
289
- return if hidden?(basename)
290
+ return if @fs.hidden?(basename)
290
291
 
291
292
  paths << [basename, abspath]
292
293
  walk_up_from = File.dirname(abspath)
@@ -296,12 +297,12 @@ module Zeitwerk
296
297
 
297
298
  root_namespace = nil
298
299
 
299
- walk_up(walk_up_from) do |dir|
300
+ @fs.walk_up(walk_up_from) do |dir|
300
301
  break if root_namespace = roots[dir]
301
302
  return if ignored_path?(dir)
302
303
 
303
304
  basename = File.basename(dir)
304
- return if hidden?(basename)
305
+ return if @fs.hidden?(basename)
305
306
 
306
307
  paths << [basename, dir] unless collapse?(dir)
307
308
  end
@@ -363,6 +364,16 @@ module Zeitwerk
363
364
  shadowed_files.member?(file)
364
365
  end
365
366
 
367
+ #: { () -> String } -> void
368
+ internal def log
369
+ return unless logger
370
+
371
+ message = yield
372
+ method_name = logger.respond_to?(:debug) ? :debug : :call
373
+ logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
374
+ end
375
+
376
+
366
377
  # --- Class methods ---------------------------------------------------------------------------
367
378
 
368
379
  class << self
@@ -464,7 +475,7 @@ module Zeitwerk
464
475
 
465
476
  #: (String, Module) -> void
466
477
  private def define_autoloads_for_dir(dir, parent)
467
- ls(dir) do |basename, abspath, ftype|
478
+ @fs.ls(dir) do |basename, abspath, ftype|
468
479
  if ftype == :file
469
480
  basename.delete_suffix!(".rb")
470
481
  cref = Cref.new(parent, cname_for(basename, abspath))
@@ -483,7 +494,7 @@ module Zeitwerk
483
494
  #: (Zeitwerk::Cref, String) -> void
484
495
  private def autoload_subdir(cref, subdir)
485
496
  if autoload_path = autoload_path_set_by_me_for?(cref)
486
- if ruby?(autoload_path)
497
+ if @fs.rb_extension?(autoload_path)
487
498
  # Scanning visited a Ruby file first, and now a directory for the same
488
499
  # constant has been found. This means we are dealing with an explicit
489
500
  # namespace whose definition was seen first.
@@ -504,7 +515,7 @@ module Zeitwerk
504
515
  else
505
516
  # For whatever reason the constant that corresponds to this namespace has
506
517
  # already been defined, we have to recurse.
507
- log("the namespace #{cref} already exists, descending into #{subdir}") if logger
518
+ log { "the namespace #{cref} already exists, descending into #{subdir}" }
508
519
  define_autoloads_for_dir(subdir, cref.get)
509
520
  end
510
521
  end
@@ -513,15 +524,15 @@ module Zeitwerk
513
524
  private def autoload_file(cref, file)
514
525
  if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
515
526
  # First autoload for a Ruby file wins, just ignore subsequent ones.
516
- if ruby?(autoload_path)
527
+ if @fs.rb_extension?(autoload_path)
517
528
  shadowed_files << file
518
- log("file #{file} is ignored because #{autoload_path} has precedence") if logger
529
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
519
530
  else
520
531
  promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
521
532
  end
522
533
  elsif cref.defined?
523
534
  shadowed_files << file
524
- log("file #{file} is ignored because #{cref} is already defined") if logger
535
+ log { "file #{file} is ignored because #{cref} is already defined" }
525
536
  else
526
537
  define_autoload(cref, file)
527
538
  end
@@ -535,7 +546,7 @@ module Zeitwerk
535
546
  autoloads.delete(dir)
536
547
  Registry.autoloads.unregister(dir)
537
548
 
538
- log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
549
+ log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
539
550
 
540
551
  # Order matters: When Module#const_added is triggered by the autoload, we
541
552
  # don't want the namespace to be registered yet.
@@ -548,10 +559,10 @@ module Zeitwerk
548
559
  cref.autoload(abspath)
549
560
 
550
561
  if logger
551
- if ruby?(abspath)
552
- log("autoload set for #{cref}, to be loaded from #{abspath}")
562
+ if @fs.rb_extension?(abspath)
563
+ log { "autoload set for #{cref}, to be loaded from #{abspath}" }
553
564
  else
554
- log("autoload set for #{cref}, to be autovivified from #{abspath}")
565
+ log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
555
566
  end
556
567
  end
557
568
 
@@ -595,28 +606,12 @@ module Zeitwerk
595
606
  end
596
607
 
597
608
  #: (String) -> void
598
- private def raise_if_conflicting_directory(dir)
599
- MUTEX.synchronize do
600
- Registry.loaders.each do |loader|
601
- next if loader == self
602
-
603
- loader.__roots.each_key do |root_dir|
604
- # Conflicting directories are rare, optimize for the common case.
605
- next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
606
-
607
- dir_slash = dir + "/"
608
- root_dir_slash = root_dir + "/"
609
- next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
610
-
611
- next if ignores?(root_dir)
612
- break if loader.__ignores?(dir)
613
-
614
- require "pp" # Needed to have pretty_inspect available.
615
- raise Error,
616
- "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
617
- " which is already managed by\n\n#{loader.pretty_inspect}\n"
618
- end
619
- end
609
+ private def raise_if_conflicting_root_dir(root_dir)
610
+ if loader = Registry.conflicting_root_dir?(self, root_dir)
611
+ require "pp" # Needed to have pretty_inspect available.
612
+ raise Error,
613
+ "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
614
+ " which is already managed by\n\n#{loader.pretty_inspect}\n"
620
615
  end
621
616
  end
622
617
 
@@ -630,7 +625,7 @@ module Zeitwerk
630
625
  #: (Zeitwerk::Cref) -> void
631
626
  private def unload_autoload(cref)
632
627
  cref.remove
633
- log("autoload for #{cref} removed") if logger
628
+ log { "autoload for #{cref} removed" }
634
629
  end
635
630
 
636
631
  #: (Zeitwerk::Cref) -> void
@@ -642,7 +637,7 @@ module Zeitwerk
642
637
  # There are a few edge scenarios in which this may happen. If the constant
643
638
  # is gone, that is OK, anyway.
644
639
  else
645
- log("#{cref} unloaded") if logger
640
+ log { "#{cref} unloaded" }
646
641
  end
647
642
  end
648
643
  end
@@ -7,7 +7,7 @@ module Zeitwerk::RealModName
7
7
 
8
8
  # Returns the real name of the class or module.
9
9
  #
10
- # We need this indirection becasue the `name` method can be overridden, and
10
+ # We need this indirection because the `name` method can be overridden, and
11
11
  # because in practice what we really need is the constant paths of modules
12
12
  # with a permanent name, not so much what the user considers to be the name of
13
13
  # a certain class or module of theirs.
@@ -6,8 +6,8 @@ module Zeitwerk::Registry
6
6
  end
7
7
 
8
8
  #: ({ (Zeitwerk::Loader) -> void }) -> void
9
- def each(&block)
10
- @loaders.each(&block)
9
+ def each(&)
10
+ @loaders.each(&)
11
11
  end
12
12
 
13
13
  #: (Zeitwerk::Loader) -> void
@@ -44,6 +44,31 @@ module Zeitwerk
44
44
  gem_loaders_by_root_file.delete_if { |_, l| l == loader }
45
45
  end
46
46
 
47
+ #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
48
+ def conflicting_root_dir?(loader, new_root_dir)
49
+ @mutex.synchronize do
50
+ loaders.each do |existing_loader|
51
+ next if existing_loader == loader
52
+
53
+ existing_loader.__roots.each_key do |existing_root_dir|
54
+ # Conflicting directories are rare, optimize for the common case.
55
+ next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
56
+
57
+ new_root_dir_slash = new_root_dir + "/"
58
+ existing_root_dir_slash = existing_root_dir + "/"
59
+ next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
60
+
61
+ next if loader.__ignores?(existing_root_dir)
62
+ break if existing_loader.__ignores?(new_root_dir)
63
+
64
+ return existing_loader
65
+ end
66
+ end
67
+
68
+ nil
69
+ end
70
+ end
71
+
47
72
  # This method returns always a loader, the same instance for the same root
48
73
  # file. That is how Zeitwerk::Loader.for_gem is idempotent.
49
74
  #
@@ -59,5 +84,6 @@ module Zeitwerk
59
84
  @autoloads = Autoloads.new
60
85
  @explicit_namespaces = ExplicitNamespaces.new
61
86
  @inceptions = Inceptions.new
87
+ @mutex = Mutex.new
62
88
  end
63
89
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Zeitwerk
4
4
  #: String
5
- VERSION = "2.7.4"
5
+ VERSION = "2.7.5"
6
6
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zeitwerk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.4
4
+ version: 2.7.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xavier Noria
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-12-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: |2
14
13
  Zeitwerk implements constant autoloading with Ruby semantics. Each gem
@@ -36,6 +35,7 @@ files:
36
35
  - lib/zeitwerk/loader/callbacks.rb
37
36
  - lib/zeitwerk/loader/config.rb
38
37
  - lib/zeitwerk/loader/eager_load.rb
38
+ - lib/zeitwerk/loader/file_system.rb
39
39
  - lib/zeitwerk/loader/helpers.rb
40
40
  - lib/zeitwerk/null_inflector.rb
41
41
  - lib/zeitwerk/real_mod_name.rb
@@ -53,7 +53,6 @@ metadata:
53
53
  changelog_uri: https://github.com/fxn/zeitwerk/blob/main/CHANGELOG.md
54
54
  source_code_uri: https://github.com/fxn/zeitwerk
55
55
  bug_tracker_uri: https://github.com/fxn/zeitwerk/issues
56
- post_install_message:
57
56
  rdoc_options: []
58
57
  require_paths:
59
58
  - lib
@@ -68,8 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
67
  - !ruby/object:Gem::Version
69
68
  version: '0'
70
69
  requirements: []
71
- rubygems_version: 3.4.19
72
- signing_key:
70
+ rubygems_version: 4.0.3
73
71
  specification_version: 4
74
72
  summary: Efficient and thread-safe constant autoloader
75
73
  test_files: []