zeitwerk 2.7.5 → 2.8.0

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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
- require "securerandom"
3
+ require 'set'
4
+ require 'securerandom'
5
5
 
6
6
  module Zeitwerk::Loader::Config
7
7
  extend Zeitwerk::Internal
@@ -15,8 +15,8 @@ module Zeitwerk::Loader::Config
15
15
 
16
16
  # Absolute paths of the root directories, mapped to their respective root namespaces:
17
17
  #
18
- # "/Users/fxn/blog/app/channels" => Object,
19
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
18
+ # '/Users/fxn/blog/app/channels' => Object,
19
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
20
20
  # ...
21
21
  #
22
22
  # Stored in a hash to preserve order, easily handle duplicates, and have a
@@ -29,6 +29,12 @@ module Zeitwerk::Loader::Config
29
29
  attr_reader :roots
30
30
  internal :roots
31
31
 
32
+ # Basename of files that define namespaces. For example, if `nsfile` is
33
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
34
+ #
35
+ #: String?
36
+ attr_reader :nsfile
37
+
32
38
  # Absolute paths of files, directories, or glob patterns to be ignored.
33
39
  #
34
40
  #: Set[String]
@@ -50,12 +56,20 @@ module Zeitwerk::Loader::Config
50
56
  private :collapse_glob_patterns
51
57
 
52
58
  # The actual collection of absolute directory names at the time the collapse
53
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
59
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
54
60
  #
55
61
  #: Set[String]
56
62
  attr_reader :collapse_dirs
57
63
  private :collapse_dirs
58
64
 
65
+ # Absolute paths of directories that are parents of collapsed directories.
66
+ # This is a cache to optimize some tree walks. Computed on setup and
67
+ # recomputed on reload.
68
+ #
69
+ #: Set[String]
70
+ attr_reader :collapse_parents
71
+ private :collapse_parents
72
+
59
73
  # Absolute paths of files or directories not to be eager loaded.
60
74
  #
61
75
  #: Set[String]
@@ -82,16 +96,19 @@ module Zeitwerk::Loader::Config
82
96
  attr_reader :on_unload_callbacks
83
97
  private :on_unload_callbacks
84
98
 
99
+ #: () -> void
85
100
  def initialize
86
101
  @inflector = Zeitwerk::Inflector.new
87
102
  @logger = self.class.default_logger
88
103
  @tag = SecureRandom.hex(3)
89
104
  @initialized_at = Time.now
90
105
  @roots = {}
106
+ @nsfile = nil
91
107
  @ignored_glob_patterns = Set.new
92
108
  @ignored_paths = Set.new
93
109
  @collapse_glob_patterns = Set.new
94
110
  @collapse_dirs = Set.new
111
+ @collapse_parents = Set.new
95
112
  @eager_load_exclusions = Set.new
96
113
  @reloading_enabled = false
97
114
  @on_setup_callbacks = []
@@ -112,7 +129,7 @@ module Zeitwerk::Loader::Config
112
129
  end
113
130
 
114
131
  unless real_mod_name(namespace)
115
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
132
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
116
133
  end
117
134
 
118
135
  abspath = File.expand_path(path)
@@ -141,6 +158,18 @@ module Zeitwerk::Loader::Config
141
158
  @tag = tag.to_s
142
159
  end
143
160
 
161
+ #: (String?) -> void ! TypeError, ArgumentError
162
+ def nsfile=(nsfile)
163
+ unless nsfile.nil?
164
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
165
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
166
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
167
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
168
+ end
169
+
170
+ @nsfile = nsfile
171
+ end
172
+
144
173
  # If `namespaces` is falsey (default), returns an array with the absolute
145
174
  # paths of the root directories as strings. If truthy, returns a hash table
146
175
  # instead. Keys are the absolute paths of the root directories as strings,
@@ -176,7 +205,7 @@ module Zeitwerk::Loader::Config
176
205
  break if @reloading_enabled
177
206
 
178
207
  if @setup
179
- raise Zeitwerk::Error, "cannot enable reloading after setup"
208
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
180
209
  else
181
210
  @reloading_enabled = true
182
211
  end
@@ -214,7 +243,11 @@ module Zeitwerk::Loader::Config
214
243
  glob_patterns = expand_paths(glob_patterns)
215
244
  mutex.synchronize do
216
245
  collapse_glob_patterns.merge(glob_patterns)
217
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
246
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
247
+ collapse_dirs.merge(new_collapse_dirs)
248
+ new_collapse_dirs.each do |dir|
249
+ collapse_parents << File.dirname(dir)
250
+ end
218
251
  end
219
252
  end
220
253
 
@@ -233,8 +266,8 @@ module Zeitwerk::Loader::Config
233
266
  # Supports multiple callbacks, and if there are many, they are executed in
234
267
  # the order in which they were defined.
235
268
  #
236
- # loader.on_load("SomeApiClient") do |klass, _abspath|
237
- # klass.endpoint = "https://api.dev"
269
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
270
+ # klass.endpoint = 'https://api.dev'
238
271
  # end
239
272
  #
240
273
  # Can also be configured for any constant loaded:
@@ -245,7 +278,7 @@ module Zeitwerk::Loader::Config
245
278
  #
246
279
  #: (String?) { (top, String) -> void } -> void ! TypeError
247
280
  def on_load(cpath = :ANY, &block)
248
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
281
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
249
282
 
250
283
  mutex.synchronize do
251
284
  (on_load_callbacks[cpath] ||= []) << block
@@ -256,7 +289,7 @@ module Zeitwerk::Loader::Config
256
289
  # Supports multiple callbacks, and if there are many, they are executed in the
257
290
  # order in which they were defined.
258
291
  #
259
- # loader.on_unload("Country") do |klass, _abspath|
292
+ # loader.on_unload('Country') do |klass, _abspath|
260
293
  # klass.clear_cache
261
294
  # end
262
295
  #
@@ -268,7 +301,7 @@ module Zeitwerk::Loader::Config
268
301
  #
269
302
  #: (String?) { (top, String) -> void } -> void ! TypeError
270
303
  def on_unload(cpath = :ANY, &block)
271
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
304
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
272
305
 
273
306
  mutex.synchronize do
274
307
  (on_unload_callbacks[cpath] ||= []) << block
@@ -315,6 +348,16 @@ module Zeitwerk::Loader::Config
315
348
  roots.key?(dir)
316
349
  end
317
350
 
351
+ #: (String) -> bool
352
+ internal def collapse?(dir)
353
+ collapse_dirs.member?(dir)
354
+ end
355
+
356
+ #: (String) -> bool
357
+ internal def collapse_parent?(dir)
358
+ collapse_parents.member?(dir)
359
+ end
360
+
318
361
  #: (String) -> bool
319
362
  private def excluded_from_eager_load?(abspath)
320
363
  # Optimize this common use case.
@@ -328,11 +371,6 @@ module Zeitwerk::Loader::Config
328
371
  false
329
372
  end
330
373
 
331
- #: (String) -> bool
332
- private def collapse?(dir)
333
- collapse_dirs.member?(dir)
334
- end
335
-
336
374
  #: (String | Pathname | Array[String | Pathname]) -> Array[String]
337
375
  private def expand_paths(paths)
338
376
  paths.flatten.map! { |path| File.expand_path(path) }
@@ -354,4 +392,12 @@ module Zeitwerk::Loader::Config
354
392
  private def recompute_collapse_dirs
355
393
  collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
356
394
  end
395
+
396
+ #: () -> void
397
+ private def recompute_collapse_parents
398
+ collapse_parents.clear
399
+ collapse_dirs.each do |dir|
400
+ collapse_parents << File.dirname(dir)
401
+ end
402
+ end
357
403
  end
@@ -1,9 +1,10 @@
1
1
  module Zeitwerk::Loader::EagerLoad
2
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`.
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`.
7
8
  #
8
9
  #: (?force: boolish) -> void
9
10
  def eager_load(force: false)
@@ -11,7 +12,7 @@ module Zeitwerk::Loader::EagerLoad
11
12
  break if @eager_loaded
12
13
  raise Zeitwerk::SetupRequired unless @setup
13
14
 
14
- log { "eager load start" }
15
+ log { 'eager load start' }
15
16
 
16
17
  actual_roots.each do |root_dir, root_namespace|
17
18
  actual_eager_load_dir(root_dir, root_namespace, force: force)
@@ -24,7 +25,7 @@ module Zeitwerk::Loader::EagerLoad
24
25
 
25
26
  @eager_loaded = true
26
27
 
27
- log { "eager load end" }
28
+ log { 'eager load end' }
28
29
  end
29
30
  end
30
31
 
@@ -91,11 +92,11 @@ module Zeitwerk::Loader::EagerLoad
91
92
  eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
92
93
  else
93
94
  root_namespace_name = real_mod_name(root_namespace)
94
- if root_namespace_name.start_with?(mod_name + "::")
95
+ if root_namespace_name.start_with?(mod_name + '::')
95
96
  actual_eager_load_dir(root_dir, root_namespace)
96
97
  elsif mod_name == root_namespace_name
97
98
  actual_eager_load_dir(root_dir, root_namespace)
98
- elsif mod_name.start_with?(root_namespace_name + "::")
99
+ elsif mod_name.start_with?(root_namespace_name + '::')
99
100
  eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
100
101
  else
101
102
  # Unrelated constant hierarchies, do nothing.
@@ -119,7 +120,7 @@ module Zeitwerk::Loader::EagerLoad
119
120
  raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
120
121
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
121
122
 
122
- file_basename = File.basename(abspath, ".rb")
123
+ file_basename = File.basename(abspath)
123
124
  raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
124
125
 
125
126
  root_namespace = nil
@@ -138,17 +139,20 @@ module Zeitwerk::Loader::EagerLoad
138
139
 
139
140
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
140
141
 
141
- base_cname = cname_for(file_basename, abspath)
142
-
143
142
  namespace = root_namespace
144
143
  paths.reverse_each do |basename, dir|
145
144
  cname = cname_for(basename, dir)
146
145
  namespace = namespace.const_get(cname, false)
147
146
  end
148
147
 
149
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
150
-
151
- namespace.const_get(base_cname, false)
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
152
156
  end
153
157
 
154
158
  # The caller is responsible for making sure `namespace` is the namespace that
@@ -171,12 +175,8 @@ module Zeitwerk::Loader::EagerLoad
171
175
  cref.get
172
176
  end
173
177
  else
174
- if collapse?(abspath)
175
- queue << [abspath, namespace]
176
- else
177
- cname = cname_for(basename, abspath)
178
- queue << [abspath, namespace.const_get(cname, false)]
179
- end
178
+ cname = cname_for(basename, abspath)
179
+ queue << [abspath, namespace.const_get(cname, false)]
180
180
  end
181
181
  end
182
182
  end
@@ -191,7 +191,7 @@ module Zeitwerk::Loader::EagerLoad
191
191
  private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
192
192
  suffix = child_name
193
193
  unless root_namespace.equal?(Object)
194
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
194
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
195
195
  end
196
196
 
197
197
  # These directories are at the same namespace level, there may be more if
@@ -204,14 +204,10 @@ module Zeitwerk::Loader::EagerLoad
204
204
  dirs = [root_dir]
205
205
  next_dirs = []
206
206
 
207
- suffix.split("::").each do |segment|
207
+ suffix.split('::').each do |segment|
208
208
  while (dir = dirs.shift)
209
209
  @fs.ls(dir) do |basename, abspath, ftype|
210
- next unless ftype == :directory
211
-
212
- if collapse?(abspath)
213
- dirs << abspath
214
- elsif segment == cname_for(basename, abspath).to_s
210
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
215
211
  next_dirs << abspath
216
212
  end
217
213
  end
@@ -12,8 +12,22 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
12
12
  @loader = loader
13
13
  end
14
14
 
15
+ # This method lists directories, filtering out the following:
16
+ #
17
+ # - Hidden entries.
18
+ # - Ignored entries.
19
+ # - Files whose extension is not `.rb`.
20
+ # - Nested root directories, since they represent separate trees.
21
+ # - Subdirectories that (recursively) contain no Ruby files.
22
+ #
23
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
24
+ # method recurses so that the caller gets a conceptually flat listing.
25
+ #
26
+ # For every entry that is not excluded, `ls` yields its basename, absolute
27
+ # path, and file type, which can only be :file or :directory.
28
+ #
15
29
  #: (String) { (String, String, Symbol) -> void } -> void
16
- def ls(dir)
30
+ def ls(dir, collapse: true, &)
17
31
  children = relevant_dir_entries(dir)
18
32
 
19
33
  # The order in which a directory is listed depends on the file system.
@@ -24,9 +38,14 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
24
38
  children.sort_by!(&:first)
25
39
 
26
40
  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
41
+ if ftype == :directory
42
+ if !has_at_least_one_ruby_file?(abspath)
43
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
44
+ next
45
+ elsif collapse && @loader.__collapse?(abspath)
46
+ ls(abspath, collapse: collapse, &)
47
+ next
48
+ end
30
49
  end
31
50
 
32
51
  yield basename, abspath, ftype
@@ -38,8 +57,47 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
38
57
  loop do
39
58
  yield abspath
40
59
  abspath, basename = File.split(abspath)
41
- break if basename == "/"
60
+ break if basename == '/'
61
+ end
62
+ end
63
+
64
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
65
+ # there is none, it returns `nil`.
66
+ #
67
+ # This method accounts for collapsed directories, which conceptually allow for
68
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
69
+ #
70
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
71
+ def has_exactly_one_nsfile?(cref, dir)
72
+ return unless @loader.nsfile
73
+
74
+ # When `dir` does not have any collapsed directories a simple lookup
75
+ # suffices. This is a common case worth optimizing.
76
+ unless @loader.__collapse_parent?(dir)
77
+ nsfile_abspath = File.join(dir, @loader.nsfile)
78
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
79
+ return nsfile_abspath
80
+ end
81
+ return
82
+ end
83
+
84
+ nsfile = nil
85
+
86
+ to_visit = [dir]
87
+ while (dir = to_visit.shift)
88
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
89
+ if ftype == :file && basename == @loader.nsfile
90
+ if nsfile
91
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
92
+ end
93
+ nsfile = abspath
94
+ elsif ftype == :directory && @loader.__collapse?(abspath)
95
+ to_visit << abspath
96
+ end
97
+ end
42
98
  end
99
+
100
+ nsfile
43
101
  end
44
102
 
45
103
  # Encodes the documented conventions.
@@ -55,7 +113,7 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
55
113
 
56
114
  #: (String) -> bool
57
115
  def rb_extension?(path)
58
- path.end_with?(".rb")
116
+ path.end_with?('.rb')
59
117
  end
60
118
 
61
119
  #: (String) -> bool
@@ -65,7 +123,7 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
65
123
 
66
124
  #: (String) -> bool
67
125
  def hidden?(basename)
68
- basename.start_with?(".")
126
+ basename.start_with?('.')
69
127
  end
70
128
 
71
129
  private
@@ -80,7 +138,7 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
80
138
 
81
139
  while (dir = to_visit.shift)
82
140
  relevant_dir_entries(dir) do |_, abspath, ftype|
83
- return true if :file == ftype
141
+ return true if ftype == :file
84
142
  to_visit << abspath
85
143
  end
86
144
  end
@@ -96,18 +154,9 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
96
154
  each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
97
155
  next if @loader.__ignored_path?(abspath)
98
156
 
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
157
+ if ftype == :file
158
+ yield basename, abspath, ftype
159
+ else
111
160
  # Conceptually, root directories represent a separate project tree.
112
161
  yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
113
162
  end
@@ -135,10 +184,10 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
135
184
  if rb_extension?(basename)
136
185
  abspath = File.join(dir, basename).freeze
137
186
  yield basename, abspath, :file # By convention.
138
- elsif :directory == ftype
187
+ elsif ftype == :directory
139
188
  abspath = File.join(dir, basename).freeze
140
189
  yield basename, abspath, :directory
141
- elsif :link == ftype
190
+ elsif ftype == :link
142
191
  abspath = File.join(dir, basename).freeze
143
192
  yield basename, abspath, :directory if dir?(abspath)
144
193
  end
@@ -155,9 +204,7 @@ class Zeitwerk::Loader::FileSystem # :nodoc:
155
204
  yield basename, abspath, :file # By convention.
156
205
  else
157
206
  abspath = File.join(dir, basename).freeze
158
- if dir?(abspath)
159
- yield basename, abspath, :directory
160
- end
207
+ yield basename, abspath, :directory if dir?(abspath)
161
208
  end
162
209
  end
163
210
  end
@@ -12,7 +12,7 @@ module Zeitwerk::Loader::Helpers
12
12
  raise TypeError, "#{inflector.class}#camelize must return a String, received #{cname.inspect}"
13
13
  end
14
14
 
15
- if cname.include?("::")
15
+ if cname.include?('::')
16
16
  raise Zeitwerk::NameError.new(<<~MESSAGE, cname)
17
17
  wrong constant name #{cname} inferred by #{inflector.class} from
18
18
 
@@ -25,7 +25,7 @@ module Zeitwerk::Loader::Helpers
25
25
  begin
26
26
  CNAME_VALIDATOR.const_defined?(cname, false)
27
27
  rescue ::NameError => error
28
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
28
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
29
29
 
30
30
  raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
31
31
  #{error.message} inferred by #{inflector.class} from #{path_type}