This commit is contained in:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user