zeitwerk 2.4.2 → 2.5.0.beta

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f45a7c3caf48f06e10dd513a7b074da7ec059fa3299751d361514aadc83d995e
4
- data.tar.gz: c8c29793e95245b47b1283891791d2c20365b8c694b3dcdfb7daab0b74c16bdb
3
+ metadata.gz: 7ac5e0ce2eba71a2099ead24de8762e3e737656ff4bcae57c83bb5797e922f72
4
+ data.tar.gz: 85e7c762a5164301ba5bcce82a31e6c49bc7c6caff2b4abff23c425f10374c69
5
5
  SHA512:
6
- metadata.gz: 6194d326b268c9333ed9d2ac1c62b65756fa9af844c5fb7ad8272ecec33aa439015506758bcd329e421508facb4153e04e45da0efb0a2a42001a6f2e0a0d3b6a
7
- data.tar.gz: 656009f40e777f641ff1dc80f54b63559514439538c73965aa9226b6c3e33c6be71c07eb30adfe7f4cd4b3be72aa6e42918392ba27167fb9502b9aed037f36d3
6
+ metadata.gz: f258acabd660f54702eb904550a109b08bdacff2cecc92d610376376f77ccf70ed421e09c64144930d2a145ebbedb7e9c3c26ff21ad2c1b40dd0537d9947b4d4
7
+ data.tar.gz: 1612dc2d505fe37b748d81a9055e36a91f9f73267ac273b39a4cbea3736cdd1baa4c557e3059d8f3a9a9807d450e29f3d75c7cb18c94c37ded70768444313336
data/README.md CHANGED
@@ -3,41 +3,48 @@
3
3
 
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/zeitwerk.svg?style=for-the-badge)](https://rubygems.org/gems/zeitwerk)
6
- [![Build Status](https://img.shields.io/travis/com/fxn/zeitwerk/master?style=for-the-badge)](https://travis-ci.com/fxn/zeitwerk)
6
+ [![Build Status](https://img.shields.io/github/workflow/status/fxn/zeitwerk/CI?event=push&style=for-the-badge)](https://github.com/fxn/zeitwerk/actions?query=event%3Apush)
7
7
 
8
8
  <!-- TOC -->
9
9
 
10
10
  - [Introduction](#introduction)
11
11
  - [Synopsis](#synopsis)
12
12
  - [File structure](#file-structure)
13
- - [Implicit namespaces](#implicit-namespaces)
14
- - [Explicit namespaces](#explicit-namespaces)
15
- - [Collapsing directories](#collapsing-directories)
13
+ - [The idea: File paths match constant paths](#the-idea-file-paths-match-constant-paths)
14
+ - [Inner simple constants](#inner-simple-constants)
15
+ - [Root directories and root namespaces](#root-directories-and-root-namespaces)
16
+ - [The default root namespace is `Object`](#the-default-root-namespace-is-object)
17
+ - [Custom root namespaces](#custom-root-namespaces)
16
18
  - [Nested root directories](#nested-root-directories)
19
+ - [Implicit namespaces](#implicit-namespaces)
20
+ - [Explicit namespaces](#explicit-namespaces)
21
+ - [Collapsing directories](#collapsing-directories)
17
22
  - [Usage](#usage)
18
- - [Setup](#setup)
19
- - [Generic](#generic)
20
- - [for_gem](#for_gem)
21
- - [Autoloading](#autoloading)
22
- - [Eager loading](#eager-loading)
23
- - [Reloading](#reloading)
24
- - [Inflection](#inflection)
25
- - [Zeitwerk::Inflector](#zeitwerkinflector)
26
- - [Zeitwerk::GemInflector](#zeitwerkgeminflector)
27
- - [Custom inflector](#custom-inflector)
28
- - [The on_load callback](#the-on_load-callback)
29
- - [Logging](#logging)
30
- - [Loader tag](#loader-tag)
31
- - [Ignoring parts of the project](#ignoring-parts-of-the-project)
32
- - [Use case: Files that do not follow the conventions](#use-case-files-that-do-not-follow-the-conventions)
33
- - [Use case: The adapter pattern](#use-case-the-adapter-pattern)
34
- - [Use case: Test files mixed with implementation files](#use-case-test-files-mixed-with-implementation-files)
35
- - [Edge cases](#edge-cases)
36
- - [Reopening third-party namespaces](#reopening-third-party-namespaces)
37
- - [Rules of thumb](#rules-of-thumb)
38
- - [Debuggers](#debuggers)
39
- - [Break](#break)
40
- - [Byebug](#byebug)
23
+ - [Setup](#setup)
24
+ - [Generic](#generic)
25
+ - [for_gem](#for_gem)
26
+ - [Autoloading](#autoloading)
27
+ - [Eager loading](#eager-loading)
28
+ - [Reloading](#reloading)
29
+ - [Inflection](#inflection)
30
+ - [Zeitwerk::Inflector](#zeitwerkinflector)
31
+ - [Zeitwerk::GemInflector](#zeitwerkgeminflector)
32
+ - [Custom inflector](#custom-inflector)
33
+ - [The on_load callback](#the-on_load-callback)
34
+ - [The on_unload callback](#the-on_unload-callback)
35
+ - [Technical details](#technical-details)
36
+ - [Logging](#logging)
37
+ - [Loader tag](#loader-tag)
38
+ - [Ignoring parts of the project](#ignoring-parts-of-the-project)
39
+ - [Use case: Files that do not follow the conventions](#use-case-files-that-do-not-follow-the-conventions)
40
+ - [Use case: The adapter pattern](#use-case-the-adapter-pattern)
41
+ - [Use case: Test files mixed with implementation files](#use-case-test-files-mixed-with-implementation-files)
42
+ - [Edge cases](#edge-cases)
43
+ - [Reopening third-party namespaces](#reopening-third-party-namespaces)
44
+ - [Rules of thumb](#rules-of-thumb)
45
+ - [Debuggers](#debuggers)
46
+ - [Break](#break)
47
+ - [Byebug](#byebug)
41
48
  - [Pronunciation](#pronunciation)
42
49
  - [Supported Ruby versions](#supported-ruby-versions)
43
50
  - [Testing](#testing)
@@ -117,6 +124,9 @@ Zeitwerk::Loader.eager_load_all
117
124
  <a id="markdown-file-structure" name="file-structure"></a>
118
125
  ## File structure
119
126
 
127
+ <a id="markdown-the-idea-file-paths-match-constant-paths" name="the-idea-file-paths-match-constant-paths"></a>
128
+ ### The idea: File paths match constant paths
129
+
120
130
  To have a file structure Zeitwerk can work with, just name files and directories after the name of the classes and modules they define:
121
131
 
122
132
  ```
@@ -126,25 +136,57 @@ lib/my_gem/bar_baz.rb -> MyGem::BarBaz
126
136
  lib/my_gem/woo/zoo.rb -> MyGem::Woo::Zoo
127
137
  ```
128
138
 
129
- Every directory configured with `push_dir` acts as root namespace. There can be several of them. For example, given
139
+ You can tune that a bit by [collapsing directories](#collapsing-directories), or by [ignoring parts of the project](#ignoring-parts-of-the-project), but that is the main idea.
140
+
141
+ <a id="markdown-inner-simple-constants" name="inner-simple-constants"></a>
142
+ ### Inner simple constants
143
+
144
+ While a simple constant like `HttpCrawler::MAX_RETRIES` can be defined in its own file:
130
145
 
131
146
  ```ruby
132
- loader.push_dir(Rails.root.join("app/models"))
133
- loader.push_dir(Rails.root.join("app/controllers"))
147
+ # http_crawler/max_retries.rb
148
+ HttpCrawler::MAX_RETRIES = 10
134
149
  ```
135
150
 
136
- Zeitwerk understands that their respective files and subdirectories belong to the root namespace:
151
+ that is not required, you can also define it the regular way:
137
152
 
153
+ ```ruby
154
+ # http_crawler.rb
155
+ class HttpCrawler
156
+ MAX_RETRIES = 10
157
+ end
138
158
  ```
139
- app/models/user.rb -> User
140
- app/controllers/admin/users_controller.rb -> Admin::UsersController
159
+
160
+ <a id="markdown-root-directories-and-root-namespaces" name="root-directories-and-root-namespaces"></a>
161
+ ### Root directories and root namespaces
162
+
163
+ Every directory configured with `push_dir` is called a _root directory_, and they represent _root namespaces_.
164
+
165
+ <a id="markdown-the-default-root-namespace-is-object" name="the-default-root-namespace-is-object"></a>
166
+ #### The default root namespace is `Object`
167
+
168
+ By default, the namespace associated to a root directory is the top-level one: `Object`.
169
+
170
+ For example, given
171
+
172
+ ```ruby
173
+ loader.push_dir("#{__dir__}/models")
174
+ loader.push_dir("#{__dir__}/serializers"))
175
+ ```
176
+
177
+ these are the expected classes and modules being defined by these files:
178
+
179
+ ```
180
+ models/user.rb -> User
181
+ serializers/user_serializer.rb -> UserSerializer
141
182
  ```
142
183
 
143
- Alternatively, you can associate a custom namespace to a root directory by passing a class or module object in the optional `namespace` keyword argument.
184
+ <a id="markdown-custom-root-namespaces" name="custom-root-namespaces"></a>
185
+ #### Custom root namespaces
144
186
 
145
- For example, Active Job queue adapters have to define a constant after their name in `ActiveJob::QueueAdapters`.
187
+ While `Object` is by far the most common root namespace, you can associate a different one to a particular root directory. The method `push_dir` accepts a class or module object in the optional `namespace` keyword argument.
146
188
 
147
- So, if you declare
189
+ For example, given:
148
190
 
149
191
  ```ruby
150
192
  require "active_job"
@@ -152,9 +194,26 @@ require "active_job/queue_adapters"
152
194
  loader.push_dir("#{__dir__}/adapters", namespace: ActiveJob::QueueAdapters)
153
195
  ```
154
196
 
155
- your adapter can be stored directly in that directory instead of the canonical `#{__dir__}/active_job/queue_adapters`.
197
+ a file defining `ActiveJob::QueueAdapters::MyQueueAdapter` does not need the conventional parent directories, you can (and have to) store the file directly below `adapters`:
156
198
 
157
- Please, note that the given namespace must be non-reloadable, though autoloaded constants in that namespace can be. That is, if you associate `app/api` with an existing `Api` module, that module should not be reloadable. However, if the project defines and autoloads the class `Api::V2::Deliveries`, that one can be reloaded.
199
+ ```
200
+ adapters/my_queue_adapter.rb -> ActiveJob::QueueAdapters::MyQueueAdapter
201
+ ```
202
+
203
+ Please, note that the given root namespace must be non-reloadable, though autoloaded constants in that namespace can be. That is, if you associate `app/api` with an existing `Api` module, that module should not be reloadable. However, if the project defines and autoloads the class `Api::Deliveries`, that one can be reloaded.
204
+
205
+ <a id="markdown-nested-root-directories" name="nested-root-directories"></a>
206
+ #### Nested root directories
207
+
208
+ Root directories should not be ideally nested, but Zeitwerk supports them because in Rails, for example, both `app/models` and `app/models/concerns` belong to the autoload paths.
209
+
210
+ Zeitwerk detects nested root directories, and treats them as roots only. In the example above, `concerns` is not considered to be a namespace below `app/models`. For example, the file:
211
+
212
+ ```
213
+ app/models/concerns/geolocatable.rb
214
+ ```
215
+
216
+ should define `Geolocatable`, not `Concerns::Geolocatable`.
158
217
 
159
218
  <a id="markdown-implicit-namespaces" name="implicit-namespaces"></a>
160
219
  ### Implicit namespaces
@@ -216,19 +275,6 @@ To illustrate usage of glob patterns, if `actions` in the example above is part
216
275
  loader.collapse("#{__dir__}/*/actions")
217
276
  ```
218
277
 
219
- <a id="markdown-nested-root-directories" name="nested-root-directories"></a>
220
- ### Nested root directories
221
-
222
- Root directories should not be ideally nested, but Zeitwerk supports them because in Rails, for example, both `app/models` and `app/models/concerns` belong to the autoload paths.
223
-
224
- Zeitwerk detects nested root directories, and treats them as roots only. In the example above, `concerns` is not considered to be a namespace below `app/models`. For example, the file:
225
-
226
- ```
227
- app/models/concerns/geolocatable.rb
228
- ```
229
-
230
- should define `Geolocatable`, not `Concerns::Geolocatable`.
231
-
232
278
  <a id="markdown-usage" name="usage"></a>
233
279
  ## Usage
234
280
 
@@ -522,26 +568,23 @@ With `on_load`, it is easy to schedule code at boot time that initializes `endpo
522
568
 
523
569
  ```ruby
524
570
  # config/environments/development.rb
525
- loader.on_load("SomeApiClient") do
526
- SomeApiClient.endpoint = "https://api.dev"
571
+ loader.on_load("SomeApiClient") do |klass, _abspath|
572
+ klass.endpoint = "https://api.dev"
527
573
  end
528
574
 
529
575
  # config/environments/production.rb
530
- loader.on_load("SomeApiClient") do
531
- SomeApiClient.endpoint = "https://api.prod"
576
+ loader.on_load("SomeApiClient") do |klass, _abspath|
577
+ klass.endpoint = "https://api.prod"
532
578
  end
533
579
  ```
534
580
 
535
- Uses cases:
581
+ Some uses cases:
536
582
 
537
583
  * Doing something with an autoloadable class or module in a Rails application during initialization, in a way that plays well with reloading. As in the previous example.
538
584
  * Delaying the execution of the block until the class is loaded for performance.
539
585
  * Delaying the execution of the block until the class is loaded because it follows the adapter pattern and better not to load the class if the user does not need it.
540
- * Etc.
541
586
 
542
- However, let me stress that the easiest way to accomplish that is to write whatever you have to do in the actual target file. `on_load` use cases are edgy, use it only if appropriate.
543
-
544
- `on_load` receives the name of the target class or module as a string. The given block is executed every time its corresponding file is loaded. That includes reloads.
587
+ `on_load` gets a target constant path as a string (e.g., "User", or "Service::NotificationsGateway"). When fired, its block receives the stored value, and the absolute path to the corresponding file or directory as a string. The callback is executed every time the target is loaded. That includes reloads.
545
588
 
546
589
  Multiple callbacks on the same target are supported, and they run in order of definition.
547
590
 
@@ -549,6 +592,66 @@ The block is executed once the loader has loaded the target. In particular, if t
549
592
 
550
593
  Defining a callback for a target not managed by the receiver is not an error, the block simply won't ever be executed.
551
594
 
595
+ It is also possible to be called when any constant managed by the loader is loaded:
596
+
597
+ ```ruby
598
+ loader.on_load do |cpath, value, abspath|
599
+ # ...
600
+ end
601
+ ```
602
+
603
+ The block gets the constant path as a string (e.g., "User", or "Foo::VERSION"), the value it stores (e.g., the class object stored in `User`, or "2.5.0"), and the absolute path to the corresponding file or directory as a string.
604
+
605
+ Multiple callbacks like these are supported, and they run in order of definition.
606
+
607
+ There are use cases for this last catch-all callback, but they are rare. If you just need to understand how things are being loaded for debugging purposes, please remember that `Zeitwerk::Loader#log!` logs plenty of information.
608
+
609
+ If both types of callbacks are defined, the specific ones run first.
610
+
611
+ <a id="markdown-the-on_unload-callback" name="the-on_unload-callback"></a>
612
+ ### The on_unload callback
613
+
614
+ When reloading is enabled, you may occasionally need to execute something before a certain autoloaded class or module is unloaded. The `on_unload` callback allows you to do that.
615
+
616
+ For example, let's imagine that a `Country` class fetches a list of countries and caches them when it is loaded. You might want to clear that cache if unloaded:
617
+
618
+ ```ruby
619
+ loader.on_unload("Country") do |klass, _abspath|
620
+ klass.clear_cache
621
+ end
622
+ ```
623
+
624
+ `on_unload` gets a target constant path as a string (e.g., "User", or "Service::NotificationsGateway"). When fired, its block receives the stored value, and the absolute path to the corresponding file or directory as a string. The callback is executed every time the target is unloaded.
625
+
626
+ `on_unload` blocks are executed before the class is unloaded, but in the middle of unloading, which happens in an unspecified order. Therefore, **that callback should not refer to any reloadable constant because there is no guarantee the constant works there**. Those blocks should rely on objects only, as in the example above, or regular constants not managed by the loader. This remark is transitive, applies to any methods invoked within the block.
627
+
628
+ Multiple callbacks on the same target are supported, and they run in order of definition.
629
+
630
+ Defining a callback for a target not managed by the receiver is not an error, the block simply won't ever be executed.
631
+
632
+ It is also possible to be called when any constant managed by the loader is unloaded:
633
+
634
+ ```ruby
635
+ loader.on_unload do |cpath, value, abspath|
636
+ # ...
637
+ end
638
+ ```
639
+
640
+ The block gets the constant path as a string (e.g., "User", or "Foo::VERSION"), the value it stores (e.g., the class object stored in `User`, or "2.5.0"), and the absolute path to the corresponding file or directory as a string.
641
+
642
+ Multiple callbacks like these are supported, and they run in order of definition.
643
+
644
+ If both types of callbacks are defined, the specific ones run first.
645
+
646
+ <a id="markdown-technical-details" name="technical-details"></a>
647
+ #### Technical details
648
+
649
+ Zeitwerk uses the word "unload" to ease communication and for symmetry with `on_load`. However, in Ruby you cannot unload things for real. So, when does `on_unload` technically happen?
650
+
651
+ When unloading, Zeitwerk issues `Module#remove_const` calls. Classes and modules are no longer reachable through their constants, and `on_unload` callbacks are executed right before those calls.
652
+
653
+ Technically, though, the objects themselves are still alive, but if everything is used as expected and they are not stored in any non-reloadable place (don't do that), they are ready for garbage collection, which is when the real unloading happens.
654
+
552
655
  <a id="markdown-logging" name="logging"></a>
553
656
  ### Logging
554
657
 
data/lib/zeitwerk.rb CHANGED
@@ -3,10 +3,12 @@
3
3
  module Zeitwerk
4
4
  require_relative "zeitwerk/real_mod_name"
5
5
  require_relative "zeitwerk/loader"
6
+ require_relative "zeitwerk/autoloads"
6
7
  require_relative "zeitwerk/registry"
7
8
  require_relative "zeitwerk/explicit_namespace"
8
9
  require_relative "zeitwerk/inflector"
9
10
  require_relative "zeitwerk/gem_inflector"
10
11
  require_relative "zeitwerk/kernel"
11
12
  require_relative "zeitwerk/error"
13
+ require_relative "zeitwerk/version"
12
14
  end
@@ -0,0 +1,69 @@
1
+ module Zeitwerk
2
+ # @private
3
+ class Autoloads
4
+ # Maps crefs for which an autoload has been defined to the corresponding
5
+ # absolute path.
6
+ #
7
+ # [Object, :User] => "/Users/fxn/blog/app/models/user.rb"
8
+ # [Object, :Hotel] => "/Users/fxn/blog/app/models/hotel"
9
+ # ...
10
+ #
11
+ # This colection is transient, callbacks delete its entries as autoloads get
12
+ # executed.
13
+ #
14
+ # @sig Hash[[Module, Symbol], String]
15
+ attr_reader :c2a
16
+
17
+ # This is the inverse of c2a, for inverse lookups.
18
+ #
19
+ # @sig Hash[String, [Module, Symbol]]
20
+ attr_reader :a2c
21
+
22
+ # @sig () -> void
23
+ def initialize
24
+ @c2a = {}
25
+ @a2c = {}
26
+ end
27
+
28
+ # @sig (Module, Symbol, String) -> void
29
+ def define(parent, cname, abspath)
30
+ parent.autoload(cname, abspath)
31
+ cref = [parent, cname]
32
+ c2a[cref] = abspath
33
+ a2c[abspath] = cref
34
+ end
35
+
36
+ # @sig () { () -> [[Module, Symbol], String] } -> void
37
+ def each(&block)
38
+ c2a.each(&block)
39
+ end
40
+
41
+ # @sig (Module, Symbol) -> String?
42
+ def abspath_for(parent, cname)
43
+ c2a[[parent, cname]]
44
+ end
45
+
46
+ # @sig (String) -> [Module, Symbol]?
47
+ def cref_for(abspath)
48
+ a2c[abspath]
49
+ end
50
+
51
+ # @sig (String) -> [Module, Symbol]?
52
+ def delete(abspath)
53
+ cref = a2c.delete(abspath)
54
+ c2a.delete(cref)
55
+ cref
56
+ end
57
+
58
+ # @sig () -> void
59
+ def clear
60
+ c2a.clear
61
+ a2c.clear
62
+ end
63
+
64
+ # @sig () -> bool
65
+ def empty?
66
+ c2a.empty? && a2c.empty?
67
+ end
68
+ end
69
+ end
@@ -62,8 +62,14 @@ module Zeitwerk
62
62
  # than accessing its name.
63
63
  return if event.self.singleton_class?
64
64
 
65
- # Note that it makes sense to compute the hash code unconditionally,
66
- # because the trace point is disabled if cpaths is empty.
65
+ # It might be tempting to return if name.nil?, to avoid the computation
66
+ # of a hash code and delete call. But Ruby does not trigger the :class
67
+ # event on Class.new or Module.new, so that would incur in an extra call
68
+ # for nothing.
69
+ #
70
+ # On the other hand, if we were called, cpaths is not empty. Otherwise
71
+ # the tracer is disabled. So we do need to go ahead with the hash code
72
+ # computation and delete call.
67
73
  if loader = cpaths.delete(real_mod_name(event.self))
68
74
  loader.on_namespace_loaded(event.self)
69
75
  disable_tracer_if_unneeded
@@ -12,7 +12,8 @@ module Kernel
12
12
  # On the other hand, if you publish a new version of a gem that is now managed
13
13
  # by Zeitwerk, client code can reference directly your classes and modules and
14
14
  # should not require anything. But if someone has legacy require calls around,
15
- # they will work as expected, and in a compatible way.
15
+ # they will work as expected, and in a compatible way. This feature is by now
16
+ # EXPERIMENTAL and UNDOCUMENTED.
16
17
  #
17
18
  # We cannot decorate with prepend + super because Kernel has already been
18
19
  # included in Object, and changes in ancestors don't get propagated into
@@ -33,9 +34,9 @@ module Kernel
33
34
  else
34
35
  zeitwerk_original_require(path).tap do |required|
35
36
  if required
36
- realpath = $LOADED_FEATURES.last
37
- if loader = Zeitwerk::Registry.loader_for(realpath)
38
- loader.on_file_autoloaded(realpath)
37
+ abspath = $LOADED_FEATURES.last
38
+ if loader = Zeitwerk::Registry.loader_for(abspath)
39
+ loader.on_file_autoloaded(abspath)
39
40
  end
40
41
  end
41
42
  end
@@ -5,78 +5,31 @@ require "securerandom"
5
5
 
6
6
  module Zeitwerk
7
7
  class Loader
8
+ require_relative "loader/helpers"
8
9
  require_relative "loader/callbacks"
9
- include Callbacks
10
- include RealModName
11
-
12
- # @sig String
13
- attr_reader :tag
10
+ require_relative "loader/config"
14
11
 
15
- # @sig #camelize
16
- attr_accessor :inflector
17
-
18
- # @sig #call | #debug | nil
19
- attr_accessor :logger
12
+ include RealModName
13
+ include Callbacks
14
+ include Helpers
15
+ include Config
20
16
 
21
- # Absolute paths of the root directories. Stored in a hash to preserve
22
- # order, easily handle duplicates, and also be able to have a fast lookup,
23
- # needed for detecting nested paths.
17
+ # Keeps track of autoloads defined by the loader which have not been
18
+ # executed so far.
24
19
  #
25
- # "/Users/fxn/blog/app/assets" => true,
26
- # "/Users/fxn/blog/app/channels" => true,
27
- # ...
20
+ # This metadata helps us implement a few things:
28
21
  #
29
- # This is a private collection maintained by the loader. The public
30
- # interface for it is `push_dir` and `dirs`.
22
+ # 1. When autoloads are triggered, ensure they define the expected constant
23
+ # and invoke user callbacks. If reloading is enabled, remember cref and
24
+ # abspath for later unloading logic.
31
25
  #
32
- # @private
33
- # @sig Hash[String, true]
34
- attr_reader :root_dirs
35
-
36
- # Absolute paths of files or directories that have to be preloaded.
26
+ # 2. When unloading, remove autoloads that have not been executed.
37
27
  #
38
- # @private
39
- # @sig Array[String]
40
- attr_reader :preloads
41
-
42
- # Absolute paths of files, directories, or glob patterns to be totally
43
- # ignored.
28
+ # 3. Eager load with a recursive const_get, rather than a recursive require,
29
+ # for consistency with lazy loading.
44
30
  #
45
31
  # @private
46
- # @sig Set[String]
47
- attr_reader :ignored_glob_patterns
48
-
49
- # The actual collection of absolute file and directory names at the time the
50
- # ignored glob patterns were expanded. Computed on setup, and recomputed on
51
- # reload.
52
- #
53
- # @private
54
- # @sig Set[String]
55
- attr_reader :ignored_paths
56
-
57
- # Absolute paths of directories or glob patterns to be collapsed.
58
- #
59
- # @private
60
- # @sig Set[String]
61
- attr_reader :collapse_glob_patterns
62
-
63
- # The actual collection of absolute directory names at the time the collapse
64
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
65
- #
66
- # @private
67
- # @sig Set[String]
68
- attr_reader :collapse_dirs
69
-
70
- # Maps real absolute paths for which an autoload has been set ---and not
71
- # executed--- to their corresponding parent class or module and constant
72
- # name.
73
- #
74
- # "/Users/fxn/blog/app/models/user.rb" => [Object, :User],
75
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => [Hotel, :Pricing]
76
- # ...
77
- #
78
- # @private
79
- # @sig Hash[String, [Module, Symbol]]
32
+ # @sig Zeitwerk::Autoloads
80
33
  attr_reader :autoloads
81
34
 
82
35
  # We keep track of autoloaded directories to remove them from the registry
@@ -93,8 +46,8 @@ module Zeitwerk
93
46
  #
94
47
  # "Admin::Role" => [".../admin/role.rb", [Admin, :Role]]
95
48
  #
96
- # The cpath as key helps implementing unloadable_cpath? The real file name
97
- # is stored in order to be able to delete it from $LOADED_FEATURES, and the
49
+ # The cpath as key helps implementing unloadable_cpath? The file name is
50
+ # stored in order to be able to delete it from $LOADED_FEATURES, and the
98
51
  # pair [Module, Symbol] is used to remove_const the constant from the class
99
52
  # or module object.
100
53
  #
@@ -123,15 +76,6 @@ module Zeitwerk
123
76
  # @sig Hash[String, Array[String]]
124
77
  attr_reader :lazy_subdirs
125
78
 
126
- # Absolute paths of files or directories not to be eager loaded.
127
- #
128
- # @private
129
- # @sig Set[String]
130
- attr_reader :eager_load_exclusions
131
-
132
- # User-oriented callbacks to be fired when a constant is loaded.
133
- attr_reader :on_load_callbacks
134
-
135
79
  # @private
136
80
  # @sig Mutex
137
81
  attr_reader :mutex
@@ -141,150 +85,21 @@ module Zeitwerk
141
85
  attr_reader :mutex2
142
86
 
143
87
  def initialize
144
- @initialized_at = Time.now
145
-
146
- @tag = SecureRandom.hex(3)
147
- @inflector = Inflector.new
148
- @logger = self.class.default_logger
149
-
150
- @root_dirs = {}
151
- @preloads = []
152
- @ignored_glob_patterns = Set.new
153
- @ignored_paths = Set.new
154
- @collapse_glob_patterns = Set.new
155
- @collapse_dirs = Set.new
156
- @autoloads = {}
157
- @autoloaded_dirs = []
158
- @to_unload = {}
159
- @lazy_subdirs = {}
160
- @eager_load_exclusions = Set.new
161
- @on_load_callbacks = {}
162
-
163
- # TODO: find a better name for these mutexes.
164
- @mutex = Mutex.new
165
- @mutex2 = Mutex.new
166
- @setup = false
167
- @eager_loaded = false
168
-
169
- @reloading_enabled = false
170
-
171
- Registry.register_loader(self)
172
- end
173
-
174
- # Sets a tag for the loader, useful for logging.
175
- #
176
- # @param tag [#to_s]
177
- # @sig (#to_s) -> void
178
- def tag=(tag)
179
- @tag = tag.to_s
180
- end
181
-
182
- # Absolute paths of the root directories. This is a read-only collection,
183
- # please push here via `push_dir`.
184
- #
185
- # @sig () -> Array[String]
186
- def dirs
187
- root_dirs.keys.freeze
188
- end
189
-
190
- # Pushes `path` to the list of root directories.
191
- #
192
- # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
193
- # the same process already manages that directory or one of its ascendants
194
- # or descendants.
195
- #
196
- # @raise [Zeitwerk::Error]
197
- # @sig (String | Pathname, Module) -> void
198
- def push_dir(path, namespace: Object)
199
- # Note that Class < Module.
200
- unless namespace.is_a?(Module)
201
- raise Error, "#{namespace.inspect} is not a class or module object, should be"
202
- end
88
+ super
203
89
 
204
- abspath = File.expand_path(path)
205
- if dir?(abspath)
206
- raise_if_conflicting_directory(abspath)
207
- root_dirs[abspath] = namespace
208
- else
209
- raise Error, "the root directory #{abspath} does not exist"
210
- end
211
- end
90
+ @autoloads = Autoloads.new
91
+ @autoloaded_dirs = []
92
+ @to_unload = {}
93
+ @lazy_subdirs = Hash.new { |h, cpath| h[cpath] = [] }
94
+ @mutex = Mutex.new
95
+ @mutex2 = Mutex.new
96
+ @setup = false
97
+ @eager_loaded = false
212
98
 
213
- # You need to call this method before setup in order to be able to reload.
214
- # There is no way to undo this, either you want to reload or you don't.
215
- #
216
- # @raise [Zeitwerk::Error]
217
- # @sig () -> void
218
- def enable_reloading
219
- mutex.synchronize do
220
- break if @reloading_enabled
221
-
222
- if @setup
223
- raise Error, "cannot enable reloading after setup"
224
- else
225
- @reloading_enabled = true
226
- end
227
- end
228
- end
229
-
230
- # @sig () -> bool
231
- def reloading_enabled?
232
- @reloading_enabled
233
- end
234
-
235
- # Files or directories to be preloaded instead of lazy loaded.
236
- #
237
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
238
- def preload(*paths)
239
- mutex.synchronize do
240
- expand_paths(paths).each do |abspath|
241
- preloads << abspath
242
- do_preload_abspath(abspath) if @setup
243
- end
244
- end
245
- end
246
-
247
- # Configure files, directories, or glob patterns to be totally ignored.
248
- #
249
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
250
- def ignore(*glob_patterns)
251
- glob_patterns = expand_paths(glob_patterns)
252
- mutex.synchronize do
253
- ignored_glob_patterns.merge(glob_patterns)
254
- ignored_paths.merge(expand_glob_patterns(glob_patterns))
255
- end
256
- end
257
-
258
- # Configure directories or glob patterns to be collapsed.
259
- #
260
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
261
- def collapse(*glob_patterns)
262
- glob_patterns = expand_paths(glob_patterns)
263
- mutex.synchronize do
264
- collapse_glob_patterns.merge(glob_patterns)
265
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
266
- end
267
- end
268
-
269
- # Configure a block to be invoked once a certain constant path is loaded.
270
- # Supports multiple callbacks, and if there are many, they are executed in
271
- # the order in which they were defined.
272
- #
273
- # loader.on_load("SomeApiClient") do
274
- # SomeApiClient.endpoint = "https://api.dev"
275
- # end
276
- #
277
- # @raise [TypeError]
278
- # @sig (String) { () -> void } -> void
279
- def on_load(cpath, &block)
280
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String)
281
-
282
- mutex.synchronize do
283
- (on_load_callbacks[cpath] ||= []) << block
284
- end
99
+ Registry.register_loader(self)
285
100
  end
286
101
 
287
- # Sets autoloads in the root namespace and preloads files, if any.
102
+ # Sets autoloads in the root namespace.
288
103
  #
289
104
  # @sig () -> void
290
105
  def setup
@@ -294,7 +109,6 @@ module Zeitwerk
294
109
  actual_root_dirs.each do |root_dir, namespace|
295
110
  set_autoloads_in_dir(root_dir, namespace)
296
111
  end
297
- do_preload
298
112
 
299
113
  @setup = true
300
114
  end
@@ -319,21 +133,26 @@ module Zeitwerk
319
133
  # is enough.
320
134
  unloaded_files = Set.new
321
135
 
322
- autoloads.each do |realpath, (parent, cname)|
136
+ autoloads.each do |(parent, cname), abspath|
323
137
  if parent.autoload?(cname)
324
138
  unload_autoload(parent, cname)
325
139
  else
326
140
  # Could happen if loaded with require_relative. That is unsupported,
327
141
  # and the constant path would escape unloadable_cpath? This is just
328
142
  # defensive code to clean things up as much as we are able to.
329
- unload_cref(parent, cname) if cdef?(parent, cname)
330
- unloaded_files.add(realpath) if ruby?(realpath)
143
+ unload_cref(parent, cname) if cdef?(parent, cname)
144
+ unloaded_files.add(abspath) if ruby?(abspath)
331
145
  end
332
146
  end
333
147
 
334
- to_unload.each_value do |(realpath, (parent, cname))|
335
- unload_cref(parent, cname) if cdef?(parent, cname)
336
- unloaded_files.add(realpath) if ruby?(realpath)
148
+ to_unload.each do |cpath, (abspath, (parent, cname))|
149
+ unless on_unload_callbacks.empty?
150
+ value = parent.const_get(cname)
151
+ run_on_unload_callbacks(cpath, value, abspath)
152
+ end
153
+
154
+ unload_cref(parent, cname) if cdef?(parent, cname)
155
+ unloaded_files.add(abspath) if ruby?(abspath)
337
156
  end
338
157
 
339
158
  unless unloaded_files.empty?
@@ -393,27 +212,29 @@ module Zeitwerk
393
212
  mutex.synchronize do
394
213
  break if @eager_loaded
395
214
 
215
+ log("eager load start") if logger
216
+
396
217
  queue = []
397
218
  actual_root_dirs.each do |root_dir, namespace|
398
- queue << [namespace, root_dir] unless eager_load_exclusions.member?(root_dir)
219
+ queue << [namespace, root_dir] unless excluded_from_eager_load?(root_dir)
399
220
  end
400
221
 
401
222
  while to_eager_load = queue.shift
402
223
  namespace, dir = to_eager_load
403
224
 
404
225
  ls(dir) do |basename, abspath|
405
- next if eager_load_exclusions.member?(abspath)
226
+ next if excluded_from_eager_load?(abspath)
406
227
 
407
228
  if ruby?(abspath)
408
- if cref = autoloads[File.realpath(abspath)]
409
- cref[0].const_get(cref[1], false)
229
+ if cref = autoloads.cref_for(abspath)
230
+ cget(*cref)
410
231
  end
411
232
  elsif dir?(abspath) && !root_dirs.key?(abspath)
412
- if collapse_dirs.member?(abspath)
233
+ if collapse?(abspath)
413
234
  queue << [namespace, abspath]
414
235
  else
415
236
  cname = inflector.camelize(basename, abspath)
416
- queue << [namespace.const_get(cname, false), abspath]
237
+ queue << [cget(namespace, cname), abspath]
417
238
  end
418
239
  end
419
240
  end
@@ -425,15 +246,9 @@ module Zeitwerk
425
246
  autoloaded_dirs.clear
426
247
 
427
248
  @eager_loaded = true
428
- end
429
- end
430
249
 
431
- # Let eager load ignore the given files or directories. The constants
432
- # defined in those files are still autoloadable.
433
- #
434
- # @sig (*(String | Pathname | Array[String | Pathname])) -> void
435
- def do_not_eager_load(*paths)
436
- mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
250
+ log("eager load end") if logger
251
+ end
437
252
  end
438
253
 
439
254
  # Says if the given constant path would be unloaded on reload. This
@@ -452,28 +267,6 @@ module Zeitwerk
452
267
  to_unload.keys.freeze
453
268
  end
454
269
 
455
- # Logs to `$stdout`, handy shortcut for debugging.
456
- #
457
- # @sig () -> void
458
- def log!
459
- @logger = ->(msg) { puts msg }
460
- end
461
-
462
- # @private
463
- # @sig (String) -> bool
464
- def manages?(dir)
465
- dir = dir + "/"
466
- ignored_paths.each do |ignored_path|
467
- return false if dir.start_with?(ignored_path + "/")
468
- end
469
-
470
- root_dirs.each_key do |root_dir|
471
- return true if root_dir.start_with?(dir) || dir.start_with?(root_dir + "/")
472
- end
473
-
474
- false
475
- end
476
-
477
270
  # --- Class methods ---------------------------------------------------------------------------
478
271
 
479
272
  class << self
@@ -521,19 +314,12 @@ module Zeitwerk
521
314
 
522
315
  private # -------------------------------------------------------------------------------------
523
316
 
524
- # @sig () -> Array[String]
525
- def actual_root_dirs
526
- root_dirs.reject do |root_dir, _namespace|
527
- !dir?(root_dir) || ignored_paths.member?(root_dir)
528
- end
529
- end
530
-
531
317
  # @sig (String, Module) -> void
532
318
  def set_autoloads_in_dir(dir, parent)
533
319
  ls(dir) do |basename, abspath|
534
320
  begin
535
321
  if ruby?(basename)
536
- basename[-3..-1] = ''
322
+ basename.delete_suffix!(".rb")
537
323
  cname = inflector.camelize(basename, abspath).to_sym
538
324
  autoload_file(parent, cname, abspath)
539
325
  elsif dir?(abspath)
@@ -543,9 +329,9 @@ module Zeitwerk
543
329
  # To resolve the ambiguity file name -> constant path this introduces,
544
330
  # the `app/models/concerns` directory is totally ignored as a namespace,
545
331
  # it counts only as root. The guard checks that.
546
- unless root_dirs.key?(abspath)
332
+ unless root_dir?(abspath)
547
333
  cname = inflector.camelize(basename, abspath).to_sym
548
- if collapse_dirs.member?(abspath)
334
+ if collapse?(abspath)
549
335
  set_autoloads_in_dir(abspath, parent)
550
336
  else
551
337
  autoload_subdir(parent, cname, abspath)
@@ -573,27 +359,28 @@ module Zeitwerk
573
359
 
574
360
  # @sig (Module, Symbol, String) -> void
575
361
  def autoload_subdir(parent, cname, subdir)
576
- if autoload_path = autoload_for?(parent, cname)
362
+ if autoload_path = autoloads.abspath_for(parent, cname)
577
363
  cpath = cpath(parent, cname)
578
364
  register_explicit_namespace(cpath) if ruby?(autoload_path)
579
365
  # We do not need to issue another autoload, the existing one is enough
580
366
  # no matter if it is for a file or a directory. Just remember the
581
367
  # subdirectory has to be visited if the namespace is used.
582
- (lazy_subdirs[cpath] ||= []) << subdir
368
+ lazy_subdirs[cpath] << subdir
583
369
  elsif !cdef?(parent, cname)
584
370
  # First time we find this namespace, set an autoload for it.
585
- (lazy_subdirs[cpath(parent, cname)] ||= []) << subdir
371
+ lazy_subdirs[cpath(parent, cname)] << subdir
586
372
  set_autoload(parent, cname, subdir)
587
373
  else
588
374
  # For whatever reason the constant that corresponds to this namespace has
589
375
  # already been defined, we have to recurse.
590
- set_autoloads_in_dir(subdir, parent.const_get(cname))
376
+ log("the namespace #{cpath(parent, cname)} already exists, descending into #{subdir}") if logger
377
+ set_autoloads_in_dir(subdir, cget(parent, cname))
591
378
  end
592
379
  end
593
380
 
594
381
  # @sig (Module, Symbol, String) -> void
595
382
  def autoload_file(parent, cname, file)
596
- if autoload_path = autoload_for?(parent, cname)
383
+ if autoload_path = strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
597
384
  # First autoload for a Ruby file wins, just ignore subsequent ones.
598
385
  if ruby?(autoload_path)
599
386
  log("file #{file} is ignored because #{autoload_path} has precedence") if logger
@@ -620,163 +407,32 @@ module Zeitwerk
620
407
  autoloads.delete(dir)
621
408
  Registry.unregister_autoload(dir)
622
409
 
410
+ log("earlier autoload for #{cpath(parent, cname)} discarded, it is actually an explicit namespace defined in #{file}") if logger
411
+
623
412
  set_autoload(parent, cname, file)
624
413
  register_explicit_namespace(cpath(parent, cname))
625
414
  end
626
415
 
627
416
  # @sig (Module, Symbol, String) -> void
628
417
  def set_autoload(parent, cname, abspath)
629
- # $LOADED_FEATURES stores real paths since Ruby 2.4.4. We set and save the
630
- # real path to be able to delete it from $LOADED_FEATURES on unload, and to
631
- # be able to do a lookup later in Kernel#require for manual require calls.
632
- #
633
- # We freeze realpath because that saves allocations in Module#autoload.
634
- # See #125.
635
- realpath = File.realpath(abspath).freeze
636
- parent.autoload(cname, realpath)
418
+ autoloads.define(parent, cname, abspath)
419
+
637
420
  if logger
638
- if ruby?(realpath)
639
- log("autoload set for #{cpath(parent, cname)}, to be loaded from #{realpath}")
421
+ if ruby?(abspath)
422
+ log("autoload set for #{cpath(parent, cname)}, to be loaded from #{abspath}")
640
423
  else
641
- log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{realpath}")
424
+ log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{abspath}")
642
425
  end
643
426
  end
644
427
 
645
- autoloads[realpath] = [parent, cname]
646
- Registry.register_autoload(self, realpath)
428
+ Registry.register_autoload(self, abspath)
647
429
 
648
430
  # See why in the documentation of Zeitwerk::Registry.inceptions.
649
431
  unless parent.autoload?(cname)
650
- Registry.register_inception(cpath(parent, cname), realpath, self)
651
- end
652
- end
653
-
654
- # @sig (Module, Symbol) -> String?
655
- def autoload_for?(parent, cname)
656
- strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
657
- end
658
-
659
- # The autoload? predicate takes into account the ancestor chain of the
660
- # receiver, like const_defined? and other methods in the constants API do.
661
- #
662
- # For example, given
663
- #
664
- # class A
665
- # autoload :X, "x.rb"
666
- # end
667
- #
668
- # class B < A
669
- # end
670
- #
671
- # B.autoload?(:X) returns "x.rb".
672
- #
673
- # We need a way to strictly check in parent ignoring ancestors.
674
- #
675
- # @sig (Module, Symbol) -> String?
676
- if method(:autoload?).arity == 1
677
- def strict_autoload_path(parent, cname)
678
- parent.autoload?(cname) if cdef?(parent, cname)
679
- end
680
- else
681
- def strict_autoload_path(parent, cname)
682
- parent.autoload?(cname, false)
683
- end
684
- end
685
-
686
- # This method is called this way because I prefer `preload` to be the method
687
- # name to configure preloads in the public interface.
688
- #
689
- # @sig () -> void
690
- def do_preload
691
- preloads.each do |abspath|
692
- do_preload_abspath(abspath)
432
+ Registry.register_inception(cpath(parent, cname), abspath, self)
693
433
  end
694
434
  end
695
435
 
696
- # @sig (String) -> void
697
- def do_preload_abspath(abspath)
698
- if ruby?(abspath)
699
- do_preload_file(abspath)
700
- elsif dir?(abspath)
701
- do_preload_dir(abspath)
702
- end
703
- end
704
-
705
- # @sig (String) -> void
706
- def do_preload_dir(dir)
707
- ls(dir) do |_basename, abspath|
708
- do_preload_abspath(abspath)
709
- end
710
- end
711
-
712
- # @sig (String) -> bool
713
- def do_preload_file(file)
714
- log("preloading #{file}") if logger
715
- require file
716
- end
717
-
718
- # @sig (Module, Symbol) -> String
719
- def cpath(parent, cname)
720
- parent.equal?(Object) ? cname.to_s : "#{real_mod_name(parent)}::#{cname}"
721
- end
722
-
723
- # @sig (String) { (String, String) -> void } -> void
724
- def ls(dir)
725
- Dir.foreach(dir) do |basename|
726
- next if basename.start_with?(".")
727
-
728
- abspath = File.join(dir, basename)
729
- next if ignored_paths.member?(abspath)
730
-
731
- # We freeze abspath because that saves allocations when passed later to
732
- # File methods. See #125.
733
- yield basename, abspath.freeze
734
- end
735
- end
736
-
737
- # @sig (String) -> bool
738
- def ruby?(path)
739
- path.end_with?(".rb")
740
- end
741
-
742
- # @sig (String) -> bool
743
- def dir?(path)
744
- File.directory?(path)
745
- end
746
-
747
- # @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
748
- def expand_paths(paths)
749
- paths.flatten.map! { |path| File.expand_path(path) }
750
- end
751
-
752
- # @sig (Array[String]) -> Array[String]
753
- def expand_glob_patterns(glob_patterns)
754
- # Note that Dir.glob works with regular file names just fine. That is,
755
- # glob patterns technically need no wildcards.
756
- glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
757
- end
758
-
759
- # @sig () -> void
760
- def recompute_ignored_paths
761
- ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
762
- end
763
-
764
- # @sig () -> void
765
- def recompute_collapse_dirs
766
- collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
767
- end
768
-
769
- # @sig (String) -> void
770
- def log(message)
771
- method_name = logger.respond_to?(:debug) ? :debug : :call
772
- logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
773
- end
774
-
775
- # @sig (Module, Symbol) -> bool
776
- def cdef?(parent, cname)
777
- parent.const_defined?(cname, false)
778
- end
779
-
780
436
  # @sig (String) -> void
781
437
  def register_explicit_namespace(cpath)
782
438
  ExplicitNamespace.register(cpath, self)
@@ -797,6 +453,13 @@ module Zeitwerk
797
453
  end
798
454
  end
799
455
 
456
+ # @sig (String, Object, String) -> void
457
+ def run_on_unload_callbacks(cpath, value, abspath)
458
+ # Order matters. If present, run the most specific one.
459
+ on_unload_callbacks[cpath]&.each { |c| c.call(value, abspath) }
460
+ on_unload_callbacks[:ANY]&.each { |c| c.call(cpath, value, abspath) }
461
+ end
462
+
800
463
  # @sig (Module, Symbol) -> void
801
464
  def unload_autoload(parent, cname)
802
465
  parent.__send__(:remove_const, cname)