File: config.rb

package info (click to toggle)
ruby-zeitwerk 2.7.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 732 kB
  • sloc: ruby: 6,240; makefile: 4
file content (357 lines) | stat: -rw-r--r-- 10,394 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# frozen_string_literal: true

require "set"
require "securerandom"

module Zeitwerk::Loader::Config
  extend Zeitwerk::Internal
  include Zeitwerk::RealModName

  #: camelize(String, String) -> String
  attr_accessor :inflector

  #: call(String) -> void | debug(String) -> void | nil
  attr_accessor :logger

  # Absolute paths of the root directories, mapped to their respective root namespaces:
  #
  #   "/Users/fxn/blog/app/channels" => Object,
  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
  #   ...
  #
  # Stored in a hash to preserve order, easily handle duplicates, and have a
  # fast lookup by directory.
  #
  # This is a private collection maintained by the loader. The public
  # interface for it is `push_dir` and `dirs`.
  #
  #: Hash[String, Module]
  attr_reader :roots
  internal :roots

  # Absolute paths of files, directories, or glob patterns to be ignored.
  #
  #: Set[String]
  attr_reader :ignored_glob_patterns
  private :ignored_glob_patterns

  # The actual collection of absolute file and directory names at the time the
  # ignored glob patterns were expanded. Computed on setup, and recomputed on
  # reload.
  #
  #: Set[String]
  attr_reader :ignored_paths
  private :ignored_paths

  # Absolute paths of directories or glob patterns to be collapsed.
  #
  #: Set[String]
  attr_reader :collapse_glob_patterns
  private :collapse_glob_patterns

  # The actual collection of absolute directory names at the time the collapse
  # glob patterns were expanded. Computed on setup, and recomputed on reload.
  #
  #: Set[String]
  attr_reader :collapse_dirs
  private :collapse_dirs

  # Absolute paths of files or directories not to be eager loaded.
  #
  #: Set[String]
  attr_reader :eager_load_exclusions
  private :eager_load_exclusions

  # User-oriented callbacks to be fired on setup and on reload.
  #
  #: Array[{ () -> void }]
  attr_reader :on_setup_callbacks
  private :on_setup_callbacks

  # User-oriented callbacks to be fired when a constant is loaded.
  #
  #: Hash[String, Array[{ (top, String) -> void }]]
  #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
  attr_reader :on_load_callbacks
  private :on_load_callbacks

  # User-oriented callbacks to be fired before constants are removed.
  #
  #: Hash[String, Array[{ (top, String) -> void }]]
  #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
  attr_reader :on_unload_callbacks
  private :on_unload_callbacks

  def initialize
    @inflector              = Zeitwerk::Inflector.new
    @logger                 = self.class.default_logger
    @tag                    = SecureRandom.hex(3)
    @initialized_at         = Time.now
    @roots                  = {}
    @ignored_glob_patterns  = Set.new
    @ignored_paths          = Set.new
    @collapse_glob_patterns = Set.new
    @collapse_dirs          = Set.new
    @eager_load_exclusions  = Set.new
    @reloading_enabled      = false
    @on_setup_callbacks     = []
    @on_load_callbacks      = {}
    @on_unload_callbacks    = {}
  end

  # Pushes `path` to the list of root directories.
  #
  # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
  # the same process already manages that directory or one of its ascendants or
  # descendants.
  #
  #: (String | Pathname, namespace: Module) -> void ! Zeitwerk::Error
  def push_dir(path, namespace: Object)
    unless namespace.is_a?(Module) # Note that Class < Module.
      raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
    end

    unless real_mod_name(namespace)
      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
    end

    abspath = File.expand_path(path)
    if dir?(abspath)
      raise_if_conflicting_directory(abspath)
      roots[abspath] = namespace
    else
      raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
    end
  end

  # Returns the loader's tag.
  #
  # Implemented as a method instead of via attr_reader for symmetry with the
  # writer below.
  #
  #: () -> String
  def tag
    @tag
  end

  # Sets a tag for the loader, useful for logging.
  #
  #: (to_s() -> String) -> void
  def tag=(tag)
    @tag = tag.to_s
  end

  # If `namespaces` is falsey (default), returns an array with the absolute
  # paths of the root directories as strings. If truthy, returns a hash table
  # instead. Keys are the absolute paths of the root directories as strings,
  # values are their corresponding namespaces, class or module objects.
  #
  # If `ignored` is falsey (default), ignored root directories are filtered out.
  #
  # These are read-only collections, please add to them with `push_dir`.
  #
  #: (?namespaces: boolish, ?ignored: boolish) -> Array[String] | Hash[String, Module]
  def dirs(namespaces: false, ignored: false)
    if namespaces
      if ignored || ignored_paths.empty?
        roots.clone
      else
        roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
      end
    else
      if ignored || ignored_paths.empty?
        roots.keys
      else
        roots.keys.reject { |root_dir| ignored_path?(root_dir) }
      end
    end.freeze
  end

  # You need to call this method before setup in order to be able to reload.
  # There is no way to undo this, either you want to reload or you don't.
  #
  #: () -> void ! Zeitwerk::Error
  def enable_reloading
    mutex.synchronize do
      break if @reloading_enabled

      if @setup
        raise Zeitwerk::Error, "cannot enable reloading after setup"
      else
        @reloading_enabled = true
      end
    end
  end

  #: () -> bool
  def reloading_enabled?
    @reloading_enabled
  end

  # Let eager load ignore the given files or directories. The constants defined
  # in those files are still autoloadable.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def do_not_eager_load(*paths)
    mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
  end

  # Configure files, directories, or glob patterns to be totally ignored.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def ignore(*glob_patterns)
    glob_patterns = expand_paths(glob_patterns)
    mutex.synchronize do
      ignored_glob_patterns.merge(glob_patterns)
      ignored_paths.merge(expand_glob_patterns(glob_patterns))
    end
  end

  # Configure directories or glob patterns to be collapsed.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def collapse(*glob_patterns)
    glob_patterns = expand_paths(glob_patterns)
    mutex.synchronize do
      collapse_glob_patterns.merge(glob_patterns)
      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
    end
  end

  # Configure a block to be called after setup and on each reload.
  # If setup was already done, the block runs immediately.
  #
  #: () { () -> void } -> void
  def on_setup(&block)
    mutex.synchronize do
      on_setup_callbacks << block
      block.call if @setup
    end
  end

  # Configure a block to be invoked once a certain constant path is loaded.
  # Supports multiple callbacks, and if there are many, they are executed in
  # the order in which they were defined.
  #
  #   loader.on_load("SomeApiClient") do |klass, _abspath|
  #     klass.endpoint = "https://api.dev"
  #   end
  #
  # Can also be configured for any constant loaded:
  #
  #   loader.on_load do |cpath, value, abspath|
  #     # ...
  #   end
  #
  #: (String?) { (top, String) -> void } -> void ! TypeError
  def on_load(cpath = :ANY, &block)
    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY

    mutex.synchronize do
      (on_load_callbacks[cpath] ||= []) << block
    end
  end

  # Configure a block to be invoked right before a certain constant is removed.
  # Supports multiple callbacks, and if there are many, they are executed in the
  # order in which they were defined.
  #
  #   loader.on_unload("Country") do |klass, _abspath|
  #     klass.clear_cache
  #   end
  #
  # Can also be configured for any removed constant:
  #
  #   loader.on_unload do |cpath, value, abspath|
  #     # ...
  #   end
  #
  #: (String?) { (top, String) -> void } -> void ! TypeError
  def on_unload(cpath = :ANY, &block)
    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY

    mutex.synchronize do
      (on_unload_callbacks[cpath] ||= []) << block
    end
  end

  # Logs to `$stdout`, handy shortcut for debugging.
  #
  #: () -> void
  def log!
    @logger = ->(msg) { puts msg }
  end

  # Returns true if the argument has been configured to be ignored, or is a
  # descendant of an ignored directory.
  #
  #: (String) -> bool
  internal def ignores?(abspath)
    # Common use case.
    return false if ignored_paths.empty?

    walk_up(abspath) do |path|
      return true  if ignored_path?(path)
      return false if roots.key?(path)
    end

    false
  end

  #: (String) -> bool
  private def ignored_path?(abspath)
    ignored_paths.member?(abspath)
  end

  #: () -> Array[String]
  private def actual_roots
    roots.reject do |root_dir, _root_namespace|
      !dir?(root_dir) || ignored_path?(root_dir)
    end
  end

  #: (String) -> bool
  private def root_dir?(dir)
    roots.key?(dir)
  end

  #: (String) -> bool
  private def excluded_from_eager_load?(abspath)
    # Optimize this common use case.
    return false if eager_load_exclusions.empty?

    walk_up(abspath) do |path|
      return true  if eager_load_exclusions.member?(path)
      return false if roots.key?(path)
    end

    false
  end

  #: (String) -> bool
  private def collapse?(dir)
    collapse_dirs.member?(dir)
  end

  #: (String | Pathname | Array[String | Pathname]) -> Array[String]
  private def expand_paths(paths)
    paths.flatten.map! { |path| File.expand_path(path) }
  end

  #: (Array[String]) -> Array[String]
  private def expand_glob_patterns(glob_patterns)
    # Note that Dir.glob works with regular file names just fine. That is,
    # glob patterns technically need no wildcards.
    glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
  end

  #: () -> void
  private def recompute_ignored_paths
    ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
  end

  #: () -> void
  private def recompute_collapse_dirs
    collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
  end
end