zeitwerk 2.5.4 → 2.6.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +394 -46
- data/lib/zeitwerk/error.rb +9 -0
- data/lib/zeitwerk/explicit_namespace.rb +14 -10
- data/lib/zeitwerk/gem_inflector.rb +2 -2
- data/lib/zeitwerk/gem_loader.rb +69 -0
- data/lib/zeitwerk/internal.rb +12 -0
- data/lib/zeitwerk/kernel.rb +6 -3
- data/lib/zeitwerk/loader/callbacks.rb +25 -17
- data/lib/zeitwerk/loader/config.rb +95 -52
- data/lib/zeitwerk/loader/eager_load.rb +234 -0
- data/lib/zeitwerk/loader/helpers.rb +106 -16
- data/lib/zeitwerk/loader.rb +222 -162
- data/lib/zeitwerk/registry.rb +9 -16
- 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
|
+
while to_eager_load = queue.shift
|
168
|
+
dir, namespace = to_eager_load
|
169
|
+
|
170
|
+
ls(dir) do |basename, abspath|
|
171
|
+
next if honour_exclusions && eager_load_exclusions.member?(abspath)
|
172
|
+
|
173
|
+
if ruby?(abspath)
|
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|
|
214
|
+
next unless dir?(abspath)
|
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,12 +12,28 @@ module Zeitwerk::Loader::Helpers
|
|
14
12
|
# --- Files and directories ---------------------------------------------------------------------
|
15
13
|
|
16
14
|
# @sig (String) { (String, String) -> void } -> void
|
17
|
-
def ls(dir)
|
18
|
-
Dir.
|
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|
|
19
26
|
next if hidden?(basename)
|
20
27
|
|
21
28
|
abspath = File.join(dir, basename)
|
22
|
-
next if
|
29
|
+
next if ignored_path?(abspath)
|
30
|
+
|
31
|
+
if dir?(abspath)
|
32
|
+
next if roots.key?(abspath)
|
33
|
+
next if !has_at_least_one_ruby_file?(abspath)
|
34
|
+
else
|
35
|
+
next unless ruby?(abspath)
|
36
|
+
end
|
23
37
|
|
24
38
|
# We freeze abspath because that saves allocations when passed later to
|
25
39
|
# File methods. See #125.
|
@@ -28,20 +42,46 @@ module Zeitwerk::Loader::Helpers
|
|
28
42
|
end
|
29
43
|
|
30
44
|
# @sig (String) -> bool
|
31
|
-
def
|
45
|
+
private def has_at_least_one_ruby_file?(dir)
|
46
|
+
to_visit = [dir]
|
47
|
+
|
48
|
+
while dir = to_visit.shift
|
49
|
+
ls(dir) do |_basename, abspath|
|
50
|
+
if dir?(abspath)
|
51
|
+
to_visit << abspath
|
52
|
+
else
|
53
|
+
return true
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
false
|
59
|
+
end
|
60
|
+
|
61
|
+
# @sig (String) -> bool
|
62
|
+
private def ruby?(path)
|
32
63
|
path.end_with?(".rb")
|
33
64
|
end
|
34
65
|
|
35
66
|
# @sig (String) -> bool
|
36
|
-
def dir?(path)
|
67
|
+
private def dir?(path)
|
37
68
|
File.directory?(path)
|
38
69
|
end
|
39
70
|
|
40
|
-
# @sig String -> bool
|
41
|
-
def hidden?(basename)
|
71
|
+
# @sig (String) -> bool
|
72
|
+
private def hidden?(basename)
|
42
73
|
basename.start_with?(".")
|
43
74
|
end
|
44
75
|
|
76
|
+
# @sig (String) { (String) -> void } -> void
|
77
|
+
private def walk_up(abspath)
|
78
|
+
loop do
|
79
|
+
yield abspath
|
80
|
+
abspath, basename = File.split(abspath)
|
81
|
+
break if basename == "/"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
45
85
|
# --- Constants ---------------------------------------------------------------------------------
|
46
86
|
|
47
87
|
# The autoload? predicate takes into account the ancestor chain of the
|
@@ -62,11 +102,11 @@ module Zeitwerk::Loader::Helpers
|
|
62
102
|
#
|
63
103
|
# @sig (Module, Symbol) -> String?
|
64
104
|
if method(:autoload?).arity == 1
|
65
|
-
def strict_autoload_path(parent, cname)
|
105
|
+
private def strict_autoload_path(parent, cname)
|
66
106
|
parent.autoload?(cname) if cdef?(parent, cname)
|
67
107
|
end
|
68
108
|
else
|
69
|
-
def strict_autoload_path(parent, cname)
|
109
|
+
private def strict_autoload_path(parent, cname)
|
70
110
|
parent.autoload?(cname, false)
|
71
111
|
end
|
72
112
|
end
|
@@ -75,23 +115,73 @@ module Zeitwerk::Loader::Helpers
|
|
75
115
|
if Symbol.method_defined?(:name)
|
76
116
|
# Symbol#name was introduced in Ruby 3.0. It returns always the same
|
77
117
|
# frozen object, so we may save a few string allocations.
|
78
|
-
def cpath(parent, cname)
|
118
|
+
private def cpath(parent, cname)
|
79
119
|
Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
|
80
120
|
end
|
81
121
|
else
|
82
|
-
def cpath(parent, cname)
|
122
|
+
private def cpath(parent, cname)
|
83
123
|
Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
|
84
124
|
end
|
85
125
|
end
|
86
126
|
|
87
127
|
# @sig (Module, Symbol) -> bool
|
88
|
-
def cdef?(parent, cname)
|
128
|
+
private def cdef?(parent, cname)
|
89
129
|
parent.const_defined?(cname, false)
|
90
130
|
end
|
91
131
|
|
92
132
|
# @raise [NameError]
|
93
133
|
# @sig (Module, Symbol) -> Object
|
94
|
-
def cget(parent, cname)
|
134
|
+
private def cget(parent, cname)
|
95
135
|
parent.const_get(cname, false)
|
96
136
|
end
|
137
|
+
|
138
|
+
# @raise [NameError]
|
139
|
+
# @sig (Module, Symbol) -> Object
|
140
|
+
private def crem(parent, cname)
|
141
|
+
parent.__send__(:remove_const, cname)
|
142
|
+
end
|
143
|
+
|
144
|
+
CNAME_VALIDATOR = Module.new
|
145
|
+
private_constant :CNAME_VALIDATOR
|
146
|
+
|
147
|
+
# @raise [Zeitwerk::NameError]
|
148
|
+
# @sig (String, String) -> Symbol
|
149
|
+
private def cname_for(basename, abspath)
|
150
|
+
cname = inflector.camelize(basename, abspath)
|
151
|
+
|
152
|
+
unless cname.is_a?(String)
|
153
|
+
raise TypeError, "#{inflector.class}#camelize must return a String, received #{cname.inspect}"
|
154
|
+
end
|
155
|
+
|
156
|
+
if cname.include?("::")
|
157
|
+
raise Zeitwerk::NameError.new(<<~MESSAGE, cname)
|
158
|
+
wrong constant name #{cname} inferred by #{inflector.class} from
|
159
|
+
|
160
|
+
#{abspath}
|
161
|
+
|
162
|
+
#{inflector.class}#camelize should return a simple constant name without "::"
|
163
|
+
MESSAGE
|
164
|
+
end
|
165
|
+
|
166
|
+
begin
|
167
|
+
CNAME_VALIDATOR.const_defined?(cname, false)
|
168
|
+
rescue ::NameError => error
|
169
|
+
path_type = ruby?(abspath) ? "file" : "directory"
|
170
|
+
|
171
|
+
raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
|
172
|
+
#{error.message} inferred by #{inflector.class} from #{path_type}
|
173
|
+
|
174
|
+
#{abspath}
|
175
|
+
|
176
|
+
Possible ways to address this:
|
177
|
+
|
178
|
+
* Tell Zeitwerk to ignore this particular #{path_type}.
|
179
|
+
* Tell Zeitwerk to ignore one of its parent directories.
|
180
|
+
* Rename the #{path_type} to comply with the naming conventions.
|
181
|
+
* Modify the inflector to handle this case.
|
182
|
+
MESSAGE
|
183
|
+
end
|
184
|
+
|
185
|
+
cname.to_sym
|
186
|
+
end
|
97
187
|
end
|