zeitwerk 2.6.1 → 2.6.2

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: cf4acabb8b402560402158aced0f30ae6bae5120dc65d719f4fb15c1385a2454
4
- data.tar.gz: 0a297687455cf8c3924a64c5e36223c35b0f69712c807ee9bc70748f8c42ff37
3
+ metadata.gz: a7faace5f41a2ea580759f539f13136531f9291010d68821fe430ee0e0b89cbb
4
+ data.tar.gz: c1da4a297fc7339d19bbfecf453972576b22f27498a780cc2534ff4d1a2731e7
5
5
  SHA512:
6
- metadata.gz: 1d6666a098ae430f452edcd8b5e458541f20b20c4ee5903c4d18fe19cdce5afe81d6311a1bff5f9412b18ae57274e9b13132f8d59d9189b0ccd6cf7711337d6e
7
- data.tar.gz: a131561faa463e489f7e9c4898bc8e212b2f6814ba3ce634b7ef62e25467f38b88e4c63106e8d53ee933681e1f0dc196244d16f13c299d2ca0771ee4031655a1
6
+ metadata.gz: dc9ba4bd627b775bdd01bcc0ef5d902c348519b63ac12d4bfb3177f1da1f9b4c0308a611327cad9c092b84ebecc50b72e419ad51e4c44f95c8ed029d2f073a8e
7
+ data.tar.gz: addc0120d3b187668e0a8128eaf904dbe406f9bd78bd1192eef4ada7bcc35c252396a9f01c2e07bf7e1ba5d1aff3ae5646ca05cec498d7cc4b659f1f6ebd5388
data/README.md CHANGED
@@ -27,8 +27,14 @@
27
27
  - [Autoloading](#autoloading)
28
28
  - [Eager loading](#eager-loading)
29
29
  - [Eager load exclusions](#eager-load-exclusions)
30
+ - [Eager load directories](#eager-load-directories)
31
+ - [Eager load namespaces](#eager-load-namespaces)
32
+ - [Eager load namespaces shared by several loaders](#eager-load-namespaces-shared-by-several-loaders)
30
33
  - [Global eager load](#global-eager-load)
34
+ - [Loading individual files](#loading-individual-files)
31
35
  - [Reloading](#reloading)
36
+ - [Configuration and usage](#configuration-and-usage)
37
+ - [Thread-safety](#thread-safety)
32
38
  - [Inflection](#inflection)
33
39
  - [Zeitwerk::Inflector](#zeitwerkinflector)
34
40
  - [Zeitwerk::GemInflector](#zeitwerkgeminflector)
@@ -44,6 +50,7 @@
44
50
  - [Use case: Files that do not follow the conventions](#use-case-files-that-do-not-follow-the-conventions)
45
51
  - [Use case: The adapter pattern](#use-case-the-adapter-pattern)
46
52
  - [Use case: Test files mixed with implementation files](#use-case-test-files-mixed-with-implementation-files)
53
+ - [Shadowed files](#shadowed-files)
47
54
  - [Edge cases](#edge-cases)
48
55
  - [Beware of circular dependencies](#beware-of-circular-dependencies)
49
56
  - [Reopening third-party namespaces](#reopening-third-party-namespaces)
@@ -464,6 +471,75 @@ Which may be handy if the project eager loads in the test suite to [ensure proje
464
471
 
465
472
  The `force` flag does not affect ignored files and directories, those are still ignored.
466
473
 
474
+ <a id="markdown-eager-load-directories" name="eager-load-directories"></a>
475
+ #### Eager load directories
476
+
477
+ The method `Zeitwerk::Loader#eager_load_dir` eager loads a given directory, recursively:
478
+
479
+ ```ruby
480
+ loader.eager_load_dir("#{__dir__}/custom_web_app/routes")
481
+ ```
482
+
483
+ This is useful when the loader is not eager loading the entire project, but you still need some subtree to be loaded for things to function properly.
484
+
485
+ Both strings and `Pathname` objects are supported as arguments. If the argument is not a directory managed by the receiver, the method raises `Zeitwerk::Error`.
486
+
487
+ [Eager load exclusions](#eager-load-exclusions), [ignored files and directories](#ignoring-parts-of-the-project), and [shadowed files](https://github.com/fxn/zeitwerk#shadowed-files) are not eager loaded.
488
+
489
+ `Zeitwerk::Loader#eager_load_dir` is idempotent, but compatible with reloading. If you eager load a directory and then reload, eager loading that directory will load its (current) contents again.
490
+
491
+ The method checks if a regular eager load was already executed, in which case it returns fast.
492
+
493
+ Nested root directories which are descendants of the argument are skipped. Those subtrees are considered to be conceptually apart.
494
+
495
+ <a id="markdown-eager-load-namespaces" name="eager-load-namespaces"></a>
496
+ #### Eager load namespaces
497
+
498
+ The method `Zeitwerk::Loader#eager_load_namespace` eager loads a given namespace, recursively:
499
+
500
+ ```ruby
501
+ loader.eager_load_namespace(MyApp::Routes)
502
+ ```
503
+
504
+ This is useful when the loader is not eager loading the entire project, but you still need some namespace to be loaded for things to function properly.
505
+
506
+ The argument has to be a class or module object and the method raises `Zeitwerk::Error` otherwise.
507
+
508
+ If the namespace is spread over multiple directories in the receiver's source tree, they are all eager loaded. For example, if you have a structure like
509
+
510
+ ```
511
+ root_dir1/my_app/routes
512
+ root_dir2/my_app/routes
513
+ root_dir3/my_app/routes
514
+ ```
515
+
516
+ where `root_directory{1,2,3}` are root directories, eager loading `MyApp::Routes` will eager load the contents of the three corresponding directories.
517
+
518
+ There might exist external source trees implementing part of the namespace. This happens routinely, because top-level constants are stored in the globally shared `Object`. It happens also when deliberately [reopening third-party namespaces](reopening-third-party-namespaces). Such external code is not eager loaded, the implementation is carefully scoped to what the receiver manages to avoid side-effects elsewhere.
519
+
520
+ This method is flexible about what it accepts. Its semantics have to be interpreted as: "_If_ you manage this namespace, or part of this namespace, please eager load what you got". In particular, if the receiver does not manage the namespace, it will simply do nothing, this is not an error condition.
521
+
522
+ [Eager load exclusions](#eager-load-exclusions), [ignored files and directories](#ignoring-parts-of-the-project), and [shadowed files](https://github.com/fxn/zeitwerk#shadowed-files) are not eager loaded.
523
+
524
+ `Zeitwerk::Loader#eager_load_namespace` is idempotent, but compatible with reloading. If you eager load a namespace and then reload, eager loading that namespace will load its (current) descendants again.
525
+
526
+ The method checks if a regular eager load was already executed, in which case it returns fast.
527
+
528
+ If root directories are assigned to custom namespaces, the method behaves as you'd expect, according to the namespacing relationship between the custom namespace and the argument.
529
+
530
+ <a id="markdown-eager-load-namespaces-shared-by-several-loaders" name="eager-load-namespaces-shared-by-several-loaders"></a>
531
+ #### Eager load namespaces shared by several loaders
532
+
533
+ The method `Zeitwerk::Loader.eager_load_namespace` broadcasts `eager_load_namespace` to all loaders.
534
+
535
+ ```ruby
536
+ Zeitwerk::Loader.eager_load_namespace(MyFramework::Routes)
537
+ ```
538
+
539
+ This may be handy, for example, if a framework supports plugins and a shared namespace needs to be eager loaded for the project to function properly.
540
+
541
+ Please, note that loaders only eager load namespaces they manage, as documented above. Therefore, this method does not allow you to eager load namespaces not managed by Zeitwerk loaders.
542
+
467
543
  <a id="markdown-global-eager-load" name="global-eager-load"></a>
468
544
  #### Global eager load
469
545
 
@@ -479,9 +555,29 @@ Note that thanks to idempotence `Zeitwerk::Loader.eager_load_all` won't eager lo
479
555
 
480
556
  This method does not accept the `force` flag, since in general it wouldn't be a good idea to force eager loading in 3rd party code.
481
557
 
558
+ <a id="markdown-loading-individual-files" name="loading-individual-files"></a>
559
+ ### Loading individual files
560
+
561
+ The method `Zeitwerk::Loader#load_file` loads an individual Ruby file:
562
+
563
+ ```ruby
564
+ loader.load_file("#{__dir__}/custom_web_app/routes.rb")
565
+ ```
566
+
567
+ This is useful when the loader is not eager loading the entire project, but you still need an individual file to be loaded for things to function properly.
568
+
569
+ Both strings and `Pathname` objects are supported as arguments. The method raises `Zeitwerk::Error` if the argument is not a Ruby file, is [ignored](#ignoring-parts-of-the-project), is [shadowed](https://github.com/fxn/zeitwerk#shadowed-files), or is not managed by the receiver.
570
+
571
+ `Zeitwerk::Loader#load_file` is idempotent, but compatible with reloading. If you load a file and then reload, a new call will load its (current) contents again.
572
+
573
+ If you want to eager load a directory, `Zeitwerk::Loader#eager_load_dir` is more efficient than invoking `Zeitwerk::Loader#load_file` on its files.
574
+
482
575
  <a id="markdown-reloading" name="reloading"></a>
483
576
  ### Reloading
484
577
 
578
+ <a id="markdown-configuration-and-usage" name="configuration-and-usage"></a>
579
+ #### Configuration and usage
580
+
485
581
  Zeitwerk is able to reload code, but you need to enable this feature:
486
582
 
487
583
  ```ruby
@@ -503,12 +599,34 @@ Reloading removes the currently loaded classes and modules and resets the loader
503
599
 
504
600
  It is important to highlight that this is an instance method. Don't worry about project dependencies managed by Zeitwerk, their loaders are independent.
505
601
 
506
- Reloading is not thread-safe:
602
+ <a id="markdown-thread-safety" name="thread-safety"></a>
603
+ #### Thread-safety
604
+
605
+ In order to reload safely, no other thread can be autoloading or reloading concurrently. Client code is responsible for this coordination.
507
606
 
508
- * You should not reload while another thread is reloading.
509
- * You should not autoload while another thread is reloading.
607
+ For example, a web framework that serves each request in its own thread and has reloading enabled could create a read-write lock on boot like this:
608
+
609
+ ```ruby
610
+ require "concurrent/atomic/read_write_lock"
510
611
 
511
- In order to reload in a thread-safe manner, frameworks need to implement some coordination. For example, a web framework that serves each request with its own thread may have a globally accessible read/write lock: When a request comes in, the framework acquires the lock for reading at the beginning, and releases it at the end. On the other hand, the code in the framework responsible for the call to `Zeitwerk::Loader#reload` needs to acquire the lock for writing.
612
+ MyFramework::RELOAD_RW_LOCK = Concurrent::ReadWriteLock.new
613
+ ```
614
+
615
+ You acquire the lock for reading for serving each individual request:
616
+
617
+ ```ruby
618
+ MyFramework::RELOAD_RW_LOCK.with_read_lock do
619
+ serve(request)
620
+ end
621
+ ```
622
+
623
+ Then, when a reload is triggered, just acquire the lock for writing in order to execute the method call safely:
624
+
625
+ ```ruby
626
+ MyFramework::RELOAD_RW_LOCK.with_write_lock do
627
+ loader.reload
628
+ end
629
+ ```
512
630
 
513
631
  On reloading, client code has to update anything that would otherwise be storing a stale object. For example, if the routing layer of a web framework stores reloadable controller class objects or instances in internal structures, on reload it has to refresh them somehow, possibly reevaluating routes.
514
632
 
@@ -905,10 +1023,39 @@ loader.ignore(tests)
905
1023
  loader.setup
906
1024
  ```
907
1025
 
1026
+ <a id="markdown-shadowed-files" name="shadowed-files"></a>
1027
+ ### Shadowed files
1028
+
1029
+ In Ruby, if you have several files called `foo.rb` in different directories of `$LOAD_PATH` and execute
1030
+
1031
+ ```ruby
1032
+ require "foo"
1033
+ ```
1034
+
1035
+ the first one found gets loaded, and the rest are ignored.
1036
+
1037
+ Zeitwerk behaves in a similar way. If `foo.rb` is present in several root directories (at the same namespace level), the constant `Foo` is autoloaded from the first one, and the rest of the files are not evaluated. If logging is enabled, you'll see something like
1038
+
1039
+ ```
1040
+ file #{file} is ignored because #{previous_occurrence} has precedence
1041
+ ```
1042
+
1043
+ (This message is not public interface and may change, you cannot rely on that exact wording.)
1044
+
1045
+ Even if there's only one `foo.rb`, if the constant `Foo` is already defined when Zeitwerk finds `foo.rb`, then the file is ignored too. This could happen if `Foo` was defined by a dependency, for example. If logging is enabled, you'll see something like
1046
+
1047
+ ```
1048
+ file #{file} is ignored because #{constant_path} is already defined
1049
+ ```
1050
+
1051
+ (This message is not public interface and may change, you cannot rely on that exact wording.)
1052
+
1053
+ Shadowing only applies to Ruby files, namespace definition can be spread over multiple directories. And you can also reopen third-party namespaces if done [orderly](#reopening-third-party-namespaces).
1054
+
908
1055
  <a id="markdown-edge-cases" name="edge-cases"></a>
909
1056
  ### Edge cases
910
1057
 
911
- A class or module that acts as a namespace:
1058
+ [Explicit namespaces](#explicit-namespaces) like `Trip` here:
912
1059
 
913
1060
  ```ruby
914
1061
  # trip.rb
@@ -922,7 +1069,7 @@ module Trip::Geolocation
922
1069
  end
923
1070
  ```
924
1071
 
925
- has to be defined with the `class` or `module` keywords, as in the example above.
1072
+ have to be defined with the `class`/`module` keywords, as in the example above.
926
1073
 
927
1074
  For technical reasons, raw constant assignment is not supported:
928
1075
 
@@ -984,7 +1131,7 @@ require "active_job"
984
1131
  require "active_job/queue_adapters"
985
1132
 
986
1133
  require "zeitwerk"
987
- # By passign the flag, we acknowledge the extra directory lib/active_job
1134
+ # By passing the flag, we acknowledge the extra directory lib/active_job
988
1135
  # has to be managed by the loader and no warning has to be issued for it.
989
1136
  loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
990
1137
  loader.setup
@@ -43,7 +43,7 @@ module Zeitwerk
43
43
  next if abspath == expected_namespace_dir
44
44
 
45
45
  basename_without_ext = basename.delete_suffix(".rb")
46
- cname = inflector.camelize(basename_without_ext, abspath)
46
+ cname = inflector.camelize(basename_without_ext, abspath).to_sym
47
47
  ftype = dir?(abspath) ? "directory" : "file"
48
48
 
49
49
  warn(<<~EOS)
@@ -19,6 +19,9 @@ module Kernel
19
19
  # included in Object, and changes in ancestors don't get propagated into
20
20
  # already existing ancestor chains on Ruby < 3.0.
21
21
  alias_method :zeitwerk_original_require, :require
22
+ class << self
23
+ alias_method :zeitwerk_original_require, :require
24
+ end
22
25
 
23
26
  # @sig (String) -> true | false
24
27
  def require(path)
@@ -42,7 +42,7 @@ module Zeitwerk::Loader::Callbacks
42
42
  # Without the mutex and subsequent delete call, t2 would reset the module.
43
43
  # That not only would reassign the constant (undesirable per se) but, worse,
44
44
  # the module object created by t2 wouldn't have any of the autoloads for its
45
- # children, since t1 would have correctly deleted its lazy_subdirs entry.
45
+ # children, since t1 would have correctly deleted its namespace_dirs entry.
46
46
  mutex2.synchronize do
47
47
  if cref = autoloads.delete(dir)
48
48
  autovivified_module = cref[0].const_set(cref[1], Module.new)
@@ -71,9 +71,9 @@ module Zeitwerk::Loader::Callbacks
71
71
  # @private
72
72
  # @sig (Module) -> void
73
73
  def on_namespace_loaded(namespace)
74
- if subdirs = lazy_subdirs.delete(real_mod_name(namespace))
75
- subdirs.each do |subdir|
76
- set_autoloads_in_dir(subdir, namespace)
74
+ if dirs = namespace_dirs.delete(real_mod_name(namespace))
75
+ dirs.each do |dir|
76
+ set_autoloads_in_dir(dir, namespace)
77
77
  end
78
78
  end
79
79
  end
@@ -4,13 +4,12 @@ require "set"
4
4
  require "securerandom"
5
5
 
6
6
  module Zeitwerk::Loader::Config
7
- # Absolute paths of the root directories. Stored in a hash to preserve
8
- # order, easily handle duplicates, have a fast lookup needed for detecting
9
- # nested paths, and store custom namespaces as values.
7
+ # Absolute paths of the root directories. Stored in a hash to preserve order,
8
+ # easily handle duplicates, have a fast lookup needed for detecting nested
9
+ # paths, and store namespaces as values.
10
10
  #
11
- # "/Users/fxn/blog/app/assets" => Object,
12
11
  # "/Users/fxn/blog/app/channels" => Object,
13
- # "/Users/fxn/blog/adapters" => ActiveJob::QueueAdapters,
12
+ # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
14
13
  # ...
15
14
  #
16
15
  # This is a private collection maintained by the loader. The public
@@ -273,12 +272,21 @@ module Zeitwerk::Loader::Config
273
272
  @logger = ->(msg) { puts msg }
274
273
  end
275
274
 
275
+ # Returns true if the argument has been configured to be ignored, or is a
276
+ # descendant of an ignored directory.
277
+ #
276
278
  # @private
277
279
  # @sig (String) -> bool
278
280
  def ignores?(abspath)
279
- ignored_paths.any? do |ignored_path|
280
- ignored_path == abspath || (dir?(ignored_path) && abspath.start_with?(ignored_path + "/"))
281
+ # Common use case.
282
+ return false if ignored_paths.empty?
283
+
284
+ walk_up(abspath) do |abspath|
285
+ return true if ignored_paths.member?(abspath)
286
+ return false if root_dirs.key?(abspath)
281
287
  end
288
+
289
+ false
282
290
  end
283
291
 
284
292
  private
@@ -297,7 +305,15 @@ module Zeitwerk::Loader::Config
297
305
 
298
306
  # @sig (String) -> bool
299
307
  def excluded_from_eager_load?(abspath)
300
- eager_load_exclusions.member?(abspath)
308
+ # Optimize this common use case.
309
+ return false if eager_load_exclusions.empty?
310
+
311
+ walk_up(abspath) do |abspath|
312
+ return true if eager_load_exclusions.member?(abspath)
313
+ return false if root_dirs.key?(abspath)
314
+ end
315
+
316
+ false
301
317
  end
302
318
 
303
319
  # @sig (String) -> bool
@@ -0,0 +1,222 @@
1
+ module Zeitwerk::Loader::EagerLoad
2
+ # Eager loads all files in the root directories, recursively. Files do not
3
+ # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
4
+ # shadowed files are not eager loaded. You can opt-out specifically in
5
+ # specific files and directories with `do_not_eager_load`, and that can be
6
+ # overridden passing `force: true`.
7
+ #
8
+ # @sig (true | false) -> void
9
+ def eager_load(force: false)
10
+ mutex.synchronize do
11
+ break if @eager_loaded
12
+
13
+ log("eager load start") if logger
14
+
15
+ actual_root_dirs.each do |root_dir, namespace|
16
+ actual_eager_load_dir(root_dir, namespace, force: force)
17
+ end
18
+
19
+ autoloaded_dirs.each do |autoloaded_dir|
20
+ Zeitwerk::Registry.unregister_autoload(autoloaded_dir)
21
+ end
22
+ autoloaded_dirs.clear
23
+
24
+ @eager_loaded = true
25
+
26
+ log("eager load end") if logger
27
+ end
28
+ end
29
+
30
+ # @sig (String | Pathname) -> void
31
+ def eager_load_dir(path)
32
+ abspath = File.expand_path(path)
33
+
34
+ raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
35
+
36
+ cnames = []
37
+
38
+ root_namespace = nil
39
+ walk_up(abspath) do |dir|
40
+ return if ignored_paths.member?(dir)
41
+ return if eager_load_exclusions.member?(dir)
42
+
43
+ break if root_namespace = root_dirs[dir]
44
+
45
+ unless collapse?(dir)
46
+ basename = File.basename(dir)
47
+ cnames << inflector.camelize(basename, dir).to_sym
48
+ end
49
+ end
50
+
51
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
52
+
53
+ return if @eager_loaded
54
+
55
+ namespace = root_namespace
56
+ cnames.reverse_each do |cname|
57
+ # Can happen if there are no Ruby files. This is not an error condition,
58
+ # the directory is actually managed. Could have Ruby files later.
59
+ return unless cdef?(namespace, cname)
60
+ namespace = cget(namespace, cname)
61
+ end
62
+
63
+ # A shortcircuiting test depends on the invocation of this method. Please
64
+ # keep them in sync if refactored.
65
+ actual_eager_load_dir(abspath, namespace)
66
+ end
67
+
68
+ # @sig (Module) -> void
69
+ def eager_load_namespace(mod)
70
+ unless mod.is_a?(Module)
71
+ raise Zeitwerk::Error, "#{mod.inspect} is not a class or module object"
72
+ end
73
+
74
+ return if @eager_loaded
75
+
76
+ actual_root_dirs.each do |root_dir, root_namespace|
77
+ if mod.equal?(Object)
78
+ # A shortcircuiting test depends on the invocation of this method.
79
+ # Please keep them in sync if refactored.
80
+ actual_eager_load_dir(root_dir, root_namespace)
81
+ elsif root_namespace.equal?(Object)
82
+ eager_load_child_namespace(mod, root_dir, root_namespace)
83
+ else
84
+ mod_name = real_mod_name(mod)
85
+ root_namespace_name = real_mod_name(root_namespace)
86
+
87
+ if root_namespace_name.start_with?(mod_name + "::")
88
+ actual_eager_load_dir(root_dir, root_namespace)
89
+ elsif mod_name == root_namespace_name
90
+ actual_eager_load_dir(root_dir, root_namespace)
91
+ elsif mod_name.start_with?(root_namespace_name + "::")
92
+ eager_load_child_namespace(mod, root_dir, root_namespace)
93
+ else
94
+ # Unrelated constant hierarchies, do nothing.
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ # Loads the given Ruby file.
101
+ #
102
+ # Raises if the argument is ignored, shadowed, or not managed by the receiver.
103
+ #
104
+ # The method is implemented as `constantize` for files, in a sense, to be able
105
+ # to descend orderly and make sure the file is loadable.
106
+ #
107
+ # @sig (String | Pathname) -> void
108
+ def load_file(path)
109
+ abspath = File.expand_path(path)
110
+
111
+ raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
112
+ raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
113
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_paths.member?(abspath)
114
+
115
+ basename = File.basename(abspath, ".rb")
116
+ base_cname = inflector.camelize(basename, abspath).to_sym
117
+
118
+ root_namespace = nil
119
+ cnames = []
120
+
121
+ walk_up(File.dirname(abspath)) do |dir|
122
+ raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_paths.member?(dir)
123
+
124
+ break if root_namespace = root_dirs[dir]
125
+
126
+ unless collapse?(dir)
127
+ basename = File.basename(dir)
128
+ cnames << inflector.camelize(basename, dir).to_sym
129
+ end
130
+ end
131
+
132
+ raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
133
+
134
+ namespace = root_namespace
135
+ cnames.reverse_each do |cname|
136
+ namespace = cget(namespace, cname)
137
+ end
138
+
139
+ raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
140
+
141
+ cget(namespace, base_cname)
142
+ end
143
+
144
+ # The caller is responsible for making sure `namespace` is the namespace that
145
+ # corresponds to `dir`.
146
+ #
147
+ # @sig (String, Module, Boolean) -> void
148
+ private def actual_eager_load_dir(dir, namespace, force: false)
149
+ honour_exclusions = !force
150
+ return if honour_exclusions && excluded_from_eager_load?(dir)
151
+
152
+ log("eager load directory #{dir} start") if logger
153
+
154
+ queue = [[dir, namespace]]
155
+ while to_eager_load = queue.shift
156
+ dir, namespace = to_eager_load
157
+
158
+ ls(dir) do |basename, abspath|
159
+ next if honour_exclusions && eager_load_exclusions.member?(abspath)
160
+
161
+ if ruby?(abspath)
162
+ if (cref = autoloads[abspath]) && !shadowed_file?(abspath)
163
+ cget(*cref)
164
+ end
165
+ else
166
+ if collapse?(abspath)
167
+ queue << [abspath, namespace]
168
+ else
169
+ cname = inflector.camelize(basename, abspath).to_sym
170
+ queue << [abspath, cget(namespace, cname)]
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ log("eager load directory #{dir} end") if logger
177
+ end
178
+
179
+ # In order to invoke this method, the caller has to ensure `child` is a
180
+ # strict namespace descendendant of `root_namespace`.
181
+ #
182
+ # @sig (Module, String, Module, Boolean) -> void
183
+ private def eager_load_child_namespace(child, root_dir, root_namespace)
184
+ suffix = real_mod_name(child)
185
+ unless root_namespace.equal?(Object)
186
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
187
+ end
188
+
189
+ # These directories are at the same namespace level, there may be more if
190
+ # we find collapsed ones. As we scan, we look for matches for the first
191
+ # segment, and store them in `next_dirs`. If there are any, we look for
192
+ # the next segments in those matches. Repeat.
193
+ #
194
+ # If we exhaust the search locating directories that match all segments,
195
+ # we just need to eager load those ones.
196
+ dirs = [root_dir]
197
+ next_dirs = []
198
+
199
+ suffix.split("::").each do |segment|
200
+ while dir = dirs.shift
201
+ ls(dir) do |basename, abspath|
202
+ next unless dir?(abspath)
203
+
204
+ if collapse?(abspath)
205
+ current_dirs << abspath
206
+ elsif segment == inflector.camelize(basename, abspath)
207
+ next_dirs << abspath
208
+ end
209
+ end
210
+ end
211
+
212
+ return if next_dirs.empty?
213
+
214
+ dirs.replace(next_dirs)
215
+ next_dirs.clear
216
+ end
217
+
218
+ dirs.each do |dir|
219
+ actual_eager_load_dir(dir, child)
220
+ end
221
+ end
222
+ end
@@ -31,7 +31,8 @@ module Zeitwerk::Loader::Helpers
31
31
  next if ignored_paths.member?(abspath)
32
32
 
33
33
  if dir?(abspath)
34
- next unless has_at_least_one_ruby_file?(abspath)
34
+ next if root_dirs.key?(abspath)
35
+ next if !has_at_least_one_ruby_file?(abspath)
35
36
  else
36
37
  next unless ruby?(abspath)
37
38
  end
@@ -74,6 +75,15 @@ module Zeitwerk::Loader::Helpers
74
75
  basename.start_with?(".")
75
76
  end
76
77
 
78
+ # @sig (String) { (String) -> void } -> void
79
+ def walk_up(abspath)
80
+ loop do
81
+ yield abspath
82
+ abspath, basename = File.split(abspath)
83
+ break if basename == "/"
84
+ end
85
+ end
86
+
77
87
  # --- Constants ---------------------------------------------------------------------------------
78
88
 
79
89
  # The autoload? predicate takes into account the ancestor chain of the
@@ -7,11 +7,13 @@ module Zeitwerk
7
7
  require_relative "loader/helpers"
8
8
  require_relative "loader/callbacks"
9
9
  require_relative "loader/config"
10
+ require_relative "loader/eager_load"
10
11
 
11
12
  include RealModName
12
13
  include Callbacks
13
14
  include Helpers
14
15
  include Config
16
+ include EagerLoad
15
17
 
16
18
  MUTEX = Mutex.new
17
19
  private_constant :MUTEX
@@ -54,7 +56,7 @@ module Zeitwerk
54
56
  # @sig Hash[String, [String, [Module, Symbol]]]
55
57
  attr_reader :to_unload
56
58
 
57
- # Maps constant paths of namespaces to arrays of corresponding directories.
59
+ # Maps namespace constant paths to their respective directories.
58
60
  #
59
61
  # For example, given this mapping:
60
62
  #
@@ -64,13 +66,24 @@ module Zeitwerk
64
66
  # ...
65
67
  # ]
66
68
  #
67
- # when `Admin` gets defined we know that it plays the role of a namespace and
68
- # that its children are spread over those directories. We'll visit them to set
69
- # up the corresponding autoloads.
69
+ # when `Admin` gets defined we know that it plays the role of a namespace
70
+ # and that its children are spread over those directories. We'll visit them
71
+ # to set up the corresponding autoloads.
70
72
  #
71
73
  # @private
72
74
  # @sig Hash[String, Array[String]]
73
- attr_reader :lazy_subdirs
75
+ attr_reader :namespace_dirs
76
+
77
+ # A shadowed file is a file managed by this loader that is ignored when
78
+ # setting autoloads because its matching constant is already taken.
79
+ #
80
+ # This private set is populated as we descend. For example, if the loader
81
+ # has only scanned the top-level, `shadowed_files` does not have shadowed
82
+ # files that may exist deep in the project tree yet.
83
+ #
84
+ # @private
85
+ # @sig Set[String]
86
+ attr_reader :shadowed_files
74
87
 
75
88
  # @private
76
89
  # @sig Mutex
@@ -86,7 +99,8 @@ module Zeitwerk
86
99
  @autoloads = {}
87
100
  @autoloaded_dirs = []
88
101
  @to_unload = {}
89
- @lazy_subdirs = Hash.new { |h, cpath| h[cpath] = [] }
102
+ @namespace_dirs = Hash.new { |h, cpath| h[cpath] = [] }
103
+ @shadowed_files = Set.new
90
104
  @mutex = Mutex.new
91
105
  @mutex2 = Mutex.new
92
106
  @setup = false
@@ -181,7 +195,8 @@ module Zeitwerk
181
195
  autoloads.clear
182
196
  autoloaded_dirs.clear
183
197
  to_unload.clear
184
- lazy_subdirs.clear
198
+ namespace_dirs.clear
199
+ shadowed_files.clear
185
200
 
186
201
  Registry.on_unload(self)
187
202
  ExplicitNamespace.unregister_loader(self)
@@ -208,58 +223,6 @@ module Zeitwerk
208
223
  setup
209
224
  end
210
225
 
211
- # Eager loads all files in the root directories, recursively. Files do not
212
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored files
213
- # are not eager loaded. You can opt-out specifically in specific files and
214
- # directories with `do_not_eager_load`, and that can be overridden passing
215
- # `force: true`.
216
- #
217
- # @sig (true | false) -> void
218
- def eager_load(force: false)
219
- mutex.synchronize do
220
- break if @eager_loaded
221
-
222
- log("eager load start") if logger
223
-
224
- honour_exclusions = !force
225
-
226
- queue = []
227
- actual_root_dirs.each do |root_dir, namespace|
228
- queue << [namespace, root_dir] unless honour_exclusions && excluded_from_eager_load?(root_dir)
229
- end
230
-
231
- while to_eager_load = queue.shift
232
- namespace, dir = to_eager_load
233
-
234
- ls(dir) do |basename, abspath|
235
- next if honour_exclusions && excluded_from_eager_load?(abspath)
236
-
237
- if ruby?(abspath)
238
- if cref = autoloads[abspath]
239
- cget(*cref)
240
- end
241
- elsif !root_dirs.key?(abspath)
242
- if collapse?(abspath)
243
- queue << [namespace, abspath]
244
- else
245
- cname = inflector.camelize(basename, abspath)
246
- queue << [cget(namespace, cname), abspath]
247
- end
248
- end
249
- end
250
- end
251
-
252
- autoloaded_dirs.each do |autoloaded_dir|
253
- Registry.unregister_autoload(autoloaded_dir)
254
- end
255
- autoloaded_dirs.clear
256
-
257
- @eager_loaded = true
258
-
259
- log("eager load end") if logger
260
- end
261
- end
262
-
263
226
  # Says if the given constant path would be unloaded on reload. This
264
227
  # predicate returns `false` if reloading is disabled.
265
228
  #
@@ -285,6 +248,15 @@ module Zeitwerk
285
248
  ExplicitNamespace.unregister_loader(self)
286
249
  end
287
250
 
251
+ # The return value of this predicate is only meaningful if the loader has
252
+ # scanned the file. This is the case in the spots where we use it.
253
+ #
254
+ # @private
255
+ # @sig (String) -> Boolean
256
+ def shadowed_file?(file)
257
+ shadowed_files.member?(file)
258
+ end
259
+
288
260
  # --- Class methods ---------------------------------------------------------------------------
289
261
 
290
262
  class << self
@@ -318,6 +290,15 @@ module Zeitwerk
318
290
  Registry.loaders.each(&:eager_load)
319
291
  end
320
292
 
293
+ # Broadcasts `eager_load_namespace` to all loaders.
294
+ #
295
+ # @sig (Module) -> void
296
+ def eager_load_namespace(mod)
297
+ Registry.loaders.each do |loader|
298
+ loader.eager_load_namespace(mod)
299
+ end
300
+ end
301
+
321
302
  # Returns an array with the absolute paths of the root directories of all
322
303
  # registered loaders. This is a read-only collection.
323
304
  #
@@ -338,19 +319,11 @@ module Zeitwerk
338
319
  cname = inflector.camelize(basename, abspath).to_sym
339
320
  autoload_file(parent, cname, abspath)
340
321
  else
341
- # In a Rails application, `app/models/concerns` is a subdirectory of
342
- # `app/models`, but both of them are root directories.
343
- #
344
- # To resolve the ambiguity file name -> constant path this introduces,
345
- # the `app/models/concerns` directory is totally ignored as a namespace,
346
- # it counts only as root. The guard checks that.
347
- unless root_dir?(abspath)
322
+ if collapse?(abspath)
323
+ set_autoloads_in_dir(abspath, parent)
324
+ else
348
325
  cname = inflector.camelize(basename, abspath).to_sym
349
- if collapse?(abspath)
350
- set_autoloads_in_dir(abspath, parent)
351
- else
352
- autoload_subdir(parent, cname, abspath)
353
- end
326
+ autoload_subdir(parent, cname, abspath)
354
327
  end
355
328
  end
356
329
  rescue ::NameError => error
@@ -380,10 +353,10 @@ module Zeitwerk
380
353
  # We do not need to issue another autoload, the existing one is enough
381
354
  # no matter if it is for a file or a directory. Just remember the
382
355
  # subdirectory has to be visited if the namespace is used.
383
- lazy_subdirs[cpath] << subdir
356
+ namespace_dirs[cpath] << subdir
384
357
  elsif !cdef?(parent, cname)
385
358
  # First time we find this namespace, set an autoload for it.
386
- lazy_subdirs[cpath(parent, cname)] << subdir
359
+ namespace_dirs[cpath(parent, cname)] << subdir
387
360
  set_autoload(parent, cname, subdir)
388
361
  else
389
362
  # For whatever reason the constant that corresponds to this namespace has
@@ -398,6 +371,7 @@ module Zeitwerk
398
371
  if autoload_path = strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname))
399
372
  # First autoload for a Ruby file wins, just ignore subsequent ones.
400
373
  if ruby?(autoload_path)
374
+ shadowed_files << file
401
375
  log("file #{file} is ignored because #{autoload_path} has precedence") if logger
402
376
  else
403
377
  promote_namespace_from_implicit_to_explicit(
@@ -408,6 +382,7 @@ module Zeitwerk
408
382
  )
409
383
  end
410
384
  elsif cdef?(parent, cname)
385
+ shadowed_files << file
411
386
  log("file #{file} is ignored because #{cpath(parent, cname)} is already defined") if logger
412
387
  else
413
388
  set_autoload(parent, cname, file)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zeitwerk
4
- VERSION = "2.6.1"
4
+ VERSION = "2.6.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zeitwerk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.6.1
4
+ version: 2.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Xavier Noria
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-09-30 00:00:00.000000000 Z
11
+ date: 2022-10-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |2
14
14
  Zeitwerk implements constant autoloading with Ruby semantics. Each gem
@@ -32,6 +32,7 @@ files:
32
32
  - lib/zeitwerk/loader.rb
33
33
  - lib/zeitwerk/loader/callbacks.rb
34
34
  - lib/zeitwerk/loader/config.rb
35
+ - lib/zeitwerk/loader/eager_load.rb
35
36
  - lib/zeitwerk/loader/helpers.rb
36
37
  - lib/zeitwerk/real_mod_name.rb
37
38
  - lib/zeitwerk/registry.rb