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