diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d80676..ba998f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# CHANGELOG
+## 2.6.9 (Unreleased)
+
+* Given a path as a string or `Pathname` object, `Zeitwerk::Loader#cpath_at`
+ returns a string with the corresponding expected constant path.
+
+ Some examples, assuming that `app/models` is a root directory:
+
+ ```ruby
+ loader.cpath_at("app/models") # => "Object"
+ loader.cpath_at("app/models/user.rb") # => "User"
+ loader.cpath_at("app/models/hotel") # => "Hotel"
+ loader.cpath_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+ ```
+
+ This method returns `nil` for some input like ignored files, and may raise
+ `Zeitwerk::Error` too. Please check its
+ [documentation](https://github.com/fxn/zeitwerk#zeitwerkloadercpath_at) for
+ further details.
+
## 2.6.8 (28 April 2023)
* The new `Zeitwerk::Loader.for_gem_extension` gives you a loader configured
diff --git a/README.md b/README.md
index e76b347..aea70de 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,8 @@
- [Beware of circular dependencies](#beware-of-circular-dependencies)
- [Reopening third-party namespaces](#reopening-third-party-namespaces)
- [Introspection](#introspection)
+ - [`Zeitwerk::Loader#dirs`](#zeitwerkloaderdirs)
+ - [`Zeitwerk::Loader#cpath_at`](#zeitwerkloadercpath_at)
- [Encodings](#encodings)
- [Rules of thumb](#rules-of-thumb)
- [Debuggers](#debuggers)
@@ -1240,6 +1242,9 @@ With that, when Zeitwerk scans the file system and reaches the gem directories `
### Introspection
+
+#### `Zeitwerk::Loader#dirs`
+
The method `Zeitwerk::Loader#dirs` returns an array with the absolute paths of the root directories as strings:
```ruby
@@ -1261,6 +1266,39 @@ By default, ignored root directories are filtered out. If you want them included
These collections are read-only. Please add to them with `Zeitwerk::Loader#push_dir`.
+
+#### `Zeitwerk::Loader#cpath_at`
+
+Given a path as a string or `Pathname` object, `Zeitwerk::Loader#cpath_at` returns a string with the corresponding expected constant path.
+
+Some examples, assuming that `app/models` is a root directory:
+
+```ruby
+loader.cpath_at("app/models") # => "Object"
+loader.cpath_at("app/models/user.rb") # => "User"
+loader.cpath_at("app/models/hotel") # => "Hotel"
+loader.cpath_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
+```
+
+If `collapsed` is a collapsed directory:
+
+```ruby
+loader.cpath_at("a/b/collapsed/c") # => "A::B::C"
+loader.cpath_at("a/b/collapsed") # => "A::B", edge case
+loader.cpath_at("a/b") # => "A::B"
+```
+
+If the argument corresponds to a hidden or ignored file or directory, the method returns `nil`. Same if the argument is not managed by the loader.
+
+`Zeitwerk::Error` is raised if the given path does not exist, or a constant path cannot be derived from it:
+
+```ruby
+loader.cpath_at("non_existing_file.rb") # => Zeitwerk::Error
+loader.cpath_at("8.rb") # => Zeitwerk::Error
+```
+
+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.
+
### Encodings
diff --git a/lib/zeitwerk/loader.rb b/lib/zeitwerk/loader.rb
index 5847e16..69581ff 100644
--- a/lib/zeitwerk/loader.rb
+++ b/lib/zeitwerk/loader.rb
@@ -228,6 +228,69 @@ def reload
setup
end
+ # @sig (String | Pathname) -> String?
+ def cpath_at(path)
+ abspath = File.expand_path(path)
+
+ raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
+
+ return unless dir?(abspath) || ruby?(abspath)
+ return if ignored_path?(abspath)
+
+ cnames = []
+ abspaths = []
+
+ if ruby?(abspath)
+ basename = File.basename(abspath, ".rb")
+ return if hidden?(basename)
+
+ cnames << inflector.camelize(basename, abspath).to_sym
+ abspaths << abspath
+ walk_up_from = File.dirname(abspath)
+ else
+ walk_up_from = abspath
+ end
+
+ root_namespace = nil
+
+ walk_up(walk_up_from) do |dir|
+ break if root_namespace = roots[dir]
+ return if ignored_path?(dir)
+
+ basename = File.basename(dir)
+ return if hidden?(basename)
+
+ unless collapse?(dir)
+ cnames << inflector.camelize(basename, dir).to_sym
+ abspaths << dir
+ end
+ end
+
+ return unless root_namespace
+
+ if cnames.empty?
+ real_mod_name(root_namespace)
+ else
+ # We reverse before validating the segments to report the leftmost
+ # problematic one, if any.
+ cnames.reverse!
+
+ validator = Module.new
+ cnames.each_with_index do |cname, i|
+ validator.const_defined?(cname)
+ rescue ::NameError
+ j = -(i + 1)
+ raise Zeitwerk::Error.new("cannot derive a constant name from #{abspaths[j]}")
+ end
+
+ if root_namespace == Object
+ cnames.join("::")
+ else
+ "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
+ end
+ end
+ end
+
# Says if the given constant path would be unloaded on reload. This
# predicate returns `false` if reloading is disabled.
#
diff --git a/test/lib/zeitwerk/test_cpath_at.rb b/test/lib/zeitwerk/test_cpath_at.rb
new file mode 100644
index 0000000..48345f0
--- /dev/null
+++ b/test/lib/zeitwerk/test_cpath_at.rb
@@ -0,0 +1,186 @@
+# frozen_string_literal: true
+
+require "pathname"
+require "test_helper"
+
+class TestCpathAtErrors < LoaderTest
+ test "raises Zeitwerk::Error if the argument does not exist" do
+ with_setup(dirs: ["."]) do
+ error = assert_raises Zeitwerk::Error do
+ loader.cpath_at("does_not_exist.rb")
+ end
+ abspath = File.expand_path("does_not_exist.rb")
+ assert_includes error.message, "#{abspath} does not exist"
+ end
+ end
+
+ test "raises Zeitwerk::Error if the argument does not yield a constant name" do
+ files = [["foo-bar.rb", nil], ["1.rb", nil]]
+ with_files(files) do
+ loader.push_dir(".")
+
+ files.each do |file, _contents|
+ error = assert_raises Zeitwerk::Error do
+ p loader.cpath_at(file)
+ end
+ abspath = File.expand_path(file)
+ assert_includes error.message, "cannot derive a constant name from #{abspath}"
+ end
+ end
+ end
+
+ test "raises Zeitwerk::Error if some intermediate segment does not yield a constant name" do
+ with_files([["x/foo-bar/y/z.rb", nil]]) do
+ loader.push_dir(".")
+ error = assert_raises Zeitwerk::Error do
+ loader.cpath_at("x/foo-bar/y/z.rb")
+ end
+ abspath = File.expand_path("x/foo-bar")
+ assert_includes error.message, "cannot derive a constant name from #{abspath}"
+ end
+ end
+end
+
+class TestCpathAtNil < LoaderTest
+ test "returns nil if the argument is not a directory or Ruby file" do
+ files = [["tasks/database.rake", nil], ["CHANGELOG", nil]]
+ with_setup(files) do
+ files.each do |file, _contents|
+ assert_nil loader.cpath_at(file)
+ end
+ end
+ end
+
+ test "returns nil if the argument is ignored" do
+ with_setup([["ignored.rb", nil]]) do
+ assert_nil loader.cpath_at("ignored.rb")
+ end
+ end
+
+ test "returns nil if the argument is a hidden Ruby file" do
+ with_setup([[".foo.rb", nil]]) do
+ assert_nil loader.cpath_at(".foo.rb")
+ end
+ end
+
+ test "returns nil if the argument does not belong to the autoload paths" do
+ with_setup(dirs: ["."]) do
+ assert_nil loader.cpath_at(__dir__)
+ assert_nil loader.cpath_at(__FILE__)
+ end
+ end
+
+ test "returns nil if an ancestor is ignored" do
+ with_setup([["ignored/x.rb", nil]]) do
+ assert_nil loader.cpath_at("ignored/x.rb")
+ end
+ end
+
+ test "returns nil if an ancestor is a hidden directory" do
+ with_setup([[".foo/x.rb", nil]]) do
+ assert_nil loader.cpath_at(".foo/x.rb")
+ end
+ end
+end
+
+class TestCpathAtString < LoaderTest
+ module M
+ def self.name
+ "Overridden"
+ end
+ end
+
+ M_REAL_NAME = "#{name}::M"
+
+ test "returns the name of the root namespace for a root directory (Object)" do
+ with_setup([["README.md", nil]]) do
+ assert_equal "Object", loader.cpath_at(".")
+ end
+ end
+
+ test "returns the name of the root namespace for a root directory (Object, Pathname)" do
+ with_setup([["README.md", nil]]) do
+ assert_equal "Object", loader.cpath_at(Pathname.new("."))
+ end
+ end
+
+ test "returns the name of the root namespace for a root directory (Custom)" do
+ with_setup(dirs: ["."], namespace: M) do
+ assert_equal M_REAL_NAME, loader.cpath_at(".")
+ end
+ end
+
+ test "returns the name of the root namespace for a root directory (Custom, Pathname)" do
+ with_setup(dirs: ["."], namespace: M) do
+ assert_equal M_REAL_NAME, loader.cpath_at(Pathname.new("."))
+ end
+ end
+
+ test "returns the name of the root directory even if it is hidden" do
+ with_setup([[".foo/x.rb", nil]], dirs: [".foo"]) do
+ assert_equal "Object", loader.cpath_at(".foo")
+ end
+ end
+
+ test "returns the cpath to a root file (Object)" do
+ with_setup([["x.rb", "X = 1"]]) do
+ assert_equal "X", loader.cpath_at("x.rb")
+ end
+ end
+
+ test "returns the cpath to a root file (Custom)" do
+ with_setup([["x.rb", "X = 1"]], namespace: M) do
+ assert_equal "#{M_REAL_NAME}::X", loader.cpath_at("x.rb")
+ end
+ end
+
+ test "returns the cpath to a subdirectory (Object)" do
+ with_setup([["a/x.rb", "A::X = 1"]]) do
+ assert_equal "A", loader.cpath_at("a")
+ end
+ end
+
+ test "returns the cpath to a subdirectory (Custom)" do
+ with_setup([["a/x.rb", "A::X = 1"]], namespace: M) do
+ assert_equal "#{M_REAL_NAME}::A", loader.cpath_at("a")
+ end
+ end
+
+ test "returns the cpath to a nested file (Object)" do
+ with_setup([["a/b/c/x.rb", "A::B::C::X = 1"]]) do
+ assert_equal "A::B::C::X", loader.cpath_at("a/b/c/x.rb")
+ end
+ end
+
+ test "returns the cpath to a nested file (Custom)" do
+ with_setup([["a/b/c/x.rb", "A::B::C::X = 1"]], namespace: M) do
+ assert_equal "#{M_REAL_NAME}::A::B::C::X", loader.cpath_at("a/b/c/x.rb")
+ end
+ end
+
+ test "returns the cpath to a nested directory (Object)" do
+ with_setup([["a/b/c/x.rb", "A::B::C::X = 1"]]) do
+ assert_equal "A::B::C", loader.cpath_at("a/b/c")
+ end
+ end
+
+ test "returns the cpath to a nested directory (Custom)" do
+ with_setup([["a/b/c/x.rb", "A::B::C::X = 1"]], namespace: M) do
+ assert_equal "#{M_REAL_NAME}::A::B::C", loader.cpath_at("a/b/c")
+ end
+ end
+
+ test "supports collapsed directories (Object)" do
+ with_setup([["a/b/collapsed/x.rb", "A::B::X = 1"]]) do
+ assert_equal "A::B::X", loader.cpath_at("a/b/collapsed/x.rb")
+ assert_equal "A::B", loader.cpath_at("a/b/collapsed")
+ end
+ end
+
+ test "supports collapsed directories (Custom)" do
+ with_setup([["a/b/collapsed/x.rb", "A::B::X = 1"]], namespace: M) do
+ assert_equal "#{M_REAL_NAME}::A::B::X", loader.cpath_at("a/b/collapsed/x.rb")
+ assert_equal "#{M_REAL_NAME}::A::B", loader.cpath_at("a/b/collapsed")
+ end
+ end
+end
diff --git a/test/support/loader_test.rb b/test/support/loader_test.rb
index 055bd04..4db9ae7 100644
--- a/test/support/loader_test.rb
+++ b/test/support/loader_test.rb
@@ -63,7 +63,11 @@ def with_files(files, rm: true)
Dir.chdir(TMP_DIR) do
files.each do |fname, contents|
FileUtils.mkdir_p(File.dirname(fname))
- File.write(fname, contents)
+ if contents
+ File.write(fname, contents)
+ else
+ FileUtils.touch(fname)
+ end
end
yield
end