zeitwerk 2.6.0 → 2.6.15
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 +4 -4
- data/README.md +430 -51
- data/lib/zeitwerk/error.rb +6 -0
- data/lib/zeitwerk/explicit_namespace.rb +14 -10
- data/lib/zeitwerk/gem_inflector.rb +2 -2
- data/lib/zeitwerk/gem_loader.rb +12 -9
- data/lib/zeitwerk/internal.rb +12 -0
- data/lib/zeitwerk/kernel.rb +6 -7
- data/lib/zeitwerk/loader/callbacks.rb +25 -17
- data/lib/zeitwerk/loader/config.rb +95 -51
- data/lib/zeitwerk/loader/eager_load.rb +234 -0
- data/lib/zeitwerk/loader/helpers.rb +92 -21
- data/lib/zeitwerk/loader.rb +224 -129
- data/lib/zeitwerk/null_inflector.rb +5 -0
- data/lib/zeitwerk/registry.rb +2 -2
- data/lib/zeitwerk/version.rb +1 -1
- data/lib/zeitwerk.rb +2 -0
- metadata +6 -3
@@ -0,0 +1,234 @@
|
|
1
|
+
module Zeitwerk::Loader::EagerLoad
|
2
|
+
# Eager loads all files in the root directories, recursively. Files do not
|
3
|
+
# need to be in `$LOAD_PATH`, absolute file names are used. 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`.
|
7
|
+
#
|
8
|
+
# @sig (true | false) -> void
|
9
|
+
def eager_load(force: false)
|
10
|
+
mutex.synchronize do
|
11
|
+
break if @eager_loaded
|
12
|
+
raise Zeitwerk::SetupRequired unless @setup
|
13
|
+
|
14
|
+
log("eager load start") if logger
|
15
|
+
|
16
|
+
actual_roots.each do |root_dir, root_namespace|
|
17
|
+
actual_eager_load_dir(root_dir, root_namespace, force: force)
|
18
|
+
end
|
19
|
+
|
20
|
+
autoloaded_dirs.each do |autoloaded_dir|
|
21
|
+
Zeitwerk::Registry.unregister_autoload(autoloaded_dir)
|
22
|
+
end
|
23
|
+
autoloaded_dirs.clear
|
24
|
+
|
25
|
+
@eager_loaded = true
|
26
|
+
|
27
|
+
log("eager load end") if logger
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# @sig (String | Pathname) -> void
|
32
|
+
def eager_load_dir(path)
|
33
|
+
raise Zeitwerk::SetupRequired unless @setup
|
34
|
+
|
35
|
+
abspath = File.expand_path(path)
|
36
|
+
|
37
|
+
raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
|
38
|
+
|
39
|
+
cnames = []
|
40
|
+
|
41
|
+
root_namespace = nil
|
42
|
+
walk_up(abspath) do |dir|
|
43
|
+
return if ignored_path?(dir)
|
44
|
+
return if eager_load_exclusions.member?(dir)
|
45
|
+
|
46
|
+
break if root_namespace = roots[dir]
|
47
|
+
|
48
|
+
basename = File.basename(dir)
|
49
|
+
return if hidden?(basename)
|
50
|
+
|
51
|
+
unless collapse?(dir)
|
52
|
+
cnames << inflector.camelize(basename, dir).to_sym
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
|
57
|
+
|
58
|
+
return if @eager_loaded
|
59
|
+
|
60
|
+
namespace = root_namespace
|
61
|
+
cnames.reverse_each do |cname|
|
62
|
+
# Can happen if there are no Ruby files. This is not an error condition,
|
63
|
+
# the directory is actually managed. Could have Ruby files later.
|
64
|
+
return unless cdef?(namespace, cname)
|
65
|
+
namespace = cget(namespace, cname)
|
66
|
+
end
|
67
|
+
|
68
|
+
# A shortcircuiting test depends on the invocation of this method. Please
|
69
|
+
# keep them in sync if refactored.
|
70
|
+
actual_eager_load_dir(abspath, namespace)
|
71
|
+
end
|
72
|
+
|
73
|
+
# @sig (Module) -> void
|
74
|
+
def eager_load_namespace(mod)
|
75
|
+
raise Zeitwerk::SetupRequired unless @setup
|
76
|
+
|
77
|
+
unless mod.is_a?(Module)
|
78
|
+
raise Zeitwerk::Error, "#{mod.inspect} is not a class or module object"
|
79
|
+
end
|
80
|
+
|
81
|
+
return if @eager_loaded
|
82
|
+
|
83
|
+
mod_name = real_mod_name(mod)
|
84
|
+
return unless mod_name
|
85
|
+
|
86
|
+
actual_roots.each do |root_dir, root_namespace|
|
87
|
+
if mod.equal?(Object)
|
88
|
+
# A shortcircuiting test depends on the invocation of this method.
|
89
|
+
# Please keep them in sync if refactored.
|
90
|
+
actual_eager_load_dir(root_dir, root_namespace)
|
91
|
+
elsif root_namespace.equal?(Object)
|
92
|
+
eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
|
93
|
+
else
|
94
|
+
root_namespace_name = real_mod_name(root_namespace)
|
95
|
+
if root_namespace_name.start_with?(mod_name + "::")
|
96
|
+
actual_eager_load_dir(root_dir, root_namespace)
|
97
|
+
elsif mod_name == root_namespace_name
|
98
|
+
actual_eager_load_dir(root_dir, root_namespace)
|
99
|
+
elsif mod_name.start_with?(root_namespace_name + "::")
|
100
|
+
eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
|
101
|
+
else
|
102
|
+
# Unrelated constant hierarchies, do nothing.
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Loads the given Ruby file.
|
109
|
+
#
|
110
|
+
# Raises if the argument is ignored, shadowed, or not managed by the receiver.
|
111
|
+
#
|
112
|
+
# The method is implemented as `constantize` for files, in a sense, to be able
|
113
|
+
# to descend orderly and make sure the file is loadable.
|
114
|
+
#
|
115
|
+
# @sig (String | Pathname) -> void
|
116
|
+
def load_file(path)
|
117
|
+
abspath = File.expand_path(path)
|
118
|
+
|
119
|
+
raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
|
120
|
+
raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
|
121
|
+
raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
|
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
|
127
|
+
|
128
|
+
root_namespace = nil
|
129
|
+
cnames = []
|
130
|
+
|
131
|
+
walk_up(File.dirname(abspath)) do |dir|
|
132
|
+
raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
|
133
|
+
|
134
|
+
break if root_namespace = roots[dir]
|
135
|
+
|
136
|
+
basename = File.basename(dir)
|
137
|
+
raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
|
138
|
+
|
139
|
+
unless collapse?(dir)
|
140
|
+
cnames << inflector.camelize(basename, dir).to_sym
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
|
145
|
+
|
146
|
+
namespace = root_namespace
|
147
|
+
cnames.reverse_each do |cname|
|
148
|
+
namespace = cget(namespace, cname)
|
149
|
+
end
|
150
|
+
|
151
|
+
raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
|
152
|
+
|
153
|
+
cget(namespace, base_cname)
|
154
|
+
end
|
155
|
+
|
156
|
+
# The caller is responsible for making sure `namespace` is the namespace that
|
157
|
+
# corresponds to `dir`.
|
158
|
+
#
|
159
|
+
# @sig (String, Module, Boolean) -> void
|
160
|
+
private def actual_eager_load_dir(dir, namespace, force: false)
|
161
|
+
honour_exclusions = !force
|
162
|
+
return if honour_exclusions && excluded_from_eager_load?(dir)
|
163
|
+
|
164
|
+
log("eager load directory #{dir} start") if logger
|
165
|
+
|
166
|
+
queue = [[dir, namespace]]
|
167
|
+
until queue.empty?
|
168
|
+
dir, namespace = queue.shift
|
169
|
+
|
170
|
+
ls(dir) do |basename, abspath, ftype|
|
171
|
+
next if honour_exclusions && eager_load_exclusions.member?(abspath)
|
172
|
+
|
173
|
+
if ftype == :file
|
174
|
+
if (cref = autoloads[abspath])
|
175
|
+
cget(*cref)
|
176
|
+
end
|
177
|
+
else
|
178
|
+
if collapse?(abspath)
|
179
|
+
queue << [abspath, namespace]
|
180
|
+
else
|
181
|
+
cname = inflector.camelize(basename, abspath).to_sym
|
182
|
+
queue << [abspath, cget(namespace, cname)]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
log("eager load directory #{dir} end") if logger
|
189
|
+
end
|
190
|
+
|
191
|
+
# In order to invoke this method, the caller has to ensure `child` is a
|
192
|
+
# strict namespace descendant of `root_namespace`.
|
193
|
+
#
|
194
|
+
# @sig (Module, String, Module, Boolean) -> void
|
195
|
+
private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
|
196
|
+
suffix = child_name
|
197
|
+
unless root_namespace.equal?(Object)
|
198
|
+
suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
|
199
|
+
end
|
200
|
+
|
201
|
+
# These directories are at the same namespace level, there may be more if
|
202
|
+
# we find collapsed ones. As we scan, we look for matches for the first
|
203
|
+
# segment, and store them in `next_dirs`. If there are any, we look for
|
204
|
+
# the next segments in those matches. Repeat.
|
205
|
+
#
|
206
|
+
# If we exhaust the search locating directories that match all segments,
|
207
|
+
# we just need to eager load those ones.
|
208
|
+
dirs = [root_dir]
|
209
|
+
next_dirs = []
|
210
|
+
|
211
|
+
suffix.split("::").each do |segment|
|
212
|
+
while (dir = dirs.shift)
|
213
|
+
ls(dir) do |basename, abspath, ftype|
|
214
|
+
next unless ftype == :directory
|
215
|
+
|
216
|
+
if collapse?(abspath)
|
217
|
+
dirs << abspath
|
218
|
+
elsif segment == inflector.camelize(basename, abspath)
|
219
|
+
next_dirs << abspath
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
return if next_dirs.empty?
|
225
|
+
|
226
|
+
dirs.replace(next_dirs)
|
227
|
+
next_dirs.clear
|
228
|
+
end
|
229
|
+
|
230
|
+
dirs.each do |dir|
|
231
|
+
actual_eager_load_dir(dir, child)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -1,12 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Zeitwerk::Loader::Helpers
|
4
|
-
private
|
5
|
-
|
6
4
|
# --- Logging -----------------------------------------------------------------------------------
|
7
5
|
|
8
6
|
# @sig (String) -> void
|
9
|
-
def log(message)
|
7
|
+
private def log(message)
|
10
8
|
method_name = logger.respond_to?(:debug) ? :debug : :call
|
11
9
|
logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
|
12
10
|
end
|
@@ -14,7 +12,7 @@ module Zeitwerk::Loader::Helpers
|
|
14
12
|
# --- Files and directories ---------------------------------------------------------------------
|
15
13
|
|
16
14
|
# @sig (String) { (String, String) -> void } -> void
|
17
|
-
def ls(dir)
|
15
|
+
private def ls(dir)
|
18
16
|
children = Dir.children(dir)
|
19
17
|
|
20
18
|
# The order in which a directory is listed depends on the file system.
|
@@ -28,30 +26,44 @@ module Zeitwerk::Loader::Helpers
|
|
28
26
|
next if hidden?(basename)
|
29
27
|
|
30
28
|
abspath = File.join(dir, basename)
|
31
|
-
next if
|
29
|
+
next if ignored_path?(abspath)
|
32
30
|
|
33
31
|
if dir?(abspath)
|
34
|
-
next
|
32
|
+
next if roots.key?(abspath)
|
33
|
+
next if !has_at_least_one_ruby_file?(abspath)
|
34
|
+
ftype = :directory
|
35
35
|
else
|
36
36
|
next unless ruby?(abspath)
|
37
|
+
ftype = :file
|
37
38
|
end
|
38
39
|
|
39
40
|
# We freeze abspath because that saves allocations when passed later to
|
40
41
|
# File methods. See #125.
|
41
|
-
yield basename, abspath.freeze
|
42
|
+
yield basename, abspath.freeze, ftype
|
42
43
|
end
|
43
44
|
end
|
44
45
|
|
46
|
+
# Looks for a Ruby file using breadth-first search. This type of search is
|
47
|
+
# important to list as less directories as possible and return fast in the
|
48
|
+
# common case in which there are Ruby files.
|
49
|
+
#
|
45
50
|
# @sig (String) -> bool
|
46
|
-
def has_at_least_one_ruby_file?(dir)
|
51
|
+
private def has_at_least_one_ruby_file?(dir)
|
47
52
|
to_visit = [dir]
|
48
53
|
|
49
|
-
while dir = to_visit.shift
|
50
|
-
|
54
|
+
while (dir = to_visit.shift)
|
55
|
+
children = Dir.children(dir)
|
56
|
+
|
57
|
+
children.each do |basename|
|
58
|
+
next if hidden?(basename)
|
59
|
+
|
60
|
+
abspath = File.join(dir, basename)
|
61
|
+
next if ignored_path?(abspath)
|
62
|
+
|
51
63
|
if dir?(abspath)
|
52
|
-
to_visit << abspath
|
64
|
+
to_visit << abspath unless roots.key?(abspath)
|
53
65
|
else
|
54
|
-
return true
|
66
|
+
return true if ruby?(abspath)
|
55
67
|
end
|
56
68
|
end
|
57
69
|
end
|
@@ -60,20 +72,29 @@ module Zeitwerk::Loader::Helpers
|
|
60
72
|
end
|
61
73
|
|
62
74
|
# @sig (String) -> bool
|
63
|
-
def ruby?(path)
|
75
|
+
private def ruby?(path)
|
64
76
|
path.end_with?(".rb")
|
65
77
|
end
|
66
78
|
|
67
79
|
# @sig (String) -> bool
|
68
|
-
def dir?(path)
|
80
|
+
private def dir?(path)
|
69
81
|
File.directory?(path)
|
70
82
|
end
|
71
83
|
|
72
84
|
# @sig (String) -> bool
|
73
|
-
def hidden?(basename)
|
85
|
+
private def hidden?(basename)
|
74
86
|
basename.start_with?(".")
|
75
87
|
end
|
76
88
|
|
89
|
+
# @sig (String) { (String) -> void } -> void
|
90
|
+
private def walk_up(abspath)
|
91
|
+
loop do
|
92
|
+
yield abspath
|
93
|
+
abspath, basename = File.split(abspath)
|
94
|
+
break if basename == "/"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
77
98
|
# --- Constants ---------------------------------------------------------------------------------
|
78
99
|
|
79
100
|
# The autoload? predicate takes into account the ancestor chain of the
|
@@ -94,11 +115,11 @@ module Zeitwerk::Loader::Helpers
|
|
94
115
|
#
|
95
116
|
# @sig (Module, Symbol) -> String?
|
96
117
|
if method(:autoload?).arity == 1
|
97
|
-
def strict_autoload_path(parent, cname)
|
118
|
+
private def strict_autoload_path(parent, cname)
|
98
119
|
parent.autoload?(cname) if cdef?(parent, cname)
|
99
120
|
end
|
100
121
|
else
|
101
|
-
def strict_autoload_path(parent, cname)
|
122
|
+
private def strict_autoload_path(parent, cname)
|
102
123
|
parent.autoload?(cname, false)
|
103
124
|
end
|
104
125
|
end
|
@@ -107,23 +128,73 @@ module Zeitwerk::Loader::Helpers
|
|
107
128
|
if Symbol.method_defined?(:name)
|
108
129
|
# Symbol#name was introduced in Ruby 3.0. It returns always the same
|
109
130
|
# frozen object, so we may save a few string allocations.
|
110
|
-
def cpath(parent, cname)
|
131
|
+
private def cpath(parent, cname)
|
111
132
|
Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
|
112
133
|
end
|
113
134
|
else
|
114
|
-
def cpath(parent, cname)
|
135
|
+
private def cpath(parent, cname)
|
115
136
|
Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
|
116
137
|
end
|
117
138
|
end
|
118
139
|
|
119
140
|
# @sig (Module, Symbol) -> bool
|
120
|
-
def cdef?(parent, cname)
|
141
|
+
private def cdef?(parent, cname)
|
121
142
|
parent.const_defined?(cname, false)
|
122
143
|
end
|
123
144
|
|
124
145
|
# @raise [NameError]
|
125
146
|
# @sig (Module, Symbol) -> Object
|
126
|
-
def cget(parent, cname)
|
147
|
+
private def cget(parent, cname)
|
127
148
|
parent.const_get(cname, false)
|
128
149
|
end
|
150
|
+
|
151
|
+
# @raise [NameError]
|
152
|
+
# @sig (Module, Symbol) -> Object
|
153
|
+
private def crem(parent, cname)
|
154
|
+
parent.__send__(:remove_const, cname)
|
155
|
+
end
|
156
|
+
|
157
|
+
CNAME_VALIDATOR = Module.new
|
158
|
+
private_constant :CNAME_VALIDATOR
|
159
|
+
|
160
|
+
# @raise [Zeitwerk::NameError]
|
161
|
+
# @sig (String, String) -> Symbol
|
162
|
+
private def cname_for(basename, abspath)
|
163
|
+
cname = inflector.camelize(basename, abspath)
|
164
|
+
|
165
|
+
unless cname.is_a?(String)
|
166
|
+
raise TypeError, "#{inflector.class}#camelize must return a String, received #{cname.inspect}"
|
167
|
+
end
|
168
|
+
|
169
|
+
if cname.include?("::")
|
170
|
+
raise Zeitwerk::NameError.new(<<~MESSAGE, cname)
|
171
|
+
wrong constant name #{cname} inferred by #{inflector.class} from
|
172
|
+
|
173
|
+
#{abspath}
|
174
|
+
|
175
|
+
#{inflector.class}#camelize should return a simple constant name without "::"
|
176
|
+
MESSAGE
|
177
|
+
end
|
178
|
+
|
179
|
+
begin
|
180
|
+
CNAME_VALIDATOR.const_defined?(cname, false)
|
181
|
+
rescue ::NameError => error
|
182
|
+
path_type = ruby?(abspath) ? "file" : "directory"
|
183
|
+
|
184
|
+
raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
|
185
|
+
#{error.message} inferred by #{inflector.class} from #{path_type}
|
186
|
+
|
187
|
+
#{abspath}
|
188
|
+
|
189
|
+
Possible ways to address this:
|
190
|
+
|
191
|
+
* Tell Zeitwerk to ignore this particular #{path_type}.
|
192
|
+
* Tell Zeitwerk to ignore one of its parent directories.
|
193
|
+
* Rename the #{path_type} to comply with the naming conventions.
|
194
|
+
* Modify the inflector to handle this case.
|
195
|
+
MESSAGE
|
196
|
+
end
|
197
|
+
|
198
|
+
cname.to_sym
|
199
|
+
end
|
129
200
|
end
|