opal-zeitwerk 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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