diff --git a/lib/zeitwerk.rb b/lib/zeitwerk.rb index fb95d23..3b81608 100644 --- a/lib/zeitwerk.rb +++ b/lib/zeitwerk.rb @@ -3,6 +3,7 @@ module Zeitwerk 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" diff --git a/lib/zeitwerk/cref.rb b/lib/zeitwerk/cref.rb new file mode 100644 index 0000000..5745fe6 --- /dev/null +++ b/lib/zeitwerk/cref.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# This private class encapsulates pairs (mod, cname). +# +# Objects represent the constant cname in the class or module object mod, and +# have API to manage them that encapsulates the constants API. Examples: +# +# cref.path +# cref.set(value) +# cref.get +# +# The constant may or may not exist in mod. +class Zeitwerk::Cref + include Zeitwerk::RealModName + + # @sig Symbol + attr_reader :cname + + # The type of the first argument is Module because Class < Module, class + # objects are also valid. + # + # @sig (Module, Symbol) -> void + def initialize(mod, cname) + @mod = mod + @cname = cname + @path = nil + end + + if Symbol.method_defined?(:name) + # Symbol#name was introduced in Ruby 3.0. It returns always the same + # frozen object, so we may save a few string allocations. + # + # @sig () -> String + def path + @path ||= Object.equal?(@mod) ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}" + end + else + # @sig () -> String + def path + @path ||= Object.equal?(@mod) ? @cname.to_s : "#{real_mod_name(@mod)}::#{@cname}" + end + end + + # The autoload? predicate takes into account the ancestor chain of the + # receiver, like const_defined? and other methods in the constants API do. + # + # For example, given + # + # class A + # autoload :X, "x.rb" + # end + # + # class B < A + # end + # + # B.autoload?(:X) returns "x.rb". + # + # We need a way to retrieve it ignoring ancestors. + # + # @sig () -> String? + if method(:autoload?).arity == 1 + # @sig () -> String? + def autoload? + @mod.autoload?(@cname) if self.defined? + end + else + # @sig () -> String? + def autoload? + @mod.autoload?(@cname, false) + end + end + + # @sig (String) -> bool + def autoload(abspath) + @mod.autoload(@cname, abspath) + end + + # @sig () -> bool + def defined? + @mod.const_defined?(@cname, false) + end + + # @sig (Object) -> Object + def set(value) + @mod.const_set(@cname, value) + end + + # @raise [NameError] + # @sig () -> Object + def get + @mod.const_get(@cname, false) + end + + # @raise [NameError] + # @sig () -> void + def remove + @mod.__send__(:remove_const, @cname) + end +end diff --git a/lib/zeitwerk/loader.rb b/lib/zeitwerk/loader.rb index 0d7dd55..d3cfddc 100644 --- a/lib/zeitwerk/loader.rb +++ b/lib/zeitwerk/loader.rb @@ -22,14 +22,13 @@ class Loader private_constant :MUTEX # Maps absolute paths for which an autoload has been set ---and not - # executed--- to their corresponding parent class or module and constant - # name. + # executed--- to their corresponding Zeitwerk::Cref object. # - # "/Users/fxn/blog/app/models/user.rb" => [Object, :User], - # "/Users/fxn/blog/app/models/hotel/pricing.rb" => [Hotel, :Pricing] + # "/Users/fxn/blog/app/models/user.rb" => #, + # "/Users/fxn/blog/app/models/hotel/pricing.rb" => #, # ... # - # @sig Hash[String, [Module, Symbol]] + # @sig Hash[String, Zeitwerk::Cref] attr_reader :autoloads internal :autoloads @@ -45,17 +44,19 @@ class Loader # Stores metadata needed for unloading. Its entries look like this: # - # "Admin::Role" => [".../admin/role.rb", [Admin, :Role]] + # "Admin::Role" => [ + # ".../admin/role.rb", + # # + # ] # # The cpath as key helps implementing unloadable_cpath? The file name is # stored in order to be able to delete it from $LOADED_FEATURES, and the - # pair [Module, Symbol] is used to remove_const the constant from the class - # or module object. + # cref is used to remove the constant from the parent class or module. # # If reloading is enabled, this hash is filled as constants are autoloaded # or eager loaded. Otherwise, the collection remains empty. # - # @sig Hash[String, [String, [Module, Symbol]]] + # @sig Hash[String, [String, Zeitwerk::Cref]] attr_reader :to_unload internal :to_unload @@ -154,22 +155,22 @@ def unload # is enough. unloaded_files = Set.new - autoloads.each do |abspath, (parent, cname)| - if parent.autoload?(cname) - unload_autoload(parent, cname) + 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(parent, cname) + unload_cref(cref) unloaded_files.add(abspath) if ruby?(abspath) end end - to_unload.each do |cpath, (abspath, (parent, cname))| + to_unload.each do |cpath, (abspath, cref)| unless on_unload_callbacks.empty? begin - value = cget(parent, cname) + 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 @@ -179,7 +180,7 @@ def unload end end - unload_cref(parent, cname) + unload_cref(cref) unloaded_files.add(abspath) if ruby?(abspath) end @@ -240,8 +241,7 @@ def all_expected_cpaths actual_roots.each do |root_dir, root_namespace| queue = [[root_dir, real_mod_name(root_namespace)]] - until queue.empty? - dir, cpath = queue.shift + while (dir, cpath = queue.shift) result[dir] = cpath prefix = cpath == "Object" ? "" : cpath + "::" @@ -445,21 +445,22 @@ def all_dirs ls(dir) do |basename, abspath, ftype| if ftype == :file basename.delete_suffix!(".rb") - autoload_file(parent, cname_for(basename, abspath), abspath) + cref = Cref.new(parent, cname_for(basename, abspath)) + autoload_file(cref, abspath) else if collapse?(abspath) define_autoloads_for_dir(abspath, parent) else - autoload_subdir(parent, cname_for(basename, abspath), abspath) + cref = Cref.new(parent, cname_for(basename, abspath)) + autoload_subdir(cref, abspath) end end end end # @sig (Module, Symbol, String) -> void - private def autoload_subdir(parent, cname, subdir) - if autoload_path = autoload_path_set_by_me_for?(parent, cname) - cpath = cpath(parent, cname) + private def autoload_subdir(cref, subdir) + if autoload_path = autoload_path_set_by_me_for?(cref) if ruby?(autoload_path) # Scanning visited a Ruby file first, and now a directory for the same # constant has been found. This means we are dealing with an explicit @@ -468,88 +469,83 @@ def all_dirs # 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. - register_explicit_namespace(cpath) + register_explicit_namespace(cref.path) end # 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. - namespace_dirs[cpath] << subdir - elsif !cdef?(parent, cname) + namespace_dirs[cref.path] << subdir + elsif !cref.defined? # First time we find this namespace, set an autoload for it. - namespace_dirs[cpath(parent, cname)] << subdir - define_autoload(parent, cname, subdir) + namespace_dirs[cref.path] << subdir + define_autoload(cref, subdir) else # For whatever reason the constant that corresponds to this namespace has # already been defined, we have to recurse. - log("the namespace #{cpath(parent, cname)} already exists, descending into #{subdir}") if logger - define_autoloads_for_dir(subdir, cget(parent, cname)) + log("the namespace #{cref.path} already exists, descending into #{subdir}") if logger + define_autoloads_for_dir(subdir, cref.get) end end # @sig (Module, Symbol, String) -> void - private def autoload_file(parent, cname, file) - if autoload_path = strict_autoload_path(parent, cname) || Registry.inception?(cpath(parent, cname)) + private def autoload_file(cref, file) + if autoload_path = cref.autoload? || Registry.inception?(cref.path) # First autoload for a Ruby file wins, just ignore subsequent ones. if ruby?(autoload_path) shadowed_files << file log("file #{file} is ignored because #{autoload_path} has precedence") if logger else - promote_namespace_from_implicit_to_explicit( - dir: autoload_path, - file: file, - parent: parent, - cname: cname - ) + promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref) end - elsif cdef?(parent, cname) + elsif cref.defined? shadowed_files << file - log("file #{file} is ignored because #{cpath(parent, cname)} is already defined") if logger + log("file #{file} is ignored because #{cref.path} is already defined") if logger else - define_autoload(parent, cname, file) + define_autoload(cref, file) end end # `dir` is the directory that would have autovivified a namespace. `file` is # the file where we've found the namespace is explicitly defined. # - # @sig (dir: String, file: String, parent: Module, cname: Symbol) -> void - private def promote_namespace_from_implicit_to_explicit(dir:, file:, parent:, cname:) + # @sig (dir: String, file: String, cref: Zeitwerk::Cref) -> void + private def promote_namespace_from_implicit_to_explicit(dir:, file:, cref:) autoloads.delete(dir) Registry.unregister_autoload(dir) - log("earlier autoload for #{cpath(parent, cname)} discarded, it is actually an explicit namespace defined in #{file}") if logger + log("earlier autoload for #{cref.path} discarded, it is actually an explicit namespace defined in #{file}") if logger - define_autoload(parent, cname, file) - register_explicit_namespace(cpath(parent, cname)) + define_autoload(cref, file) + register_explicit_namespace(cref.path) end # @sig (Module, Symbol, String) -> void - private def define_autoload(parent, cname, abspath) - parent.autoload(cname, abspath) + private def define_autoload(cref, abspath) + cref.autoload(abspath) if logger if ruby?(abspath) - log("autoload set for #{cpath(parent, cname)}, to be loaded from #{abspath}") + log("autoload set for #{cref.path}, to be loaded from #{abspath}") else - log("autoload set for #{cpath(parent, cname)}, to be autovivified from #{abspath}") + log("autoload set for #{cref.path}, to be autovivified from #{abspath}") end end - autoloads[abspath] = [parent, cname] + autoloads[abspath] = cref Registry.register_autoload(self, abspath) # See why in the documentation of Zeitwerk::Registry.inceptions. - unless parent.autoload?(cname) - Registry.register_inception(cpath(parent, cname), abspath, self) + unless cref.autoload? + Registry.register_inception(cref.path, abspath, self) end end # @sig (Module, Symbol) -> String? - private def autoload_path_set_by_me_for?(parent, cname) - if autoload_path = strict_autoload_path(parent, cname) + private def autoload_path_set_by_me_for?(cref) + if autoload_path = cref.autoload? autoload_path if autoloads.key?(autoload_path) else - Registry.inception?(cpath(parent, cname)) + Registry.inception?(cref.path) end end @@ -590,21 +586,21 @@ def all_dirs end # @sig (Module, Symbol) -> void - private def unload_autoload(parent, cname) - crem(parent, cname) - log("autoload for #{cpath(parent, cname)} removed") if logger + private def unload_autoload(cref) + cref.remove + log("autoload for #{cref.path} removed") if logger end # @sig (Module, Symbol) -> void - private def unload_cref(parent, cname) + private def unload_cref(cref) # Let's optimistically remove_const. The way we use it, this is going to # succeed always if all is good. - crem(parent, cname) + cref.remove rescue ::NameError # There are a few edge scenarios in which this may happen. If the constant # is gone, that is OK, anyway. else - log("#{cpath(parent, cname)} unloaded") if logger + log("#{cref.path} unloaded") if logger end end end diff --git a/lib/zeitwerk/loader/callbacks.rb b/lib/zeitwerk/loader/callbacks.rb index a450aeb..c702179 100644 --- a/lib/zeitwerk/loader/callbacks.rb +++ b/lib/zeitwerk/loader/callbacks.rb @@ -8,29 +8,28 @@ module Zeitwerk::Loader::Callbacks # # @sig (String) -> void internal def on_file_autoloaded(file) - cref = autoloads.delete(file) - cpath = cpath(*cref) + cref = autoloads.delete(file) Zeitwerk::Registry.unregister_autoload(file) - if cdef?(*cref) - log("constant #{cpath} loaded from file #{file}") if logger - to_unload[cpath] = [file, cref] if reloading_enabled? - run_on_load_callbacks(cpath, cget(*cref), file) unless on_load_callbacks.empty? + if cref.defined? + log("constant #{cref.path} loaded from file #{file}") if logger + to_unload[cref.path] = [file, cref] if reloading_enabled? + run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty? else - msg = "expected file #{file} to define constant #{cpath}, but didn't" + msg = "expected file #{file} to define constant #{cref.path}, but didn't" log(msg) if logger # Ruby still keeps the autoload defined, but we remove it because the # contract in Zeitwerk is more strict. - crem(*cref) + cref.remove # Since the expected constant was not defined, there is nothing to unload. # However, if the exception is rescued and reloading is enabled, we still # need to deleted the file from $LOADED_FEATURES. - to_unload[cpath] = [file, cref] if reloading_enabled? + to_unload[cref.path] = [file, cref] if reloading_enabled? - raise Zeitwerk::NameError.new(msg, cref.last) + raise Zeitwerk::NameError.new(msg, cref.cname) end end @@ -53,8 +52,8 @@ module Zeitwerk::Loader::Callbacks # children, since t1 would have correctly deleted its namespace_dirs entry. dirs_autoload_monitor.synchronize do if cref = autoloads.delete(dir) - autovivified_module = cref[0].const_set(cref[1], Module.new) - cpath = autovivified_module.name + implicit_namespace = cref.set(Module.new) + cpath = implicit_namespace.name log("module #{cpath} autovivified from directory #{dir}") if logger to_unload[cpath] = [dir, cref] if reloading_enabled? @@ -65,9 +64,9 @@ module Zeitwerk::Loader::Callbacks # these to be able to unregister later if eager loading. autoloaded_dirs << dir - on_namespace_loaded(autovivified_module) + on_namespace_loaded(implicit_namespace) - run_on_load_callbacks(cpath, autovivified_module, dir) unless on_load_callbacks.empty? + run_on_load_callbacks(cpath, implicit_namespace, dir) unless on_load_callbacks.empty? end end end diff --git a/lib/zeitwerk/loader/eager_load.rb b/lib/zeitwerk/loader/eager_load.rb index 01c5672..83fa0cc 100644 --- a/lib/zeitwerk/loader/eager_load.rb +++ b/lib/zeitwerk/loader/eager_load.rb @@ -61,8 +61,8 @@ def eager_load_dir(path) cnames.reverse_each do |cname| # Can happen if there are no Ruby files. This is not an error condition, # the directory is actually managed. Could have Ruby files later. - return unless cdef?(namespace, cname) - namespace = cget(namespace, cname) + return unless namespace.const_defined?(cname, false) + namespace = namespace.const_get(cname, false) end # A shortcircuiting test depends on the invocation of this method. Please @@ -145,12 +145,12 @@ def load_file(path) namespace = root_namespace cnames.reverse_each do |cname| - namespace = cget(namespace, cname) + namespace = namespace.const_get(cname, false) end raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath) - cget(namespace, base_cname) + namespace.const_get(base_cname, false) end # The caller is responsible for making sure `namespace` is the namespace that @@ -164,22 +164,20 @@ def load_file(path) log("eager load directory #{dir} start") if logger queue = [[dir, namespace]] - until queue.empty? - dir, namespace = queue.shift - + while (dir, namespace = queue.shift) ls(dir) do |basename, abspath, ftype| next if honour_exclusions && eager_load_exclusions.member?(abspath) if ftype == :file if (cref = autoloads[abspath]) - cget(*cref) + cref.get end else if collapse?(abspath) queue << [abspath, namespace] else cname = inflector.camelize(basename, abspath).to_sym - queue << [abspath, cget(namespace, cname)] + queue << [abspath, namespace.const_get(cname, false)] end end end diff --git a/lib/zeitwerk/loader/helpers.rb b/lib/zeitwerk/loader/helpers.rb index 700c44e..6d93660 100644 --- a/lib/zeitwerk/loader/helpers.rb +++ b/lib/zeitwerk/loader/helpers.rb @@ -100,64 +100,7 @@ module Zeitwerk::Loader::Helpers end end - # --- Constants --------------------------------------------------------------------------------- - - # The autoload? predicate takes into account the ancestor chain of the - # receiver, like const_defined? and other methods in the constants API do. - # - # For example, given - # - # class A - # autoload :X, "x.rb" - # end - # - # class B < A - # end - # - # B.autoload?(:X) returns "x.rb". - # - # We need a way to strictly check in parent ignoring ancestors. - # - # @sig (Module, Symbol) -> String? - if method(:autoload?).arity == 1 - private def strict_autoload_path(parent, cname) - parent.autoload?(cname) if cdef?(parent, cname) - end - else - private def strict_autoload_path(parent, cname) - parent.autoload?(cname, false) - end - end - - # @sig (Module, Symbol) -> String - if Symbol.method_defined?(:name) - # Symbol#name was introduced in Ruby 3.0. It returns always the same - # frozen object, so we may save a few string allocations. - private def cpath(parent, cname) - Object == parent ? cname.name : "#{real_mod_name(parent)}::#{cname.name}" - end - else - private def cpath(parent, cname) - Object == parent ? cname.to_s : "#{real_mod_name(parent)}::#{cname}" - end - end - - # @sig (Module, Symbol) -> bool - private def cdef?(parent, cname) - parent.const_defined?(cname, false) - end - - # @raise [NameError] - # @sig (Module, Symbol) -> Object - private def cget(parent, cname) - parent.const_get(cname, false) - end - - # @raise [NameError] - # @sig (Module, Symbol) -> Object - private def crem(parent, cname) - parent.__send__(:remove_const, cname) - end + # --- Inflection -------------------------------------------------------------------------------- CNAME_VALIDATOR = Module.new private_constant :CNAME_VALIDATOR diff --git a/test/lib/zeitwerk/test_cref.rb b/test/lib/zeitwerk/test_cref.rb new file mode 100644 index 0000000..a2d1f51 --- /dev/null +++ b/test/lib/zeitwerk/test_cref.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "test_helper" + +class TestCref < LoaderTest + def klass + self.class + end + + def new_cref(mod = klass, cname = :Foo) + Zeitwerk::Cref.new(mod, :Foo) + end + + test "#cname" do + assert_equal :Foo, new_cref.cname + end + + test "#path for Object" do + assert_equal "Foo", new_cref(Object).path + end + + test "#path for another namespace" do + assert_equal "#{self.class}::Foo", new_cref.path + end + + test "#autoload?" do + on_teardown { remove_const :Foo, from: klass } + + klass.autoload(:Foo, "/foo") + assert_equal "/foo", new_cref.autoload? + end + + test "#autoload" do + on_teardown { remove_const :Foo, from: klass } + + new_cref.autoload("/foo") + assert_equal "/foo", klass.autoload?(:Foo) + end + + test "#defined? finds a constant defined in mod" do + on_teardown { remove_const :Foo, from: klass } + + klass.const_set(:Foo, 1) + assert new_cref.defined? + end + + test "#defined? ignores the ancestors" do + cname = :TMP_DIR + assert klass.superclass.const_defined?(cname) # precondition + assert !new_cref(klass, cname).defined? + end + + test "#set" do + on_teardown { remove_const :Foo, from: klass } + + assert_equal 1, new_cref.set(1) + assert_equal 1, klass::Foo + end + + test "#get" do + on_teardown { remove_const :Foo, from: klass } + + klass.const_set(:Foo, 1) + assert_equal 1, new_cref.get + end + + test "#get with unknown cname" do + assert_raises(NameError) { new_cref.get } + end + + test "#remove" do + cref = new_cref + + cref.set(1) + assert cref.defined? # precondition + + cref.remove + assert !cref.defined? + end + + test "#remove with unknown cname" do + assert_raises(NameError) { new_cref.remove } + end +end