Skip to content

Commit

Permalink
Define Zeitwerk::Loader#cpath_at
Browse files Browse the repository at this point in the history
Closes #270.
  • Loading branch information
fxn committed Jul 23, 2023
1 parent 3a84e57 commit 50f661c
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 1 deletion.
19 changes: 19 additions & 0 deletions 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
Expand Down
38 changes: 38 additions & 0 deletions README.md
Expand Up @@ -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)
Expand Down Expand Up @@ -1240,6 +1242,9 @@ With that, when Zeitwerk scans the file system and reaches the gem directories `
<a id="markdown-introspection" name="introspection"></a>
### Introspection

<a id="markdown-zeitwerkloaderdirs" name="zeitwerkloaderdirs"></a>
#### `Zeitwerk::Loader#dirs`

The method `Zeitwerk::Loader#dirs` returns an array with the absolute paths of the root directories as strings:

```ruby
Expand All @@ -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`.

<a id="markdown-zeitwerkloadercpath_at" name="zeitwerkloadercpath_at"></a>
#### `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.

<a id="markdown-encodings" name="encodings"></a>
### Encodings

Expand Down
63 changes: 63 additions & 0 deletions lib/zeitwerk/loader.rb
Expand Up @@ -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.
#
Expand Down
186 changes: 186 additions & 0 deletions 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
6 changes: 5 additions & 1 deletion test/support/loader_test.rb
Expand Up @@ -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
Expand Down

0 comments on commit 50f661c

Please sign in to comment.