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
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2019–ω Xavier Noria
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
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"
require_relative "zeitwerk/inflector"
require_relative "zeitwerk/gem_inflector"
require_relative "zeitwerk/null_inflector"
require_relative "zeitwerk/error"
require_relative "zeitwerk/version"
require_relative "zeitwerk/core_ext/kernel"
require_relative "zeitwerk/core_ext/module"
# This is a dangerous method.
#
# @experimental
# @sig () -> void
def self.with_loader
loader = Zeitwerk::Loader.new
yield loader
ensure
loader.unregister
end
end
@@ -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