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.
@@ -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