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.
@@ -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 ignored_paths.member?(abspath)
29
+ next if ignored_path?(abspath)
32
30
 
33
31
  if dir?(abspath)
34
- next unless has_at_least_one_ruby_file?(abspath)
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
- ls(dir) do |_basename, abspath|
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