opal-zeitwerk 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +20 -20
- data/README.md +1073 -466
- data/lib/opal/zeitwerk/version.rb +6 -6
- data/lib/opal-zeitwerk.rb +4 -4
- data/opal/zeitwerk/error.rb +10 -10
- data/opal/zeitwerk/explicit_namespace.rb +78 -71
- data/opal/zeitwerk/gem_inflector.rb +15 -0
- data/opal/zeitwerk/inflector.rb +44 -47
- data/opal/zeitwerk/kernel.rb +64 -32
- data/opal/zeitwerk/loader/callbacks.rb +88 -58
- data/opal/zeitwerk/loader/config.rb +301 -0
- data/opal/zeitwerk/loader/helpers.rb +115 -0
- data/opal/zeitwerk/loader.rb +170 -435
- data/opal/zeitwerk/real_mod_name.rb +20 -21
- data/opal/zeitwerk/registry.rb +143 -121
- data/opal/zeitwerk.rb +14 -13
- metadata +8 -5
@@ -0,0 +1,301 @@
|
|
1
|
+
require "set"
|
2
|
+
require "securerandom"
|
3
|
+
|
4
|
+
module Zeitwerk::Loader::Config
|
5
|
+
# Absolute paths of the root directories. Stored in a hash to preserve
|
6
|
+
# order, easily handle duplicates, and also be able to have a fast lookup,
|
7
|
+
# needed for detecting nested paths.
|
8
|
+
#
|
9
|
+
# "/Users/fxn/blog/app/assets" => true,
|
10
|
+
# "/Users/fxn/blog/app/channels" => true,
|
11
|
+
# ...
|
12
|
+
#
|
13
|
+
# This is a private collection maintained by the loader. The public
|
14
|
+
# interface for it is `push_dir` and `dirs`.
|
15
|
+
#
|
16
|
+
# @private
|
17
|
+
# @sig Hash[String, true]
|
18
|
+
attr_reader :root_dirs
|
19
|
+
|
20
|
+
# @sig #camelize
|
21
|
+
attr_accessor :inflector
|
22
|
+
|
23
|
+
# Absolute paths of files, directories, or glob patterns to be totally
|
24
|
+
# ignored.
|
25
|
+
#
|
26
|
+
# @private
|
27
|
+
# @sig Set[String]
|
28
|
+
attr_reader :ignored_glob_patterns
|
29
|
+
|
30
|
+
# The actual collection of absolute file and directory names at the time the
|
31
|
+
# ignored glob patterns were expanded. Computed on setup, and recomputed on
|
32
|
+
# reload.
|
33
|
+
#
|
34
|
+
# @private
|
35
|
+
# @sig Set[String]
|
36
|
+
attr_reader :ignored_paths
|
37
|
+
|
38
|
+
# Absolute paths of directories or glob patterns to be collapsed.
|
39
|
+
#
|
40
|
+
# @private
|
41
|
+
# @sig Set[String]
|
42
|
+
attr_reader :collapse_glob_patterns
|
43
|
+
|
44
|
+
# The actual collection of absolute directory names at the time the collapse
|
45
|
+
# glob patterns were expanded. Computed on setup, and recomputed on reload.
|
46
|
+
#
|
47
|
+
# @private
|
48
|
+
# @sig Set[String]
|
49
|
+
attr_reader :collapse_dirs
|
50
|
+
|
51
|
+
# Absolute paths of files or directories not to be eager loaded.
|
52
|
+
#
|
53
|
+
# @private
|
54
|
+
# @sig Set[String]
|
55
|
+
attr_reader :eager_load_exclusions
|
56
|
+
|
57
|
+
# User-oriented callbacks to be fired on setup and on reload.
|
58
|
+
#
|
59
|
+
# @private
|
60
|
+
# @sig Array[{ () -> void }]
|
61
|
+
attr_reader :on_setup_callbacks
|
62
|
+
|
63
|
+
# User-oriented callbacks to be fired when a constant is loaded.
|
64
|
+
#
|
65
|
+
# @private
|
66
|
+
# @sig Hash[String, Array[{ (Object, String) -> void }]]
|
67
|
+
# Hash[Symbol, Array[{ (String, Object, String) -> void }]]
|
68
|
+
attr_reader :on_load_callbacks
|
69
|
+
|
70
|
+
# User-oriented callbacks to be fired before constants are removed.
|
71
|
+
#
|
72
|
+
# @private
|
73
|
+
# @sig Hash[String, Array[{ (Object, String) -> void }]]
|
74
|
+
# Hash[Symbol, Array[{ (String, Object, String) -> void }]]
|
75
|
+
attr_reader :on_unload_callbacks
|
76
|
+
|
77
|
+
# @sig #call | #debug | nil
|
78
|
+
attr_accessor :logger
|
79
|
+
|
80
|
+
def initialize
|
81
|
+
@initialized_at = Time.now
|
82
|
+
@root_dirs = {}
|
83
|
+
@inflector = Zeitwerk::Inflector.new
|
84
|
+
@ignored_glob_patterns = Set.new
|
85
|
+
@ignored_paths = Set.new
|
86
|
+
@collapse_glob_patterns = Set.new
|
87
|
+
@collapse_dirs = Set.new
|
88
|
+
@eager_load_exclusions = Set.new
|
89
|
+
@reloading_enabled = false
|
90
|
+
@on_setup_callbacks = []
|
91
|
+
@on_load_callbacks = {}
|
92
|
+
@on_unload_callbacks = {}
|
93
|
+
@logger = self.class.default_logger
|
94
|
+
@tag = SecureRandom.hex(3)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Pushes `path` to the list of root directories.
|
98
|
+
#
|
99
|
+
# Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
|
100
|
+
# the same process already manages that directory or one of its ascendants or
|
101
|
+
# descendants.
|
102
|
+
#
|
103
|
+
# @raise [Zeitwerk::Error]
|
104
|
+
# @sig (String | Pathname, Module) -> void
|
105
|
+
def push_dir(path, namespace: Object)
|
106
|
+
# Note that Class < Module.
|
107
|
+
unless namespace.is_a?(Module)
|
108
|
+
raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
|
109
|
+
end
|
110
|
+
|
111
|
+
abspath = File.expand_path(path)
|
112
|
+
if dir?(abspath)
|
113
|
+
raise_if_conflicting_directory(abspath)
|
114
|
+
root_dirs[abspath] = namespace
|
115
|
+
else
|
116
|
+
warn_string = "Zeitwerk: the root path #{abspath} does not exist, not added"
|
117
|
+
`console.warn(warn_string)`
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Returns the loader's tag.
|
122
|
+
#
|
123
|
+
# Implemented as a method instead of via attr_reader for symmetry with the
|
124
|
+
# writer below.
|
125
|
+
#
|
126
|
+
# @sig () -> String
|
127
|
+
def tag
|
128
|
+
@tag
|
129
|
+
end
|
130
|
+
|
131
|
+
# Sets a tag for the loader, useful for logging.
|
132
|
+
#
|
133
|
+
# @param tag [#to_s]
|
134
|
+
# @sig (#to_s) -> void
|
135
|
+
def tag=(tag)
|
136
|
+
@tag = tag.to_s
|
137
|
+
end
|
138
|
+
|
139
|
+
# Absolute paths of the root directories. This is a read-only collection,
|
140
|
+
# please push here via `push_dir`.
|
141
|
+
#
|
142
|
+
# @sig () -> Array[String]
|
143
|
+
def dirs
|
144
|
+
root_dirs.keys
|
145
|
+
end
|
146
|
+
|
147
|
+
# You need to call this method before setup in order to be able to reload.
|
148
|
+
# There is no way to undo this, either you want to reload or you don't.
|
149
|
+
#
|
150
|
+
# @raise [Zeitwerk::Error]
|
151
|
+
# @sig () -> void
|
152
|
+
def enable_reloading
|
153
|
+
return if @reloading_enabled
|
154
|
+
|
155
|
+
if @setup
|
156
|
+
raise Zeitwerk::Error, "cannot enable reloading after setup"
|
157
|
+
else
|
158
|
+
@reloading_enabled = true
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# @sig () -> bool
|
163
|
+
def reloading_enabled?
|
164
|
+
@reloading_enabled
|
165
|
+
end
|
166
|
+
|
167
|
+
# Let eager load ignore the given files or directories. The constants defined
|
168
|
+
# in those files are still autoloadable.
|
169
|
+
#
|
170
|
+
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
|
171
|
+
def do_not_eager_load(*paths)
|
172
|
+
eager_load_exclusions.merge(expand_paths(paths))
|
173
|
+
end
|
174
|
+
|
175
|
+
# Configure files, directories, or glob patterns to be totally ignored.
|
176
|
+
#
|
177
|
+
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
|
178
|
+
def ignore(*glob_patterns)
|
179
|
+
glob_patterns = expand_paths(glob_patterns)
|
180
|
+
ignored_glob_patterns.merge(glob_patterns)
|
181
|
+
ignored_paths.merge(expand_glob_patterns(glob_patterns))
|
182
|
+
end
|
183
|
+
|
184
|
+
# Configure directories or glob patterns to be collapsed.
|
185
|
+
#
|
186
|
+
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
|
187
|
+
def collapse(*glob_patterns)
|
188
|
+
glob_patterns = expand_paths(glob_patterns)
|
189
|
+
collapse_glob_patterns.merge(glob_patterns)
|
190
|
+
collapse_dirs.merge(expand_glob_patterns(glob_patterns))
|
191
|
+
end
|
192
|
+
|
193
|
+
# Configure a block to be called after setup and on each reload.
|
194
|
+
# If setup was already done, the block runs immediately.
|
195
|
+
#
|
196
|
+
# @sig () { () -> void } -> void
|
197
|
+
def on_setup(&block)
|
198
|
+
on_setup_callbacks << block
|
199
|
+
block.call if @setup
|
200
|
+
end
|
201
|
+
|
202
|
+
# Configure a block to be invoked once a certain constant path is loaded.
|
203
|
+
# Supports multiple callbacks, and if there are many, they are executed in
|
204
|
+
# the order in which they were defined.
|
205
|
+
#
|
206
|
+
# loader.on_load("SomeApiClient") do |klass, _abspath|
|
207
|
+
# klass.endpoint = "https://api.dev"
|
208
|
+
# end
|
209
|
+
#
|
210
|
+
# Can also be configured for any constant loaded:
|
211
|
+
#
|
212
|
+
# loader.on_load do |cpath, value, abspath|
|
213
|
+
# # ...
|
214
|
+
# end
|
215
|
+
#
|
216
|
+
# @raise [TypeError]
|
217
|
+
# @sig (String) { (Object, String) -> void } -> void
|
218
|
+
# (:ANY) { (String, Object, String) -> void } -> void
|
219
|
+
def on_load(cpath = :ANY, &block)
|
220
|
+
raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
|
221
|
+
|
222
|
+
(on_load_callbacks[cpath] ||= []) << block
|
223
|
+
end
|
224
|
+
|
225
|
+
# Configure a block to be invoked right before a certain constant is removed.
|
226
|
+
# Supports multiple callbacks, and if there are many, they are executed in the
|
227
|
+
# order in which they were defined.
|
228
|
+
#
|
229
|
+
# loader.on_unload("Country") do |klass, _abspath|
|
230
|
+
# klass.clear_cache
|
231
|
+
# end
|
232
|
+
#
|
233
|
+
# Can also be configured for any removed constant:
|
234
|
+
#
|
235
|
+
# loader.on_unload do |cpath, value, abspath|
|
236
|
+
# # ...
|
237
|
+
# end
|
238
|
+
#
|
239
|
+
# @raise [TypeError]
|
240
|
+
# @sig (String) { (Object) -> void } -> void
|
241
|
+
# (:ANY) { (String, Object) -> void } -> void
|
242
|
+
def on_unload(cpath = :ANY, &block)
|
243
|
+
raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
|
244
|
+
|
245
|
+
(on_unload_callbacks[cpath] ||= []) << block
|
246
|
+
end
|
247
|
+
|
248
|
+
# @private
|
249
|
+
# @sig (String) -> bool
|
250
|
+
def ignores?(abspath)
|
251
|
+
ignored_paths.any? do |ignored_path|
|
252
|
+
ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
private
|
257
|
+
|
258
|
+
# @sig () -> Array[String]
|
259
|
+
def actual_root_dirs
|
260
|
+
root_dirs.reject do |root_dir, _namespace|
|
261
|
+
!dir?(root_dir) || ignored_paths.member?(root_dir)
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# @sig (String) -> bool
|
266
|
+
def root_dir?(dir)
|
267
|
+
root_dirs.key?(dir)
|
268
|
+
end
|
269
|
+
|
270
|
+
# @sig (String) -> bool
|
271
|
+
def excluded_from_eager_load?(abspath)
|
272
|
+
eager_load_exclusions.member?(abspath)
|
273
|
+
end
|
274
|
+
|
275
|
+
# @sig (String) -> bool
|
276
|
+
def collapse?(dir)
|
277
|
+
collapse_dirs.member?(dir)
|
278
|
+
end
|
279
|
+
|
280
|
+
# @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
|
281
|
+
def expand_paths(paths)
|
282
|
+
paths.flatten.map! { |path| File.expand_path(path) }
|
283
|
+
end
|
284
|
+
|
285
|
+
# @sig (Array[String]) -> Array[String]
|
286
|
+
def expand_glob_patterns(glob_patterns)
|
287
|
+
# Note that Dir.glob works with regular file names just fine. That is,
|
288
|
+
# glob patterns technically need no wildcards.
|
289
|
+
glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
|
290
|
+
end
|
291
|
+
|
292
|
+
# @sig () -> void
|
293
|
+
def recompute_ignored_paths
|
294
|
+
ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
|
295
|
+
end
|
296
|
+
|
297
|
+
# @sig () -> void
|
298
|
+
def recompute_collapse_dirs
|
299
|
+
collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
|
300
|
+
end
|
301
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module Zeitwerk::Loader::Helpers
|
2
|
+
private
|
3
|
+
|
4
|
+
# --- Files and directories ---------------------------------------------------------------------
|
5
|
+
|
6
|
+
# @sig (String) { (String, String) -> void } -> void
|
7
|
+
def ls(dir)
|
8
|
+
outer_ls = false
|
9
|
+
# cache the Opal.modules keys array for subsequent ls calls during setup
|
10
|
+
%x{
|
11
|
+
if (#@module_paths === nil) {
|
12
|
+
#@module_paths = Object.keys(Opal.modules);
|
13
|
+
outer_ls = true;
|
14
|
+
}
|
15
|
+
}
|
16
|
+
visited_abspaths = `{}`
|
17
|
+
dir_first_char = dir[0]
|
18
|
+
path_start = dir.size + 1
|
19
|
+
path_parts = `[]`
|
20
|
+
basename = ''
|
21
|
+
@module_paths.each do |abspath|
|
22
|
+
%x{
|
23
|
+
if (abspath[0] === dir_first_char) {
|
24
|
+
if (!abspath.startsWith(dir)) { #{next} }
|
25
|
+
path_parts = abspath.slice(path_start).split('/');
|
26
|
+
basename = path_parts[0];
|
27
|
+
abspath = dir + '/' + basename;
|
28
|
+
if (visited_abspaths.hasOwnProperty(abspath)) { #{next} }
|
29
|
+
visited_abspaths[abspath] = true;
|
30
|
+
#{yield basename, abspath unless ignored_paths.member?(abspath)}
|
31
|
+
}
|
32
|
+
}
|
33
|
+
end
|
34
|
+
# remove cache, because Opal.modules may change after setup
|
35
|
+
%x{
|
36
|
+
if (outer_ls) { #@module_paths = nil }
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
# @sig (String) -> bool
|
41
|
+
def ruby?(abspath)
|
42
|
+
`Opal.modules.hasOwnProperty(abspath)`
|
43
|
+
end
|
44
|
+
|
45
|
+
# @sig (String) -> bool
|
46
|
+
def dir?(path)
|
47
|
+
dir_path = path + '/'
|
48
|
+
module_paths = if @module_paths # possibly set by ls
|
49
|
+
@module_paths
|
50
|
+
else
|
51
|
+
`Object.keys(Opal.modules)`
|
52
|
+
end
|
53
|
+
path_first = `path[0]`
|
54
|
+
module_paths.each do |m_path|
|
55
|
+
%x{
|
56
|
+
if (m_path[0] !== path_first) { #{ next } }
|
57
|
+
if (m_path.startsWith(dir_path)) { #{return true} }
|
58
|
+
}
|
59
|
+
end
|
60
|
+
false
|
61
|
+
end
|
62
|
+
|
63
|
+
# --- Constants ---------------------------------------------------------------------------------
|
64
|
+
|
65
|
+
# The autoload? predicate takes into account the ancestor chain of the
|
66
|
+
# receiver, like const_defined? and other methods in the constants API do.
|
67
|
+
#
|
68
|
+
# For example, given
|
69
|
+
#
|
70
|
+
# class A
|
71
|
+
# autoload :X, "x.rb"
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# class B < A
|
75
|
+
# end
|
76
|
+
#
|
77
|
+
# B.autoload?(:X) returns "x.rb".
|
78
|
+
#
|
79
|
+
# We need a way to strictly check in parent ignoring ancestors.
|
80
|
+
#
|
81
|
+
# @sig (Module, Symbol) -> String?
|
82
|
+
if method(:autoload?).arity == 1
|
83
|
+
def strict_autoload_path(parent, cname)
|
84
|
+
parent.autoload?(cname) if cdef?(parent, cname)
|
85
|
+
end
|
86
|
+
else
|
87
|
+
def strict_autoload_path(parent, cname)
|
88
|
+
parent.autoload?(cname, false)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# @sig (Module, Symbol) -> String
|
93
|
+
if Symbol.method_defined?(:name)
|
94
|
+
# Symbol#name was introduced in Ruby 3.0. It returns always the same
|
95
|
+
# frozen object, so we may save a few string allocations.
|
96
|
+
def cpath(parent, cname)
|
97
|
+
Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}"
|
98
|
+
end
|
99
|
+
else
|
100
|
+
def cpath(parent, cname)
|
101
|
+
Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# @sig (Module, Symbol) -> bool
|
106
|
+
def cdef?(parent, cname)
|
107
|
+
parent.const_defined?(cname, false)
|
108
|
+
end
|
109
|
+
|
110
|
+
# @raise [NameError]
|
111
|
+
# @sig (Module, Symbol) -> Object
|
112
|
+
def cget(parent, cname)
|
113
|
+
parent.const_get(cname, false)
|
114
|
+
end
|
115
|
+
end
|