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