Add bin and edit workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 9s

This commit is contained in:
2026-09-16 13:11:16 -06:00
parent c8ac4fcae5
commit 4cee170d66
17576 changed files with 895740 additions and 2 deletions
@@ -0,0 +1,64 @@
# frozen_string_literal: true
module Kernel
module_function
# Zeitwerk's main idea is to define autoloads for project constants, and then
# intercept them when triggered in this thin `Kernel#require` wrapper.
#
# That allows us to complete the circle, invoke callbacks, autovivify modules,
# define autoloads for just autoloaded namespaces, update internal state, etc.
#
# On the other hand, if you publish a new version of a gem that is now managed
# by Zeitwerk, client code can reference directly your classes and modules and
# should not require anything. But if someone has legacy require calls around,
# they will work as expected, and in a compatible way. This feature is by now
# EXPERIMENTAL and UNDOCUMENTED.
alias_method :zeitwerk_original_require, :require
class << self
alias_method :zeitwerk_original_require, :require
end
# @sig (String) -> true | false
def require(path)
if loader = Zeitwerk::Registry.loader_for(path)
if path.end_with?(".rb")
required = zeitwerk_original_require(path)
loader.__on_file_autoloaded(path) if required
required
else
loader.__on_dir_autoloaded(path)
true
end
else
required = zeitwerk_original_require(path)
if required
abspath = $LOADED_FEATURES.last
if loader = Zeitwerk::Registry.loader_for(abspath)
loader.__on_file_autoloaded(abspath)
end
end
required
end
end
# By now, I have seen no way so far to decorate require_relative.
#
# For starters, at least in CRuby, require_relative does not delegate to
# require. Both require and require_relative delegate the bulk of their work
# to an internal C function called rb_require_safe. So, our require wrapper is
# not executed.
#
# On the other hand, we cannot use the aliasing technique above because
# require_relative receives a path relative to the directory of the file in
# which the call is performed. If a wrapper here invoked the original method,
# Ruby would resolve the relative path taking lib/zeitwerk as base directory.
#
# A workaround could be to extract the base directory from caller_locations,
# but what if someone else decorated require_relative before us? You can't
# really know with certainty where's the original call site in the stack.
#
# However, the main use case for require_relative is to load files from your
# own project. Projects managed by Zeitwerk don't do this for files managed by
# Zeitwerk, precisely.
end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Zeitwerk::ConstAdded # :nodoc:
# @sig (Symbol) -> void
def const_added(cname)
if loader = Zeitwerk::Registry::ExplicitNamespaces.__loader_for(self, cname)
namespace = const_get(cname, false)
unless namespace.is_a?(Module)
cref = Zeitwerk::Cref.new(self, cname)
raise Zeitwerk::Error, "#{cref} is expected to be a namespace, should be a class or module (got #{namespace.class})"
end
loader.__on_namespace_loaded(Zeitwerk::Cref.new(self, cname), namespace)
end
super
end
Module.prepend(self)
end
@@ -0,0 +1,71 @@
# 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. Examples:
#
# cref.path
# cref.set(value)
# cref.get
#
# The constant may or may not exist in mod.
class Zeitwerk::Cref
require_relative "cref/map"
include Zeitwerk::RealModName
# @sig Module
attr_reader :mod
# @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
# @sig () -> String
def path
@path ||= Object.equal?(@mod) ? @cname.name : "#{real_mod_name(@mod)}::#{@cname.name}".freeze
end
alias to_s path
# @sig () -> String?
def autoload?
@mod.autoload?(@cname, false)
end
# @sig (String) -> bool
def autoload(abspath)
@mod.autoload(@cname, abspath)
end
# @sig () -> bool
def defined?
@mod.const_defined?(@cname, false)
end
# @sig (top) -> top
def set(value)
@mod.const_set(@cname, value)
end
# @raise [NameError]
# @sig () -> top
def get
@mod.const_get(@cname, false)
end
# @raise [NameError]
# @sig () -> void
def remove
@mod.__send__(:remove_const, @cname)
end
end
@@ -0,0 +1,124 @@
# frozen_string_literal: true
# This class emulates a hash table whose keys are of type Zeitwerk::Cref.
#
# It is a synchronized 2-level hash. The keys of the top one, stored in `@map`,
# are class and module objects, but their hash code is forced to be their object
# IDs (see why below). Then, each one of them stores a hash table keyed on
# constant names as symbols. We finally store the values in those.
#
# For example, if we store values 0, 1, and 2 for the crefs that would
# correspond to `M::X`, `M::Y`, and `N::Z`, the map will look like this:
#
# { M => { X: 0, :Y => 1 }, N => { Z: 2 } }
#
# This structure is internal, so only the needed interface is implemented.
#
# Why not use tables that map pairs [Module, Symbol] to their values? Because
# class and module objects are not guaranteed to be hashable, the `hash` method
# may have been overridden:
#
# https://github.com/fxn/zeitwerk/issues/188
#
# We can also use a 1-level hash whose keys are the corresponding class and
# module names. In the example above it would be:
#
# { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
#
# The gem used this approach for several years.
#
# Another option would be to make crefs hashable. I tried with hash code
#
# real_mod_hash(mod) ^ cname.hash
#
# and the matching eql?, but that was about 1.8x slower.
#
# Finally, I came with this solution which is 1.6x faster than the previous one
# based on class and module names, even being synchronized. Also, client code
# feels natural, since crefs are central objects in Zeitwerk's implementation.
class Zeitwerk::Cref::Map # :nodoc: all
def initialize
@map = {}
@map.compare_by_identity
@mutex = Mutex.new
end
# @sig (Zeitwerk::Cref, V) -> V
def []=(cref, value)
@mutex.synchronize do
cnames = (@map[cref.mod] ||= {})
cnames[cref.cname] = value
end
end
# @sig (Zeitwerk::Cref) -> top?
def [](cref)
@mutex.synchronize do
@map[cref.mod]&.[](cref.cname)
end
end
# @sig (Zeitwerk::Cref, { () -> V }) -> V
def get_or_set(cref, &block)
@mutex.synchronize do
cnames = (@map[cref.mod] ||= {})
cnames.fetch(cref.cname) { cnames[cref.cname] = block.call }
end
end
# @sig (Zeitwerk::Cref) -> top?
def delete(cref)
delete_mod_cname(cref.mod, cref.cname)
end
# Ad-hoc for loader_for, called from const_added. That is a hot path, I prefer
# to not create a cref in every call, since that is global.
#
# @sig (Module, Symbol) -> top?
def delete_mod_cname(mod, cname)
@mutex.synchronize do
if cnames = @map[mod]
value = cnames.delete(cname)
@map.delete(mod) if cnames.empty?
value
end
end
end
# @sig (top) -> void
def delete_by_value(value)
@mutex.synchronize do
@map.delete_if do |mod, cnames|
cnames.delete_if { _2 == value }
cnames.empty?
end
end
end
# Order of yielded crefs is undefined.
#
# @sig () { (Zeitwerk::Cref) -> void } -> void
def each_key
@mutex.synchronize do
@map.each do |mod, cnames|
cnames.each_key do |cname|
yield Zeitwerk::Cref.new(mod, cname)
end
end
end
end
# @sig () -> void
def clear
@mutex.synchronize do
@map.clear
end
end
# @sig () -> bool
def empty? # for tests
@mutex.synchronize do
@map.empty?
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Zeitwerk
class Error < StandardError
end
class ReloadingDisabledError < Error
def initialize
super("can't reload, please call loader.enable_reloading before setup")
end
end
class NameError < ::NameError
end
class SetupRequired < Error
def initialize
super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
end
end
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Zeitwerk
class GemInflector < Inflector
# @sig (String) -> void
def initialize(root_file)
namespace = File.basename(root_file, ".rb")
root_dir = File.dirname(root_file)
@version_file = File.join(root_dir, namespace, "version.rb")
end
# @sig (String, String) -> String
def camelize(basename, abspath)
abspath == @version_file ? "VERSION" : super
end
end
end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module Zeitwerk
# @private
class GemLoader < Loader
include RealModName
# Users should not create instances directly, the public interface is
# `Zeitwerk::Loader.for_gem`.
private_class_method :new
# @private
# @sig (String, bool) -> Zeitwerk::GemLoader
def self.__new(root_file, namespace:, warn_on_extra_files:)
new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
end
# @sig (String, bool) -> void
def initialize(root_file, namespace:, warn_on_extra_files:)
super()
@tag = File.basename(root_file, ".rb")
@tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
@inflector = GemInflector.new(root_file)
@root_file = File.expand_path(root_file)
@root_dir = File.dirname(root_file)
@warn_on_extra_files = warn_on_extra_files
push_dir(@root_dir, namespace: namespace)
end
# @sig () -> void
def setup
warn_on_extra_files if @warn_on_extra_files
super
end
private
# @sig () -> void
def warn_on_extra_files
expected_namespace_dir = @root_file.delete_suffix(".rb")
ls(@root_dir) do |basename, abspath, ftype|
next if abspath == @root_file
next if abspath == expected_namespace_dir
basename_without_ext = basename.delete_suffix(".rb")
cname = inflector.camelize(basename_without_ext, abspath).to_sym
warn(<<~EOS)
WARNING: Zeitwerk defines the constant #{cname} after the #{ftype}
#{abspath}
To prevent that, please configure the loader to ignore it:
loader.ignore("\#{__dir__}/#{basename}")
Otherwise, there is a flag to silence this warning:
Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
EOS
end
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
module Zeitwerk
class Inflector
# Very basic snake case -> camel case conversion.
#
# inflector = Zeitwerk::Inflector.new
# inflector.camelize("post", ...) # => "Post"
# inflector.camelize("users_controller", ...) # => "UsersController"
# inflector.camelize("api", ...) # => "Api"
#
# Takes into account hard-coded mappings configured with `inflect`.
#
# @sig (String, String) -> String
def camelize(basename, _abspath)
overrides[basename] || basename.split('_').each(&:capitalize!).join
end
# Configures hard-coded inflections:
#
# inflector = Zeitwerk::Inflector.new
# inflector.inflect(
# "html_parser" => "HTMLParser",
# "mysql_adapter" => "MySQLAdapter"
# )
#
# inflector.camelize("html_parser", abspath) # => "HTMLParser"
# inflector.camelize("mysql_adapter", abspath) # => "MySQLAdapter"
# inflector.camelize("users_controller", abspath) # => "UsersController"
#
# @sig (Hash[String, String]) -> void
def inflect(inflections)
overrides.merge!(inflections)
end
private
# Hard-coded basename to constant name user maps that override the default
# inflection logic.
#
# @sig () -> Hash[String, String]
def overrides
@overrides ||= {}
end
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
# This is a private module.
module Zeitwerk::Internal
# @sig (Symbol) -> void
def internal(method_name)
private method_name
mangled = "__#{method_name}"
alias_method mangled, method_name
public mangled
end
end
@@ -0,0 +1,641 @@
# frozen_string_literal: true
require "monitor"
require "set"
module Zeitwerk
class Loader
require_relative "loader/helpers"
require_relative "loader/callbacks"
require_relative "loader/config"
require_relative "loader/eager_load"
extend Internal
include RealModName
include Callbacks
include Helpers
include Config
include EagerLoad
MUTEX = Mutex.new
private_constant :MUTEX
# Maps absolute paths for which an autoload has been set ---and not
# executed--- to their corresponding Zeitwerk::Cref object.
#
# "/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, ...>,
# ...
#
# @sig Hash[String, Zeitwerk::Cref]
attr_reader :autoloads
internal :autoloads
# When the path passed to Module#autoload is in the stack of features being
# loaded at the moment, Ruby passes. For example, Module#autoload? returns
# `nil` even if the autoload has not been attempted. See
#
# https://bugs.ruby-lang.org/issues/21035
#
# We call these "inceptions".
#
# A common case is the entry point of gems managed by Zeitwerk. Their main
# file is normally required and, while doing so, the loader sets an autoload
# on the gem namespace. That autoload hits this edge case.
#
# There is some logic that neeeds to know if an autoload for a given
# constant already exists. We check Module#autoload? first, and fallback to
# the inceptions just in case.
#
# This map keeps track of pairs (cref, autoload_path) found by the loader.
# The module Zeitwerk::Registry::Inceptions, on the other hand, acts as a
# global registry for them.
#
# @sig Zeitwerk::Cref::Map[String]
attr_reader :inceptions
internal :inceptions
# We keep track of autoloaded directories to remove them from the registry
# at the end of eager loading.
#
# Files are removed as they are autoloaded, but directories need to wait due
# to concurrency (see why in Zeitwerk::Loader::Callbacks#on_dir_autoloaded).
#
# @sig Array[String]
attr_reader :autoloaded_dirs
internal :autoloaded_dirs
# If reloading is enabled, this collection maps autoload paths to their
# autoloaded crefs.
#
# On unload, the autoload paths are passed to callbacks, files deleted from
# $LOADED_FEATURES, and the crefs are deleted.
#
# @sig Hash[String, Zeitwerk::Cref]
attr_reader :to_unload
internal :to_unload
# Maps namespace crefs to the directories that conform the namespace.
#
# When these crefs get defined we know their children are spread over those
# directories. We'll visit them to set up the corresponding autoloads.
#
# @sig Zeitwerk::Cref::Map[String]
attr_reader :namespace_dirs
internal :namespace_dirs
# A shadowed file is a file managed by this loader that is ignored when
# setting autoloads because its matching constant is already taken.
#
# This private set is populated lazily, as we descend. For example, if the
# loader has only scanned the top-level, `shadowed_files` does not have the
# shadowed files that may exist deep in the project tree.
#
# @sig Set[String]
attr_reader :shadowed_files
internal :shadowed_files
# @sig Mutex
attr_reader :mutex
private :mutex
# @sig Monitor
attr_reader :dirs_autoload_monitor
private :dirs_autoload_monitor
def initialize
super
@autoloads = {}
@inceptions = Zeitwerk::Cref::Map.new
@autoloaded_dirs = []
@to_unload = {}
@namespace_dirs = Zeitwerk::Cref::Map.new
@shadowed_files = Set.new
@setup = false
@eager_loaded = false
@mutex = Mutex.new
@dirs_autoload_monitor = Monitor.new
Registry.register_loader(self)
end
# Sets autoloads in the root namespaces.
#
# @sig () -> void
def setup
mutex.synchronize do
break if @setup
actual_roots.each do |root_dir, root_namespace|
define_autoloads_for_dir(root_dir, root_namespace)
end
on_setup_callbacks.each(&:call)
@setup = true
end
end
# Removes loaded constants and configured autoloads.
#
# The objects the constants stored are no longer reachable through them. In
# addition, since said objects are normally not referenced from anywhere
# else, they are eligible for garbage collection, which would effectively
# unload them.
#
# This method is public but undocumented. Main interface is `reload`, which
# means `unload` + `setup`. This one is available to be used together with
# `unregister`, which is undocumented too.
#
# @sig () -> void
def unload
mutex.synchronize do
raise SetupRequired unless @setup
# 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 ruby?(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
unload_cref(cref)
unloaded_files.add(abspath) if ruby?(abspath)
end
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/Shopify/bootsnap/blob/master/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
autoloads.clear
autoloaded_dirs.clear
to_unload.clear
namespace_dirs.clear
shadowed_files.clear
unregister_inceptions
unregister_explicit_namespaces
Registry.on_unload(self)
@setup = false
@eager_loaded = false
end
end
# Unloads all loaded code, and calls setup again so that the loader is able
# to pick any changes in the file system.
#
# This method is not thread-safe, please see how this can be achieved by
# client code in the README of the project.
#
# @raise [Zeitwerk::Error]
# @sig () -> void
def reload
raise ReloadingDisabledError unless reloading_enabled?
raise SetupRequired unless @setup
unload
recompute_ignored_paths
recompute_collapse_dirs
setup
end
# Returns a hash that maps the absolute paths of the managed files and
# directories to their respective expected constant paths.
#
# @sig () -> Hash[String, String]
def all_expected_cpaths
result = {}
actual_roots.each do |root_dir, root_namespace|
queue = [[root_dir, real_mod_name(root_namespace)]]
while (dir, cpath = queue.shift)
result[dir] = cpath
prefix = cpath == "Object" ? "" : cpath + "::"
ls(dir) do |basename, abspath, ftype|
if ftype == :file
basename.delete_suffix!(".rb")
result[abspath] = prefix + inflector.camelize(basename, abspath)
else
if collapse?(abspath)
queue << [abspath, cpath]
else
queue << [abspath, prefix + inflector.camelize(basename, abspath)]
end
end
end
end
end
result
end
# @sig (String | Pathname) -> String?
def cpath_expected_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)
paths = []
if ruby?(abspath)
basename = File.basename(abspath, ".rb")
return if hidden?(basename)
paths << [basename, 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)
paths << [basename, abspath] unless collapse?(dir)
end
return unless root_namespace
if paths.empty?
real_mod_name(root_namespace)
else
cnames = paths.reverse_each.map { |b, a| cname_for(b, a) }
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.
#
# This is an undocumented method that I wrote to help transition from the
# classic autoloader in Rails. Its usage was removed from Rails in 7.0.
#
# @sig (String) -> bool
def unloadable_cpath?(cpath)
unloadable_cpaths.include?(cpath)
end
# Returns an array with the constant paths that would be unloaded on reload.
# This predicate returns an empty array if reloading is disabled.
#
# This is an undocumented method that I wrote to help transition from the
# classic autoloader in Rails. Its usage was removed from Rails in 7.0.
#
# @sig () -> Array[String]
def unloadable_cpaths
to_unload.values.map(&:path)
end
# This is a dangerous method.
#
# @experimental
# @sig () -> void
def unregister
unregister_inceptions
unregister_explicit_namespaces
Registry.unregister_loader(self)
end
# The return value of this predicate is only meaningful if the loader has
# scanned the file. This is the case in the spots where we use it.
#
# @sig (String) -> Boolean
internal def shadowed_file?(file)
shadowed_files.member?(file)
end
# --- Class methods ---------------------------------------------------------------------------
class << self
include RealModName
# @sig #call | #debug | nil
attr_accessor :default_logger
# This is a shortcut for
#
# require "zeitwerk"
#
# loader = Zeitwerk::Loader.new
# loader.tag = File.basename(__FILE__, ".rb")
# loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
# loader.push_dir(__dir__)
#
# except that this method returns the same object in subsequent calls from
# the same file, in the unlikely case the gem wants to be able to reload.
#
# This method returns a subclass of Zeitwerk::Loader, but the exact type
# is private, client code can only rely on the interface.
#
# @sig (bool) -> Zeitwerk::GemLoader
def for_gem(warn_on_extra_files: true)
called_from = caller_locations(1, 1).first.path
Registry.loader_for_gem(called_from, namespace: Object, warn_on_extra_files: warn_on_extra_files)
end
# This is a shortcut for
#
# require "zeitwerk"
#
# loader = Zeitwerk::Loader.new
# loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
# loader.inflector = Zeitwerk::GemInflector.new(__FILE__)
# loader.push_dir(__dir__, namespace: namespace)
#
# except that this method returns the same object in subsequent calls from
# the same file, in the unlikely case the gem wants to be able to reload.
#
# This method returns a subclass of Zeitwerk::Loader, but the exact type
# is private, client code can only rely on the interface.
#
# @sig (bool) -> Zeitwerk::GemLoader
def for_gem_extension(namespace)
unless namespace.is_a?(Module) # Note that Class < Module.
raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
end
unless real_mod_name(namespace)
raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
end
called_from = caller_locations(1, 1).first.path
Registry.loader_for_gem(called_from, namespace: namespace, warn_on_extra_files: false)
end
# Broadcasts `eager_load` to all loaders. Those that have not been setup
# are skipped.
#
# @sig () -> void
def eager_load_all
Registry.loaders.each do |loader|
begin
loader.eager_load
rescue SetupRequired
# This is fine, we eager load what can be eager loaded.
end
end
end
# Broadcasts `eager_load_namespace` to all loaders. Those that have not
# been setup are skipped.
#
# @sig (Module) -> void
def eager_load_namespace(mod)
Registry.loaders.each do |loader|
begin
loader.eager_load_namespace(mod)
rescue SetupRequired
# This is fine, we eager load what can be eager loaded.
end
end
end
# Returns an array with the absolute paths of the root directories of all
# registered loaders. This is a read-only collection.
#
# @sig () -> Array[String]
def all_dirs
Registry.loaders.flat_map(&:dirs).freeze
end
end
# @sig (String, Module) -> void
private def define_autoloads_for_dir(dir, parent)
ls(dir) do |basename, abspath, ftype|
if ftype == :file
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)
end
end
end
end
# @sig (Module, Symbol, String) -> void
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
# namespace whose definition was seen first.
#
# 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(cref)
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.get_or_set(cref) { [] } << subdir
elsif !cref.defined?
# First time we find this namespace, set an autoload for it.
namespace_dirs.get_or_set(cref) { [] } << 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 #{cref} 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(cref, file)
if autoload_path = cref.autoload? || Registry::Inceptions.registered?(cref)
# 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, cref: cref)
end
elsif cref.defined?
shadowed_files << file
log("file #{file} is ignored because #{cref} is already defined") if logger
else
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, 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 #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
# Order matters: When Module#const_added is triggered by the autoload, we
# don't want the namespace to be registered yet.
define_autoload(cref, file)
register_explicit_namespace(cref)
end
# @sig (Module, Symbol, String) -> void
private def define_autoload(cref, abspath)
cref.autoload(abspath)
if logger
if ruby?(abspath)
log("autoload set for #{cref}, to be loaded from #{abspath}")
else
log("autoload set for #{cref}, to be autovivified from #{abspath}")
end
end
autoloads[abspath] = cref
Registry.register_autoload(self, abspath)
register_inception(cref, abspath) unless cref.autoload?
end
# @sig (Module, Symbol) -> String?
private def autoload_path_set_by_me_for?(cref)
if autoload_path = cref.autoload?
autoload_path if autoloads.key?(autoload_path)
else
inceptions[cref]
end
end
# @sig (Zeitwerk::Cref) -> void
private def register_explicit_namespace(cref)
Registry::ExplicitNamespaces.__register(cref, self)
end
# @sig () -> void
private def unregister_explicit_namespaces
Registry::ExplicitNamespaces.__unregister_loader(self)
end
# @sig (Zeitwerk::Cref, String) -> void
private def register_inception(cref, abspath)
inceptions[cref] = abspath
Registry::Inceptions.register(cref, abspath)
end
# @sig () -> void
private def unregister_inceptions
inceptions.each_key do |cref|
Registry::Inceptions.unregister(cref)
end
inceptions.clear
end
# @sig (String) -> void
private def raise_if_conflicting_directory(dir)
MUTEX.synchronize do
dir_slash = dir + "/"
Registry.loaders.each do |loader|
next if loader == self
next if loader.__ignores?(dir)
loader.__roots.each_key do |root_dir|
next if ignores?(root_dir)
root_dir_slash = root_dir + "/"
if dir_slash.start_with?(root_dir_slash) || root_dir_slash.start_with?(dir_slash)
require "pp" # Needed for pretty_inspect, even in Ruby 2.5.
raise Error,
"loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
" which is already managed by\n\n#{loader.pretty_inspect}\n"
end
end
end
end
end
# @sig (String, top, String) -> void
private def run_on_unload_callbacks(cref, value, abspath)
# Order matters. If present, run the most specific one.
on_unload_callbacks[cref.path]&.each { |c| c.call(value, abspath) }
on_unload_callbacks[:ANY]&.each { |c| c.call(cref.path, value, abspath) }
end
# @sig (Module, Symbol) -> void
private def unload_autoload(cref)
cref.remove
log("autoload for #{cref} removed") if logger
end
# @sig (Module, Symbol) -> void
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.
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("#{cref} unloaded") if logger
end
end
end
@@ -0,0 +1,98 @@
# frozen_string_literal: true
module Zeitwerk::Loader::Callbacks # :nodoc: all
extend Zeitwerk::Internal
# Invoked from our decorated Kernel#require when a managed file is autoloaded.
#
# @raise [Zeitwerk::NameError]
# @sig (String) -> void
internal def on_file_autoloaded(file)
cref = autoloads.delete(file)
Zeitwerk::Registry.unregister_autoload(file)
if cref.defined?
log("constant #{cref} loaded from file #{file}") if logger
to_unload[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 #{cref}, 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.
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[file] = cref if reloading_enabled?
raise Zeitwerk::NameError.new(msg, cref.cname)
end
end
# Invoked from our decorated Kernel#require when a managed directory is
# autoloaded.
#
# @sig (String) -> void
internal def on_dir_autoloaded(dir)
# Module#autoload does not serialize concurrent requires in CRuby < 3.2, and
# we handle directories ourselves without going through Kernel#require, so
# the callback needs to account for concurrency.
#
# Multi-threading would introduce a race condition here in which thread t1
# autovivifies the module, and while autoloads for its children are being
# set, thread t2 autoloads the same namespace.
#
# Without the mutex and subsequent delete call, t2 would reset the module.
# That not only would reassign the constant (undesirable per se) but, worse,
# the module object created by t2 wouldn't have any of the autoloads for its
# children, since t1 would have correctly deleted its namespace_dirs entry.
dirs_autoload_monitor.synchronize do
if cref = autoloads.delete(dir)
implicit_namespace = cref.set(Module.new)
cpath = implicit_namespace.name
log("module #{cpath} autovivified from directory #{dir}") if logger
to_unload[dir] = cref if reloading_enabled?
# We don't unregister `dir` in the registry because concurrent threads
# wouldn't find a loader associated to it in Kernel#require and would
# try to require the directory. Instead, we are going to keep track of
# these to be able to unregister later if eager loading.
autoloaded_dirs << dir
on_namespace_loaded(cref, implicit_namespace)
run_on_load_callbacks(cpath, implicit_namespace, dir) unless on_load_callbacks.empty?
end
end
end
# Invoked when a namespace is created, either from const_added or from module
# autovivification. If the namespace has matching subdirectories, we descend
# into them now.
#
# @sig (Zeitwerk::Cref, Module) -> void
internal def on_namespace_loaded(cref, namespace)
if dirs = namespace_dirs.delete(cref)
dirs.each do |dir|
define_autoloads_for_dir(dir, namespace)
end
end
end
private
# @sig (String, top, String) -> void
def run_on_load_callbacks(cpath, value, abspath)
# Order matters. If present, run the most specific one.
callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)
callbacks&.each { |c| c.call(value, abspath) }
callbacks = on_load_callbacks[:ANY]
callbacks&.each { |c| c.call(cpath, value, abspath) }
end
end
@@ -0,0 +1,364 @@
# frozen_string_literal: true
require "set"
require "securerandom"
module Zeitwerk::Loader::Config
extend Zeitwerk::Internal
include Zeitwerk::RealModName
# @sig #camelize
attr_accessor :inflector
# @sig #call | #debug | nil
attr_accessor :logger
# Absolute paths of the root directories, mapped to their respective root namespaces:
#
# "/Users/fxn/blog/app/channels" => Object,
# "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
# ...
#
# Stored in a hash to preserve order, easily handle duplicates, and have a
# fast lookup by directory.
#
# This is a private collection maintained by the loader. The public
# interface for it is `push_dir` and `dirs`.
#
# @sig Hash[String, Module]
attr_reader :roots
internal :roots
# Absolute paths of files, directories, or glob patterns to be totally
# ignored.
#
# @sig Set[String]
attr_reader :ignored_glob_patterns
private :ignored_glob_patterns
# The actual collection of absolute file and directory names at the time the
# ignored glob patterns were expanded. Computed on setup, and recomputed on
# reload.
#
# @sig Set[String]
attr_reader :ignored_paths
private :ignored_paths
# Absolute paths of directories or glob patterns to be collapsed.
#
# @sig Set[String]
attr_reader :collapse_glob_patterns
private :collapse_glob_patterns
# The actual collection of absolute directory names at the time the collapse
# glob patterns were expanded. Computed on setup, and recomputed on reload.
#
# @sig Set[String]
attr_reader :collapse_dirs
private :collapse_dirs
# Absolute paths of files or directories not to be eager loaded.
#
# @sig Set[String]
attr_reader :eager_load_exclusions
private :eager_load_exclusions
# User-oriented callbacks to be fired on setup and on reload.
#
# @sig Array[{ () -> void }]
attr_reader :on_setup_callbacks
private :on_setup_callbacks
# User-oriented callbacks to be fired when a constant is loaded.
#
# @sig Hash[String, Array[{ (top, String) -> void }]]
# Hash[Symbol, Array[{ (String, top, String) -> void }]]
attr_reader :on_load_callbacks
private :on_load_callbacks
# User-oriented callbacks to be fired before constants are removed.
#
# @sig Hash[String, Array[{ (top, String) -> void }]]
# Hash[Symbol, Array[{ (String, top, String) -> void }]]
attr_reader :on_unload_callbacks
private :on_unload_callbacks
def initialize
@inflector = Zeitwerk::Inflector.new
@logger = self.class.default_logger
@tag = SecureRandom.hex(3)
@initialized_at = Time.now
@roots = {}
@ignored_glob_patterns = Set.new
@ignored_paths = Set.new
@collapse_glob_patterns = Set.new
@collapse_dirs = Set.new
@eager_load_exclusions = Set.new
@reloading_enabled = false
@on_setup_callbacks = []
@on_load_callbacks = {}
@on_unload_callbacks = {}
end
# Pushes `path` to the list of root directories.
#
# Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
# the same process already manages that directory or one of its ascendants or
# descendants.
#
# @raise [Zeitwerk::Error]
# @sig (String | Pathname, Module) -> void
def push_dir(path, namespace: Object)
unless namespace.is_a?(Module) # Note that Class < Module.
raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
end
unless real_mod_name(namespace)
raise Zeitwerk::Error, "root namespaces cannot be anonymous"
end
abspath = File.expand_path(path)
if dir?(abspath)
raise_if_conflicting_directory(abspath)
roots[abspath] = namespace
else
raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
end
end
# Returns the loader's tag.
#
# Implemented as a method instead of via attr_reader for symmetry with the
# writer below.
#
# @sig () -> String
def tag
@tag
end
# Sets a tag for the loader, useful for logging.
#
# @sig (#to_s) -> void
def tag=(tag)
@tag = tag.to_s
end
# If `namespaces` is falsey (default), returns an array with the absolute
# paths of the root directories as strings. If truthy, returns a hash table
# instead. Keys are the absolute paths of the root directories as strings,
# values are their corresponding namespaces, class or module objects.
#
# If `ignored` is falsey (default), ignored root directories are filtered out.
#
# These are read-only collections, please add to them with `push_dir`.
#
# @sig () -> Array[String] | Hash[String, Module]
def dirs(namespaces: false, ignored: false)
if namespaces
if ignored || ignored_paths.empty?
roots.clone
else
roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
end
else
if ignored || ignored_paths.empty?
roots.keys
else
roots.keys.reject { |root_dir| ignored_path?(root_dir) }
end
end.freeze
end
# You need to call this method before setup in order to be able to reload.
# There is no way to undo this, either you want to reload or you don't.
#
# @raise [Zeitwerk::Error]
# @sig () -> void
def enable_reloading
mutex.synchronize do
break if @reloading_enabled
if @setup
raise Zeitwerk::Error, "cannot enable reloading after setup"
else
@reloading_enabled = true
end
end
end
# @sig () -> bool
def reloading_enabled?
@reloading_enabled
end
# Let eager load ignore the given files or directories. The constants defined
# in those files are still autoloadable.
#
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
def do_not_eager_load(*paths)
mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
end
# Configure files, directories, or glob patterns to be totally ignored.
#
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
def ignore(*glob_patterns)
glob_patterns = expand_paths(glob_patterns)
mutex.synchronize do
ignored_glob_patterns.merge(glob_patterns)
ignored_paths.merge(expand_glob_patterns(glob_patterns))
end
end
# Configure directories or glob patterns to be collapsed.
#
# @sig (*(String | Pathname | Array[String | Pathname])) -> void
def collapse(*glob_patterns)
glob_patterns = expand_paths(glob_patterns)
mutex.synchronize do
collapse_glob_patterns.merge(glob_patterns)
collapse_dirs.merge(expand_glob_patterns(glob_patterns))
end
end
# Configure a block to be called after setup and on each reload.
# If setup was already done, the block runs immediately.
#
# @sig () { () -> void } -> void
def on_setup(&block)
mutex.synchronize do
on_setup_callbacks << block
block.call if @setup
end
end
# Configure a block to be invoked once a certain constant path is loaded.
# Supports multiple callbacks, and if there are many, they are executed in
# the order in which they were defined.
#
# loader.on_load("SomeApiClient") do |klass, _abspath|
# klass.endpoint = "https://api.dev"
# end
#
# Can also be configured for any constant loaded:
#
# loader.on_load do |cpath, value, abspath|
# # ...
# end
#
# @raise [TypeError]
# @sig (String) { (top, String) -> void } -> void
# (:ANY) { (String, top, String) -> void } -> void
def on_load(cpath = :ANY, &block)
raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
mutex.synchronize do
(on_load_callbacks[cpath] ||= []) << block
end
end
# Configure a block to be invoked right before a certain constant is removed.
# Supports multiple callbacks, and if there are many, they are executed in the
# order in which they were defined.
#
# loader.on_unload("Country") do |klass, _abspath|
# klass.clear_cache
# end
#
# Can also be configured for any removed constant:
#
# loader.on_unload do |cpath, value, abspath|
# # ...
# end
#
# @raise [TypeError]
# @sig (String) { (top) -> void } -> void
# (:ANY) { (String, top) -> void } -> void
def on_unload(cpath = :ANY, &block)
raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
mutex.synchronize do
(on_unload_callbacks[cpath] ||= []) << block
end
end
# Logs to `$stdout`, handy shortcut for debugging.
#
# @sig () -> void
def log!
@logger = ->(msg) { puts msg }
end
# Returns true if the argument has been configured to be ignored, or is a
# descendant of an ignored directory.
#
# @sig (String) -> bool
internal def ignores?(abspath)
# Common use case.
return false if ignored_paths.empty?
walk_up(abspath) do |path|
return true if ignored_path?(path)
return false if roots.key?(path)
end
false
end
# @sig (String) -> bool
private def ignored_path?(abspath)
ignored_paths.member?(abspath)
end
# @sig () -> Array[String]
private def actual_roots
roots.reject do |root_dir, _root_namespace|
!dir?(root_dir) || ignored_path?(root_dir)
end
end
# @sig (String) -> bool
private def root_dir?(dir)
roots.key?(dir)
end
# @sig (String) -> bool
private def excluded_from_eager_load?(abspath)
# Optimize this common use case.
return false if eager_load_exclusions.empty?
walk_up(abspath) do |path|
return true if eager_load_exclusions.member?(path)
return false if roots.key?(path)
end
false
end
# @sig (String) -> bool
private def collapse?(dir)
collapse_dirs.member?(dir)
end
# @sig (String | Pathname | Array[String | Pathname]) -> Array[String]
private def expand_paths(paths)
paths.flatten.map! { |path| File.expand_path(path) }
end
# @sig (Array[String]) -> Array[String]
private def expand_glob_patterns(glob_patterns)
# Note that Dir.glob works with regular file names just fine. That is,
# glob patterns technically need no wildcards.
glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
end
# @sig () -> void
private def recompute_ignored_paths
ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
end
# @sig () -> void
private def recompute_collapse_dirs
collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
end
end
@@ -0,0 +1,232 @@
module Zeitwerk::Loader::EagerLoad
# Eager loads all files in the root directories, recursively. Files do not
# 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`.
#
# @sig (true | false) -> void
def eager_load(force: false)
mutex.synchronize do
break if @eager_loaded
raise Zeitwerk::SetupRequired unless @setup
log("eager load start") if logger
actual_roots.each do |root_dir, root_namespace|
actual_eager_load_dir(root_dir, root_namespace, force: force)
end
autoloaded_dirs.each do |autoloaded_dir|
Zeitwerk::Registry.unregister_autoload(autoloaded_dir)
end
autoloaded_dirs.clear
@eager_loaded = true
log("eager load end") if logger
end
end
# @sig (String | Pathname) -> void
def eager_load_dir(path)
raise Zeitwerk::SetupRequired unless @setup
abspath = File.expand_path(path)
raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
cnames = []
root_namespace = nil
walk_up(abspath) do |dir|
return if ignored_path?(dir)
return if eager_load_exclusions.member?(dir)
break if root_namespace = roots[dir]
basename = File.basename(dir)
return if hidden?(basename)
unless collapse?(dir)
cnames << inflector.camelize(basename, dir).to_sym
end
end
raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
return if @eager_loaded
namespace = root_namespace
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 namespace.const_defined?(cname, false)
namespace = namespace.const_get(cname, false)
end
# A shortcircuiting test depends on the invocation of this method. Please
# keep them in sync if refactored.
actual_eager_load_dir(abspath, namespace)
end
# @sig (Module) -> void
def eager_load_namespace(mod)
raise Zeitwerk::SetupRequired unless @setup
unless mod.is_a?(Module)
raise Zeitwerk::Error, "#{mod.inspect} is not a class or module object"
end
return if @eager_loaded
mod_name = real_mod_name(mod)
return unless mod_name
actual_roots.each do |root_dir, root_namespace|
if Object.equal?(mod)
# A shortcircuiting test depends on the invocation of this method.
# Please keep them in sync if refactored.
actual_eager_load_dir(root_dir, root_namespace)
elsif root_namespace.equal?(Object)
eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
else
root_namespace_name = real_mod_name(root_namespace)
if root_namespace_name.start_with?(mod_name + "::")
actual_eager_load_dir(root_dir, root_namespace)
elsif mod_name == root_namespace_name
actual_eager_load_dir(root_dir, root_namespace)
elsif mod_name.start_with?(root_namespace_name + "::")
eager_load_child_namespace(mod, mod_name, root_dir, root_namespace)
else
# Unrelated constant hierarchies, do nothing.
end
end
end
end
# Loads the given Ruby file.
#
# Raises if the argument is ignored, shadowed, or not managed by the receiver.
#
# The method is implemented as `constantize` for files, in a sense, to be able
# to descend orderly and make sure the file is loadable.
#
# @sig (String | Pathname) -> void
def load_file(path)
abspath = File.expand_path(path)
raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
basename = File.basename(abspath, ".rb")
raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
base_cname = inflector.camelize(basename, abspath).to_sym
root_namespace = nil
cnames = []
walk_up(File.dirname(abspath)) do |dir|
raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(dir)
break if root_namespace = roots[dir]
basename = File.basename(dir)
raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
unless collapse?(dir)
cnames << inflector.camelize(basename, dir).to_sym
end
end
raise Zeitwerk::Error.new("I do not manage #{abspath}") unless root_namespace
namespace = root_namespace
cnames.reverse_each do |cname|
namespace = namespace.const_get(cname, false)
end
raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
namespace.const_get(base_cname, false)
end
# The caller is responsible for making sure `namespace` is the namespace that
# corresponds to `dir`.
#
# @sig (String, Module, Boolean) -> void
private def actual_eager_load_dir(dir, namespace, force: false)
honour_exclusions = !force
return if honour_exclusions && excluded_from_eager_load?(dir)
log("eager load directory #{dir} start") if logger
queue = [[dir, namespace]]
while (current_dir, namespace = queue.shift)
ls(current_dir) do |basename, abspath, ftype|
next if honour_exclusions && eager_load_exclusions.member?(abspath)
if ftype == :file
if (cref = autoloads[abspath])
cref.get
end
else
if collapse?(abspath)
queue << [abspath, namespace]
else
cname = inflector.camelize(basename, abspath).to_sym
queue << [abspath, namespace.const_get(cname, false)]
end
end
end
end
log("eager load directory #{dir} end") if logger
end
# In order to invoke this method, the caller has to ensure `child` is a
# strict namespace descendant of `root_namespace`.
#
# @sig (Module, String, Module, Boolean) -> void
private def eager_load_child_namespace(child, child_name, root_dir, root_namespace)
suffix = child_name
unless root_namespace.equal?(Object)
suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
end
# These directories are at the same namespace level, there may be more if
# we find collapsed ones. As we scan, we look for matches for the first
# segment, and store them in `next_dirs`. If there are any, we look for
# the next segments in those matches. Repeat.
#
# If we exhaust the search locating directories that match all segments,
# we just need to eager load those ones.
dirs = [root_dir]
next_dirs = []
suffix.split("::").each do |segment|
while (dir = dirs.shift)
ls(dir) do |basename, abspath, ftype|
next unless ftype == :directory
if collapse?(abspath)
dirs << abspath
elsif segment == inflector.camelize(basename, abspath)
next_dirs << abspath
end
end
end
return if next_dirs.empty?
dirs.replace(next_dirs)
next_dirs.clear
end
dirs.each do |dir|
actual_eager_load_dir(dir, child)
end
end
end
@@ -0,0 +1,146 @@
# frozen_string_literal: true
module Zeitwerk::Loader::Helpers
# --- Logging -----------------------------------------------------------------------------------
# @sig (String) -> void
private def log(message)
method_name = logger.respond_to?(:debug) ? :debug : :call
logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
end
# --- Files and directories ---------------------------------------------------------------------
# @sig (String) { (String, String) -> void } -> void
private def ls(dir)
children = Dir.children(dir)
# The order in which a directory is listed depends on the file system.
#
# Since client code may run in different platforms, it seems convenient to
# order directory entries. This provides consistent eager loading across
# platforms, for example.
children.sort!
children.each do |basename|
next if hidden?(basename)
abspath = File.join(dir, basename)
next if ignored_path?(abspath)
if dir?(abspath)
next if roots.key?(abspath)
if !has_at_least_one_ruby_file?(abspath)
log("directory #{abspath} is ignored because it has no Ruby files") if logger
next
end
ftype = :directory
else
next unless ruby?(abspath)
ftype = :file
end
# We freeze abspath because that saves allocations when passed later to
# File methods. See #125.
yield basename, abspath.freeze, ftype
end
end
# Looks for a Ruby file using breadth-first search. This type of search is
# important to list as less directories as possible and return fast in the
# common case in which there are Ruby files.
#
# @sig (String) -> bool
private def has_at_least_one_ruby_file?(dir)
to_visit = [dir]
while (dir = to_visit.shift)
Dir.each_child(dir) do |basename|
next if hidden?(basename)
abspath = File.join(dir, basename)
next if ignored_path?(abspath)
if dir?(abspath)
to_visit << abspath unless roots.key?(abspath)
else
return true if ruby?(abspath)
end
end
end
false
end
# @sig (String) -> bool
private def ruby?(path)
path.end_with?(".rb")
end
# @sig (String) -> bool
private def dir?(path)
File.directory?(path)
end
# @sig (String) -> bool
private def hidden?(basename)
basename.start_with?(".")
end
# @sig (String) { (String) -> void } -> void
private def walk_up(abspath)
loop do
yield abspath
abspath, basename = File.split(abspath)
break if basename == "/"
end
end
# --- Inflection --------------------------------------------------------------------------------
CNAME_VALIDATOR = Module.new
private_constant :CNAME_VALIDATOR
# @raise [Zeitwerk::NameError]
# @sig (String, String) -> Symbol
private def cname_for(basename, abspath)
cname = inflector.camelize(basename, abspath)
unless cname.is_a?(String)
raise TypeError, "#{inflector.class}#camelize must return a String, received #{cname.inspect}"
end
if cname.include?("::")
raise Zeitwerk::NameError.new(<<~MESSAGE, cname)
wrong constant name #{cname} inferred by #{inflector.class} from
#{abspath}
#{inflector.class}#camelize should return a simple constant name without "::"
MESSAGE
end
begin
CNAME_VALIDATOR.const_defined?(cname, false)
rescue ::NameError => error
path_type = ruby?(abspath) ? "file" : "directory"
raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
#{error.message} inferred by #{inflector.class} from #{path_type}
#{abspath}
Possible ways to address this:
* Tell Zeitwerk to ignore this particular #{path_type}.
* Tell Zeitwerk to ignore one of its parent directories.
* Rename the #{path_type} to comply with the naming conventions.
* Modify the inflector to handle this case.
MESSAGE
end
cname.to_sym
end
end
@@ -0,0 +1,6 @@
class Zeitwerk::NullInflector
# @sig (String, String) -> String
def camelize(basename, _abspath)
basename
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Zeitwerk::RealModName
UNBOUND_METHOD_MODULE_NAME = Module.instance_method(:name)
private_constant :UNBOUND_METHOD_MODULE_NAME
# Returns the real name of the class or module, as set after the first
# constant to which it was assigned (or nil).
#
# The name method can be overridden, hence the indirection in this method.
#
# @sig (Module) -> String?
def real_mod_name(mod)
UNBOUND_METHOD_MODULE_NAME.bind_call(mod)
end
end
@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Zeitwerk
module Registry # :nodoc: all
require_relative "registry/explicit_namespaces"
require_relative "registry/inceptions"
class << self
# Keeps track of all loaders. Useful to broadcast messages and to prevent
# them from being garbage collected.
#
# @private
# @sig Array[Zeitwerk::Loader]
attr_reader :loaders
# Registers gem loaders to let `for_gem` be idempotent in case of reload.
#
# @private
# @sig Hash[String, Zeitwerk::Loader]
attr_reader :gem_loaders_by_root_file
# Maps absolute paths to the loaders responsible for them.
#
# This information is used by our decorated `Kernel#require` to be able to
# invoke callbacks and autovivify modules.
#
# @private
# @sig Hash[String, Zeitwerk::Loader]
attr_reader :autoloads
# Registers a loader.
#
# @private
# @sig (Zeitwerk::Loader) -> void
def register_loader(loader)
loaders << loader
end
# @private
# @sig (Zeitwerk::Loader) -> void
def unregister_loader(loader)
loaders.delete(loader)
gem_loaders_by_root_file.delete_if { |_, l| l == loader }
autoloads.delete_if { |_, l| l == loader }
end
# This method returns always a loader, the same instance for the same root
# file. That is how Zeitwerk::Loader.for_gem is idempotent.
#
# @private
# @sig (String) -> Zeitwerk::Loader
def loader_for_gem(root_file, namespace:, warn_on_extra_files:)
gem_loaders_by_root_file[root_file] ||= GemLoader.__new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files)
end
# @private
# @sig (Zeitwerk::Loader, String) -> String
def register_autoload(loader, abspath)
autoloads[abspath] = loader
end
# @private
# @sig (String) -> void
def unregister_autoload(abspath)
autoloads.delete(abspath)
end
# @private
# @sig (String) -> Zeitwerk::Loader?
def loader_for(path)
autoloads[path]
end
# @private
# @sig (Zeitwerk::Loader) -> void
def on_unload(loader)
autoloads.delete_if { |_path, object| object == loader }
end
end
@loaders = []
@gem_loaders_by_root_file = {}
@autoloads = {}
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
module Zeitwerk::Registry
# This module is a registry for explicit namespaces.
#
# When a loader determines that a certain file should define an explicit
# namespace, it registers it here, associating its cref with itself.
#
# If the namespace is autoloaded, our const_added callback retrieves its
# loader by calling loader_for. That way, the loader is able to scan the
# subdirectories that conform the namespace and set autoloads for their
# expected constants just in time.
#
# Once autoloaded, the namespace is unregistered.
#
# The implementation assumes an explicit namespace is managed by one loader.
# Loaders that reopen namespaces owned by other projects are responsible for
# loading their constant before setup. This is documented.
module ExplicitNamespaces # :nodoc: all
# Maps crefs of explicit namespaces with their corresponding loader.
#
# Entries are added as the namespaces are found, and removed as they are
# autoloaded.
#
# @sig Zeitwerk::Cref::Map[Zeitwerk::Loader]
@loaders = Zeitwerk::Cref::Map.new
class << self
extend Zeitwerk::Internal
# Registers `cref` as being the constant path of an explicit namespace
# managed by `loader`.
#
# @sig (Zeitwerk::Cref, Zeitwerk::Loader) -> void
internal def register(cref, loader)
@loaders[cref] = loader
end
# @sig (Module, Symbol) -> Zeitwerk::Loader?
internal def loader_for(mod, cname)
@loaders.delete_mod_cname(mod, cname)
end
# @sig (Zeitwerk::Loader) -> void
internal def unregister_loader(loader)
@loaders.delete_by_value(loader)
end
# This is an internal method only used by the test suite.
#
# @sig (Symbol | String) -> Zeitwerk::Loader?
internal def registered?(cref)
@loaders[cref]
end
# This is an internal method only used by the test suite.
#
# @sig () -> void
internal def clear
@loaders.clear
end
end
end
end
@@ -0,0 +1,31 @@
module Zeitwerk::Registry
# Loaders know their own inceptions, but there is a use case in which we need
# to know if a given cpath is an inception globally. This is what this
# registry is for.
module Inceptions # :nodoc: all
# @sig Zeitwerk::Cref::Map[String]
@inceptions = Zeitwerk::Cref::Map.new
class << self
# @sig (Zeitwerk::Cref, String) -> void
def register(cref, autoload_path)
@inceptions[cref] = autoload_path
end
# @sig (String) -> String?
def registered?(cref)
@inceptions[cref]
end
# @sig (String) -> void
def unregister(cref)
@inceptions.delete(cref)
end
# @sig () -> void
def clear # for tests
@inceptions.clear
end
end
end
end
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Zeitwerk
VERSION = "2.7.2"
end