zeitwerk 2.7.4 → 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.
@@ -5,9 +5,9 @@ module Zeitwerk
5
5
  # Very basic snake case -> camel case conversion.
6
6
  #
7
7
  # inflector = Zeitwerk::Inflector.new
8
- # inflector.camelize("post", ...) # => "Post"
9
- # inflector.camelize("users_controller", ...) # => "UsersController"
10
- # inflector.camelize("api", ...) # => "Api"
8
+ # inflector.camelize('post', ...) # => 'Post'
9
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
10
+ # inflector.camelize('api', ...) # => 'Api'
11
11
  #
12
12
  # Takes into account hard-coded mappings configured with `inflect`.
13
13
  #
@@ -20,13 +20,13 @@ module Zeitwerk
20
20
  #
21
21
  # inflector = Zeitwerk::Inflector.new
22
22
  # inflector.inflect(
23
- # "html_parser" => "HTMLParser",
24
- # "mysql_adapter" => "MySQLAdapter"
23
+ # 'html_parser' => 'HTMLParser',
24
+ # 'mysql_adapter' => 'MySQLAdapter'
25
25
  # )
26
26
  #
27
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
28
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
29
- # inflector.camelize("users_controller", abspath) # => "UsersController"
27
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
28
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
29
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
30
30
  #
31
31
  #: (Hash[String, String]) -> void
32
32
  def inflect(inflections)
@@ -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
 
@@ -77,7 +77,7 @@ module Zeitwerk::Loader::Callbacks # :nodoc: all
77
77
  internal def on_namespace_loaded(cref, namespace)
78
78
  if dirs = namespace_dirs.delete(cref)
79
79
  dirs.each do |dir|
80
- define_autoloads_for_dir(dir, namespace)
80
+ define_autoloads_for_dir(dir, namespace, external: false)
81
81
  end
82
82
  end
83
83
  end
@@ -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,12 +129,12 @@ 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)
119
- if dir?(abspath)
120
- raise_if_conflicting_directory(abspath)
136
+ if @fs.dir?(abspath)
137
+ raise_if_conflicting_root_dir(abspath)
121
138
  roots[abspath] = namespace
122
139
  else
123
140
  raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
@@ -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
@@ -290,49 +323,54 @@ module Zeitwerk::Loader::Config
290
323
  # Common use case.
291
324
  return false if ignored_paths.empty?
292
325
 
293
- walk_up(abspath) do |path|
326
+ @fs.walk_up(abspath) do |path|
294
327
  return true if ignored_path?(path)
295
- return false if roots.key?(path)
328
+ return false if root_dir?(path)
296
329
  end
297
330
 
298
331
  false
299
332
  end
300
333
 
301
334
  #: (String) -> bool
302
- private def ignored_path?(abspath)
335
+ internal def ignored_path?(abspath)
303
336
  ignored_paths.member?(abspath)
304
337
  end
305
338
 
306
339
  #: () -> Array[String]
307
340
  private def actual_roots
308
341
  roots.reject do |root_dir, _root_namespace|
309
- !dir?(root_dir) || ignored_path?(root_dir)
342
+ !@fs.dir?(root_dir) || ignored_path?(root_dir)
310
343
  end
311
344
  end
312
345
 
313
346
  #: (String) -> bool
314
- private def root_dir?(dir)
347
+ internal def root_dir?(dir)
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.
321
364
  return false if eager_load_exclusions.empty?
322
365
 
323
- walk_up(abspath) do |path|
366
+ @fs.walk_up(abspath) do |path|
324
367
  return true if eager_load_exclusions.member?(path)
325
- return false if roots.key?(path)
368
+ return false if root_dir?(path)
326
369
  end
327
370
 
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") if logger
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") if logger
28
+ log { 'eager load end' }
28
29
  end
29
30
  end
30
31
 
@@ -34,23 +35,21 @@ module Zeitwerk::Loader::EagerLoad
34
35
 
35
36
  abspath = File.expand_path(path)
36
37
 
37
- raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
38
+ raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
38
39
 
39
- cnames = []
40
+ paths = []
40
41
 
41
42
  root_namespace = nil
42
- walk_up(abspath) do |dir|
43
+ @fs.walk_up(abspath) do |dir|
43
44
  return if ignored_path?(dir)
44
45
  return if eager_load_exclusions.member?(dir)
45
46
 
46
47
  break if root_namespace = roots[dir]
47
48
 
48
49
  basename = File.basename(dir)
49
- return if hidden?(basename)
50
+ return if @fs.hidden?(basename)
50
51
 
51
- unless collapse?(dir)
52
- cnames << inflector.camelize(basename, dir).to_sym
53
- end
52
+ paths << [basename, dir] unless collapse?(dir)
54
53
  end
55
54
 
56
55
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
@@ -58,7 +57,8 @@ module Zeitwerk::Loader::EagerLoad
58
57
  return if @eager_loaded
59
58
 
60
59
  namespace = root_namespace
61
- cnames.reverse_each do |cname|
60
+ paths.reverse_each do |basename, dir|
61
+ cname = cname_for(basename, dir)
62
62
  # Can happen if there are no Ruby files. This is not an error condition,
63
63
  # the directory is actually managed. Could have Ruby files later.
64
64
  return unless namespace.const_defined?(cname, false)
@@ -92,11 +92,11 @@ module Zeitwerk::Loader::EagerLoad
92
92
  eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
93
93
  else
94
94
  root_namespace_name = real_mod_name(root_namespace)
95
- if root_namespace_name.start_with?(mod_name + "::")
95
+ if root_namespace_name.start_with?(mod_name + '::')
96
96
  actual_eager_load_dir(root_dir, root_namespace)
97
97
  elsif mod_name == root_namespace_name
98
98
  actual_eager_load_dir(root_dir, root_namespace)
99
- elsif mod_name.start_with?(root_namespace_name + "::")
99
+ elsif mod_name.start_with?(root_namespace_name + '::')
100
100
  eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
101
101
  else
102
102
  # Unrelated constant hierarchies, do nothing.
@@ -117,40 +117,42 @@ module Zeitwerk::Loader::EagerLoad
117
117
  abspath = File.expand_path(path)
118
118
 
119
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 dir?(abspath) || !ruby?(abspath)
120
+ raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
121
121
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
122
122
 
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
123
+ file_basename = File.basename(abspath)
124
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
127
125
 
128
126
  root_namespace = nil
129
- cnames = []
127
+ paths = []
130
128
 
131
- walk_up(File.dirname(abspath)) do |dir|
129
+ @fs.walk_up(File.dirname(abspath)) do |dir|
132
130
  raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
133
131
 
134
132
  break if root_namespace = roots[dir]
135
133
 
136
134
  basename = File.basename(dir)
137
- raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
135
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
138
136
 
139
- unless collapse?(dir)
140
- cnames << inflector.camelize(basename, dir).to_sym
141
- end
137
+ paths << [basename, dir] unless collapse?(dir)
142
138
  end
143
139
 
144
140
  raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
145
141
 
146
142
  namespace = root_namespace
147
- cnames.reverse_each do |cname|
143
+ paths.reverse_each do |basename, dir|
144
+ cname = cname_for(basename, dir)
148
145
  namespace = namespace.const_get(cname, false)
149
146
  end
150
147
 
151
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
152
-
153
- 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
154
156
  end
155
157
 
156
158
  # The caller is responsible for making sure `namespace` is the namespace that
@@ -161,11 +163,11 @@ module Zeitwerk::Loader::EagerLoad
161
163
  honour_exclusions = !force
162
164
  return if honour_exclusions && excluded_from_eager_load?(dir)
163
165
 
164
- log("eager load directory #{dir} start") if logger
166
+ log { "eager load directory #{dir} start" }
165
167
 
166
168
  queue = [[dir, namespace]]
167
169
  while (current_dir, namespace = queue.shift)
168
- ls(current_dir) do |basename, abspath, ftype|
170
+ @fs.ls(current_dir) do |basename, abspath, ftype|
169
171
  next if honour_exclusions && eager_load_exclusions.member?(abspath)
170
172
 
171
173
  if ftype == :file
@@ -173,17 +175,13 @@ module Zeitwerk::Loader::EagerLoad
173
175
  cref.get
174
176
  end
175
177
  else
176
- if collapse?(abspath)
177
- queue << [abspath, namespace]
178
- else
179
- cname = inflector.camelize(basename, abspath).to_sym
180
- queue << [abspath, namespace.const_get(cname, false)]
181
- end
178
+ cname = cname_for(basename, abspath)
179
+ queue << [abspath, namespace.const_get(cname, false)]
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
@@ -193,7 +191,7 @@ module Zeitwerk::Loader::EagerLoad
193
191
  private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
194
192
  suffix = child_name
195
193
  unless root_namespace.equal?(Object)
196
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
194
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
197
195
  end
198
196
 
199
197
  # These directories are at the same namespace level, there may be more if
@@ -206,14 +204,10 @@ module Zeitwerk::Loader::EagerLoad
206
204
  dirs = [root_dir]
207
205
  next_dirs = []
208
206
 
209
- suffix.split("::").each do |segment|
207
+ suffix.split('::').each do |segment|
210
208
  while (dir = dirs.shift)
211
- ls(dir) do |basename, abspath, ftype|
212
- next unless ftype == :directory
213
-
214
- if collapse?(abspath)
215
- dirs << abspath
216
- elsif segment == inflector.camelize(basename, abspath)
209
+ @fs.ls(dir) do |basename, abspath, ftype|
210
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
217
211
  next_dirs << abspath
218
212
  end
219
213
  end