Bump zeitwerk from 2.7.5 to 2.8.1#988
Closed
dependabot[bot] wants to merge 1 commit into
Closed
Conversation
Contributor
4 similar comments
Contributor
Contributor
Contributor
Contributor
Contributor
gem compare zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT version:
2.7.5: 2.7.5
2.8.1: 2.8.1
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md +125/-71
lib/zeitwerk.rb +13/-13
lib/zeitwerk/core_ext/kernel.rb +1/-1
lib/zeitwerk/cref.rb +8/-1
lib/zeitwerk/cref/map.rb +1/-1
lib/zeitwerk/error.rb +12/-1
lib/zeitwerk/gem_inflector.rb +3/-3
lib/zeitwerk/gem_loader.rb +4/-4
lib/zeitwerk/inflector.rb +8/-8
lib/zeitwerk/loader.rb +165/-127
lib/zeitwerk/loader/callbacks.rb +1/-1
lib/zeitwerk/loader/config.rb +64/-18
lib/zeitwerk/loader/eager_load.rb +23/-27
lib/zeitwerk/loader/file_system.rb +72/-25
lib/zeitwerk/loader/helpers.rb +2/-2
lib/zeitwerk/registry.rb +6/-6
lib/zeitwerk/registry/loaders.rb +2/-2
lib/zeitwerk/version.rb +1/-1 |
4 similar comments
Contributor
gem compare zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT version:
2.7.5: 2.7.5
2.8.1: 2.8.1
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md +125/-71
lib/zeitwerk.rb +13/-13
lib/zeitwerk/core_ext/kernel.rb +1/-1
lib/zeitwerk/cref.rb +8/-1
lib/zeitwerk/cref/map.rb +1/-1
lib/zeitwerk/error.rb +12/-1
lib/zeitwerk/gem_inflector.rb +3/-3
lib/zeitwerk/gem_loader.rb +4/-4
lib/zeitwerk/inflector.rb +8/-8
lib/zeitwerk/loader.rb +165/-127
lib/zeitwerk/loader/callbacks.rb +1/-1
lib/zeitwerk/loader/config.rb +64/-18
lib/zeitwerk/loader/eager_load.rb +23/-27
lib/zeitwerk/loader/file_system.rb +72/-25
lib/zeitwerk/loader/helpers.rb +2/-2
lib/zeitwerk/registry.rb +6/-6
lib/zeitwerk/registry/loaders.rb +2/-2
lib/zeitwerk/version.rb +1/-1 |
Contributor
gem compare zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT version:
2.7.5: 2.7.5
2.8.1: 2.8.1
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md +125/-71
lib/zeitwerk.rb +13/-13
lib/zeitwerk/core_ext/kernel.rb +1/-1
lib/zeitwerk/cref.rb +8/-1
lib/zeitwerk/cref/map.rb +1/-1
lib/zeitwerk/error.rb +12/-1
lib/zeitwerk/gem_inflector.rb +3/-3
lib/zeitwerk/gem_loader.rb +4/-4
lib/zeitwerk/inflector.rb +8/-8
lib/zeitwerk/loader.rb +165/-127
lib/zeitwerk/loader/callbacks.rb +1/-1
lib/zeitwerk/loader/config.rb +64/-18
lib/zeitwerk/loader/eager_load.rb +23/-27
lib/zeitwerk/loader/file_system.rb +72/-25
lib/zeitwerk/loader/helpers.rb +2/-2
lib/zeitwerk/registry.rb +6/-6
lib/zeitwerk/registry/loaders.rb +2/-2
lib/zeitwerk/version.rb +1/-1 |
Contributor
gem compare zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT version:
2.7.5: 2.7.5
2.8.1: 2.8.1
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md +125/-71
lib/zeitwerk.rb +13/-13
lib/zeitwerk/core_ext/kernel.rb +1/-1
lib/zeitwerk/cref.rb +8/-1
lib/zeitwerk/cref/map.rb +1/-1
lib/zeitwerk/error.rb +12/-1
lib/zeitwerk/gem_inflector.rb +3/-3
lib/zeitwerk/gem_loader.rb +4/-4
lib/zeitwerk/inflector.rb +8/-8
lib/zeitwerk/loader.rb +165/-127
lib/zeitwerk/loader/callbacks.rb +1/-1
lib/zeitwerk/loader/config.rb +64/-18
lib/zeitwerk/loader/eager_load.rb +23/-27
lib/zeitwerk/loader/file_system.rb +72/-25
lib/zeitwerk/loader/helpers.rb +2/-2
lib/zeitwerk/registry.rb +6/-6
lib/zeitwerk/registry/loaders.rb +2/-2
lib/zeitwerk/version.rb +1/-1 |
Contributor
gem compare zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT version:
2.7.5: 2.7.5
2.8.1: 2.8.1
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md +125/-71
lib/zeitwerk.rb +13/-13
lib/zeitwerk/core_ext/kernel.rb +1/-1
lib/zeitwerk/cref.rb +8/-1
lib/zeitwerk/cref/map.rb +1/-1
lib/zeitwerk/error.rb +12/-1
lib/zeitwerk/gem_inflector.rb +3/-3
lib/zeitwerk/gem_loader.rb +4/-4
lib/zeitwerk/inflector.rb +8/-8
lib/zeitwerk/loader.rb +165/-127
lib/zeitwerk/loader/callbacks.rb +1/-1
lib/zeitwerk/loader/config.rb +64/-18
lib/zeitwerk/loader/eager_load.rb +23/-27
lib/zeitwerk/loader/file_system.rb +72/-25
lib/zeitwerk/loader/helpers.rb +2/-2
lib/zeitwerk/registry.rb +6/-6
lib/zeitwerk/registry/loaders.rb +2/-2
lib/zeitwerk/version.rb +1/-1 |
Contributor
gem compare --diff zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/README.md 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/README.md 2026-05-20 07:03:18.817903146 +0000
@@ -21,0 +22,2 @@
+ - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
+ - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
@@ -101 +103 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -184 +186 @@
-loader.inflector.inflect("max_retries" => "MAX_RETRIES")
+loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
@@ -223,2 +225,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -267 +269,6 @@
-Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
+Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
+
+<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
+#### Explicit namespaces defined in ordinary files
+
+Let's consider:
@@ -274 +281 @@
-There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
+Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
@@ -276 +283,3 @@
-The classes and modules from the namespace are already available in the body of the class or module defining it:
+In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
+
+The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
@@ -287 +296,42 @@
-An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
+<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
+#### Explicit namespaces defined in nsfiles
+
+If the loader has an nsfile configured (defaults to `nil`):
+
+```ruby
+loader.nsfile = 'ns.rb' # must be set before setup
+```
+
+you can alternatively define the explicit namespace inside its directory:
+
+```
+my_component/ns.rb -> MyComponent
+my_component/widget.rb -> MyComponent::Widget
+```
+
+This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
+
+A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
+
+Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
+
+```
+my_component/src/ns.rb -> MyComponent
+my_component/src/widget.rb -> MyComponent::Widget
+my_component/assets/widget.js
+my_component/tests/test_widget.rb
+```
+
+Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
+
+However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
+
+Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
+
+Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
+
+```ruby
+loader.nsfile = 'index.rb'
+```
+
+there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
@@ -375 +425 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -377 +427 @@
-loader.tag = File.basename(__FILE__, ".rb")
+loader.tag = File.basename(__FILE__, '.rb')
@@ -387 +437 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -433 +483 @@
-gem "net-http-niche_feature"
+gem 'net-http-niche_feature'
@@ -446 +496 @@
-require "net/http/niche_feature"
+require 'net/http/niche_feature'
@@ -451,2 +501,2 @@
-require "net/http"
-require "zeitwerk"
+require 'net/http'
+require 'zeitwerk'
@@ -467 +517 @@
- VERSION = "1.0.0"
+ VERSION = '1.0.0'
@@ -489 +539 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -498 +548 @@
-That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
+That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
@@ -685 +735 @@
-require "concurrent/atomic/read_write_lock"
+require 'concurrent/atomic/read_write_lock'
@@ -730,2 +780,2 @@
- "html_parser" => "HTMLParser",
- "mysql_adapter" => "MySQLAdapter"
+ 'html_parser' => 'HTMLParser',
+ 'mysql_adapter' => 'MySQLAdapter'
@@ -738,2 +788,2 @@
-loader.inflector.inflect "html_parser" => "HTMLParser"
-loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
+loader.inflector.inflect 'html_parser' => 'HTMLParser'
+loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
@@ -745 +795 @@
-loader.inflector.inflect("xml" => "XML")
+loader.inflector.inflect('xml' => 'XML')
@@ -760,2 +810,2 @@
- "xml" => "XML",
- "xml_parser" => "XMLParser"
+ 'xml' => 'XML',
+ 'xml_parser' => 'XMLParser'
@@ -816 +866 @@
- "HTML" + super($1, abspath)
+ 'HTML' + super($1, abspath)
@@ -847,2 +897,2 @@
-require "zeitwerk"
-require_relative "my_gem/inflector"
+require 'zeitwerk'
+require_relative 'my_gem/inflector'
@@ -916,2 +966,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.dev"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.dev'
@@ -921,2 +971,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.prod"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.prod'
@@ -966 +1016 @@
-loader.on_unload("Country") do |klass, _abspath|
+loader.on_unload('Country') do |klass, _abspath|
@@ -1051 +1101 @@
-loader.tag = "grep_me"
+loader.tag = 'grep_me'
@@ -1107 +1157 @@
-require_relative "my_gem/core_ext/kernel"
+require_relative 'my_gem/core_ext/kernel'
@@ -1121 +1171 @@
-require "pg"
+require 'pg'
@@ -1159 +1209 @@
-require "foo"
+require 'foo'
@@ -1228,2 +1278,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -1231 +1281 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -1250,2 +1300,2 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.dirs # => ["/foo"]
+loader.push_dir(Pathname.new('/foo'))
+loader.dirs # => ['/foo']
@@ -1258,3 +1308,3 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.push_dir(Pathname.new("/bar"), namespace: Bar)
-loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
+loader.push_dir(Pathname.new('/foo'))
+loader.push_dir(Pathname.new('/bar'), namespace: Bar)
+loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
@@ -1287,4 +1337,4 @@
-loader.cpath_expected_at("app/models") # => "Object"
-loader.cpath_expected_at("app/models/user.rb") # => "User"
-loader.cpath_expected_at("app/models/hotel") # => "Hotel"
-loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+loader.cpath_expected_at('app/models') # => 'Object'
+loader.cpath_expected_at('app/models/user.rb') # => 'User'
+loader.cpath_expected_at('app/models/hotel') # => 'Hotel'
+loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
@@ -1296,3 +1346,3 @@
-loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
-loader.cpath_expected_at("a/b/collapsed") # => "A::B", edge case
-loader.cpath_expected_at("a/b") # => "A::B"
+loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
+loader.cpath_expected_at('a/b/collapsed') # => 'A::B', edge case
+loader.cpath_expected_at('a/b') # => 'A::B'
@@ -1306 +1356 @@
-loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
@@ -1312 +1362 @@
-loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
+loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
@@ -1315 +1365,3 @@
-This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1332 +1383,0 @@
-lib/my_gem/ignored.rb
@@ -1335,0 +1387 @@
+lib/my_gem/ignored.rb
@@ -1343,9 +1395,9 @@
- "/.../lib" => "Object",
- "/.../lib/my_gem.rb" => "MyGem",
- "/.../lib/my_gem" => "MyGem",
- "/.../lib/my_gem/version.rb" => "MyGem::VERSION",
- "/.../lib/my_gem/drivers" => "MyGem::Drivers",
- "/.../lib/my_gem/drivers/unix.rb" => "MyGem::Drivers::Unix",
- "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
- "/.../lib/my_gem/collapsed" => "MyGem",
- "/.../lib/my_gem/collapsed/foo.rb" => "MyGem::Foo"
+ '/.../lib' => 'Object',
+ '/.../lib/my_gem.rb' => 'MyGem',
+ '/.../lib/my_gem' => 'MyGem',
+ '/.../lib/my_gem/version.rb' => 'MyGem::VERSION',
+ '/.../lib/my_gem/drivers' => 'MyGem::Drivers',
+ '/.../lib/my_gem/drivers/unix.rb' => 'MyGem::Drivers::Unix',
+ '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
+ '/.../lib/my_gem/collapsed' => 'MyGem',
+ '/.../lib/my_gem/collapsed/foo.rb' => 'MyGem::Foo'
@@ -1357 +1409 @@
-The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
+The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
@@ -1363 +1415,3 @@
-This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1434,2 +1488,2 @@
-test "capitalizes the first letter" do
- assert_equal "User", camelize("user")
+test 'capitalizes the first letter' do
+ assert_equal 'User', camelize('user')
@@ -1449 +1503 @@
-Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
+Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
lib/zeitwerk.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk.rb 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk.rb 2026-05-20 07:03:18.818903140 +0000
@@ -4,11 +4,11 @@
- require_relative "zeitwerk/real_mod_name"
- require_relative "zeitwerk/internal"
- require_relative "zeitwerk/cref"
- require_relative "zeitwerk/loader"
- require_relative "zeitwerk/gem_loader"
- require_relative "zeitwerk/registry"
- require_relative "zeitwerk/inflector"
- require_relative "zeitwerk/gem_inflector"
- require_relative "zeitwerk/null_inflector"
- require_relative "zeitwerk/error"
- require_relative "zeitwerk/version"
+ require_relative 'zeitwerk/real_mod_name'
+ require_relative 'zeitwerk/internal'
+ require_relative 'zeitwerk/cref'
+ require_relative 'zeitwerk/loader'
+ require_relative 'zeitwerk/gem_loader'
+ require_relative 'zeitwerk/registry'
+ require_relative 'zeitwerk/inflector'
+ require_relative 'zeitwerk/gem_inflector'
+ require_relative 'zeitwerk/null_inflector'
+ require_relative 'zeitwerk/error'
+ require_relative 'zeitwerk/version'
@@ -16,2 +16,2 @@
- require_relative "zeitwerk/core_ext/kernel"
- require_relative "zeitwerk/core_ext/module"
+ require_relative 'zeitwerk/core_ext/kernel'
+ require_relative 'zeitwerk/core_ext/module'
lib/zeitwerk/core_ext/kernel.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:18.818903140 +0000
@@ -25 +25 @@
- if path.end_with?(".rb")
+ if path.end_with?('.rb')
lib/zeitwerk/cref.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/cref.rb 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/cref.rb 2026-05-20 07:03:18.818903140 +0000
@@ -14 +14 @@
- require_relative "cref/map"
+ require_relative 'cref/map'
@@ -67,0 +68,7 @@
+ end
+
+ #: () -> String?
+ def location
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
+ location.join(':')
+ end
lib/zeitwerk/cref/map.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:18.818903140 +0000
@@ -30 +30 @@
-# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
+# { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
lib/zeitwerk/error.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/error.rb 2026-05-20 07:03:18.814903165 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/error.rb 2026-05-20 07:03:18.818903140 +0000
@@ -20 +20,12 @@
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
+ end
+ end
+
+ class ConflictingNamespaceDefinitionError < Error
+ #: (String, location: String?, conflicting_file: String) -> void
+ def initialize(cpath, location:, conflicting_file:)
+ if location
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
+ else
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
+ end
lib/zeitwerk/gem_inflector.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:18.818903140 +0000
@@ -7 +7 @@
- namespace = File.basename(root_file, ".rb")
+ namespace = File.basename(root_file, '.rb')
@@ -9 +9 @@
- @version_file = File.join(root_dir, namespace, "version.rb")
+ @version_file = File.join(root_dir, namespace, 'version.rb')
@@ -14 +14 @@
- abspath == @version_file ? "VERSION" : super
+ abspath == @version_file ? 'VERSION' : super
lib/zeitwerk/gem_loader.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:18.818903140 +0000
@@ -22,2 +22,2 @@
- @tag = File.basename(root_file, ".rb")
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
+ @tag = File.basename(root_file, '.rb')
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
@@ -43 +43 @@
- expected_namespace_dir = @root_file.delete_suffix(".rb")
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
@@ -49 +49 @@
- basename_without_ext = basename.delete_suffix(".rb")
+ basename_without_ext = basename.delete_suffix('.rb')
lib/zeitwerk/inflector.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb 2026-05-20 07:03:18.818903140 +0000
@@ -8,3 +8,3 @@
- # inflector.camelize("post", ...) # => "Post"
- # inflector.camelize("users_controller", ...) # => "UsersController"
- # inflector.camelize("api", ...) # => "Api"
+ # inflector.camelize('post', ...) # => 'Post'
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
+ # inflector.camelize('api', ...) # => 'Api'
@@ -23,2 +23,2 @@
- # "html_parser" => "HTMLParser",
- # "mysql_adapter" => "MySQLAdapter"
+ # 'html_parser' => 'HTMLParser',
+ # 'mysql_adapter' => 'MySQLAdapter'
@@ -27,3 +27,3 @@
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
- # inflector.camelize("users_controller", abspath) # => "UsersController"
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
lib/zeitwerk/loader.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader.rb 2026-05-20 07:03:18.819903133 +0000
@@ -3,2 +3,2 @@
-require "monitor"
-require "set"
+require 'monitor'
+require 'set'
@@ -8,5 +8,5 @@
- require_relative "loader/helpers"
- require_relative "loader/callbacks"
- require_relative "loader/config"
- require_relative "loader/eager_load"
- require_relative "loader/file_system"
+ require_relative 'loader/helpers'
+ require_relative 'loader/callbacks'
+ require_relative 'loader/config'
+ require_relative 'loader/eager_load'
+ require_relative 'loader/file_system'
@@ -25,2 +25,2 @@
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
@@ -104,0 +105 @@
+ #: () -> void
@@ -132 +133 @@
- define_autoloads_for_dir(root_dir, root_namespace)
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
@@ -155,0 +157,3 @@
+ __unload
+ end
+ end
@@ -157,33 +161,19 @@
- # We are going to keep track of the files that were required by our
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
- # loadable by Kernel#require again.
- #
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
- # is enough.
- unloaded_files = Set.new
-
- autoloads.each do |abspath, cref|
- if cref.autoload?
- unload_autoload(cref)
- else
- # Could happen if loaded with require_relative. That is unsupported,
- # and the constant path would escape unloadable_cpath? This is just
- # defensive code to clean things up as much as we are able to.
- unload_cref(cref)
- unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
- end
- end
-
- to_unload.each do |abspath, cref|
- unless on_unload_callbacks.empty?
- begin
- value = cref.get
- rescue ::NameError
- # Perhaps the user deleted the constant by hand, or perhaps an
- # autoload failed to define the expected constant but the user
- # rescued the exception.
- else
- run_on_unload_callbacks(cref, value, abspath)
- end
- end
-
+ # This is an internal method.
+ #
+ #: () -> void
+ def __unload
+ # We are going to keep track of the files that were required by our
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
+ # loadable by Kernel#require again.
+ #
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
+ # is enough.
+ unloaded_files = Set.new
+
+ autoloads.each do |abspath, cref|
+ if cref.autoload?
+ unload_autoload(cref)
+ else
+ # Could happen if loaded with require_relative. That is unsupported,
+ # and the constant path would escape unloadable_cpath? This is just
+ # defensive code to clean things up as much as we are able to.
@@ -192,0 +183 @@
+ end
@@ -194,13 +185,11 @@
- unless unloaded_files.empty?
- # Bootsnap decorates Kernel#require to speed it up using a cache and
- # this optimization does not check if $LOADED_FEATURES has the file.
- #
- # To make it aware of changes, the gem defines singleton methods in
- # $LOADED_FEATURES:
- #
- # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
- #
- # Rails applications may depend on bootsnap, so for unloading to work
- # in that setting it is preferable that we restrict our API choice to
- # one of those methods.
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ to_unload.each do |abspath, cref|
+ unless on_unload_callbacks.empty?
+ begin
+ value = cref.get
+ rescue ::NameError
+ # Perhaps the user deleted the constant by hand, or perhaps an
+ # autoload failed to define the expected constant but the user
+ # rescued the exception.
+ else
+ run_on_unload_callbacks(cref, value, abspath)
+ end
@@ -209,5 +198,3 @@
- autoloads.clear
- autoloaded_dirs.clear
- to_unload.clear
- namespace_dirs.clear
- shadowed_files.clear
+ unload_cref(cref)
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
+ end
@@ -215,2 +202,14 @@
- unregister_inceptions
- unregister_explicit_namespaces
+ unless unloaded_files.empty?
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
+ # this optimization does not check if $LOADED_FEATURES has the file.
+ #
+ # To make it aware of changes, the gem defines singleton methods in
+ # $LOADED_FEATURES:
+ #
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
+ #
+ # Rails applications may depend on bootsnap, so for unloading to work
+ # in that setting it is preferable that we restrict our API choice to
+ # one of those methods.
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ end
@@ -218 +217,5 @@
- Registry.autoloads.unregister_loader(self)
+ autoloads.clear
+ autoloaded_dirs.clear
+ to_unload.clear
+ namespace_dirs.clear
+ shadowed_files.clear
@@ -220,3 +223,7 @@
- @setup = false
- @eager_loaded = false
- end
+ unregister_inceptions
+ unregister_explicit_namespaces
+
+ Registry.autoloads.unregister_loader(self)
+
+ @setup = false
+ @eager_loaded = false
@@ -236,0 +244 @@
+
@@ -238,0 +247,2 @@
+ recompute_collapse_parents
+
@@ -255 +265 @@
- prefix = cpath == "Object" ? "" : cpath + "::"
+ prefix = cpath == 'Object' ? '' : cpath + '::'
@@ -257 +267 @@
- @fs.ls(dir) do |basename, abspath, ftype|
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
@@ -259,5 +269,2 @@
- basename.delete_suffix!(".rb")
- result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
- else
- if collapse?(abspath)
- queue << [abspath, cpath]
+ if basename == @nsfile
+ result[abspath] = cpath
@@ -265 +272,2 @@
- queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
+ basename.delete_suffix!('.rb')
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
@@ -266,0 +275,4 @@
+ elsif collapse?(abspath)
+ queue.unshift([abspath, cpath])
+ else
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
@@ -288,2 +300,2 @@
- if :file == ftype
- basename = File.basename(abspath, ".rb")
+ if ftype == :file
+ basename = File.basename(abspath)
@@ -292 +304 @@
- paths << [basename, abspath]
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
@@ -318 +330 @@
- cnames.join("::")
+ cnames.join('::')
@@ -320 +332 @@
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
@@ -387 +399 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -390 +402 @@
- # loader.tag = File.basename(__FILE__, ".rb")
+ # loader.tag = File.basename(__FILE__, '.rb')
@@ -408 +420 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -411 +423 @@
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
@@ -428 +440 @@
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
@@ -476,2 +488,8 @@
- #: (String, Module) -> void
- private def define_autoloads_for_dir(dir, parent)
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
+ # expected to define.
+ #
+ # The `external` flag indicates whether `mod` has been externally defined,
+ # as is the case with root namespaces or reopened third-party namespaces.
+ #
+ #: (String, Module, external: boolish) -> void
+ private def define_autoloads_for_dir(dir, mod, external:)
@@ -480,9 +498,8 @@
- basename.delete_suffix!(".rb")
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_file(cref, abspath)
- else
- if collapse?(abspath)
- define_autoloads_for_dir(abspath, parent)
- else
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_subdir(cref, abspath)
+ if basename == @nsfile
+ if external
+ cpath = real_mod_name(mod)
+ location = Object.const_source_location(cpath)&.join(':')
+ location = nil if location&.empty?
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
+ end
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
@@ -489,0 +507,7 @@
+
+ basename.delete_suffix!('.rb')
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_file(cref, abspath)
+ else
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_subdir(cref, abspath, external:)
@@ -495 +519,25 @@
- private def autoload_subdir(cref, subdir)
+ private def visit_file(cref, file)
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
+ if @fs.rb_extension?(autoload_path)
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
+ end
+ shadowed_files << file
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
+ else
+ promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
+ end
+ elsif cref.defined?
+ shadowed_files << file
+ if location = cref.location
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
+ else
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
+ end
+ else
+ define_autoload(cref, file)
+ end
+ end
+
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
+ private def visit_subdir(cref, subdir, external:)
@@ -497,0 +546,6 @@
+ # The namespace that corresponds to this subdirectory is defined in a
+ # file, either regular or nsfile. Therefore, a nsfile would be a
+ # duplication.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
+ end
@@ -499,2 +553 @@
- # constant has been found. This means we are dealing with an explicit
- # namespace whose definition was seen first.
+ # constant has been found. This is an explicit namespace.
@@ -502,3 +555,2 @@
- # Registering is idempotent, and we have to keep the autoload pointing
- # to the file. This may run again if more directories are found later
- # on, no big deal.
+ # The namespace may be spread over multiple directories and perhaps it
+ # was already registered, but registering is idempotent, just do it.
@@ -505,0 +558,3 @@
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ # Scanning found a matching directory first, and now we saw a nsfile.
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
@@ -507,3 +561,0 @@
- # If the existing autoload points to a file, it has to be preserved, if
- # not, it is fine as it is. In either case, we do not need to override.
- # Just remember the subdirectory conforms this namespace.
@@ -512 +564,6 @@
- # First time we find this namespace, set an autoload for it.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ define_autoload(cref, nsfile_abspath)
+ register_explicit_namespace(cref)
+ else
+ define_autoload(cref, subdir)
+ end
@@ -514 +570,0 @@
- define_autoload(cref, subdir)
@@ -519,19 +575 @@
- define_autoloads_for_dir(subdir, cref.get)
- end
- end
-
- #: (Zeitwerk::Cref, String) -> void
- private def autoload_file(cref, file)
- if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
- # First autoload for a Ruby file wins, just ignore subsequent ones.
- if @fs.rb_extension?(autoload_path)
- shadowed_files << file
- log { "file #{file} is ignored because #{autoload_path} has precedence" }
- else
- promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
- end
- elsif cref.defined?
- shadowed_files << file
- log { "file #{file} is ignored because #{cref} is already defined" }
- else
- define_autoload(cref, file)
+ define_autoloads_for_dir(subdir, cref.get, external:)
@@ -611 +649 @@
- require "pp" # Needed to have pretty_inspect available.
+ require 'pp' # Needed to have pretty_inspect available.
lib/zeitwerk/loader/callbacks.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:18.819903133 +0000
@@ -80 +80 @@
- define_autoloads_for_dir(dir, namespace)
+ define_autoloads_for_dir(dir, namespace, external: false)
lib/zeitwerk/loader/config.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:18.819903133 +0000
@@ -3,2 +3,2 @@
-require "set"
-require "securerandom"
+require 'set'
+require 'securerandom'
@@ -18,2 +18,2 @@
- # "/Users/fxn/blog/app/channels" => Object,
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
+ # '/Users/fxn/blog/app/channels' => Object,
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
@@ -31,0 +32,6 @@
+ # Basename of files that define namespaces. For example, if `nsfile` is
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
+ #
+ #: String?
+ attr_reader :nsfile
+
@@ -53 +59 @@
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
@@ -58,0 +65,8 @@
+ # Absolute paths of directories that are parents of collapsed directories.
+ # This is a cache to optimize some tree walks. Computed on setup and
+ # recomputed on reload.
+ #
+ #: Set[String]
+ attr_reader :collapse_parents
+ private :collapse_parents
+
@@ -84,0 +99 @@
+ #: () -> void
@@ -90,0 +106 @@
+ @nsfile = nil
@@ -94,0 +111 @@
+ @collapse_parents = Set.new
@@ -115 +132 @@
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
@@ -143,0 +161,12 @@
+ #: (String?) -> void ! TypeError, ArgumentError
+ def nsfile=(nsfile)
+ unless nsfile.nil?
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
+ end
+
+ @nsfile = nsfile
+ end
+
@@ -179 +208 @@
- raise Zeitwerk::Error, "cannot enable reloading after setup"
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
@@ -217 +246,5 @@
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
+ collapse_dirs.merge(new_collapse_dirs)
+ new_collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
@@ -236,2 +269,2 @@
- # loader.on_load("SomeApiClient") do |klass, _abspath|
- # klass.endpoint = "https://api.dev"
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
+ # klass.endpoint = 'https://api.dev'
@@ -248 +281 @@
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -259 +292 @@
- # loader.on_unload("Country") do |klass, _abspath|
+ # loader.on_unload('Country') do |klass, _abspath|
@@ -271 +304 @@
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -318,0 +352,10 @@
+ internal def collapse?(dir)
+ collapse_dirs.member?(dir)
+ end
+
+ #: (String) -> bool
+ internal def collapse_parent?(dir)
+ collapse_parents.member?(dir)
+ end
+
+ #: (String) -> bool
@@ -331,5 +373,0 @@
- #: (String) -> bool
- private def collapse?(dir)
- collapse_dirs.member?(dir)
- end
-
@@ -355,0 +394,8 @@
+ end
+
+ #: () -> void
+ private def recompute_collapse_parents
+ collapse_parents.clear
+ collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
lib/zeitwerk/loader/eager_load.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:18.819903133 +0000
@@ -3,4 +3,5 @@
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
- # shadowed files are not eager loaded. You can opt-out specifically in
- # specific files and directories with `do_not_eager_load`, and that can be
- # overridden passing `force: true`.
+ # need to be in `$LOAD_PATH`, absolute file names are used.
+ #
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
+ # files and directories with `do_not_eager_load`, and that can be overridden
+ # passing `force: true`.
@@ -14 +15 @@
- log { "eager load start" }
+ log { 'eager load start' }
@@ -27 +28 @@
- log { "eager load end" }
+ log { 'eager load end' }
@@ -94 +95 @@
- if root_namespace_name.start_with?(mod_name + "::")
+ if root_namespace_name.start_with?(mod_name + '::')
@@ -98 +99 @@
- elsif mod_name.start_with?(root_namespace_name + "::")
+ elsif mod_name.start_with?(root_namespace_name + '::')
@@ -122 +123 @@
- file_basename = File.basename(abspath, ".rb")
+ file_basename = File.basename(abspath)
@@ -141,2 +141,0 @@
- base_cname = cname_for(file_basename, abspath)
-
@@ -149,3 +148,8 @@
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
-
- namespace.const_get(base_cname, false)
+ if file_basename == @nsfile
+ namespace
+ elsif shadowed_file?(abspath)
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
+ else
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
+ namespace.const_get(cname, false)
+ end
@@ -174,6 +178,2 @@
- if collapse?(abspath)
- queue << [abspath, namespace]
- else
- cname = cname_for(basename, abspath)
- queue << [abspath, namespace.const_get(cname, false)]
- end
+ cname = cname_for(basename, abspath)
+ queue << [abspath, namespace.const_get(cname, false)]
@@ -194 +194 @@
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
@@ -207 +207 @@
- suffix.split("::").each do |segment|
+ suffix.split('::').each do |segment|
@@ -210,5 +210 @@
- next unless ftype == :directory
-
- if collapse?(abspath)
- dirs << abspath
- elsif segment == cname_for(basename, abspath).to_s
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
lib/zeitwerk/loader/file_system.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:18.815903159 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:18.819903133 +0000
@@ -14,0 +15,14 @@
+ # This method lists directories, filtering out the following:
+ #
+ # - Hidden entries.
+ # - Ignored entries.
+ # - Files whose extension is not `.rb`.
+ # - Nested root directories, since they represent separate trees.
+ # - Subdirectories that (recursively) contain no Ruby files.
+ #
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
+ # method recurses so that the caller gets a conceptually flat listing.
+ #
+ # For every entry that is not excluded, `ls` yields its basename, absolute
+ # path, and file type, which can only be :file or :directory.
+ #
@@ -16 +30 @@
- def ls(dir)
+ def ls(dir, collapse: true, &block)
@@ -27,3 +41,8 @@
- if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
- @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
- next
+ if ftype == :directory
+ if !has_at_least_one_ruby_file?(abspath)
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
+ next
+ elsif collapse && @loader.__collapse?(abspath)
+ ls(abspath, collapse: collapse, &block)
+ next
+ end
@@ -41 +60,38 @@
- break if basename == "/"
+ break if basename == '/'
+ end
+ end
+
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
+ # there is none, it returns `nil`.
+ #
+ # This method accounts for collapsed directories, which conceptually allow for
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
+ #
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
+ def has_exactly_one_nsfile?(cref, dir)
+ return unless @loader.nsfile
+
+ # When `dir` does not have any collapsed directories a simple lookup
+ # suffices. This is a common case worth optimizing.
+ unless @loader.__collapse_parent?(dir)
+ nsfile_abspath = File.join(dir, @loader.nsfile)
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
+ return nsfile_abspath
+ end
+ return
+ end
+
+ nsfile = nil
+
+ to_visit = [dir]
+ while (dir = to_visit.shift)
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
+ if ftype == :file && basename == @loader.nsfile
+ if nsfile
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
+ end
+ nsfile = abspath
+ elsif ftype == :directory && @loader.__collapse?(abspath)
+ to_visit << abspath
+ end
+ end
@@ -42,0 +99,2 @@
+
+ nsfile
@@ -58 +116 @@
- path.end_with?(".rb")
+ path.end_with?('.rb')
@@ -68 +126 @@
- basename.start_with?(".")
+ basename.start_with?('.')
@@ -83 +141 @@
- return true if :file == ftype
+ return true if ftype == :file
@@ -99,12 +157,3 @@
- if :link == ftype
- begin
- ftype = File.stat(abspath).ftype.to_sym
- rescue Errno::ENOENT
- warn "ignoring broken symlink #{abspath}"
- next
- end
- end
-
- if :file == ftype
- yield basename, abspath, ftype if rb_extension?(basename)
- elsif :directory == ftype
+ if ftype == :file
+ yield basename, abspath, ftype
+ else
@@ -138 +187 @@
- elsif :directory == ftype
+ elsif ftype == :directory
@@ -141 +190 @@
- elsif :link == ftype
+ elsif ftype == :link
@@ -158,3 +207 @@
- if dir?(abspath)
- yield basename, abspath, :directory
- end
+ yield basename, abspath, :directory if dir?(abspath)
lib/zeitwerk/loader/helpers.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:18.816903152 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:18.819903133 +0000
@@ -15 +15 @@
- if cname.include?("::")
+ if cname.include?('::')
@@ -28 +28 @@
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
lib/zeitwerk/registry.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/registry.rb 2026-05-20 07:03:18.816903152 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/registry.rb 2026-05-20 07:03:18.819903133 +0000
@@ -5,4 +5,4 @@
- require_relative "registry/autoloads"
- require_relative "registry/explicit_namespaces"
- require_relative "registry/inceptions"
- require_relative "registry/loaders"
+ require_relative 'registry/autoloads'
+ require_relative 'registry/explicit_namespaces'
+ require_relative 'registry/inceptions'
+ require_relative 'registry/loaders'
@@ -57,2 +57,2 @@
- new_root_dir_slash = new_root_dir + "/"
- existing_root_dir_slash = existing_root_dir + "/"
+ new_root_dir_slash = new_root_dir + '/'
+ existing_root_dir_slash = existing_root_dir + '/'
lib/zeitwerk/registry/loaders.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:18.816903152 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:18.820903127 +0000
@@ -9,2 +9,2 @@
- def each(&)
- @loaders.each(&)
+ def each(&block)
+ @loaders.each(&block)
lib/zeitwerk/version.rb
--- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/version.rb 2026-05-20 07:03:18.816903152 +0000
+++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/version.rb 2026-05-20 07:03:18.820903127 +0000
@@ -5 +5 @@
- VERSION = "2.7.5"
+ VERSION = '2.8.1' |
Contributor
gem compare --diff zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/README.md 2026-05-20 07:03:27.387262953 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/README.md 2026-05-20 07:03:27.395262873 +0000
@@ -21,0 +22,2 @@
+ - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
+ - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
@@ -101 +103 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -184 +186 @@
-loader.inflector.inflect("max_retries" => "MAX_RETRIES")
+loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
@@ -223,2 +225,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -267 +269,6 @@
-Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
+Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
+
+<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
+#### Explicit namespaces defined in ordinary files
+
+Let's consider:
@@ -274 +281 @@
-There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
+Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
@@ -276 +283,3 @@
-The classes and modules from the namespace are already available in the body of the class or module defining it:
+In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
+
+The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
@@ -287 +296,42 @@
-An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
+<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
+#### Explicit namespaces defined in nsfiles
+
+If the loader has an nsfile configured (defaults to `nil`):
+
+```ruby
+loader.nsfile = 'ns.rb' # must be set before setup
+```
+
+you can alternatively define the explicit namespace inside its directory:
+
+```
+my_component/ns.rb -> MyComponent
+my_component/widget.rb -> MyComponent::Widget
+```
+
+This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
+
+A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
+
+Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
+
+```
+my_component/src/ns.rb -> MyComponent
+my_component/src/widget.rb -> MyComponent::Widget
+my_component/assets/widget.js
+my_component/tests/test_widget.rb
+```
+
+Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
+
+However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
+
+Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
+
+Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
+
+```ruby
+loader.nsfile = 'index.rb'
+```
+
+there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
@@ -375 +425 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -377 +427 @@
-loader.tag = File.basename(__FILE__, ".rb")
+loader.tag = File.basename(__FILE__, '.rb')
@@ -387 +437 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -433 +483 @@
-gem "net-http-niche_feature"
+gem 'net-http-niche_feature'
@@ -446 +496 @@
-require "net/http/niche_feature"
+require 'net/http/niche_feature'
@@ -451,2 +501,2 @@
-require "net/http"
-require "zeitwerk"
+require 'net/http'
+require 'zeitwerk'
@@ -467 +517 @@
- VERSION = "1.0.0"
+ VERSION = '1.0.0'
@@ -489 +539 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -498 +548 @@
-That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
+That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
@@ -685 +735 @@
-require "concurrent/atomic/read_write_lock"
+require 'concurrent/atomic/read_write_lock'
@@ -730,2 +780,2 @@
- "html_parser" => "HTMLParser",
- "mysql_adapter" => "MySQLAdapter"
+ 'html_parser' => 'HTMLParser',
+ 'mysql_adapter' => 'MySQLAdapter'
@@ -738,2 +788,2 @@
-loader.inflector.inflect "html_parser" => "HTMLParser"
-loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
+loader.inflector.inflect 'html_parser' => 'HTMLParser'
+loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
@@ -745 +795 @@
-loader.inflector.inflect("xml" => "XML")
+loader.inflector.inflect('xml' => 'XML')
@@ -760,2 +810,2 @@
- "xml" => "XML",
- "xml_parser" => "XMLParser"
+ 'xml' => 'XML',
+ 'xml_parser' => 'XMLParser'
@@ -816 +866 @@
- "HTML" + super($1, abspath)
+ 'HTML' + super($1, abspath)
@@ -847,2 +897,2 @@
-require "zeitwerk"
-require_relative "my_gem/inflector"
+require 'zeitwerk'
+require_relative 'my_gem/inflector'
@@ -916,2 +966,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.dev"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.dev'
@@ -921,2 +971,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.prod"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.prod'
@@ -966 +1016 @@
-loader.on_unload("Country") do |klass, _abspath|
+loader.on_unload('Country') do |klass, _abspath|
@@ -1051 +1101 @@
-loader.tag = "grep_me"
+loader.tag = 'grep_me'
@@ -1107 +1157 @@
-require_relative "my_gem/core_ext/kernel"
+require_relative 'my_gem/core_ext/kernel'
@@ -1121 +1171 @@
-require "pg"
+require 'pg'
@@ -1159 +1209 @@
-require "foo"
+require 'foo'
@@ -1228,2 +1278,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -1231 +1281 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -1250,2 +1300,2 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.dirs # => ["/foo"]
+loader.push_dir(Pathname.new('/foo'))
+loader.dirs # => ['/foo']
@@ -1258,3 +1308,3 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.push_dir(Pathname.new("/bar"), namespace: Bar)
-loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
+loader.push_dir(Pathname.new('/foo'))
+loader.push_dir(Pathname.new('/bar'), namespace: Bar)
+loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
@@ -1287,4 +1337,4 @@
-loader.cpath_expected_at("app/models") # => "Object"
-loader.cpath_expected_at("app/models/user.rb") # => "User"
-loader.cpath_expected_at("app/models/hotel") # => "Hotel"
-loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+loader.cpath_expected_at('app/models') # => 'Object'
+loader.cpath_expected_at('app/models/user.rb') # => 'User'
+loader.cpath_expected_at('app/models/hotel') # => 'Hotel'
+loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
@@ -1296,3 +1346,3 @@
-loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
-loader.cpath_expected_at("a/b/collapsed") # => "A::B", edge case
-loader.cpath_expected_at("a/b") # => "A::B"
+loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
+loader.cpath_expected_at('a/b/collapsed') # => 'A::B', edge case
+loader.cpath_expected_at('a/b') # => 'A::B'
@@ -1306 +1356 @@
-loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
@@ -1312 +1362 @@
-loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
+loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
@@ -1315 +1365,3 @@
-This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1332 +1383,0 @@
-lib/my_gem/ignored.rb
@@ -1335,0 +1387 @@
+lib/my_gem/ignored.rb
@@ -1343,9 +1395,9 @@
- "/.../lib" => "Object",
- "/.../lib/my_gem.rb" => "MyGem",
- "/.../lib/my_gem" => "MyGem",
- "/.../lib/my_gem/version.rb" => "MyGem::VERSION",
- "/.../lib/my_gem/drivers" => "MyGem::Drivers",
- "/.../lib/my_gem/drivers/unix.rb" => "MyGem::Drivers::Unix",
- "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
- "/.../lib/my_gem/collapsed" => "MyGem",
- "/.../lib/my_gem/collapsed/foo.rb" => "MyGem::Foo"
+ '/.../lib' => 'Object',
+ '/.../lib/my_gem.rb' => 'MyGem',
+ '/.../lib/my_gem' => 'MyGem',
+ '/.../lib/my_gem/version.rb' => 'MyGem::VERSION',
+ '/.../lib/my_gem/drivers' => 'MyGem::Drivers',
+ '/.../lib/my_gem/drivers/unix.rb' => 'MyGem::Drivers::Unix',
+ '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
+ '/.../lib/my_gem/collapsed' => 'MyGem',
+ '/.../lib/my_gem/collapsed/foo.rb' => 'MyGem::Foo'
@@ -1357 +1409 @@
-The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
+The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
@@ -1363 +1415,3 @@
-This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1434,2 +1488,2 @@
-test "capitalizes the first letter" do
- assert_equal "User", camelize("user")
+test 'capitalizes the first letter' do
+ assert_equal 'User', camelize('user')
@@ -1449 +1503 @@
-Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
+Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
lib/zeitwerk.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk.rb 2026-05-20 07:03:27.387262953 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk.rb 2026-05-20 07:03:27.395262873 +0000
@@ -4,11 +4,11 @@
- require_relative "zeitwerk/real_mod_name"
- require_relative "zeitwerk/internal"
- require_relative "zeitwerk/cref"
- require_relative "zeitwerk/loader"
- require_relative "zeitwerk/gem_loader"
- require_relative "zeitwerk/registry"
- require_relative "zeitwerk/inflector"
- require_relative "zeitwerk/gem_inflector"
- require_relative "zeitwerk/null_inflector"
- require_relative "zeitwerk/error"
- require_relative "zeitwerk/version"
+ require_relative 'zeitwerk/real_mod_name'
+ require_relative 'zeitwerk/internal'
+ require_relative 'zeitwerk/cref'
+ require_relative 'zeitwerk/loader'
+ require_relative 'zeitwerk/gem_loader'
+ require_relative 'zeitwerk/registry'
+ require_relative 'zeitwerk/inflector'
+ require_relative 'zeitwerk/gem_inflector'
+ require_relative 'zeitwerk/null_inflector'
+ require_relative 'zeitwerk/error'
+ require_relative 'zeitwerk/version'
@@ -16,2 +16,2 @@
- require_relative "zeitwerk/core_ext/kernel"
- require_relative "zeitwerk/core_ext/module"
+ require_relative 'zeitwerk/core_ext/kernel'
+ require_relative 'zeitwerk/core_ext/module'
lib/zeitwerk/core_ext/kernel.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:27.387262953 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:27.396262863 +0000
@@ -25 +25 @@
- if path.end_with?(".rb")
+ if path.end_with?('.rb')
lib/zeitwerk/cref.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/cref.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/cref.rb 2026-05-20 07:03:27.396262863 +0000
@@ -14 +14 @@
- require_relative "cref/map"
+ require_relative 'cref/map'
@@ -67,0 +68,7 @@
+ end
+
+ #: () -> String?
+ def location
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
+ location.join(':')
+ end
lib/zeitwerk/cref/map.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:27.397262853 +0000
@@ -30 +30 @@
-# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
+# { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
lib/zeitwerk/error.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/error.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/error.rb 2026-05-20 07:03:27.397262853 +0000
@@ -20 +20,12 @@
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
+ end
+ end
+
+ class ConflictingNamespaceDefinitionError < Error
+ #: (String, location: String?, conflicting_file: String) -> void
+ def initialize(cpath, location:, conflicting_file:)
+ if location
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
+ else
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
+ end
lib/zeitwerk/gem_inflector.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:27.397262853 +0000
@@ -7 +7 @@
- namespace = File.basename(root_file, ".rb")
+ namespace = File.basename(root_file, '.rb')
@@ -9 +9 @@
- @version_file = File.join(root_dir, namespace, "version.rb")
+ @version_file = File.join(root_dir, namespace, 'version.rb')
@@ -14 +14 @@
- abspath == @version_file ? "VERSION" : super
+ abspath == @version_file ? 'VERSION' : super
lib/zeitwerk/gem_loader.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:27.397262853 +0000
@@ -22,2 +22,2 @@
- @tag = File.basename(root_file, ".rb")
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
+ @tag = File.basename(root_file, '.rb')
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
@@ -43 +43 @@
- expected_namespace_dir = @root_file.delete_suffix(".rb")
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
@@ -49 +49 @@
- basename_without_ext = basename.delete_suffix(".rb")
+ basename_without_ext = basename.delete_suffix('.rb')
lib/zeitwerk/inflector.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb 2026-05-20 07:03:27.388262943 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb 2026-05-20 07:03:27.397262853 +0000
@@ -8,3 +8,3 @@
- # inflector.camelize("post", ...) # => "Post"
- # inflector.camelize("users_controller", ...) # => "UsersController"
- # inflector.camelize("api", ...) # => "Api"
+ # inflector.camelize('post', ...) # => 'Post'
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
+ # inflector.camelize('api', ...) # => 'Api'
@@ -23,2 +23,2 @@
- # "html_parser" => "HTMLParser",
- # "mysql_adapter" => "MySQLAdapter"
+ # 'html_parser' => 'HTMLParser',
+ # 'mysql_adapter' => 'MySQLAdapter'
@@ -27,3 +27,3 @@
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
- # inflector.camelize("users_controller", abspath) # => "UsersController"
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
lib/zeitwerk/loader.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader.rb 2026-05-20 07:03:27.389262933 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader.rb 2026-05-20 07:03:27.398262843 +0000
@@ -3,2 +3,2 @@
-require "monitor"
-require "set"
+require 'monitor'
+require 'set'
@@ -8,5 +8,5 @@
- require_relative "loader/helpers"
- require_relative "loader/callbacks"
- require_relative "loader/config"
- require_relative "loader/eager_load"
- require_relative "loader/file_system"
+ require_relative 'loader/helpers'
+ require_relative 'loader/callbacks'
+ require_relative 'loader/config'
+ require_relative 'loader/eager_load'
+ require_relative 'loader/file_system'
@@ -25,2 +25,2 @@
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
@@ -104,0 +105 @@
+ #: () -> void
@@ -132 +133 @@
- define_autoloads_for_dir(root_dir, root_namespace)
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
@@ -155,0 +157,3 @@
+ __unload
+ end
+ end
@@ -157,33 +161,19 @@
- # We are going to keep track of the files that were required by our
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
- # loadable by Kernel#require again.
- #
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
- # is enough.
- unloaded_files = Set.new
-
- autoloads.each do |abspath, cref|
- if cref.autoload?
- unload_autoload(cref)
- else
- # Could happen if loaded with require_relative. That is unsupported,
- # and the constant path would escape unloadable_cpath? This is just
- # defensive code to clean things up as much as we are able to.
- unload_cref(cref)
- unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
- end
- end
-
- to_unload.each do |abspath, cref|
- unless on_unload_callbacks.empty?
- begin
- value = cref.get
- rescue ::NameError
- # Perhaps the user deleted the constant by hand, or perhaps an
- # autoload failed to define the expected constant but the user
- # rescued the exception.
- else
- run_on_unload_callbacks(cref, value, abspath)
- end
- end
-
+ # This is an internal method.
+ #
+ #: () -> void
+ def __unload
+ # We are going to keep track of the files that were required by our
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
+ # loadable by Kernel#require again.
+ #
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
+ # is enough.
+ unloaded_files = Set.new
+
+ autoloads.each do |abspath, cref|
+ if cref.autoload?
+ unload_autoload(cref)
+ else
+ # Could happen if loaded with require_relative. That is unsupported,
+ # and the constant path would escape unloadable_cpath? This is just
+ # defensive code to clean things up as much as we are able to.
@@ -192,0 +183 @@
+ end
@@ -194,13 +185,11 @@
- unless unloaded_files.empty?
- # Bootsnap decorates Kernel#require to speed it up using a cache and
- # this optimization does not check if $LOADED_FEATURES has the file.
- #
- # To make it aware of changes, the gem defines singleton methods in
- # $LOADED_FEATURES:
- #
- # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
- #
- # Rails applications may depend on bootsnap, so for unloading to work
- # in that setting it is preferable that we restrict our API choice to
- # one of those methods.
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ to_unload.each do |abspath, cref|
+ unless on_unload_callbacks.empty?
+ begin
+ value = cref.get
+ rescue ::NameError
+ # Perhaps the user deleted the constant by hand, or perhaps an
+ # autoload failed to define the expected constant but the user
+ # rescued the exception.
+ else
+ run_on_unload_callbacks(cref, value, abspath)
+ end
@@ -209,5 +198,3 @@
- autoloads.clear
- autoloaded_dirs.clear
- to_unload.clear
- namespace_dirs.clear
- shadowed_files.clear
+ unload_cref(cref)
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
+ end
@@ -215,2 +202,14 @@
- unregister_inceptions
- unregister_explicit_namespaces
+ unless unloaded_files.empty?
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
+ # this optimization does not check if $LOADED_FEATURES has the file.
+ #
+ # To make it aware of changes, the gem defines singleton methods in
+ # $LOADED_FEATURES:
+ #
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
+ #
+ # Rails applications may depend on bootsnap, so for unloading to work
+ # in that setting it is preferable that we restrict our API choice to
+ # one of those methods.
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ end
@@ -218 +217,5 @@
- Registry.autoloads.unregister_loader(self)
+ autoloads.clear
+ autoloaded_dirs.clear
+ to_unload.clear
+ namespace_dirs.clear
+ shadowed_files.clear
@@ -220,3 +223,7 @@
- @setup = false
- @eager_loaded = false
- end
+ unregister_inceptions
+ unregister_explicit_namespaces
+
+ Registry.autoloads.unregister_loader(self)
+
+ @setup = false
+ @eager_loaded = false
@@ -236,0 +244 @@
+
@@ -238,0 +247,2 @@
+ recompute_collapse_parents
+
@@ -255 +265 @@
- prefix = cpath == "Object" ? "" : cpath + "::"
+ prefix = cpath == 'Object' ? '' : cpath + '::'
@@ -257 +267 @@
- @fs.ls(dir) do |basename, abspath, ftype|
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
@@ -259,5 +269,2 @@
- basename.delete_suffix!(".rb")
- result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
- else
- if collapse?(abspath)
- queue << [abspath, cpath]
+ if basename == @nsfile
+ result[abspath] = cpath
@@ -265 +272,2 @@
- queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
+ basename.delete_suffix!('.rb')
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
@@ -266,0 +275,4 @@
+ elsif collapse?(abspath)
+ queue.unshift([abspath, cpath])
+ else
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
@@ -288,2 +300,2 @@
- if :file == ftype
- basename = File.basename(abspath, ".rb")
+ if ftype == :file
+ basename = File.basename(abspath)
@@ -292 +304 @@
- paths << [basename, abspath]
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
@@ -318 +330 @@
- cnames.join("::")
+ cnames.join('::')
@@ -320 +332 @@
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
@@ -387 +399 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -390 +402 @@
- # loader.tag = File.basename(__FILE__, ".rb")
+ # loader.tag = File.basename(__FILE__, '.rb')
@@ -408 +420 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -411 +423 @@
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
@@ -428 +440 @@
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
@@ -476,2 +488,8 @@
- #: (String, Module) -> void
- private def define_autoloads_for_dir(dir, parent)
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
+ # expected to define.
+ #
+ # The `external` flag indicates whether `mod` has been externally defined,
+ # as is the case with root namespaces or reopened third-party namespaces.
+ #
+ #: (String, Module, external: boolish) -> void
+ private def define_autoloads_for_dir(dir, mod, external:)
@@ -480,9 +498,8 @@
- basename.delete_suffix!(".rb")
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_file(cref, abspath)
- else
- if collapse?(abspath)
- define_autoloads_for_dir(abspath, parent)
- else
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_subdir(cref, abspath)
+ if basename == @nsfile
+ if external
+ cpath = real_mod_name(mod)
+ location = Object.const_source_location(cpath)&.join(':')
+ location = nil if location&.empty?
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
+ end
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
@@ -489,0 +507,7 @@
+
+ basename.delete_suffix!('.rb')
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_file(cref, abspath)
+ else
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_subdir(cref, abspath, external:)
@@ -495 +519,25 @@
- private def autoload_subdir(cref, subdir)
+ private def visit_file(cref, file)
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
+ if @fs.rb_extension?(autoload_path)
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
+ end
+ shadowed_files << file
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
+ else
+ promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
+ end
+ elsif cref.defined?
+ shadowed_files << file
+ if location = cref.location
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
+ else
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
+ end
+ else
+ define_autoload(cref, file)
+ end
+ end
+
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
+ private def visit_subdir(cref, subdir, external:)
@@ -497,0 +546,6 @@
+ # The namespace that corresponds to this subdirectory is defined in a
+ # file, either regular or nsfile. Therefore, a nsfile would be a
+ # duplication.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
+ end
@@ -499,2 +553 @@
- # constant has been found. This means we are dealing with an explicit
- # namespace whose definition was seen first.
+ # constant has been found. This is an explicit namespace.
@@ -502,3 +555,2 @@
- # Registering is idempotent, and we have to keep the autoload pointing
- # to the file. This may run again if more directories are found later
- # on, no big deal.
+ # The namespace may be spread over multiple directories and perhaps it
+ # was already registered, but registering is idempotent, just do it.
@@ -505,0 +558,3 @@
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ # Scanning found a matching directory first, and now we saw a nsfile.
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
@@ -507,3 +561,0 @@
- # If the existing autoload points to a file, it has to be preserved, if
- # not, it is fine as it is. In either case, we do not need to override.
- # Just remember the subdirectory conforms this namespace.
@@ -512 +564,6 @@
- # First time we find this namespace, set an autoload for it.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ define_autoload(cref, nsfile_abspath)
+ register_explicit_namespace(cref)
+ else
+ define_autoload(cref, subdir)
+ end
@@ -514 +570,0 @@
- define_autoload(cref, subdir)
@@ -519,19 +575 @@
- define_autoloads_for_dir(subdir, cref.get)
- end
- end
-
- #: (Zeitwerk::Cref, String) -> void
- private def autoload_file(cref, file)
- if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
- # First autoload for a Ruby file wins, just ignore subsequent ones.
- if @fs.rb_extension?(autoload_path)
- shadowed_files << file
- log { "file #{file} is ignored because #{autoload_path} has precedence" }
- else
- promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
- end
- elsif cref.defined?
- shadowed_files << file
- log { "file #{file} is ignored because #{cref} is already defined" }
- else
- define_autoload(cref, file)
+ define_autoloads_for_dir(subdir, cref.get, external:)
@@ -611 +649 @@
- require "pp" # Needed to have pretty_inspect available.
+ require 'pp' # Needed to have pretty_inspect available.
lib/zeitwerk/loader/callbacks.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:27.389262933 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:27.399262833 +0000
@@ -80 +80 @@
- define_autoloads_for_dir(dir, namespace)
+ define_autoloads_for_dir(dir, namespace, external: false)
lib/zeitwerk/loader/config.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:27.389262933 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:27.399262833 +0000
@@ -3,2 +3,2 @@
-require "set"
-require "securerandom"
+require 'set'
+require 'securerandom'
@@ -18,2 +18,2 @@
- # "/Users/fxn/blog/app/channels" => Object,
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
+ # '/Users/fxn/blog/app/channels' => Object,
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
@@ -31,0 +32,6 @@
+ # Basename of files that define namespaces. For example, if `nsfile` is
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
+ #
+ #: String?
+ attr_reader :nsfile
+
@@ -53 +59 @@
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
@@ -58,0 +65,8 @@
+ # Absolute paths of directories that are parents of collapsed directories.
+ # This is a cache to optimize some tree walks. Computed on setup and
+ # recomputed on reload.
+ #
+ #: Set[String]
+ attr_reader :collapse_parents
+ private :collapse_parents
+
@@ -84,0 +99 @@
+ #: () -> void
@@ -90,0 +106 @@
+ @nsfile = nil
@@ -94,0 +111 @@
+ @collapse_parents = Set.new
@@ -115 +132 @@
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
@@ -143,0 +161,12 @@
+ #: (String?) -> void ! TypeError, ArgumentError
+ def nsfile=(nsfile)
+ unless nsfile.nil?
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
+ end
+
+ @nsfile = nsfile
+ end
+
@@ -179 +208 @@
- raise Zeitwerk::Error, "cannot enable reloading after setup"
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
@@ -217 +246,5 @@
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
+ collapse_dirs.merge(new_collapse_dirs)
+ new_collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
@@ -236,2 +269,2 @@
- # loader.on_load("SomeApiClient") do |klass, _abspath|
- # klass.endpoint = "https://api.dev"
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
+ # klass.endpoint = 'https://api.dev'
@@ -248 +281 @@
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -259 +292 @@
- # loader.on_unload("Country") do |klass, _abspath|
+ # loader.on_unload('Country') do |klass, _abspath|
@@ -271 +304 @@
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -318,0 +352,10 @@
+ internal def collapse?(dir)
+ collapse_dirs.member?(dir)
+ end
+
+ #: (String) -> bool
+ internal def collapse_parent?(dir)
+ collapse_parents.member?(dir)
+ end
+
+ #: (String) -> bool
@@ -331,5 +373,0 @@
- #: (String) -> bool
- private def collapse?(dir)
- collapse_dirs.member?(dir)
- end
-
@@ -355,0 +394,8 @@
+ end
+
+ #: () -> void
+ private def recompute_collapse_parents
+ collapse_parents.clear
+ collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
lib/zeitwerk/loader/eager_load.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:27.389262933 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:27.399262833 +0000
@@ -3,4 +3,5 @@
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
- # shadowed files are not eager loaded. You can opt-out specifically in
- # specific files and directories with `do_not_eager_load`, and that can be
- # overridden passing `force: true`.
+ # need to be in `$LOAD_PATH`, absolute file names are used.
+ #
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
+ # files and directories with `do_not_eager_load`, and that can be overridden
+ # passing `force: true`.
@@ -14 +15 @@
- log { "eager load start" }
+ log { 'eager load start' }
@@ -27 +28 @@
- log { "eager load end" }
+ log { 'eager load end' }
@@ -94 +95 @@
- if root_namespace_name.start_with?(mod_name + "::")
+ if root_namespace_name.start_with?(mod_name + '::')
@@ -98 +99 @@
- elsif mod_name.start_with?(root_namespace_name + "::")
+ elsif mod_name.start_with?(root_namespace_name + '::')
@@ -122 +123 @@
- file_basename = File.basename(abspath, ".rb")
+ file_basename = File.basename(abspath)
@@ -141,2 +141,0 @@
- base_cname = cname_for(file_basename, abspath)
-
@@ -149,3 +148,8 @@
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
-
- namespace.const_get(base_cname, false)
+ if file_basename == @nsfile
+ namespace
+ elsif shadowed_file?(abspath)
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
+ else
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
+ namespace.const_get(cname, false)
+ end
@@ -174,6 +178,2 @@
- if collapse?(abspath)
- queue << [abspath, namespace]
- else
- cname = cname_for(basename, abspath)
- queue << [abspath, namespace.const_get(cname, false)]
- end
+ cname = cname_for(basename, abspath)
+ queue << [abspath, namespace.const_get(cname, false)]
@@ -194 +194 @@
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
@@ -207 +207 @@
- suffix.split("::").each do |segment|
+ suffix.split('::').each do |segment|
@@ -210,5 +210 @@
- next unless ftype == :directory
-
- if collapse?(abspath)
- dirs << abspath
- elsif segment == cname_for(basename, abspath).to_s
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
lib/zeitwerk/loader/file_system.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:27.391262913 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:27.399262833 +0000
@@ -14,0 +15,14 @@
+ # This method lists directories, filtering out the following:
+ #
+ # - Hidden entries.
+ # - Ignored entries.
+ # - Files whose extension is not `.rb`.
+ # - Nested root directories, since they represent separate trees.
+ # - Subdirectories that (recursively) contain no Ruby files.
+ #
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
+ # method recurses so that the caller gets a conceptually flat listing.
+ #
+ # For every entry that is not excluded, `ls` yields its basename, absolute
+ # path, and file type, which can only be :file or :directory.
+ #
@@ -16 +30 @@
- def ls(dir)
+ def ls(dir, collapse: true, &block)
@@ -27,3 +41,8 @@
- if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
- @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
- next
+ if ftype == :directory
+ if !has_at_least_one_ruby_file?(abspath)
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
+ next
+ elsif collapse && @loader.__collapse?(abspath)
+ ls(abspath, collapse: collapse, &block)
+ next
+ end
@@ -41 +60,38 @@
- break if basename == "/"
+ break if basename == '/'
+ end
+ end
+
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
+ # there is none, it returns `nil`.
+ #
+ # This method accounts for collapsed directories, which conceptually allow for
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
+ #
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
+ def has_exactly_one_nsfile?(cref, dir)
+ return unless @loader.nsfile
+
+ # When `dir` does not have any collapsed directories a simple lookup
+ # suffices. This is a common case worth optimizing.
+ unless @loader.__collapse_parent?(dir)
+ nsfile_abspath = File.join(dir, @loader.nsfile)
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
+ return nsfile_abspath
+ end
+ return
+ end
+
+ nsfile = nil
+
+ to_visit = [dir]
+ while (dir = to_visit.shift)
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
+ if ftype == :file && basename == @loader.nsfile
+ if nsfile
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
+ end
+ nsfile = abspath
+ elsif ftype == :directory && @loader.__collapse?(abspath)
+ to_visit << abspath
+ end
+ end
@@ -42,0 +99,2 @@
+
+ nsfile
@@ -58 +116 @@
- path.end_with?(".rb")
+ path.end_with?('.rb')
@@ -68 +126 @@
- basename.start_with?(".")
+ basename.start_with?('.')
@@ -83 +141 @@
- return true if :file == ftype
+ return true if ftype == :file
@@ -99,12 +157,3 @@
- if :link == ftype
- begin
- ftype = File.stat(abspath).ftype.to_sym
- rescue Errno::ENOENT
- warn "ignoring broken symlink #{abspath}"
- next
- end
- end
-
- if :file == ftype
- yield basename, abspath, ftype if rb_extension?(basename)
- elsif :directory == ftype
+ if ftype == :file
+ yield basename, abspath, ftype
+ else
@@ -138 +187 @@
- elsif :directory == ftype
+ elsif ftype == :directory
@@ -141 +190 @@
- elsif :link == ftype
+ elsif ftype == :link
@@ -158,3 +207 @@
- if dir?(abspath)
- yield basename, abspath, :directory
- end
+ yield basename, abspath, :directory if dir?(abspath)
lib/zeitwerk/loader/helpers.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:27.391262913 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:27.399262833 +0000
@@ -15 +15 @@
- if cname.include?("::")
+ if cname.include?('::')
@@ -28 +28 @@
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
lib/zeitwerk/registry.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/registry.rb 2026-05-20 07:03:27.391262913 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/registry.rb 2026-05-20 07:03:27.400262823 +0000
@@ -5,4 +5,4 @@
- require_relative "registry/autoloads"
- require_relative "registry/explicit_namespaces"
- require_relative "registry/inceptions"
- require_relative "registry/loaders"
+ require_relative 'registry/autoloads'
+ require_relative 'registry/explicit_namespaces'
+ require_relative 'registry/inceptions'
+ require_relative 'registry/loaders'
@@ -57,2 +57,2 @@
- new_root_dir_slash = new_root_dir + "/"
- existing_root_dir_slash = existing_root_dir + "/"
+ new_root_dir_slash = new_root_dir + '/'
+ existing_root_dir_slash = existing_root_dir + '/'
lib/zeitwerk/registry/loaders.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:27.393262893 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:27.400262823 +0000
@@ -9,2 +9,2 @@
- def each(&)
- @loaders.each(&)
+ def each(&block)
+ @loaders.each(&block)
lib/zeitwerk/version.rb
--- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/version.rb 2026-05-20 07:03:27.393262893 +0000
+++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/version.rb 2026-05-20 07:03:27.400262823 +0000
@@ -5 +5 @@
- VERSION = "2.7.5"
+ VERSION = '2.8.1' |
Contributor
gem compare --diff zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/README.md 2026-05-20 07:03:31.418318124 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/README.md 2026-05-20 07:03:31.424318167 +0000
@@ -21,0 +22,2 @@
+ - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
+ - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
@@ -101 +103 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -184 +186 @@
-loader.inflector.inflect("max_retries" => "MAX_RETRIES")
+loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
@@ -223,2 +225,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -267 +269,6 @@
-Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
+Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
+
+<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
+#### Explicit namespaces defined in ordinary files
+
+Let's consider:
@@ -274 +281 @@
-There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
+Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
@@ -276 +283,3 @@
-The classes and modules from the namespace are already available in the body of the class or module defining it:
+In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
+
+The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
@@ -287 +296,42 @@
-An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
+<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
+#### Explicit namespaces defined in nsfiles
+
+If the loader has an nsfile configured (defaults to `nil`):
+
+```ruby
+loader.nsfile = 'ns.rb' # must be set before setup
+```
+
+you can alternatively define the explicit namespace inside its directory:
+
+```
+my_component/ns.rb -> MyComponent
+my_component/widget.rb -> MyComponent::Widget
+```
+
+This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
+
+A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
+
+Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
+
+```
+my_component/src/ns.rb -> MyComponent
+my_component/src/widget.rb -> MyComponent::Widget
+my_component/assets/widget.js
+my_component/tests/test_widget.rb
+```
+
+Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
+
+However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
+
+Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
+
+Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
+
+```ruby
+loader.nsfile = 'index.rb'
+```
+
+there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
@@ -375 +425 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -377 +427 @@
-loader.tag = File.basename(__FILE__, ".rb")
+loader.tag = File.basename(__FILE__, '.rb')
@@ -387 +437 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -433 +483 @@
-gem "net-http-niche_feature"
+gem 'net-http-niche_feature'
@@ -446 +496 @@
-require "net/http/niche_feature"
+require 'net/http/niche_feature'
@@ -451,2 +501,2 @@
-require "net/http"
-require "zeitwerk"
+require 'net/http'
+require 'zeitwerk'
@@ -467 +517 @@
- VERSION = "1.0.0"
+ VERSION = '1.0.0'
@@ -489 +539 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -498 +548 @@
-That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
+That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
@@ -685 +735 @@
-require "concurrent/atomic/read_write_lock"
+require 'concurrent/atomic/read_write_lock'
@@ -730,2 +780,2 @@
- "html_parser" => "HTMLParser",
- "mysql_adapter" => "MySQLAdapter"
+ 'html_parser' => 'HTMLParser',
+ 'mysql_adapter' => 'MySQLAdapter'
@@ -738,2 +788,2 @@
-loader.inflector.inflect "html_parser" => "HTMLParser"
-loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
+loader.inflector.inflect 'html_parser' => 'HTMLParser'
+loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
@@ -745 +795 @@
-loader.inflector.inflect("xml" => "XML")
+loader.inflector.inflect('xml' => 'XML')
@@ -760,2 +810,2 @@
- "xml" => "XML",
- "xml_parser" => "XMLParser"
+ 'xml' => 'XML',
+ 'xml_parser' => 'XMLParser'
@@ -816 +866 @@
- "HTML" + super($1, abspath)
+ 'HTML' + super($1, abspath)
@@ -847,2 +897,2 @@
-require "zeitwerk"
-require_relative "my_gem/inflector"
+require 'zeitwerk'
+require_relative 'my_gem/inflector'
@@ -916,2 +966,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.dev"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.dev'
@@ -921,2 +971,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.prod"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.prod'
@@ -966 +1016 @@
-loader.on_unload("Country") do |klass, _abspath|
+loader.on_unload('Country') do |klass, _abspath|
@@ -1051 +1101 @@
-loader.tag = "grep_me"
+loader.tag = 'grep_me'
@@ -1107 +1157 @@
-require_relative "my_gem/core_ext/kernel"
+require_relative 'my_gem/core_ext/kernel'
@@ -1121 +1171 @@
-require "pg"
+require 'pg'
@@ -1159 +1209 @@
-require "foo"
+require 'foo'
@@ -1228,2 +1278,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -1231 +1281 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -1250,2 +1300,2 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.dirs # => ["/foo"]
+loader.push_dir(Pathname.new('/foo'))
+loader.dirs # => ['/foo']
@@ -1258,3 +1308,3 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.push_dir(Pathname.new("/bar"), namespace: Bar)
-loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
+loader.push_dir(Pathname.new('/foo'))
+loader.push_dir(Pathname.new('/bar'), namespace: Bar)
+loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
@@ -1287,4 +1337,4 @@
-loader.cpath_expected_at("app/models") # => "Object"
-loader.cpath_expected_at("app/models/user.rb") # => "User"
-loader.cpath_expected_at("app/models/hotel") # => "Hotel"
-loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+loader.cpath_expected_at('app/models') # => 'Object'
+loader.cpath_expected_at('app/models/user.rb') # => 'User'
+loader.cpath_expected_at('app/models/hotel') # => 'Hotel'
+loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
@@ -1296,3 +1346,3 @@
-loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
-loader.cpath_expected_at("a/b/collapsed") # => "A::B", edge case
-loader.cpath_expected_at("a/b") # => "A::B"
+loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
+loader.cpath_expected_at('a/b/collapsed') # => 'A::B', edge case
+loader.cpath_expected_at('a/b') # => 'A::B'
@@ -1306 +1356 @@
-loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
@@ -1312 +1362 @@
-loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
+loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
@@ -1315 +1365,3 @@
-This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1332 +1383,0 @@
-lib/my_gem/ignored.rb
@@ -1335,0 +1387 @@
+lib/my_gem/ignored.rb
@@ -1343,9 +1395,9 @@
- "/.../lib" => "Object",
- "/.../lib/my_gem.rb" => "MyGem",
- "/.../lib/my_gem" => "MyGem",
- "/.../lib/my_gem/version.rb" => "MyGem::VERSION",
- "/.../lib/my_gem/drivers" => "MyGem::Drivers",
- "/.../lib/my_gem/drivers/unix.rb" => "MyGem::Drivers::Unix",
- "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
- "/.../lib/my_gem/collapsed" => "MyGem",
- "/.../lib/my_gem/collapsed/foo.rb" => "MyGem::Foo"
+ '/.../lib' => 'Object',
+ '/.../lib/my_gem.rb' => 'MyGem',
+ '/.../lib/my_gem' => 'MyGem',
+ '/.../lib/my_gem/version.rb' => 'MyGem::VERSION',
+ '/.../lib/my_gem/drivers' => 'MyGem::Drivers',
+ '/.../lib/my_gem/drivers/unix.rb' => 'MyGem::Drivers::Unix',
+ '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
+ '/.../lib/my_gem/collapsed' => 'MyGem',
+ '/.../lib/my_gem/collapsed/foo.rb' => 'MyGem::Foo'
@@ -1357 +1409 @@
-The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
+The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
@@ -1363 +1415,3 @@
-This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1434,2 +1488,2 @@
-test "capitalizes the first letter" do
- assert_equal "User", camelize("user")
+test 'capitalizes the first letter' do
+ assert_equal 'User', camelize('user')
@@ -1449 +1503 @@
-Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
+Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
lib/zeitwerk.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk.rb 2026-05-20 07:03:31.418318124 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk.rb 2026-05-20 07:03:31.424318167 +0000
@@ -4,11 +4,11 @@
- require_relative "zeitwerk/real_mod_name"
- require_relative "zeitwerk/internal"
- require_relative "zeitwerk/cref"
- require_relative "zeitwerk/loader"
- require_relative "zeitwerk/gem_loader"
- require_relative "zeitwerk/registry"
- require_relative "zeitwerk/inflector"
- require_relative "zeitwerk/gem_inflector"
- require_relative "zeitwerk/null_inflector"
- require_relative "zeitwerk/error"
- require_relative "zeitwerk/version"
+ require_relative 'zeitwerk/real_mod_name'
+ require_relative 'zeitwerk/internal'
+ require_relative 'zeitwerk/cref'
+ require_relative 'zeitwerk/loader'
+ require_relative 'zeitwerk/gem_loader'
+ require_relative 'zeitwerk/registry'
+ require_relative 'zeitwerk/inflector'
+ require_relative 'zeitwerk/gem_inflector'
+ require_relative 'zeitwerk/null_inflector'
+ require_relative 'zeitwerk/error'
+ require_relative 'zeitwerk/version'
@@ -16,2 +16,2 @@
- require_relative "zeitwerk/core_ext/kernel"
- require_relative "zeitwerk/core_ext/module"
+ require_relative 'zeitwerk/core_ext/kernel'
+ require_relative 'zeitwerk/core_ext/module'
lib/zeitwerk/core_ext/kernel.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:31.418318124 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:31.424318167 +0000
@@ -25 +25 @@
- if path.end_with?(".rb")
+ if path.end_with?('.rb')
lib/zeitwerk/cref.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/cref.rb 2026-05-20 07:03:31.419318131 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/cref.rb 2026-05-20 07:03:31.425318174 +0000
@@ -14 +14 @@
- require_relative "cref/map"
+ require_relative 'cref/map'
@@ -67,0 +68,7 @@
+ end
+
+ #: () -> String?
+ def location
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
+ location.join(':')
+ end
lib/zeitwerk/cref/map.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:31.419318131 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:31.425318174 +0000
@@ -30 +30 @@
-# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
+# { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
lib/zeitwerk/error.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/error.rb 2026-05-20 07:03:31.419318131 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/error.rb 2026-05-20 07:03:31.425318174 +0000
@@ -20 +20,12 @@
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
+ end
+ end
+
+ class ConflictingNamespaceDefinitionError < Error
+ #: (String, location: String?, conflicting_file: String) -> void
+ def initialize(cpath, location:, conflicting_file:)
+ if location
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
+ else
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
+ end
lib/zeitwerk/gem_inflector.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:31.419318131 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:31.425318174 +0000
@@ -7 +7 @@
- namespace = File.basename(root_file, ".rb")
+ namespace = File.basename(root_file, '.rb')
@@ -9 +9 @@
- @version_file = File.join(root_dir, namespace, "version.rb")
+ @version_file = File.join(root_dir, namespace, 'version.rb')
@@ -14 +14 @@
- abspath == @version_file ? "VERSION" : super
+ abspath == @version_file ? 'VERSION' : super
lib/zeitwerk/gem_loader.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:31.420318139 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:31.426318182 +0000
@@ -22,2 +22,2 @@
- @tag = File.basename(root_file, ".rb")
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
+ @tag = File.basename(root_file, '.rb')
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
@@ -43 +43 @@
- expected_namespace_dir = @root_file.delete_suffix(".rb")
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
@@ -49 +49 @@
- basename_without_ext = basename.delete_suffix(".rb")
+ basename_without_ext = basename.delete_suffix('.rb')
lib/zeitwerk/inflector.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb 2026-05-20 07:03:31.420318139 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb 2026-05-20 07:03:31.427318189 +0000
@@ -8,3 +8,3 @@
- # inflector.camelize("post", ...) # => "Post"
- # inflector.camelize("users_controller", ...) # => "UsersController"
- # inflector.camelize("api", ...) # => "Api"
+ # inflector.camelize('post', ...) # => 'Post'
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
+ # inflector.camelize('api', ...) # => 'Api'
@@ -23,2 +23,2 @@
- # "html_parser" => "HTMLParser",
- # "mysql_adapter" => "MySQLAdapter"
+ # 'html_parser' => 'HTMLParser',
+ # 'mysql_adapter' => 'MySQLAdapter'
@@ -27,3 +27,3 @@
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
- # inflector.camelize("users_controller", abspath) # => "UsersController"
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
lib/zeitwerk/loader.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader.rb 2026-05-20 07:03:31.420318139 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader.rb 2026-05-20 07:03:31.427318189 +0000
@@ -3,2 +3,2 @@
-require "monitor"
-require "set"
+require 'monitor'
+require 'set'
@@ -8,5 +8,5 @@
- require_relative "loader/helpers"
- require_relative "loader/callbacks"
- require_relative "loader/config"
- require_relative "loader/eager_load"
- require_relative "loader/file_system"
+ require_relative 'loader/helpers'
+ require_relative 'loader/callbacks'
+ require_relative 'loader/config'
+ require_relative 'loader/eager_load'
+ require_relative 'loader/file_system'
@@ -25,2 +25,2 @@
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
@@ -104,0 +105 @@
+ #: () -> void
@@ -132 +133 @@
- define_autoloads_for_dir(root_dir, root_namespace)
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
@@ -155,0 +157,3 @@
+ __unload
+ end
+ end
@@ -157,33 +161,19 @@
- # We are going to keep track of the files that were required by our
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
- # loadable by Kernel#require again.
- #
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
- # is enough.
- unloaded_files = Set.new
-
- autoloads.each do |abspath, cref|
- if cref.autoload?
- unload_autoload(cref)
- else
- # Could happen if loaded with require_relative. That is unsupported,
- # and the constant path would escape unloadable_cpath? This is just
- # defensive code to clean things up as much as we are able to.
- unload_cref(cref)
- unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
- end
- end
-
- to_unload.each do |abspath, cref|
- unless on_unload_callbacks.empty?
- begin
- value = cref.get
- rescue ::NameError
- # Perhaps the user deleted the constant by hand, or perhaps an
- # autoload failed to define the expected constant but the user
- # rescued the exception.
- else
- run_on_unload_callbacks(cref, value, abspath)
- end
- end
-
+ # This is an internal method.
+ #
+ #: () -> void
+ def __unload
+ # We are going to keep track of the files that were required by our
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
+ # loadable by Kernel#require again.
+ #
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
+ # is enough.
+ unloaded_files = Set.new
+
+ autoloads.each do |abspath, cref|
+ if cref.autoload?
+ unload_autoload(cref)
+ else
+ # Could happen if loaded with require_relative. That is unsupported,
+ # and the constant path would escape unloadable_cpath? This is just
+ # defensive code to clean things up as much as we are able to.
@@ -192,0 +183 @@
+ end
@@ -194,13 +185,11 @@
- unless unloaded_files.empty?
- # Bootsnap decorates Kernel#require to speed it up using a cache and
- # this optimization does not check if $LOADED_FEATURES has the file.
- #
- # To make it aware of changes, the gem defines singleton methods in
- # $LOADED_FEATURES:
- #
- # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
- #
- # Rails applications may depend on bootsnap, so for unloading to work
- # in that setting it is preferable that we restrict our API choice to
- # one of those methods.
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ to_unload.each do |abspath, cref|
+ unless on_unload_callbacks.empty?
+ begin
+ value = cref.get
+ rescue ::NameError
+ # Perhaps the user deleted the constant by hand, or perhaps an
+ # autoload failed to define the expected constant but the user
+ # rescued the exception.
+ else
+ run_on_unload_callbacks(cref, value, abspath)
+ end
@@ -209,5 +198,3 @@
- autoloads.clear
- autoloaded_dirs.clear
- to_unload.clear
- namespace_dirs.clear
- shadowed_files.clear
+ unload_cref(cref)
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
+ end
@@ -215,2 +202,14 @@
- unregister_inceptions
- unregister_explicit_namespaces
+ unless unloaded_files.empty?
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
+ # this optimization does not check if $LOADED_FEATURES has the file.
+ #
+ # To make it aware of changes, the gem defines singleton methods in
+ # $LOADED_FEATURES:
+ #
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
+ #
+ # Rails applications may depend on bootsnap, so for unloading to work
+ # in that setting it is preferable that we restrict our API choice to
+ # one of those methods.
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ end
@@ -218 +217,5 @@
- Registry.autoloads.unregister_loader(self)
+ autoloads.clear
+ autoloaded_dirs.clear
+ to_unload.clear
+ namespace_dirs.clear
+ shadowed_files.clear
@@ -220,3 +223,7 @@
- @setup = false
- @eager_loaded = false
- end
+ unregister_inceptions
+ unregister_explicit_namespaces
+
+ Registry.autoloads.unregister_loader(self)
+
+ @setup = false
+ @eager_loaded = false
@@ -236,0 +244 @@
+
@@ -238,0 +247,2 @@
+ recompute_collapse_parents
+
@@ -255 +265 @@
- prefix = cpath == "Object" ? "" : cpath + "::"
+ prefix = cpath == 'Object' ? '' : cpath + '::'
@@ -257 +267 @@
- @fs.ls(dir) do |basename, abspath, ftype|
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
@@ -259,5 +269,2 @@
- basename.delete_suffix!(".rb")
- result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
- else
- if collapse?(abspath)
- queue << [abspath, cpath]
+ if basename == @nsfile
+ result[abspath] = cpath
@@ -265 +272,2 @@
- queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
+ basename.delete_suffix!('.rb')
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
@@ -266,0 +275,4 @@
+ elsif collapse?(abspath)
+ queue.unshift([abspath, cpath])
+ else
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
@@ -288,2 +300,2 @@
- if :file == ftype
- basename = File.basename(abspath, ".rb")
+ if ftype == :file
+ basename = File.basename(abspath)
@@ -292 +304 @@
- paths << [basename, abspath]
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
@@ -318 +330 @@
- cnames.join("::")
+ cnames.join('::')
@@ -320 +332 @@
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
@@ -387 +399 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -390 +402 @@
- # loader.tag = File.basename(__FILE__, ".rb")
+ # loader.tag = File.basename(__FILE__, '.rb')
@@ -408 +420 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -411 +423 @@
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
@@ -428 +440 @@
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
@@ -476,2 +488,8 @@
- #: (String, Module) -> void
- private def define_autoloads_for_dir(dir, parent)
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
+ # expected to define.
+ #
+ # The `external` flag indicates whether `mod` has been externally defined,
+ # as is the case with root namespaces or reopened third-party namespaces.
+ #
+ #: (String, Module, external: boolish) -> void
+ private def define_autoloads_for_dir(dir, mod, external:)
@@ -480,9 +498,8 @@
- basename.delete_suffix!(".rb")
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_file(cref, abspath)
- else
- if collapse?(abspath)
- define_autoloads_for_dir(abspath, parent)
- else
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_subdir(cref, abspath)
+ if basename == @nsfile
+ if external
+ cpath = real_mod_name(mod)
+ location = Object.const_source_location(cpath)&.join(':')
+ location = nil if location&.empty?
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
+ end
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
@@ -489,0 +507,7 @@
+
+ basename.delete_suffix!('.rb')
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_file(cref, abspath)
+ else
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_subdir(cref, abspath, external:)
@@ -495 +519,25 @@
- private def autoload_subdir(cref, subdir)
+ private def visit_file(cref, file)
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
+ if @fs.rb_extension?(autoload_path)
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
+ end
+ shadowed_files << file
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
+ else
+ promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
+ end
+ elsif cref.defined?
+ shadowed_files << file
+ if location = cref.location
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
+ else
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
+ end
+ else
+ define_autoload(cref, file)
+ end
+ end
+
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
+ private def visit_subdir(cref, subdir, external:)
@@ -497,0 +546,6 @@
+ # The namespace that corresponds to this subdirectory is defined in a
+ # file, either regular or nsfile. Therefore, a nsfile would be a
+ # duplication.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
+ end
@@ -499,2 +553 @@
- # constant has been found. This means we are dealing with an explicit
- # namespace whose definition was seen first.
+ # constant has been found. This is an explicit namespace.
@@ -502,3 +555,2 @@
- # Registering is idempotent, and we have to keep the autoload pointing
- # to the file. This may run again if more directories are found later
- # on, no big deal.
+ # The namespace may be spread over multiple directories and perhaps it
+ # was already registered, but registering is idempotent, just do it.
@@ -505,0 +558,3 @@
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ # Scanning found a matching directory first, and now we saw a nsfile.
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
@@ -507,3 +561,0 @@
- # If the existing autoload points to a file, it has to be preserved, if
- # not, it is fine as it is. In either case, we do not need to override.
- # Just remember the subdirectory conforms this namespace.
@@ -512 +564,6 @@
- # First time we find this namespace, set an autoload for it.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ define_autoload(cref, nsfile_abspath)
+ register_explicit_namespace(cref)
+ else
+ define_autoload(cref, subdir)
+ end
@@ -514 +570,0 @@
- define_autoload(cref, subdir)
@@ -519,19 +575 @@
- define_autoloads_for_dir(subdir, cref.get)
- end
- end
-
- #: (Zeitwerk::Cref, String) -> void
- private def autoload_file(cref, file)
- if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
- # First autoload for a Ruby file wins, just ignore subsequent ones.
- if @fs.rb_extension?(autoload_path)
- shadowed_files << file
- log { "file #{file} is ignored because #{autoload_path} has precedence" }
- else
- promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
- end
- elsif cref.defined?
- shadowed_files << file
- log { "file #{file} is ignored because #{cref} is already defined" }
- else
- define_autoload(cref, file)
+ define_autoloads_for_dir(subdir, cref.get, external:)
@@ -611 +649 @@
- require "pp" # Needed to have pretty_inspect available.
+ require 'pp' # Needed to have pretty_inspect available.
lib/zeitwerk/loader/callbacks.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:31.420318139 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:31.427318189 +0000
@@ -80 +80 @@
- define_autoloads_for_dir(dir, namespace)
+ define_autoloads_for_dir(dir, namespace, external: false)
lib/zeitwerk/loader/config.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:31.421318146 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:31.427318189 +0000
@@ -3,2 +3,2 @@
-require "set"
-require "securerandom"
+require 'set'
+require 'securerandom'
@@ -18,2 +18,2 @@
- # "/Users/fxn/blog/app/channels" => Object,
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
+ # '/Users/fxn/blog/app/channels' => Object,
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
@@ -31,0 +32,6 @@
+ # Basename of files that define namespaces. For example, if `nsfile` is
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
+ #
+ #: String?
+ attr_reader :nsfile
+
@@ -53 +59 @@
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
@@ -58,0 +65,8 @@
+ # Absolute paths of directories that are parents of collapsed directories.
+ # This is a cache to optimize some tree walks. Computed on setup and
+ # recomputed on reload.
+ #
+ #: Set[String]
+ attr_reader :collapse_parents
+ private :collapse_parents
+
@@ -84,0 +99 @@
+ #: () -> void
@@ -90,0 +106 @@
+ @nsfile = nil
@@ -94,0 +111 @@
+ @collapse_parents = Set.new
@@ -115 +132 @@
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
@@ -143,0 +161,12 @@
+ #: (String?) -> void ! TypeError, ArgumentError
+ def nsfile=(nsfile)
+ unless nsfile.nil?
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
+ end
+
+ @nsfile = nsfile
+ end
+
@@ -179 +208 @@
- raise Zeitwerk::Error, "cannot enable reloading after setup"
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
@@ -217 +246,5 @@
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
+ collapse_dirs.merge(new_collapse_dirs)
+ new_collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
@@ -236,2 +269,2 @@
- # loader.on_load("SomeApiClient") do |klass, _abspath|
- # klass.endpoint = "https://api.dev"
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
+ # klass.endpoint = 'https://api.dev'
@@ -248 +281 @@
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -259 +292 @@
- # loader.on_unload("Country") do |klass, _abspath|
+ # loader.on_unload('Country') do |klass, _abspath|
@@ -271 +304 @@
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -318,0 +352,10 @@
+ internal def collapse?(dir)
+ collapse_dirs.member?(dir)
+ end
+
+ #: (String) -> bool
+ internal def collapse_parent?(dir)
+ collapse_parents.member?(dir)
+ end
+
+ #: (String) -> bool
@@ -331,5 +373,0 @@
- #: (String) -> bool
- private def collapse?(dir)
- collapse_dirs.member?(dir)
- end
-
@@ -355,0 +394,8 @@
+ end
+
+ #: () -> void
+ private def recompute_collapse_parents
+ collapse_parents.clear
+ collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
lib/zeitwerk/loader/eager_load.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:31.421318146 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:31.428318196 +0000
@@ -3,4 +3,5 @@
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
- # shadowed files are not eager loaded. You can opt-out specifically in
- # specific files and directories with `do_not_eager_load`, and that can be
- # overridden passing `force: true`.
+ # need to be in `$LOAD_PATH`, absolute file names are used.
+ #
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
+ # files and directories with `do_not_eager_load`, and that can be overridden
+ # passing `force: true`.
@@ -14 +15 @@
- log { "eager load start" }
+ log { 'eager load start' }
@@ -27 +28 @@
- log { "eager load end" }
+ log { 'eager load end' }
@@ -94 +95 @@
- if root_namespace_name.start_with?(mod_name + "::")
+ if root_namespace_name.start_with?(mod_name + '::')
@@ -98 +99 @@
- elsif mod_name.start_with?(root_namespace_name + "::")
+ elsif mod_name.start_with?(root_namespace_name + '::')
@@ -122 +123 @@
- file_basename = File.basename(abspath, ".rb")
+ file_basename = File.basename(abspath)
@@ -141,2 +141,0 @@
- base_cname = cname_for(file_basename, abspath)
-
@@ -149,3 +148,8 @@
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
-
- namespace.const_get(base_cname, false)
+ if file_basename == @nsfile
+ namespace
+ elsif shadowed_file?(abspath)
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
+ else
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
+ namespace.const_get(cname, false)
+ end
@@ -174,6 +178,2 @@
- if collapse?(abspath)
- queue << [abspath, namespace]
- else
- cname = cname_for(basename, abspath)
- queue << [abspath, namespace.const_get(cname, false)]
- end
+ cname = cname_for(basename, abspath)
+ queue << [abspath, namespace.const_get(cname, false)]
@@ -194 +194 @@
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
@@ -207 +207 @@
- suffix.split("::").each do |segment|
+ suffix.split('::').each do |segment|
@@ -210,5 +210 @@
- next unless ftype == :directory
-
- if collapse?(abspath)
- dirs << abspath
- elsif segment == cname_for(basename, abspath).to_s
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
lib/zeitwerk/loader/file_system.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:31.421318146 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:31.428318196 +0000
@@ -14,0 +15,14 @@
+ # This method lists directories, filtering out the following:
+ #
+ # - Hidden entries.
+ # - Ignored entries.
+ # - Files whose extension is not `.rb`.
+ # - Nested root directories, since they represent separate trees.
+ # - Subdirectories that (recursively) contain no Ruby files.
+ #
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
+ # method recurses so that the caller gets a conceptually flat listing.
+ #
+ # For every entry that is not excluded, `ls` yields its basename, absolute
+ # path, and file type, which can only be :file or :directory.
+ #
@@ -16 +30 @@
- def ls(dir)
+ def ls(dir, collapse: true, &block)
@@ -27,3 +41,8 @@
- if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
- @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
- next
+ if ftype == :directory
+ if !has_at_least_one_ruby_file?(abspath)
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
+ next
+ elsif collapse && @loader.__collapse?(abspath)
+ ls(abspath, collapse: collapse, &block)
+ next
+ end
@@ -41 +60,38 @@
- break if basename == "/"
+ break if basename == '/'
+ end
+ end
+
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
+ # there is none, it returns `nil`.
+ #
+ # This method accounts for collapsed directories, which conceptually allow for
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
+ #
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
+ def has_exactly_one_nsfile?(cref, dir)
+ return unless @loader.nsfile
+
+ # When `dir` does not have any collapsed directories a simple lookup
+ # suffices. This is a common case worth optimizing.
+ unless @loader.__collapse_parent?(dir)
+ nsfile_abspath = File.join(dir, @loader.nsfile)
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
+ return nsfile_abspath
+ end
+ return
+ end
+
+ nsfile = nil
+
+ to_visit = [dir]
+ while (dir = to_visit.shift)
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
+ if ftype == :file && basename == @loader.nsfile
+ if nsfile
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
+ end
+ nsfile = abspath
+ elsif ftype == :directory && @loader.__collapse?(abspath)
+ to_visit << abspath
+ end
+ end
@@ -42,0 +99,2 @@
+
+ nsfile
@@ -58 +116 @@
- path.end_with?(".rb")
+ path.end_with?('.rb')
@@ -68 +126 @@
- basename.start_with?(".")
+ basename.start_with?('.')
@@ -83 +141 @@
- return true if :file == ftype
+ return true if ftype == :file
@@ -99,12 +157,3 @@
- if :link == ftype
- begin
- ftype = File.stat(abspath).ftype.to_sym
- rescue Errno::ENOENT
- warn "ignoring broken symlink #{abspath}"
- next
- end
- end
-
- if :file == ftype
- yield basename, abspath, ftype if rb_extension?(basename)
- elsif :directory == ftype
+ if ftype == :file
+ yield basename, abspath, ftype
+ else
@@ -138 +187 @@
- elsif :directory == ftype
+ elsif ftype == :directory
@@ -141 +190 @@
- elsif :link == ftype
+ elsif ftype == :link
@@ -158,3 +207 @@
- if dir?(abspath)
- yield basename, abspath, :directory
- end
+ yield basename, abspath, :directory if dir?(abspath)
lib/zeitwerk/loader/helpers.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:31.421318146 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:31.428318196 +0000
@@ -15 +15 @@
- if cname.include?("::")
+ if cname.include?('::')
@@ -28 +28 @@
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
lib/zeitwerk/registry.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/registry.rb 2026-05-20 07:03:31.422318153 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/registry.rb 2026-05-20 07:03:31.428318196 +0000
@@ -5,4 +5,4 @@
- require_relative "registry/autoloads"
- require_relative "registry/explicit_namespaces"
- require_relative "registry/inceptions"
- require_relative "registry/loaders"
+ require_relative 'registry/autoloads'
+ require_relative 'registry/explicit_namespaces'
+ require_relative 'registry/inceptions'
+ require_relative 'registry/loaders'
@@ -57,2 +57,2 @@
- new_root_dir_slash = new_root_dir + "/"
- existing_root_dir_slash = existing_root_dir + "/"
+ new_root_dir_slash = new_root_dir + '/'
+ existing_root_dir_slash = existing_root_dir + '/'
lib/zeitwerk/registry/loaders.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:31.422318153 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:31.429318203 +0000
@@ -9,2 +9,2 @@
- def each(&)
- @loaders.each(&)
+ def each(&block)
+ @loaders.each(&block)
lib/zeitwerk/version.rb
--- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/version.rb 2026-05-20 07:03:31.422318153 +0000
+++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/version.rb 2026-05-20 07:03:31.429318203 +0000
@@ -5 +5 @@
- VERSION = "2.7.5"
+ VERSION = '2.8.1' |
Contributor
gem compare --diff zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/README.md 2026-05-20 07:03:32.837698897 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/README.md 2026-05-20 07:03:32.841698869 +0000
@@ -21,0 +22,2 @@
+ - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
+ - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
@@ -101 +103 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -184 +186 @@
-loader.inflector.inflect("max_retries" => "MAX_RETRIES")
+loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
@@ -223,2 +225,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -267 +269,6 @@
-Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
+Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
+
+<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
+#### Explicit namespaces defined in ordinary files
+
+Let's consider:
@@ -274 +281 @@
-There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
+Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
@@ -276 +283,3 @@
-The classes and modules from the namespace are already available in the body of the class or module defining it:
+In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
+
+The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
@@ -287 +296,42 @@
-An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
+<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
+#### Explicit namespaces defined in nsfiles
+
+If the loader has an nsfile configured (defaults to `nil`):
+
+```ruby
+loader.nsfile = 'ns.rb' # must be set before setup
+```
+
+you can alternatively define the explicit namespace inside its directory:
+
+```
+my_component/ns.rb -> MyComponent
+my_component/widget.rb -> MyComponent::Widget
+```
+
+This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
+
+A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
+
+Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
+
+```
+my_component/src/ns.rb -> MyComponent
+my_component/src/widget.rb -> MyComponent::Widget
+my_component/assets/widget.js
+my_component/tests/test_widget.rb
+```
+
+Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
+
+However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
+
+Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
+
+Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
+
+```ruby
+loader.nsfile = 'index.rb'
+```
+
+there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
@@ -375 +425 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -377 +427 @@
-loader.tag = File.basename(__FILE__, ".rb")
+loader.tag = File.basename(__FILE__, '.rb')
@@ -387 +437 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -433 +483 @@
-gem "net-http-niche_feature"
+gem 'net-http-niche_feature'
@@ -446 +496 @@
-require "net/http/niche_feature"
+require 'net/http/niche_feature'
@@ -451,2 +501,2 @@
-require "net/http"
-require "zeitwerk"
+require 'net/http'
+require 'zeitwerk'
@@ -467 +517 @@
- VERSION = "1.0.0"
+ VERSION = '1.0.0'
@@ -489 +539 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -498 +548 @@
-That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
+That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
@@ -685 +735 @@
-require "concurrent/atomic/read_write_lock"
+require 'concurrent/atomic/read_write_lock'
@@ -730,2 +780,2 @@
- "html_parser" => "HTMLParser",
- "mysql_adapter" => "MySQLAdapter"
+ 'html_parser' => 'HTMLParser',
+ 'mysql_adapter' => 'MySQLAdapter'
@@ -738,2 +788,2 @@
-loader.inflector.inflect "html_parser" => "HTMLParser"
-loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
+loader.inflector.inflect 'html_parser' => 'HTMLParser'
+loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
@@ -745 +795 @@
-loader.inflector.inflect("xml" => "XML")
+loader.inflector.inflect('xml' => 'XML')
@@ -760,2 +810,2 @@
- "xml" => "XML",
- "xml_parser" => "XMLParser"
+ 'xml' => 'XML',
+ 'xml_parser' => 'XMLParser'
@@ -816 +866 @@
- "HTML" + super($1, abspath)
+ 'HTML' + super($1, abspath)
@@ -847,2 +897,2 @@
-require "zeitwerk"
-require_relative "my_gem/inflector"
+require 'zeitwerk'
+require_relative 'my_gem/inflector'
@@ -916,2 +966,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.dev"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.dev'
@@ -921,2 +971,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.prod"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.prod'
@@ -966 +1016 @@
-loader.on_unload("Country") do |klass, _abspath|
+loader.on_unload('Country') do |klass, _abspath|
@@ -1051 +1101 @@
-loader.tag = "grep_me"
+loader.tag = 'grep_me'
@@ -1107 +1157 @@
-require_relative "my_gem/core_ext/kernel"
+require_relative 'my_gem/core_ext/kernel'
@@ -1121 +1171 @@
-require "pg"
+require 'pg'
@@ -1159 +1209 @@
-require "foo"
+require 'foo'
@@ -1228,2 +1278,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -1231 +1281 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -1250,2 +1300,2 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.dirs # => ["/foo"]
+loader.push_dir(Pathname.new('/foo'))
+loader.dirs # => ['/foo']
@@ -1258,3 +1308,3 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.push_dir(Pathname.new("/bar"), namespace: Bar)
-loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
+loader.push_dir(Pathname.new('/foo'))
+loader.push_dir(Pathname.new('/bar'), namespace: Bar)
+loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
@@ -1287,4 +1337,4 @@
-loader.cpath_expected_at("app/models") # => "Object"
-loader.cpath_expected_at("app/models/user.rb") # => "User"
-loader.cpath_expected_at("app/models/hotel") # => "Hotel"
-loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+loader.cpath_expected_at('app/models') # => 'Object'
+loader.cpath_expected_at('app/models/user.rb') # => 'User'
+loader.cpath_expected_at('app/models/hotel') # => 'Hotel'
+loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
@@ -1296,3 +1346,3 @@
-loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
-loader.cpath_expected_at("a/b/collapsed") # => "A::B", edge case
-loader.cpath_expected_at("a/b") # => "A::B"
+loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
+loader.cpath_expected_at('a/b/collapsed') # => 'A::B', edge case
+loader.cpath_expected_at('a/b') # => 'A::B'
@@ -1306 +1356 @@
-loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
@@ -1312 +1362 @@
-loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
+loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
@@ -1315 +1365,3 @@
-This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1332 +1383,0 @@
-lib/my_gem/ignored.rb
@@ -1335,0 +1387 @@
+lib/my_gem/ignored.rb
@@ -1343,9 +1395,9 @@
- "/.../lib" => "Object",
- "/.../lib/my_gem.rb" => "MyGem",
- "/.../lib/my_gem" => "MyGem",
- "/.../lib/my_gem/version.rb" => "MyGem::VERSION",
- "/.../lib/my_gem/drivers" => "MyGem::Drivers",
- "/.../lib/my_gem/drivers/unix.rb" => "MyGem::Drivers::Unix",
- "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
- "/.../lib/my_gem/collapsed" => "MyGem",
- "/.../lib/my_gem/collapsed/foo.rb" => "MyGem::Foo"
+ '/.../lib' => 'Object',
+ '/.../lib/my_gem.rb' => 'MyGem',
+ '/.../lib/my_gem' => 'MyGem',
+ '/.../lib/my_gem/version.rb' => 'MyGem::VERSION',
+ '/.../lib/my_gem/drivers' => 'MyGem::Drivers',
+ '/.../lib/my_gem/drivers/unix.rb' => 'MyGem::Drivers::Unix',
+ '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
+ '/.../lib/my_gem/collapsed' => 'MyGem',
+ '/.../lib/my_gem/collapsed/foo.rb' => 'MyGem::Foo'
@@ -1357 +1409 @@
-The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
+The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
@@ -1363 +1415,3 @@
-This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1434,2 +1488,2 @@
-test "capitalizes the first letter" do
- assert_equal "User", camelize("user")
+test 'capitalizes the first letter' do
+ assert_equal 'User', camelize('user')
@@ -1449 +1503 @@
-Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
+Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
lib/zeitwerk.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk.rb 2026-05-20 07:03:32.837698897 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk.rb 2026-05-20 07:03:32.841698869 +0000
@@ -4,11 +4,11 @@
- require_relative "zeitwerk/real_mod_name"
- require_relative "zeitwerk/internal"
- require_relative "zeitwerk/cref"
- require_relative "zeitwerk/loader"
- require_relative "zeitwerk/gem_loader"
- require_relative "zeitwerk/registry"
- require_relative "zeitwerk/inflector"
- require_relative "zeitwerk/gem_inflector"
- require_relative "zeitwerk/null_inflector"
- require_relative "zeitwerk/error"
- require_relative "zeitwerk/version"
+ require_relative 'zeitwerk/real_mod_name'
+ require_relative 'zeitwerk/internal'
+ require_relative 'zeitwerk/cref'
+ require_relative 'zeitwerk/loader'
+ require_relative 'zeitwerk/gem_loader'
+ require_relative 'zeitwerk/registry'
+ require_relative 'zeitwerk/inflector'
+ require_relative 'zeitwerk/gem_inflector'
+ require_relative 'zeitwerk/null_inflector'
+ require_relative 'zeitwerk/error'
+ require_relative 'zeitwerk/version'
@@ -16,2 +16,2 @@
- require_relative "zeitwerk/core_ext/kernel"
- require_relative "zeitwerk/core_ext/module"
+ require_relative 'zeitwerk/core_ext/kernel'
+ require_relative 'zeitwerk/core_ext/module'
lib/zeitwerk/core_ext/kernel.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:32.837698897 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:32.841698869 +0000
@@ -25 +25 @@
- if path.end_with?(".rb")
+ if path.end_with?('.rb')
lib/zeitwerk/cref.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/cref.rb 2026-05-20 07:03:32.837698897 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/cref.rb 2026-05-20 07:03:32.841698869 +0000
@@ -14 +14 @@
- require_relative "cref/map"
+ require_relative 'cref/map'
@@ -67,0 +68,7 @@
+ end
+
+ #: () -> String?
+ def location
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
+ location.join(':')
+ end
lib/zeitwerk/cref/map.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:32.837698897 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:32.841698869 +0000
@@ -30 +30 @@
-# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
+# { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
lib/zeitwerk/error.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/error.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/error.rb 2026-05-20 07:03:32.841698869 +0000
@@ -20 +20,12 @@
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
+ end
+ end
+
+ class ConflictingNamespaceDefinitionError < Error
+ #: (String, location: String?, conflicting_file: String) -> void
+ def initialize(cpath, location:, conflicting_file:)
+ if location
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
+ else
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
+ end
lib/zeitwerk/gem_inflector.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:32.842698862 +0000
@@ -7 +7 @@
- namespace = File.basename(root_file, ".rb")
+ namespace = File.basename(root_file, '.rb')
@@ -9 +9 @@
- @version_file = File.join(root_dir, namespace, "version.rb")
+ @version_file = File.join(root_dir, namespace, 'version.rb')
@@ -14 +14 @@
- abspath == @version_file ? "VERSION" : super
+ abspath == @version_file ? 'VERSION' : super
lib/zeitwerk/gem_loader.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:32.842698862 +0000
@@ -22,2 +22,2 @@
- @tag = File.basename(root_file, ".rb")
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
+ @tag = File.basename(root_file, '.rb')
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
@@ -43 +43 @@
- expected_namespace_dir = @root_file.delete_suffix(".rb")
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
@@ -49 +49 @@
- basename_without_ext = basename.delete_suffix(".rb")
+ basename_without_ext = basename.delete_suffix('.rb')
lib/zeitwerk/inflector.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb 2026-05-20 07:03:32.842698862 +0000
@@ -8,3 +8,3 @@
- # inflector.camelize("post", ...) # => "Post"
- # inflector.camelize("users_controller", ...) # => "UsersController"
- # inflector.camelize("api", ...) # => "Api"
+ # inflector.camelize('post', ...) # => 'Post'
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
+ # inflector.camelize('api', ...) # => 'Api'
@@ -23,2 +23,2 @@
- # "html_parser" => "HTMLParser",
- # "mysql_adapter" => "MySQLAdapter"
+ # 'html_parser' => 'HTMLParser',
+ # 'mysql_adapter' => 'MySQLAdapter'
@@ -27,3 +27,3 @@
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
- # inflector.camelize("users_controller", abspath) # => "UsersController"
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
lib/zeitwerk/loader.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader.rb 2026-05-20 07:03:32.842698862 +0000
@@ -3,2 +3,2 @@
-require "monitor"
-require "set"
+require 'monitor'
+require 'set'
@@ -8,5 +8,5 @@
- require_relative "loader/helpers"
- require_relative "loader/callbacks"
- require_relative "loader/config"
- require_relative "loader/eager_load"
- require_relative "loader/file_system"
+ require_relative 'loader/helpers'
+ require_relative 'loader/callbacks'
+ require_relative 'loader/config'
+ require_relative 'loader/eager_load'
+ require_relative 'loader/file_system'
@@ -25,2 +25,2 @@
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
@@ -104,0 +105 @@
+ #: () -> void
@@ -132 +133 @@
- define_autoloads_for_dir(root_dir, root_namespace)
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
@@ -155,0 +157,3 @@
+ __unload
+ end
+ end
@@ -157,33 +161,19 @@
- # We are going to keep track of the files that were required by our
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
- # loadable by Kernel#require again.
- #
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
- # is enough.
- unloaded_files = Set.new
-
- autoloads.each do |abspath, cref|
- if cref.autoload?
- unload_autoload(cref)
- else
- # Could happen if loaded with require_relative. That is unsupported,
- # and the constant path would escape unloadable_cpath? This is just
- # defensive code to clean things up as much as we are able to.
- unload_cref(cref)
- unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
- end
- end
-
- to_unload.each do |abspath, cref|
- unless on_unload_callbacks.empty?
- begin
- value = cref.get
- rescue ::NameError
- # Perhaps the user deleted the constant by hand, or perhaps an
- # autoload failed to define the expected constant but the user
- # rescued the exception.
- else
- run_on_unload_callbacks(cref, value, abspath)
- end
- end
-
+ # This is an internal method.
+ #
+ #: () -> void
+ def __unload
+ # We are going to keep track of the files that were required by our
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
+ # loadable by Kernel#require again.
+ #
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
+ # is enough.
+ unloaded_files = Set.new
+
+ autoloads.each do |abspath, cref|
+ if cref.autoload?
+ unload_autoload(cref)
+ else
+ # Could happen if loaded with require_relative. That is unsupported,
+ # and the constant path would escape unloadable_cpath? This is just
+ # defensive code to clean things up as much as we are able to.
@@ -192,0 +183 @@
+ end
@@ -194,13 +185,11 @@
- unless unloaded_files.empty?
- # Bootsnap decorates Kernel#require to speed it up using a cache and
- # this optimization does not check if $LOADED_FEATURES has the file.
- #
- # To make it aware of changes, the gem defines singleton methods in
- # $LOADED_FEATURES:
- #
- # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
- #
- # Rails applications may depend on bootsnap, so for unloading to work
- # in that setting it is preferable that we restrict our API choice to
- # one of those methods.
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ to_unload.each do |abspath, cref|
+ unless on_unload_callbacks.empty?
+ begin
+ value = cref.get
+ rescue ::NameError
+ # Perhaps the user deleted the constant by hand, or perhaps an
+ # autoload failed to define the expected constant but the user
+ # rescued the exception.
+ else
+ run_on_unload_callbacks(cref, value, abspath)
+ end
@@ -209,5 +198,3 @@
- autoloads.clear
- autoloaded_dirs.clear
- to_unload.clear
- namespace_dirs.clear
- shadowed_files.clear
+ unload_cref(cref)
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
+ end
@@ -215,2 +202,14 @@
- unregister_inceptions
- unregister_explicit_namespaces
+ unless unloaded_files.empty?
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
+ # this optimization does not check if $LOADED_FEATURES has the file.
+ #
+ # To make it aware of changes, the gem defines singleton methods in
+ # $LOADED_FEATURES:
+ #
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
+ #
+ # Rails applications may depend on bootsnap, so for unloading to work
+ # in that setting it is preferable that we restrict our API choice to
+ # one of those methods.
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ end
@@ -218 +217,5 @@
- Registry.autoloads.unregister_loader(self)
+ autoloads.clear
+ autoloaded_dirs.clear
+ to_unload.clear
+ namespace_dirs.clear
+ shadowed_files.clear
@@ -220,3 +223,7 @@
- @setup = false
- @eager_loaded = false
- end
+ unregister_inceptions
+ unregister_explicit_namespaces
+
+ Registry.autoloads.unregister_loader(self)
+
+ @setup = false
+ @eager_loaded = false
@@ -236,0 +244 @@
+
@@ -238,0 +247,2 @@
+ recompute_collapse_parents
+
@@ -255 +265 @@
- prefix = cpath == "Object" ? "" : cpath + "::"
+ prefix = cpath == 'Object' ? '' : cpath + '::'
@@ -257 +267 @@
- @fs.ls(dir) do |basename, abspath, ftype|
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
@@ -259,5 +269,2 @@
- basename.delete_suffix!(".rb")
- result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
- else
- if collapse?(abspath)
- queue << [abspath, cpath]
+ if basename == @nsfile
+ result[abspath] = cpath
@@ -265 +272,2 @@
- queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
+ basename.delete_suffix!('.rb')
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
@@ -266,0 +275,4 @@
+ elsif collapse?(abspath)
+ queue.unshift([abspath, cpath])
+ else
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
@@ -288,2 +300,2 @@
- if :file == ftype
- basename = File.basename(abspath, ".rb")
+ if ftype == :file
+ basename = File.basename(abspath)
@@ -292 +304 @@
- paths << [basename, abspath]
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
@@ -318 +330 @@
- cnames.join("::")
+ cnames.join('::')
@@ -320 +332 @@
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
@@ -387 +399 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -390 +402 @@
- # loader.tag = File.basename(__FILE__, ".rb")
+ # loader.tag = File.basename(__FILE__, '.rb')
@@ -408 +420 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -411 +423 @@
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
@@ -428 +440 @@
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
@@ -476,2 +488,8 @@
- #: (String, Module) -> void
- private def define_autoloads_for_dir(dir, parent)
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
+ # expected to define.
+ #
+ # The `external` flag indicates whether `mod` has been externally defined,
+ # as is the case with root namespaces or reopened third-party namespaces.
+ #
+ #: (String, Module, external: boolish) -> void
+ private def define_autoloads_for_dir(dir, mod, external:)
@@ -480,9 +498,8 @@
- basename.delete_suffix!(".rb")
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_file(cref, abspath)
- else
- if collapse?(abspath)
- define_autoloads_for_dir(abspath, parent)
- else
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_subdir(cref, abspath)
+ if basename == @nsfile
+ if external
+ cpath = real_mod_name(mod)
+ location = Object.const_source_location(cpath)&.join(':')
+ location = nil if location&.empty?
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
+ end
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
@@ -489,0 +507,7 @@
+
+ basename.delete_suffix!('.rb')
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_file(cref, abspath)
+ else
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_subdir(cref, abspath, external:)
@@ -495 +519,25 @@
- private def autoload_subdir(cref, subdir)
+ private def visit_file(cref, file)
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
+ if @fs.rb_extension?(autoload_path)
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
+ end
+ shadowed_files << file
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
+ else
+ promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
+ end
+ elsif cref.defined?
+ shadowed_files << file
+ if location = cref.location
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
+ else
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
+ end
+ else
+ define_autoload(cref, file)
+ end
+ end
+
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
+ private def visit_subdir(cref, subdir, external:)
@@ -497,0 +546,6 @@
+ # The namespace that corresponds to this subdirectory is defined in a
+ # file, either regular or nsfile. Therefore, a nsfile would be a
+ # duplication.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
+ end
@@ -499,2 +553 @@
- # constant has been found. This means we are dealing with an explicit
- # namespace whose definition was seen first.
+ # constant has been found. This is an explicit namespace.
@@ -502,3 +555,2 @@
- # Registering is idempotent, and we have to keep the autoload pointing
- # to the file. This may run again if more directories are found later
- # on, no big deal.
+ # The namespace may be spread over multiple directories and perhaps it
+ # was already registered, but registering is idempotent, just do it.
@@ -505,0 +558,3 @@
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ # Scanning found a matching directory first, and now we saw a nsfile.
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
@@ -507,3 +561,0 @@
- # If the existing autoload points to a file, it has to be preserved, if
- # not, it is fine as it is. In either case, we do not need to override.
- # Just remember the subdirectory conforms this namespace.
@@ -512 +564,6 @@
- # First time we find this namespace, set an autoload for it.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ define_autoload(cref, nsfile_abspath)
+ register_explicit_namespace(cref)
+ else
+ define_autoload(cref, subdir)
+ end
@@ -514 +570,0 @@
- define_autoload(cref, subdir)
@@ -519,19 +575 @@
- define_autoloads_for_dir(subdir, cref.get)
- end
- end
-
- #: (Zeitwerk::Cref, String) -> void
- private def autoload_file(cref, file)
- if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
- # First autoload for a Ruby file wins, just ignore subsequent ones.
- if @fs.rb_extension?(autoload_path)
- shadowed_files << file
- log { "file #{file} is ignored because #{autoload_path} has precedence" }
- else
- promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
- end
- elsif cref.defined?
- shadowed_files << file
- log { "file #{file} is ignored because #{cref} is already defined" }
- else
- define_autoload(cref, file)
+ define_autoloads_for_dir(subdir, cref.get, external:)
@@ -611 +649 @@
- require "pp" # Needed to have pretty_inspect available.
+ require 'pp' # Needed to have pretty_inspect available.
lib/zeitwerk/loader/callbacks.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:32.838698890 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:32.842698862 +0000
@@ -80 +80 @@
- define_autoloads_for_dir(dir, namespace)
+ define_autoloads_for_dir(dir, namespace, external: false)
lib/zeitwerk/loader/config.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:32.842698862 +0000
@@ -3,2 +3,2 @@
-require "set"
-require "securerandom"
+require 'set'
+require 'securerandom'
@@ -18,2 +18,2 @@
- # "/Users/fxn/blog/app/channels" => Object,
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
+ # '/Users/fxn/blog/app/channels' => Object,
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
@@ -31,0 +32,6 @@
+ # Basename of files that define namespaces. For example, if `nsfile` is
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
+ #
+ #: String?
+ attr_reader :nsfile
+
@@ -53 +59 @@
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
@@ -58,0 +65,8 @@
+ # Absolute paths of directories that are parents of collapsed directories.
+ # This is a cache to optimize some tree walks. Computed on setup and
+ # recomputed on reload.
+ #
+ #: Set[String]
+ attr_reader :collapse_parents
+ private :collapse_parents
+
@@ -84,0 +99 @@
+ #: () -> void
@@ -90,0 +106 @@
+ @nsfile = nil
@@ -94,0 +111 @@
+ @collapse_parents = Set.new
@@ -115 +132 @@
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
@@ -143,0 +161,12 @@
+ #: (String?) -> void ! TypeError, ArgumentError
+ def nsfile=(nsfile)
+ unless nsfile.nil?
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
+ end
+
+ @nsfile = nsfile
+ end
+
@@ -179 +208 @@
- raise Zeitwerk::Error, "cannot enable reloading after setup"
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
@@ -217 +246,5 @@
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
+ collapse_dirs.merge(new_collapse_dirs)
+ new_collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
@@ -236,2 +269,2 @@
- # loader.on_load("SomeApiClient") do |klass, _abspath|
- # klass.endpoint = "https://api.dev"
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
+ # klass.endpoint = 'https://api.dev'
@@ -248 +281 @@
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -259 +292 @@
- # loader.on_unload("Country") do |klass, _abspath|
+ # loader.on_unload('Country') do |klass, _abspath|
@@ -271 +304 @@
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -318,0 +352,10 @@
+ internal def collapse?(dir)
+ collapse_dirs.member?(dir)
+ end
+
+ #: (String) -> bool
+ internal def collapse_parent?(dir)
+ collapse_parents.member?(dir)
+ end
+
+ #: (String) -> bool
@@ -331,5 +373,0 @@
- #: (String) -> bool
- private def collapse?(dir)
- collapse_dirs.member?(dir)
- end
-
@@ -355,0 +394,8 @@
+ end
+
+ #: () -> void
+ private def recompute_collapse_parents
+ collapse_parents.clear
+ collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
lib/zeitwerk/loader/eager_load.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:32.842698862 +0000
@@ -3,4 +3,5 @@
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
- # shadowed files are not eager loaded. You can opt-out specifically in
- # specific files and directories with `do_not_eager_load`, and that can be
- # overridden passing `force: true`.
+ # need to be in `$LOAD_PATH`, absolute file names are used.
+ #
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
+ # files and directories with `do_not_eager_load`, and that can be overridden
+ # passing `force: true`.
@@ -14 +15 @@
- log { "eager load start" }
+ log { 'eager load start' }
@@ -27 +28 @@
- log { "eager load end" }
+ log { 'eager load end' }
@@ -94 +95 @@
- if root_namespace_name.start_with?(mod_name + "::")
+ if root_namespace_name.start_with?(mod_name + '::')
@@ -98 +99 @@
- elsif mod_name.start_with?(root_namespace_name + "::")
+ elsif mod_name.start_with?(root_namespace_name + '::')
@@ -122 +123 @@
- file_basename = File.basename(abspath, ".rb")
+ file_basename = File.basename(abspath)
@@ -141,2 +141,0 @@
- base_cname = cname_for(file_basename, abspath)
-
@@ -149,3 +148,8 @@
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
-
- namespace.const_get(base_cname, false)
+ if file_basename == @nsfile
+ namespace
+ elsif shadowed_file?(abspath)
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
+ else
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
+ namespace.const_get(cname, false)
+ end
@@ -174,6 +178,2 @@
- if collapse?(abspath)
- queue << [abspath, namespace]
- else
- cname = cname_for(basename, abspath)
- queue << [abspath, namespace.const_get(cname, false)]
- end
+ cname = cname_for(basename, abspath)
+ queue << [abspath, namespace.const_get(cname, false)]
@@ -194 +194 @@
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
@@ -207 +207 @@
- suffix.split("::").each do |segment|
+ suffix.split('::').each do |segment|
@@ -210,5 +210 @@
- next unless ftype == :directory
-
- if collapse?(abspath)
- dirs << abspath
- elsif segment == cname_for(basename, abspath).to_s
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
lib/zeitwerk/loader/file_system.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:32.842698862 +0000
@@ -14,0 +15,14 @@
+ # This method lists directories, filtering out the following:
+ #
+ # - Hidden entries.
+ # - Ignored entries.
+ # - Files whose extension is not `.rb`.
+ # - Nested root directories, since they represent separate trees.
+ # - Subdirectories that (recursively) contain no Ruby files.
+ #
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
+ # method recurses so that the caller gets a conceptually flat listing.
+ #
+ # For every entry that is not excluded, `ls` yields its basename, absolute
+ # path, and file type, which can only be :file or :directory.
+ #
@@ -16 +30 @@
- def ls(dir)
+ def ls(dir, collapse: true, &block)
@@ -27,3 +41,8 @@
- if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
- @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
- next
+ if ftype == :directory
+ if !has_at_least_one_ruby_file?(abspath)
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
+ next
+ elsif collapse && @loader.__collapse?(abspath)
+ ls(abspath, collapse: collapse, &block)
+ next
+ end
@@ -41 +60,38 @@
- break if basename == "/"
+ break if basename == '/'
+ end
+ end
+
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
+ # there is none, it returns `nil`.
+ #
+ # This method accounts for collapsed directories, which conceptually allow for
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
+ #
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
+ def has_exactly_one_nsfile?(cref, dir)
+ return unless @loader.nsfile
+
+ # When `dir` does not have any collapsed directories a simple lookup
+ # suffices. This is a common case worth optimizing.
+ unless @loader.__collapse_parent?(dir)
+ nsfile_abspath = File.join(dir, @loader.nsfile)
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
+ return nsfile_abspath
+ end
+ return
+ end
+
+ nsfile = nil
+
+ to_visit = [dir]
+ while (dir = to_visit.shift)
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
+ if ftype == :file && basename == @loader.nsfile
+ if nsfile
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
+ end
+ nsfile = abspath
+ elsif ftype == :directory && @loader.__collapse?(abspath)
+ to_visit << abspath
+ end
+ end
@@ -42,0 +99,2 @@
+
+ nsfile
@@ -58 +116 @@
- path.end_with?(".rb")
+ path.end_with?('.rb')
@@ -68 +126 @@
- basename.start_with?(".")
+ basename.start_with?('.')
@@ -83 +141 @@
- return true if :file == ftype
+ return true if ftype == :file
@@ -99,12 +157,3 @@
- if :link == ftype
- begin
- ftype = File.stat(abspath).ftype.to_sym
- rescue Errno::ENOENT
- warn "ignoring broken symlink #{abspath}"
- next
- end
- end
-
- if :file == ftype
- yield basename, abspath, ftype if rb_extension?(basename)
- elsif :directory == ftype
+ if ftype == :file
+ yield basename, abspath, ftype
+ else
@@ -138 +187 @@
- elsif :directory == ftype
+ elsif ftype == :directory
@@ -141 +190 @@
- elsif :link == ftype
+ elsif ftype == :link
@@ -158,3 +207 @@
- if dir?(abspath)
- yield basename, abspath, :directory
- end
+ yield basename, abspath, :directory if dir?(abspath)
lib/zeitwerk/loader/helpers.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:32.843698855 +0000
@@ -15 +15 @@
- if cname.include?("::")
+ if cname.include?('::')
@@ -28 +28 @@
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
lib/zeitwerk/registry.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/registry.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/registry.rb 2026-05-20 07:03:32.843698855 +0000
@@ -5,4 +5,4 @@
- require_relative "registry/autoloads"
- require_relative "registry/explicit_namespaces"
- require_relative "registry/inceptions"
- require_relative "registry/loaders"
+ require_relative 'registry/autoloads'
+ require_relative 'registry/explicit_namespaces'
+ require_relative 'registry/inceptions'
+ require_relative 'registry/loaders'
@@ -57,2 +57,2 @@
- new_root_dir_slash = new_root_dir + "/"
- existing_root_dir_slash = existing_root_dir + "/"
+ new_root_dir_slash = new_root_dir + '/'
+ existing_root_dir_slash = existing_root_dir + '/'
lib/zeitwerk/registry/loaders.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:32.843698855 +0000
@@ -9,2 +9,2 @@
- def each(&)
- @loaders.each(&)
+ def each(&block)
+ @loaders.each(&block)
lib/zeitwerk/version.rb
--- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/version.rb 2026-05-20 07:03:32.839698883 +0000
+++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/version.rb 2026-05-20 07:03:32.843698855 +0000
@@ -5 +5 @@
- VERSION = "2.7.5"
+ VERSION = '2.8.1' |
Contributor
gem compare --diff zeitwerk 2.7.5 2.8.1Compared versions: ["2.7.5", "2.8.1"]
DIFFERENT files:
2.7.5->2.8.1:
* Changed:
README.md
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/README.md 2026-05-20 07:03:34.929312910 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/README.md 2026-05-20 07:03:34.933312885 +0000
@@ -21,0 +22,2 @@
+ - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
+ - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
@@ -101 +103 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -184 +186 @@
-loader.inflector.inflect("max_retries" => "MAX_RETRIES")
+loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
@@ -223,2 +225,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -267 +269,6 @@
-Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
+Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
+
+<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
+#### Explicit namespaces defined in ordinary files
+
+Let's consider:
@@ -274 +281 @@
-There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
+Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
@@ -276 +283,3 @@
-The classes and modules from the namespace are already available in the body of the class or module defining it:
+In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
+
+The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
@@ -287 +296,42 @@
-An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
+<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
+#### Explicit namespaces defined in nsfiles
+
+If the loader has an nsfile configured (defaults to `nil`):
+
+```ruby
+loader.nsfile = 'ns.rb' # must be set before setup
+```
+
+you can alternatively define the explicit namespace inside its directory:
+
+```
+my_component/ns.rb -> MyComponent
+my_component/widget.rb -> MyComponent::Widget
+```
+
+This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
+
+A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
+
+Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
+
+```
+my_component/src/ns.rb -> MyComponent
+my_component/src/widget.rb -> MyComponent::Widget
+my_component/assets/widget.js
+my_component/tests/test_widget.rb
+```
+
+Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
+
+However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
+
+Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
+
+Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
+
+```ruby
+loader.nsfile = 'index.rb'
+```
+
+there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
@@ -375 +425 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -377 +427 @@
-loader.tag = File.basename(__FILE__, ".rb")
+loader.tag = File.basename(__FILE__, '.rb')
@@ -387 +437 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -433 +483 @@
-gem "net-http-niche_feature"
+gem 'net-http-niche_feature'
@@ -446 +496 @@
-require "net/http/niche_feature"
+require 'net/http/niche_feature'
@@ -451,2 +501,2 @@
-require "net/http"
-require "zeitwerk"
+require 'net/http'
+require 'zeitwerk'
@@ -467 +517 @@
- VERSION = "1.0.0"
+ VERSION = '1.0.0'
@@ -489 +539 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -498 +548 @@
-That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
+That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
@@ -685 +735 @@
-require "concurrent/atomic/read_write_lock"
+require 'concurrent/atomic/read_write_lock'
@@ -730,2 +780,2 @@
- "html_parser" => "HTMLParser",
- "mysql_adapter" => "MySQLAdapter"
+ 'html_parser' => 'HTMLParser',
+ 'mysql_adapter' => 'MySQLAdapter'
@@ -738,2 +788,2 @@
-loader.inflector.inflect "html_parser" => "HTMLParser"
-loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
+loader.inflector.inflect 'html_parser' => 'HTMLParser'
+loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
@@ -745 +795 @@
-loader.inflector.inflect("xml" => "XML")
+loader.inflector.inflect('xml' => 'XML')
@@ -760,2 +810,2 @@
- "xml" => "XML",
- "xml_parser" => "XMLParser"
+ 'xml' => 'XML',
+ 'xml_parser' => 'XMLParser'
@@ -816 +866 @@
- "HTML" + super($1, abspath)
+ 'HTML' + super($1, abspath)
@@ -847,2 +897,2 @@
-require "zeitwerk"
-require_relative "my_gem/inflector"
+require 'zeitwerk'
+require_relative 'my_gem/inflector'
@@ -916,2 +966,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.dev"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.dev'
@@ -921,2 +971,2 @@
-loader.on_load("SomeApiClient") do |klass, _abspath|
- klass.endpoint = "https://api.prod"
+loader.on_load('SomeApiClient') do |klass, _abspath|
+ klass.endpoint = 'https://api.prod'
@@ -966 +1016 @@
-loader.on_unload("Country") do |klass, _abspath|
+loader.on_unload('Country') do |klass, _abspath|
@@ -1051 +1101 @@
-loader.tag = "grep_me"
+loader.tag = 'grep_me'
@@ -1107 +1157 @@
-require_relative "my_gem/core_ext/kernel"
+require_relative 'my_gem/core_ext/kernel'
@@ -1121 +1171 @@
-require "pg"
+require 'pg'
@@ -1159 +1209 @@
-require "foo"
+require 'foo'
@@ -1228,2 +1278,2 @@
-require "active_job"
-require "active_job/queue_adapters"
+require 'active_job'
+require 'active_job/queue_adapters'
@@ -1231 +1281 @@
-require "zeitwerk"
+require 'zeitwerk'
@@ -1250,2 +1300,2 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.dirs # => ["/foo"]
+loader.push_dir(Pathname.new('/foo'))
+loader.dirs # => ['/foo']
@@ -1258,3 +1308,3 @@
-loader.push_dir(Pathname.new("/foo"))
-loader.push_dir(Pathname.new("/bar"), namespace: Bar)
-loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
+loader.push_dir(Pathname.new('/foo'))
+loader.push_dir(Pathname.new('/bar'), namespace: Bar)
+loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
@@ -1287,4 +1337,4 @@
-loader.cpath_expected_at("app/models") # => "Object"
-loader.cpath_expected_at("app/models/user.rb") # => "User"
-loader.cpath_expected_at("app/models/hotel") # => "Hotel"
-loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+loader.cpath_expected_at('app/models') # => 'Object'
+loader.cpath_expected_at('app/models/user.rb') # => 'User'
+loader.cpath_expected_at('app/models/hotel') # => 'Hotel'
+loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
@@ -1296,3 +1346,3 @@
-loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
-loader.cpath_expected_at("a/b/collapsed") # => "A::B", edge case
-loader.cpath_expected_at("a/b") # => "A::B"
+loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
+loader.cpath_expected_at('a/b/collapsed') # => 'A::B', edge case
+loader.cpath_expected_at('a/b') # => 'A::B'
@@ -1306 +1356 @@
-loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
@@ -1312 +1362 @@
-loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
+loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
@@ -1315 +1365,3 @@
-This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1332 +1383,0 @@
-lib/my_gem/ignored.rb
@@ -1335,0 +1387 @@
+lib/my_gem/ignored.rb
@@ -1343,9 +1395,9 @@
- "/.../lib" => "Object",
- "/.../lib/my_gem.rb" => "MyGem",
- "/.../lib/my_gem" => "MyGem",
- "/.../lib/my_gem/version.rb" => "MyGem::VERSION",
- "/.../lib/my_gem/drivers" => "MyGem::Drivers",
- "/.../lib/my_gem/drivers/unix.rb" => "MyGem::Drivers::Unix",
- "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
- "/.../lib/my_gem/collapsed" => "MyGem",
- "/.../lib/my_gem/collapsed/foo.rb" => "MyGem::Foo"
+ '/.../lib' => 'Object',
+ '/.../lib/my_gem.rb' => 'MyGem',
+ '/.../lib/my_gem' => 'MyGem',
+ '/.../lib/my_gem/version.rb' => 'MyGem::VERSION',
+ '/.../lib/my_gem/drivers' => 'MyGem::Drivers',
+ '/.../lib/my_gem/drivers/unix.rb' => 'MyGem::Drivers::Unix',
+ '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
+ '/.../lib/my_gem/collapsed' => 'MyGem',
+ '/.../lib/my_gem/collapsed/foo.rb' => 'MyGem::Foo'
@@ -1357 +1409 @@
-The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
+The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
@@ -1363 +1415,3 @@
-This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
+This method does not parse file contents and does not guarantee files define the returned constant path.
+
+Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
@@ -1434,2 +1488,2 @@
-test "capitalizes the first letter" do
- assert_equal "User", camelize("user")
+test 'capitalizes the first letter' do
+ assert_equal 'User', camelize('user')
@@ -1449 +1503 @@
-Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
+Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
lib/zeitwerk.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk.rb 2026-05-20 07:03:34.929312910 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk.rb 2026-05-20 07:03:34.933312885 +0000
@@ -4,11 +4,11 @@
- require_relative "zeitwerk/real_mod_name"
- require_relative "zeitwerk/internal"
- require_relative "zeitwerk/cref"
- require_relative "zeitwerk/loader"
- require_relative "zeitwerk/gem_loader"
- require_relative "zeitwerk/registry"
- require_relative "zeitwerk/inflector"
- require_relative "zeitwerk/gem_inflector"
- require_relative "zeitwerk/null_inflector"
- require_relative "zeitwerk/error"
- require_relative "zeitwerk/version"
+ require_relative 'zeitwerk/real_mod_name'
+ require_relative 'zeitwerk/internal'
+ require_relative 'zeitwerk/cref'
+ require_relative 'zeitwerk/loader'
+ require_relative 'zeitwerk/gem_loader'
+ require_relative 'zeitwerk/registry'
+ require_relative 'zeitwerk/inflector'
+ require_relative 'zeitwerk/gem_inflector'
+ require_relative 'zeitwerk/null_inflector'
+ require_relative 'zeitwerk/error'
+ require_relative 'zeitwerk/version'
@@ -16,2 +16,2 @@
- require_relative "zeitwerk/core_ext/kernel"
- require_relative "zeitwerk/core_ext/module"
+ require_relative 'zeitwerk/core_ext/kernel'
+ require_relative 'zeitwerk/core_ext/module'
lib/zeitwerk/core_ext/kernel.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:34.929312910 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb 2026-05-20 07:03:34.935312872 +0000
@@ -25 +25 @@
- if path.end_with?(".rb")
+ if path.end_with?('.rb')
lib/zeitwerk/cref.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/cref.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/cref.rb 2026-05-20 07:03:34.935312872 +0000
@@ -14 +14 @@
- require_relative "cref/map"
+ require_relative 'cref/map'
@@ -67,0 +68,7 @@
+ end
+
+ #: () -> String?
+ def location
+ if (location = @mod.const_source_location(@cname)) && !location.empty?
+ location.join(':')
+ end
lib/zeitwerk/cref/map.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb 2026-05-20 07:03:34.935312872 +0000
@@ -30 +30 @@
-# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
+# { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
lib/zeitwerk/error.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/error.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/error.rb 2026-05-20 07:03:34.935312872 +0000
@@ -20 +20,12 @@
- super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
+ super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
+ end
+ end
+
+ class ConflictingNamespaceDefinitionError < Error
+ #: (String, location: String?, conflicting_file: String) -> void
+ def initialize(cpath, location:, conflicting_file:)
+ if location
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
+ else
+ super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
+ end
lib/zeitwerk/gem_inflector.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb 2026-05-20 07:03:34.935312872 +0000
@@ -7 +7 @@
- namespace = File.basename(root_file, ".rb")
+ namespace = File.basename(root_file, '.rb')
@@ -9 +9 @@
- @version_file = File.join(root_dir, namespace, "version.rb")
+ @version_file = File.join(root_dir, namespace, 'version.rb')
@@ -14 +14 @@
- abspath == @version_file ? "VERSION" : super
+ abspath == @version_file ? 'VERSION' : super
lib/zeitwerk/gem_loader.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb 2026-05-20 07:03:34.935312872 +0000
@@ -22,2 +22,2 @@
- @tag = File.basename(root_file, ".rb")
- @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
+ @tag = File.basename(root_file, '.rb')
+ @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
@@ -43 +43 @@
- expected_namespace_dir = @root_file.delete_suffix(".rb")
+ expected_namespace_dir = @root_file.delete_suffix('.rb')
@@ -49 +49 @@
- basename_without_ext = basename.delete_suffix(".rb")
+ basename_without_ext = basename.delete_suffix('.rb')
lib/zeitwerk/inflector.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb 2026-05-20 07:03:34.930312904 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb 2026-05-20 07:03:34.935312872 +0000
@@ -8,3 +8,3 @@
- # inflector.camelize("post", ...) # => "Post"
- # inflector.camelize("users_controller", ...) # => "UsersController"
- # inflector.camelize("api", ...) # => "Api"
+ # inflector.camelize('post', ...) # => 'Post'
+ # inflector.camelize('users_controller', ...) # => 'UsersController'
+ # inflector.camelize('api', ...) # => 'Api'
@@ -23,2 +23,2 @@
- # "html_parser" => "HTMLParser",
- # "mysql_adapter" => "MySQLAdapter"
+ # 'html_parser' => 'HTMLParser',
+ # 'mysql_adapter' => 'MySQLAdapter'
@@ -27,3 +27,3 @@
- # inflector.camelize("html_parser", abspath) # => "HTMLParser"
- # inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
- # inflector.camelize("users_controller", abspath) # => "UsersController"
+ # inflector.camelize('html_parser', abspath) # => 'HTMLParser'
+ # inflector.camelize('mysql_adapter', abspath) # => 'MySQLAdapter'
+ # inflector.camelize('users_controller', abspath) # => 'UsersController'
lib/zeitwerk/loader.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader.rb 2026-05-20 07:03:34.936312865 +0000
@@ -3,2 +3,2 @@
-require "monitor"
-require "set"
+require 'monitor'
+require 'set'
@@ -8,5 +8,5 @@
- require_relative "loader/helpers"
- require_relative "loader/callbacks"
- require_relative "loader/config"
- require_relative "loader/eager_load"
- require_relative "loader/file_system"
+ require_relative 'loader/helpers'
+ require_relative 'loader/callbacks'
+ require_relative 'loader/config'
+ require_relative 'loader/eager_load'
+ require_relative 'loader/file_system'
@@ -25,2 +25,2 @@
- # "/Users/fxn/blog/app/models/user.rb" => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
- # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
+ # '/Users/fxn/blog/app/models/user.rb' => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
+ # '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
@@ -104,0 +105 @@
+ #: () -> void
@@ -132 +133 @@
- define_autoloads_for_dir(root_dir, root_namespace)
+ define_autoloads_for_dir(root_dir, root_namespace, external: true)
@@ -155,0 +157,3 @@
+ __unload
+ end
+ end
@@ -157,33 +161,19 @@
- # We are going to keep track of the files that were required by our
- # autoloads to later remove them from $LOADED_FEATURES, thus making them
- # loadable by Kernel#require again.
- #
- # Directories are not stored in $LOADED_FEATURES, keeping track of files
- # is enough.
- unloaded_files = Set.new
-
- autoloads.each do |abspath, cref|
- if cref.autoload?
- unload_autoload(cref)
- else
- # Could happen if loaded with require_relative. That is unsupported,
- # and the constant path would escape unloadable_cpath? This is just
- # defensive code to clean things up as much as we are able to.
- unload_cref(cref)
- unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
- end
- end
-
- to_unload.each do |abspath, cref|
- unless on_unload_callbacks.empty?
- begin
- value = cref.get
- rescue ::NameError
- # Perhaps the user deleted the constant by hand, or perhaps an
- # autoload failed to define the expected constant but the user
- # rescued the exception.
- else
- run_on_unload_callbacks(cref, value, abspath)
- end
- end
-
+ # This is an internal method.
+ #
+ #: () -> void
+ def __unload
+ # We are going to keep track of the files that were required by our
+ # autoloads to later remove them from $LOADED_FEATURES, thus making them
+ # loadable by Kernel#require again.
+ #
+ # Directories are not stored in $LOADED_FEATURES, keeping track of files
+ # is enough.
+ unloaded_files = Set.new
+
+ autoloads.each do |abspath, cref|
+ if cref.autoload?
+ unload_autoload(cref)
+ else
+ # Could happen if loaded with require_relative. That is unsupported,
+ # and the constant path would escape unloadable_cpath? This is just
+ # defensive code to clean things up as much as we are able to.
@@ -192,0 +183 @@
+ end
@@ -194,13 +185,11 @@
- unless unloaded_files.empty?
- # Bootsnap decorates Kernel#require to speed it up using a cache and
- # this optimization does not check if $LOADED_FEATURES has the file.
- #
- # To make it aware of changes, the gem defines singleton methods in
- # $LOADED_FEATURES:
- #
- # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
- #
- # Rails applications may depend on bootsnap, so for unloading to work
- # in that setting it is preferable that we restrict our API choice to
- # one of those methods.
- $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ to_unload.each do |abspath, cref|
+ unless on_unload_callbacks.empty?
+ begin
+ value = cref.get
+ rescue ::NameError
+ # Perhaps the user deleted the constant by hand, or perhaps an
+ # autoload failed to define the expected constant but the user
+ # rescued the exception.
+ else
+ run_on_unload_callbacks(cref, value, abspath)
+ end
@@ -209,5 +198,3 @@
- autoloads.clear
- autoloaded_dirs.clear
- to_unload.clear
- namespace_dirs.clear
- shadowed_files.clear
+ unload_cref(cref)
+ unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
+ end
@@ -215,2 +202,14 @@
- unregister_inceptions
- unregister_explicit_namespaces
+ unless unloaded_files.empty?
+ # Bootsnap decorates Kernel#require to speed it up using a cache and
+ # this optimization does not check if $LOADED_FEATURES has the file.
+ #
+ # To make it aware of changes, the gem defines singleton methods in
+ # $LOADED_FEATURES:
+ #
+ # https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
+ #
+ # Rails applications may depend on bootsnap, so for unloading to work
+ # in that setting it is preferable that we restrict our API choice to
+ # one of those methods.
+ $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
+ end
@@ -218 +217,5 @@
- Registry.autoloads.unregister_loader(self)
+ autoloads.clear
+ autoloaded_dirs.clear
+ to_unload.clear
+ namespace_dirs.clear
+ shadowed_files.clear
@@ -220,3 +223,7 @@
- @setup = false
- @eager_loaded = false
- end
+ unregister_inceptions
+ unregister_explicit_namespaces
+
+ Registry.autoloads.unregister_loader(self)
+
+ @setup = false
+ @eager_loaded = false
@@ -236,0 +244 @@
+
@@ -238,0 +247,2 @@
+ recompute_collapse_parents
+
@@ -255 +265 @@
- prefix = cpath == "Object" ? "" : cpath + "::"
+ prefix = cpath == 'Object' ? '' : cpath + '::'
@@ -257 +267 @@
- @fs.ls(dir) do |basename, abspath, ftype|
+ @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
@@ -259,5 +269,2 @@
- basename.delete_suffix!(".rb")
- result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
- else
- if collapse?(abspath)
- queue << [abspath, cpath]
+ if basename == @nsfile
+ result[abspath] = cpath
@@ -265 +272,2 @@
- queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
+ basename.delete_suffix!('.rb')
+ result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
@@ -266,0 +275,4 @@
+ elsif collapse?(abspath)
+ queue.unshift([abspath, cpath])
+ else
+ queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
@@ -288,2 +300,2 @@
- if :file == ftype
- basename = File.basename(abspath, ".rb")
+ if ftype == :file
+ basename = File.basename(abspath)
@@ -292 +304 @@
- paths << [basename, abspath]
+ paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
@@ -318 +330 @@
- cnames.join("::")
+ cnames.join('::')
@@ -320 +332 @@
- "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
@@ -387 +399 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -390 +402 @@
- # loader.tag = File.basename(__FILE__, ".rb")
+ # loader.tag = File.basename(__FILE__, '.rb')
@@ -408 +420 @@
- # require "zeitwerk"
+ # require 'zeitwerk'
@@ -411 +423 @@
- # loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
+ # loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
@@ -428 +440 @@
- raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
+ raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
@@ -476,2 +488,8 @@
- #: (String, Module) -> void
- private def define_autoloads_for_dir(dir, parent)
+ # Scans `dir` and sets autoloads in `mod` for the constants its contents are
+ # expected to define.
+ #
+ # The `external` flag indicates whether `mod` has been externally defined,
+ # as is the case with root namespaces or reopened third-party namespaces.
+ #
+ #: (String, Module, external: boolish) -> void
+ private def define_autoloads_for_dir(dir, mod, external:)
@@ -480,9 +498,8 @@
- basename.delete_suffix!(".rb")
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_file(cref, abspath)
- else
- if collapse?(abspath)
- define_autoloads_for_dir(abspath, parent)
- else
- cref = Cref.new(parent, cname_for(basename, abspath))
- autoload_subdir(cref, abspath)
+ if basename == @nsfile
+ if external
+ cpath = real_mod_name(mod)
+ location = Object.const_source_location(cpath)&.join(':')
+ location = nil if location&.empty?
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
+ end
+ next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
@@ -489,0 +507,7 @@
+
+ basename.delete_suffix!('.rb')
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_file(cref, abspath)
+ else
+ cref = Cref.new(mod, cname_for(basename, abspath))
+ visit_subdir(cref, abspath, external:)
@@ -495 +519,25 @@
- private def autoload_subdir(cref, subdir)
+ private def visit_file(cref, file)
+ if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
+ if @fs.rb_extension?(autoload_path)
+ if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
+ end
+ shadowed_files << file
+ log { "file #{file} is ignored because #{autoload_path} has precedence" }
+ else
+ promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
+ end
+ elsif cref.defined?
+ shadowed_files << file
+ if location = cref.location
+ log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
+ else
+ log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
+ end
+ else
+ define_autoload(cref, file)
+ end
+ end
+
+ #: (Zeitwerk::Cref, String, external: boolish) -> void
+ private def visit_subdir(cref, subdir, external:)
@@ -497,0 +546,6 @@
+ # The namespace that corresponds to this subdirectory is defined in a
+ # file, either regular or nsfile. Therefore, a nsfile would be a
+ # duplication.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
+ end
@@ -499,2 +553 @@
- # constant has been found. This means we are dealing with an explicit
- # namespace whose definition was seen first.
+ # constant has been found. This is an explicit namespace.
@@ -502,3 +555,2 @@
- # Registering is idempotent, and we have to keep the autoload pointing
- # to the file. This may run again if more directories are found later
- # on, no big deal.
+ # The namespace may be spread over multiple directories and perhaps it
+ # was already registered, but registering is idempotent, just do it.
@@ -505,0 +558,3 @@
+ elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ # Scanning found a matching directory first, and now we saw a nsfile.
+ promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
@@ -507,3 +561,0 @@
- # If the existing autoload points to a file, it has to be preserved, if
- # not, it is fine as it is. In either case, we do not need to override.
- # Just remember the subdirectory conforms this namespace.
@@ -512 +564,6 @@
- # First time we find this namespace, set an autoload for it.
+ if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
+ define_autoload(cref, nsfile_abspath)
+ register_explicit_namespace(cref)
+ else
+ define_autoload(cref, subdir)
+ end
@@ -514 +570,0 @@
- define_autoload(cref, subdir)
@@ -519,19 +575 @@
- define_autoloads_for_dir(subdir, cref.get)
- end
- end
-
- #: (Zeitwerk::Cref, String) -> void
- private def autoload_file(cref, file)
- if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
- # First autoload for a Ruby file wins, just ignore subsequent ones.
- if @fs.rb_extension?(autoload_path)
- shadowed_files << file
- log { "file #{file} is ignored because #{autoload_path} has precedence" }
- else
- promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
- end
- elsif cref.defined?
- shadowed_files << file
- log { "file #{file} is ignored because #{cref} is already defined" }
- else
- define_autoload(cref, file)
+ define_autoloads_for_dir(subdir, cref.get, external:)
@@ -611 +649 @@
- require "pp" # Needed to have pretty_inspect available.
+ require 'pp' # Needed to have pretty_inspect available.
lib/zeitwerk/loader/callbacks.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb 2026-05-20 07:03:34.936312865 +0000
@@ -80 +80 @@
- define_autoloads_for_dir(dir, namespace)
+ define_autoloads_for_dir(dir, namespace, external: false)
lib/zeitwerk/loader/config.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb 2026-05-20 07:03:34.936312865 +0000
@@ -3,2 +3,2 @@
-require "set"
-require "securerandom"
+require 'set'
+require 'securerandom'
@@ -18,2 +18,2 @@
- # "/Users/fxn/blog/app/channels" => Object,
- # "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
+ # '/Users/fxn/blog/app/channels' => Object,
+ # '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
@@ -31,0 +32,6 @@
+ # Basename of files that define namespaces. For example, if `nsfile` is
+ # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
+ #
+ #: String?
+ attr_reader :nsfile
+
@@ -53 +59 @@
- # glob patterns were expanded. Computed on setup, and recomputed on reload.
+ # glob patterns were expanded. Computed on setup and recomputed on reload.
@@ -58,0 +65,8 @@
+ # Absolute paths of directories that are parents of collapsed directories.
+ # This is a cache to optimize some tree walks. Computed on setup and
+ # recomputed on reload.
+ #
+ #: Set[String]
+ attr_reader :collapse_parents
+ private :collapse_parents
+
@@ -84,0 +99 @@
+ #: () -> void
@@ -90,0 +106 @@
+ @nsfile = nil
@@ -94,0 +111 @@
+ @collapse_parents = Set.new
@@ -115 +132 @@
- raise Zeitwerk::Error, "root namespaces cannot be anonymous"
+ raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
@@ -143,0 +161,12 @@
+ #: (String?) -> void ! TypeError, ArgumentError
+ def nsfile=(nsfile)
+ unless nsfile.nil?
+ raise TypeError, 'nsfiles must be strings' unless nsfile.is_a?(String)
+ raise ArgumentError, 'nsfiles must have .rb extension' unless @fs.rb_extension?(nsfile)
+ raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
+ raise ArgumentError, 'nsfiles cannot be hidden' if @fs.hidden?(nsfile)
+ end
+
+ @nsfile = nsfile
+ end
+
@@ -179 +208 @@
- raise Zeitwerk::Error, "cannot enable reloading after setup"
+ raise Zeitwerk::Error, 'cannot enable reloading after setup'
@@ -217 +246,5 @@
- collapse_dirs.merge(expand_glob_patterns(glob_patterns))
+ new_collapse_dirs = expand_glob_patterns(glob_patterns)
+ collapse_dirs.merge(new_collapse_dirs)
+ new_collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
@@ -236,2 +269,2 @@
- # loader.on_load("SomeApiClient") do |klass, _abspath|
- # klass.endpoint = "https://api.dev"
+ # loader.on_load('SomeApiClient') do |klass, _abspath|
+ # klass.endpoint = 'https://api.dev'
@@ -248 +281 @@
- raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -259 +292 @@
- # loader.on_unload("Country") do |klass, _abspath|
+ # loader.on_unload('Country') do |klass, _abspath|
@@ -271 +304 @@
- raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
+ raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
@@ -318,0 +352,10 @@
+ internal def collapse?(dir)
+ collapse_dirs.member?(dir)
+ end
+
+ #: (String) -> bool
+ internal def collapse_parent?(dir)
+ collapse_parents.member?(dir)
+ end
+
+ #: (String) -> bool
@@ -331,5 +373,0 @@
- #: (String) -> bool
- private def collapse?(dir)
- collapse_dirs.member?(dir)
- end
-
@@ -355,0 +394,8 @@
+ end
+
+ #: () -> void
+ private def recompute_collapse_parents
+ collapse_parents.clear
+ collapse_dirs.each do |dir|
+ collapse_parents << File.dirname(dir)
+ end
lib/zeitwerk/loader/eager_load.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb 2026-05-20 07:03:34.936312865 +0000
@@ -3,4 +3,5 @@
- # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
- # shadowed files are not eager loaded. You can opt-out specifically in
- # specific files and directories with `do_not_eager_load`, and that can be
- # overridden passing `force: true`.
+ # need to be in `$LOAD_PATH`, absolute file names are used.
+ #
+ # Ignored files are not eager loaded. You can opt-out specifically in specific
+ # files and directories with `do_not_eager_load`, and that can be overridden
+ # passing `force: true`.
@@ -14 +15 @@
- log { "eager load start" }
+ log { 'eager load start' }
@@ -27 +28 @@
- log { "eager load end" }
+ log { 'eager load end' }
@@ -94 +95 @@
- if root_namespace_name.start_with?(mod_name + "::")
+ if root_namespace_name.start_with?(mod_name + '::')
@@ -98 +99 @@
- elsif mod_name.start_with?(root_namespace_name + "::")
+ elsif mod_name.start_with?(root_namespace_name + '::')
@@ -122 +123 @@
- file_basename = File.basename(abspath, ".rb")
+ file_basename = File.basename(abspath)
@@ -141,2 +141,0 @@
- base_cname = cname_for(file_basename, abspath)
-
@@ -149,3 +148,8 @@
- raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
-
- namespace.const_get(base_cname, false)
+ if file_basename == @nsfile
+ namespace
+ elsif shadowed_file?(abspath)
+ raise Zeitwerk::Error.new("#{abspath} is shadowed")
+ else
+ cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
+ namespace.const_get(cname, false)
+ end
@@ -174,6 +178,2 @@
- if collapse?(abspath)
- queue << [abspath, namespace]
- else
- cname = cname_for(basename, abspath)
- queue << [abspath, namespace.const_get(cname, false)]
- end
+ cname = cname_for(basename, abspath)
+ queue << [abspath, namespace.const_get(cname, false)]
@@ -194 +194 @@
- suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
+ suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
@@ -207 +207 @@
- suffix.split("::").each do |segment|
+ suffix.split('::').each do |segment|
@@ -210,5 +210 @@
- next unless ftype == :directory
-
- if collapse?(abspath)
- dirs << abspath
- elsif segment == cname_for(basename, abspath).to_s
+ if ftype == :directory && segment == cname_for(basename, abspath).to_s
lib/zeitwerk/loader/file_system.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb 2026-05-20 07:03:34.936312865 +0000
@@ -14,0 +15,14 @@
+ # This method lists directories, filtering out the following:
+ #
+ # - Hidden entries.
+ # - Ignored entries.
+ # - Files whose extension is not `.rb`.
+ # - Nested root directories, since they represent separate trees.
+ # - Subdirectories that (recursively) contain no Ruby files.
+ #
+ # If `collapse` is true, collapsed directories are not yielded, instead, the
+ # method recurses so that the caller gets a conceptually flat listing.
+ #
+ # For every entry that is not excluded, `ls` yields its basename, absolute
+ # path, and file type, which can only be :file or :directory.
+ #
@@ -16 +30 @@
- def ls(dir)
+ def ls(dir, collapse: true, &block)
@@ -27,3 +41,8 @@
- if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
- @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
- next
+ if ftype == :directory
+ if !has_at_least_one_ruby_file?(abspath)
+ @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
+ next
+ elsif collapse && @loader.__collapse?(abspath)
+ ls(abspath, collapse: collapse, &block)
+ next
+ end
@@ -41 +60,38 @@
- break if basename == "/"
+ break if basename == '/'
+ end
+ end
+
+ # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
+ # there is none, it returns `nil`.
+ #
+ # This method accounts for collapsed directories, which conceptually allow for
+ # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
+ #
+ #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
+ def has_exactly_one_nsfile?(cref, dir)
+ return unless @loader.nsfile
+
+ # When `dir` does not have any collapsed directories a simple lookup
+ # suffices. This is a common case worth optimizing.
+ unless @loader.__collapse_parent?(dir)
+ nsfile_abspath = File.join(dir, @loader.nsfile)
+ if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
+ return nsfile_abspath
+ end
+ return
+ end
+
+ nsfile = nil
+
+ to_visit = [dir]
+ while (dir = to_visit.shift)
+ relevant_dir_entries(dir) do |basename, abspath, ftype|
+ if ftype == :file && basename == @loader.nsfile
+ if nsfile
+ raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
+ end
+ nsfile = abspath
+ elsif ftype == :directory && @loader.__collapse?(abspath)
+ to_visit << abspath
+ end
+ end
@@ -42,0 +99,2 @@
+
+ nsfile
@@ -58 +116 @@
- path.end_with?(".rb")
+ path.end_with?('.rb')
@@ -68 +126 @@
- basename.start_with?(".")
+ basename.start_with?('.')
@@ -83 +141 @@
- return true if :file == ftype
+ return true if ftype == :file
@@ -99,12 +157,3 @@
- if :link == ftype
- begin
- ftype = File.stat(abspath).ftype.to_sym
- rescue Errno::ENOENT
- warn "ignoring broken symlink #{abspath}"
- next
- end
- end
-
- if :file == ftype
- yield basename, abspath, ftype if rb_extension?(basename)
- elsif :directory == ftype
+ if ftype == :file
+ yield basename, abspath, ftype
+ else
@@ -138 +187 @@
- elsif :directory == ftype
+ elsif ftype == :directory
@@ -141 +190 @@
- elsif :link == ftype
+ elsif ftype == :link
@@ -158,3 +207 @@
- if dir?(abspath)
- yield basename, abspath, :directory
- end
+ yield basename, abspath, :directory if dir?(abspath)
lib/zeitwerk/loader/helpers.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb 2026-05-20 07:03:34.936312865 +0000
@@ -15 +15 @@
- if cname.include?("::")
+ if cname.include?('::')
@@ -28 +28 @@
- path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
+ path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
lib/zeitwerk/registry.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/registry.rb 2026-05-20 07:03:34.931312897 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/registry.rb 2026-05-20 07:03:34.936312865 +0000
@@ -5,4 +5,4 @@
- require_relative "registry/autoloads"
- require_relative "registry/explicit_namespaces"
- require_relative "registry/inceptions"
- require_relative "registry/loaders"
+ require_relative 'registry/autoloads'
+ require_relative 'registry/explicit_namespaces'
+ require_relative 'registry/inceptions'
+ require_relative 'registry/loaders'
@@ -57,2 +57,2 @@
- new_root_dir_slash = new_root_dir + "/"
- existing_root_dir_slash = existing_root_dir + "/"
+ new_root_dir_slash = new_root_dir + '/'
+ existing_root_dir_slash = existing_root_dir + '/'
lib/zeitwerk/registry/loaders.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:34.932312891 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb 2026-05-20 07:03:34.936312865 +0000
@@ -9,2 +9,2 @@
- def each(&)
- @loaders.each(&)
+ def each(&block)
+ @loaders.each(&block)
lib/zeitwerk/version.rb
--- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/version.rb 2026-05-20 07:03:34.932312891 +0000
+++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/version.rb 2026-05-20 07:03:34.936312865 +0000
@@ -5 +5 @@
- VERSION = "2.7.5"
+ VERSION = '2.8.1' |
Bumps [zeitwerk](https://github.com/fxn/zeitwerk) from 2.7.5 to 2.8.1. - [Changelog](https://github.com/fxn/zeitwerk/blob/main/CHANGELOG.md) - [Commits](fxn/zeitwerk@v2.7.5...v2.8.1) --- updated-dependencies: - dependency-name: zeitwerk dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com>
1bb6541 to
9de8a18
Compare
Contributor
Author
|
Superseded by #990. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps zeitwerk from 2.7.5 to 2.8.1.
Changelog
Sourced from zeitwerk's changelog.
Commits
14e4143Ready for 2.8.194d607dRemove anonymous blocks2d2ddedRemove README conventionb5d4e72Replace comments with arrows9d6fefbReword5b5075fRewordbb68741Ready for 2.8.0ebb0d55Refactors in test_nsfiles.rb1553930Add some cops1be53c8Update the CHANGELOG