initial commit

This commit is contained in:
Xavier Noria 2019-01-11 13:37:32 +01:00
commit 60e6918790
40 changed files with 2604 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
test/tmp
*.gem

11
.travis.yml Normal file
View File

@ -0,0 +1,11 @@
language: ruby
before_install:
- "rm ${BUNDLE_GEMFILE}.lock"
- "travis_retry gem update --system"
- "travis_retry gem install bundler -N"
rvm:
- 2.4.4
- 2.4
- 2.5
- 2.6
- ruby-head

8
Gemfile Normal file
View File

@ -0,0 +1,8 @@
source 'https://rubygems.org'
gemspec
gem "rake"
gem "minitest"
gem "minitest-focus"
gem "minitest-reporters"

33
Gemfile.lock Normal file
View File

@ -0,0 +1,33 @@
PATH
remote: .
specs:
zeitwerk (1.0.0.beta)
GEM
remote: https://rubygems.org/
specs:
ansi (1.5.0)
builder (3.2.3)
minitest (5.11.3)
minitest-focus (1.1.2)
minitest (>= 4, < 6)
minitest-reporters (1.3.5)
ansi
builder
minitest (>= 5.0)
ruby-progressbar
rake (12.3.2)
ruby-progressbar (1.10.0)
PLATFORMS
ruby
DEPENDENCIES
minitest
minitest-focus
minitest-reporters
rake
zeitwerk!
BUNDLED WITH
1.17.3

20
MIT-LICENSE Normal file
View File

@ -0,0 +1,20 @@
Copyright (c) 2019ω Xavier Noria
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

339
README.md Normal file
View File

@ -0,0 +1,339 @@
# Zeitwerk
[![Build Status](https://travis-ci.com/fxn/zeitwerk.svg?branch=master)](https://travis-ci.com/fxn/zeitwerk)
<!-- TOC -->
- [Zeitwerk](#zeitwerk)
- [Introduction](#introduction)
- [Synopsis](#synopsis)
- [File structure](#file-structure)
- [Implicit namespaces](#implicit-namespaces)
- [Explicit namespaces](#explicit-namespaces)
- [Nested root directories](#nested-root-directories)
- [Usage](#usage)
- [Setup](#setup)
- [Reloading](#reloading)
- [Eager loading](#eager-loading)
- [Preloading](#preloading)
- [Inflection](#inflection)
- [Zeitwerk::Inflector](#zeitwerkinflector)
- [Zeitwerk::GemInflector](#zeitwerkgeminflector)
- [Custom inflector](#custom-inflector)
- [Logging](#logging)
- [Ignoring parts of the project](#ignoring-parts-of-the-project)
- [Supported Ruby versions](#supported-ruby-versions)
- [Motivation](#motivation)
- [Thanks](#thanks)
- [License](#license)
<!-- /TOC -->
## Introduction
Zeitwerk is an efficient and thread-safe code loader for Ruby.
Given a conventional file structure, Zeitwerk loads your project's classes and modules on demand. You don't need to write `require` calls for your own files, rather, you can streamline your programming knowing that your classes and modules are available everywhere. This feature is efficient, thread-safe, and matches Ruby's semantics for constants.
The library is designed so that each gem and application can have their own loader, independent of each other. Each loader has its own configuration, inflector, and optional logger.
Zeitwerk is also able to reload code, which may be handy for web applications. Coordination is needed to reload in a thread-safe manner. The documentation below explains how to do this.
Finally, in some production setups it may be optimal to eager load all code upfront. Zeitwerk is able to do that too.
## Synopsis
Main interface for gems:
```ruby
# lib/my_gem.rb (main file)
require "zeitwerk"
Zeitwerk::Loader.for_gem.setup # ready!
module MyGem
# ...
end
```
Main generic interface:
```ruby
loader = Zeitwerk::Loader.new
loader.push_dir(...)
loader.setup # ready!
```
The `loader` variable can go out of scope. Zeitwerk keeps a registry with all of them, and so the object won't be garbage collected and remain active.
Later, you can reload if you want to:
```ruby
loader.reload
```
and you can also eager load all the code:
```ruby
loader.eager_load
```
It is also possible to broadcast `eager_load` to all instances:
```
Zeitwerk::Loader.eager_load_all
```
## File structure
To have a file structure Zeitwerk can work with, just name files and directories after the name of the classes and modules they define:
```
lib/my_gem.rb -> MyGem
lib/my_gem/foo.rb -> MyGem::Foo
lib/my_gem/bar_baz.rb -> MyGem::BarBaz
lib/my_gem/woo/zoo.rb -> MyGem::Woo::Zoo
```
Every directory configured with `push_dir` acts as root namespace. There can be several of them. For example, given
```ruby
loader.push_dir(Rails.root.join("app/models"))
loader.push_dir(Rails.root.join("app/controllers"))
```
Zeitwerk understands that their respective files and subdirectories belong to the root namespace:
```
app/models/user.rb -> User
app/controllers/admin/users_controller.rb -> Admin::UsersController
```
### Implicit namespaces
Directories without a matching Ruby file get modules autovivified automatically by Zeitwerk. For example, in
```
app/controllers/admin/users_controller.rb -> Admin::UsersController
```
`Admin` is autovivified as a module on demand, you do not need to define an `Admin` class or module in an `admin.rb` file explicitly.
### Explicit namespaces
Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
```
app/models/hotel.rb -> Hotel
app/models/hotel/pricing.rb -> Hotel::Pricing
```
Zeitwerk does not autovivify a `Hotel` module in that case. The file `app/models/hotel.rb` explicitly defines `Hotel` and Zeitwerk loads it as needed before going for `Hotel::Pricing`.
### Nested root directories
Root directories should not be ideally nested, but Zeitwerk supports them because in Rails, for example, both `app/models` and `app/models/concerns` belong to the autoload paths.
Zeitwerk detects nested root directories, and treats them as roots only. In the example above, `concerns` is not considered to be a namespace below `app/models`. For example, the file:
```
app/models/concerns/geolocatable.rb
```
should define `Geolocatable`, not `Concerns::Geolocatable`.
## Usage
### Setup
Loaders are ready to load code right after calling `setup` on them:
```ruby
loader.setup
```
Customization should generally be done before that call. In particular, in the generic interface you may set the root directories from which you want to load files:
```ruby
loader.push_dir(...)
loader.push_dir(...)
loader.setup
```
The loader returned by `Zeitwerk::Loader.for_gem` has the directory of the caller pushed, normally that is the absolute path of `lib`. In that sense, `for_gem` can be used also by projects with a gem structure, even if they are not technically gems. That is, you don't need a gemspec or anything.
### Reloading
In order to reload code:
```ruby
loader.reload
```
Generally speaking, reloading is useful for services, servers, web applications, etc. Gems that implement regular libraries, so to speak, won't normally have a use case for reloading.
It is important to highlight that this is and instance method. Therefore, reloading the code of a project managed by a particular loader does _not_ reload the code of other gems using Zeitwerk at all.
In order for reloading to be thread-safe, you need to implement some coordination. For example, a web framework that serves each request with its own thread may have a globally accessible RW lock. When a request comes in, the framework acquires the lock for reading at the beginning, and the code in the framework that calls `loader.reload` needs to acquire the lock for writing.
### Eager loading
Zeitwerk instances are able to eager load their managed files:
```ruby
loader.eager_load
```
You can opt-out of eager loading individual files or directories:
```ruby
db_adapters = File.expand_path("my_gem/db_adapters", __dir__)
cache_adapters = File.expand_path("my_gem/cache_adapters", __dir__)
loader.do_not_eager_load(db_adapters, cache_adapters)
loader.setup
loader.eager_load # won't go into the directories with db/cache adapters
```
Files and directories excluded from eager loading can still be loaded on demand, so an idiom like this is possible:
```ruby
db_adapter = Object.const_get("MyGem::DbAdapters::#{config[:db_adapter]}")
```
Please check `Zeitwerk::Loader#ignore` if you want files or directories to not be even autoloadable.
If you want to eager load yourself and all dependencies using Zeitwerk, you can broadcast the `eager_load` call to all instances:
```ruby
Zeitwerk::Loader.eager_load_all
```
In that case, exclusions are per autoloader, and so will apply to each of them accordingly.
This may be handy in top-level services, like web applications.
### Preloading
Zeitwerk instances are able to preload files and directories.
```ruby
loader.preload("app/models/videogame.rb")
loader.preload("app/models/book.rb")
```
The example above depicts several calls are supported, but `preload` accepts multiple arguments and arrays of strings as well.
The call can happen after `setup` (preloads on the spot), or before `setup` (executes during setup).
If you're using reloading, preloads run on each reload too.
This is a feature specifically thought for STIs in Rails, preloading the leafs of a STI tree ensures all classes are known when doing a query.
### Inflection
Each individual loader needs an inflector to figure out which constant path would a given file or directory map to. Zeitwerk ships with two basic inflectors.
#### Zeitwerk::Inflector
This is a very basic inflector that converts snake case to camel case:
```
user -> User
users_controller -> UsersController
html_parser -> HtmlParser
```
There are no inflection rules or global configuration that can affect this inflector. It is deterministic.
This is the default inflector.
#### Zeitwerk::GemInflector
The loader instantiated behind the scenes by `Zeitwerk::Loader.for_gem` gets assigned by default an inflector that is like the basic one, except it expects `lib/my_gem/version.rb` to define `MyGem::VERSION`.
#### Custom inflector
The inflectors that ship with Zeitwerk are deterministic and simple. But you can configure your own:
```ruby
# frozen_string_literal: true
class MyInflector < Zeitwerk::Inflector # or Zeitwerk::GemInflector
def camelize(basename, _abspath)
case basename
when "api"
"API"
when "mysql_adapter"
"MySQLAdapter"
else
super
end
end
end
```
The first argument, `basename`, is a string with the basename of the file or directory to be inflected. In the case of a file, without extension. The inflector needs to return this basename inflected. Therefore, a simple constant name without colons.
The second argument, `abspath`, is a string with the absolute path to the file or directory in case you need it to decide how to inflect the basename.
Then, assign the inflector before calling `setup`:
```
loader.inflector = MyInflector.new
```
This needs to be assigned before the call to `setup`.
### Logging
Zeitwerk is silent by default, but you can configure a callable as logger:
```ruby
loader.logger = method(:puts)
```
If there is a logger configured, the loader is going to print traces when autoloads are set, files loaded, and modules autovivified.
If your project has namespaces, you'll notice in the traces Zeitwerk sets autoloads for _directories_. That's a technique used to be able to descend into subdirectories on demand, avoiding that way unnecessary tree walks.
### Ignoring parts of the project
Sometimes it might be convenient to tell Zeitwerk to completely ignore some particular file or directory. For example, let's suppose that your gem decorates something in `Kernel`:
```ruby
# lib/my_gem/core_ext/kernel.rb
Kernel.module_eval do
# ...
end
```
That file does not follow the conventions and you need to tell Zeitwerk:
```ruby
kernel_ext = File.expand_path("my_gem/core_ext/kernel.rb", __dir__)
loader.ignore(kernel_ext)
loader.setup
```
You can pass several arguments to this method, also an array of strings. And you can call `ignore` multiple times too.
## Supported Ruby versions
Zeitwerk works with MRI 2.4.4 and above.
## Motivation
Since `require` has global side-effects, and there is no static way to verify that you have issued the `require` calls for code that your file depends on, in practice it is very easy to forget some. That introduces bugs that depend on the load order. Zeitwerk provides a way to forget about `require` in your own code, just name things following conventions and done.
On the other hand, autoloading in Rails is based on `const_missing`, which lacks fundamental information like the nesting and the resolution algorithm that was being used. Because of that, Rails autoloading is not able to match Ruby's semantics and that introduces a series of gotchas. The original goal of this project was to bring a better autoloading mechanism for Rails 6.
## Thanks
I'd like to thank [@matthewd](https://github.com/matthewd) for the discussions we've had about this topic in the past years, I learned a couple of tricks used in Zeitwerk from him.
Also would like to thank [@Shopify](https://github.com/Shopify), [@rafaelfranca](https://github.com/rafaelfranca), and [@dylanahsmith](https://github.com/dylanahsmith), for sharing [this PoC](https://github.com/Shopify/autoload_reloader). The technique Zeitwerk uses to support explicit namespaces was copied from that project.
## License
Released under the MIT License, Copyright (c) 2019<i>ω</i> Xavier Noria.

8
Rakefile Normal file
View File

@ -0,0 +1,8 @@
require 'rake/testtask'
task :default => :test
Rake::TestTask.new do |t|
t.test_files = Dir.glob('test/lib/**/test_*.rb')
t.libs << "test"
end

7
bin/test Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
if [[ -z $1 ]]; then
bundle exec rake
else
bundle exec rake TEST="$1"
fi

9
lib/zeitwerk.rb Normal file
View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Zeitwerk
require_relative "zeitwerk/loader"
require_relative "zeitwerk/registry"
require_relative "zeitwerk/inflector"
require_relative "zeitwerk/gem_inflector"
require_relative "zeitwerk/kernel"
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Zeitwerk
class GemInflector < Inflector
# @param root_file [String]
def initialize(root_file)
namespace = File.basename(root_file, ".rb")
lib_dir = File.dirname(root_file)
@version_file = File.join(lib_dir, namespace, "version.rb")
end
# @param basename [String]
# @param abspath [String]
# @return [String]
def camelize(basename, abspath)
(basename == "version" && abspath == @version_file) ? "VERSION" : super
end
end
end

18
lib/zeitwerk/inflector.rb Normal file
View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Zeitwerk
class Inflector # :nodoc:
# Very basic snake case -> camel case conversion.
#
# Zeitwerk::Inflector.camelize("post", ...) # => "Post"
# Zeitwerk::Inflector.camelize("users_controller", ...) # => "UsersController"
# Zeitwerk::Inflector.camelize("api", ...) # => "Api"
#
# @param basename [String]
# @param _abspath [String]
# @return [String]
def camelize(basename, _abspath)
basename.split('_').map(&:capitalize).join
end
end
end

33
lib/zeitwerk/kernel.rb Normal file
View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Kernel
module_function
# We cannot decorate with prepend + super because Kernel has already been
# included in Object, and changes in ancestors don't get propagated into
# already existing ancestor chains.
alias_method :zeitwerk_original_require, :require
# @param path [String]
# @return [Boolean]
def require(path)
if loader = Zeitwerk::Registry.loader_for(path)
if path.end_with?(".rb")
zeitwerk_original_require(path).tap do |required|
loader.on_file_loaded(path) if required
end
else
loader.on_dir_loaded(path)
end
else
zeitwerk_original_require(path).tap do |required|
if required
realpath = $LOADED_FEATURES.last
if loader = Zeitwerk::Registry.loader_for(realpath)
loader.on_file_loaded(realpath)
end
end
end
end
end
end

477
lib/zeitwerk/loader.rb Normal file
View File

@ -0,0 +1,477 @@
# frozen_string_literal: true
require "set"
module Zeitwerk
class Loader
# @return [#camelize]
attr_accessor :inflector
# @return [#call, nil]
attr_accessor :logger
# Absolute paths of directories from which you want to load constants. This
# is a private attribute, client code should use `push_dir`.
#
# Stored in a hash to preserve order, easily handle duplicates, and also be
# able to have a fast lookup, needed for detecting nested paths.
#
# "/Users/fxn/blog/app/assets" => true,
# "/Users/fxn/blog/app/channels" => true,
# ...
#
# @private
# @return [{String => true}]
attr_reader :dirs
# Absolute paths of files or directories that have to be preloaded.
#
# @private
# @return [<String>]
attr_reader :preloads
# Absolute paths of files or directories to be totally ignored.
#
# @private
# @return [String]
attr_reader :ignored
# Maps real absolute paths for which an autoload has been set to their
# corresponding parent class or module and constant name.
#
# "/Users/fxn/blog/app/models/user.rb" => [Object, "User"],
# "/Users/fxn/blog/app/models/hotel/pricing.rb" => [Hotel, "Pricing"]
# ...
#
# @private
# @return [{String => (Module, String)}]
attr_reader :autoloads
# Maps constant paths of namespaces to arrays of corresponding directories.
#
# For example, given this mapping:
#
# "Admin" => [
# "/Users/fxn/blog/app/controllers/admin",
# "/Users/fxn/blog/app/models/admin",
# ...
# ]
#
# when `Admin` gets defined we know that it plays the role of a namespace and
# that its children are spread over those directories. We'll visit them to set
# up the corresponding autoloads.
#
# @private
# @return [{String => <String>}]
attr_reader :lazy_subdirs
# @private
# @return [Set]
attr_reader :eager_load_exclusions
# @private
# @return [Mutex]
attr_reader :mutex
# This tracer listens to `:class` events, and it is used to support explicit
# namespaces. Benchmarks have shown the tracer does not impact performance
# in any measurable way.
#
# @private
# @return [TracePoint]
attr_reader :tracer
def initialize
self.inflector = Inflector.new
@dirs = {}
@preloads = []
@ignored = Set.new
@autoloads = {}
@lazy_subdirs = {}
@eager_load_exclusions = Set.new
@mutex = Mutex.new
@setup = false
@eager_loaded = false
@tracer = TracePoint.trace(:class) do |tp|
unless lazy_subdirs.empty? # do not even compute the hash key if not needed
if subdirs = lazy_subdirs.delete(tp.self.name)
subdirs.each { |subdir| set_autoloads_in_dir(subdir, tp.self) }
end
end
end
Registry.register_loader(self)
end
# Pushes `paths` to the list of root directories.
#
# @param path [<String, Pathname>]
# @return [void]
def push_dir(path)
abspath = File.expand_path(path)
mutex.synchronize do
if dir?(abspath)
dirs[abspath] = true
else
raise ArgumentError, "the root directory #{abspath} does not exist"
end
end
end
# Files or directories to be preloaded instead of lazy loaded.
#
# @param paths [<String, Pathname, <String, Pathname>>]
# @return [void]
def preload(*paths)
mutex.synchronize do
expand_paths(paths).each do |abspath|
preloads << abspath
if @setup
if ruby?(abspath)
do_preload_file(abspath)
elsif dir?(abspath)
do_preload_dir(abspath)
end
end
end
end
end
# Files or directories to be totally ignored.
#
# @param paths [<String, Pathname, <String, Pathname>>]
# @return [void]
def ignore(*paths)
mutex.synchronize { ignored.merge(expand_paths(paths)) }
end
# Sets autoloads in the root namespace and preloads files, if any.
#
# @return [void]
def setup
mutex.synchronize do
unless @setup
actual_dirs.each { |dir| set_autoloads_in_dir(dir, Object) }
tracer.enable
do_preload
@setup = true
end
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.
#
# @private
# @return [void]
def unload
mutex.synchronize do
autoloads.each do |path, (parent, cname)|
# If the constant was loaded, we unload it. Otherwise, this removes
# the autoload in the parent, which is something we want to do anyway.
parent.send(:remove_const, cname) rescue :user_removed_it_by_hand_that_is_fine
# Let Kernel#require load the same path later again by removing it
# from $LOADED_FEATURES. We check the extension to avoid unnecessary
# array lookups, since directories are not stored in $LOADED_FEATURES.
$LOADED_FEATURES.delete(path) if ruby?(path)
end
autoloads.clear
lazy_subdirs.clear
Registry.on_unload(self)
tracer.disable
@setup = 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.
#
# @return [void]
def reload
unload
setup
end
# Eager loads all files in the root directories, recursively. Files do not
# need to be in `$LOAD_PATH`, absolute file names are used. Ignored files
# are not eager loaded. You can opt-out specifically in specific files and
# directories with `do_not_eager_load`.
#
# @return [void]
def eager_load
mutex.synchronize do
unless @eager_loaded
actual_dirs.each do |dir|
eager_load_dir(dir) unless eager_load_exclusions.member?(dir)
end
tracer.disable if eager_load_exclusions.empty?
@eager_loaded = true
end
end
end
# Let eager load ignore the given files or directories. The constants
# defined in those files are still autoloadable.
#
# @param paths [<String, Pathname, <String, Pathname>>]
# @return [void]
def do_not_eager_load(*paths)
mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
end
# --- Class methods ---------------------------------------------------------------------------
# This is a shortcut for
#
# require "zeitwerk"
# loader = Zeitwerk::Loader.new
# loader.inflector = Zeitwerk::GemInflector.new
# 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.
#
# `Zeitwerk::GemInflector` is a subclass of `Zeitwerk::Inflector` that
# camelizes "lib/my_gem/version.rb" as "MyGem::VERSION".
#
# @return [Zeitwerk::Loader]
def self.for_gem
called_from = caller[0].split(':')[0]
Registry.loader_for_gem(called_from)
end
# Broadcasts `eager_load` to all loaders.
#
# @return [void]
def self.eager_load_all
Registry.loaders.each(&:eager_load)
end
# --- Callbacks -------------------------------------------------------------------------------
# Callback invoked from Kernel when a managed file is loaded.
#
# @private
# @param file [String]
# @return [void]
def on_file_loaded(file)
if logger
parent, cname = autoloads[file]
logger.call("constant #{cpath(parent, cname)} loaded from file #{file}")
end
end
# Callback invoked from Kernel when a managed directory is loaded.
#
# @private
# @param dir [String]
# @return [void]
def on_dir_loaded(dir)
parent, cname = autoloads[dir]
autovivified = parent.const_set(cname, Module.new)
logger.call("module #{cpath(parent, cname)} autovivified from directory #{dir}") if logger
if subdirs = lazy_subdirs[cpath(parent, cname)]
subdirs.each { |subdir| set_autoloads_in_dir(subdir, autovivified) }
end
end
private # -------------------------------------------------------------------------------------
# @return [<String>]
def actual_dirs
dirs.each_key.reject { |dir| ignored.member?(dir) }
end
# @param dir [String]
# @param parent [Module]
# @return [void]
def set_autoloads_in_dir(dir, parent)
each_abspath(dir) do |abspath|
cname = inflector.camelize(File.basename(abspath, ".rb"), abspath)
if ruby?(abspath)
autoload_file(parent, cname, abspath)
elsif dir?(abspath)
# In a Rails application, `app/models/concerns` is a subdirectory of
# `app/models`, but both of them are root directories.
#
# To resolve the ambiguity file name -> constant path this introduces,
# the `app/models/concerns` directory is totally ignored as a namespace,
# it counts only as root. The guard checks that.
autoload_subdir(parent, cname, abspath) unless dirs.key?(abspath)
end
end
end
# @param parent [Module]
# @paran cname [String]
# @param subdir [String]
# @return [void]
def autoload_subdir(parent, cname, subdir)
if autoload_for?(parent, cname)
# If there is already an autoload for this cname, maybe there are
# multiple directories defining the namespace, or the cname is going to
# be defined in a file (explicit namespace). In either case, we do not
# need to issue another autoload, the existing one is fine.
(lazy_subdirs[cpath(parent, cname)] ||= []) << subdir
elsif !parent.const_defined?(cname, false)
# First time we find this namespace, set an autoload for it.
(lazy_subdirs[cpath(parent, cname)] ||= []) << subdir
set_autoload(parent, cname, subdir)
else
# For whatever reason the constant that corresponds to this namespace has
# already been defined, we have to recurse.
set_autoloads_in_dir(subdir, parent.const_get(cname))
end
end
# @param parent [Module]
# @paran cname [String]
# @param file [String]
# @return [void]
def autoload_file(parent, cname, file)
if autoload_path = autoload_for?(parent, cname)
# First autoload for a Ruby file wins, just ignore subsequent ones.
return if ruby?(autoload_path)
# Override autovivification, we want the namespace to become the
# class/module defined in this file.
autoloads.delete(autoload_path)
Registry.unregister_autoload(autoload_path)
set_autoload(parent, cname, file)
elsif !parent.const_defined?(cname, false)
set_autoload(parent, cname, file)
end
end
# @param parent [Module]
# @param cname [String]
# @param abspath [String]
# @return [void]
def set_autoload(parent, cname, abspath)
# $LOADED_FEATURES stores real paths since Ruby 2.4.4. We set and save the
# real path to be able to delete it from $LOADED_FEATURES on unload, and to
# be able to do a lookup later in Kernel#require for manual require calls.
realpath = File.realpath(abspath)
parent.autoload(cname, realpath)
if logger
if ruby?(realpath)
logger.call("autoload set for #{cpath(parent, cname)}, to be loaded from #{realpath}")
else
logger.call("autoload set for #{cpath(parent, cname)}, to be autovivified from #{realpath}")
end
end
autoloads[realpath] = [parent, cname]
Registry.register_autoload(self, realpath)
# See why in the documentation of Zeitwerk::Registry.inceptions.
unless parent.autoload?(cname)
Registry.register_inception(cpath(parent, cname), realpath, self)
end
end
# @param parent [Module]
# @param cname [String]
# @return [String, nil]
def autoload_for?(parent, cname)
parent.autoload?(cname) || Registry.inception?(cpath(parent, cname))
end
# @param dir [String]
# @return [void]
def eager_load_dir(dir)
each_abspath(dir) do |abspath|
next if eager_load_exclusions.member?(abspath)
if ruby?(abspath)
require abspath
elsif dir?(abspath)
eager_load_dir(abspath)
end
end
end
# This method is called this way because I prefer `preload` to be the method
# name to configure preloads in the public interface.
#
# @return [void]
def do_preload
preloads.each do |abspath|
if ruby?(abspath)
do_preload_file(abspath)
elsif dir?(abspath)
do_preload_dir(abspath)
end
end
end
# @param dir [String]
# @return [void]
def do_preload_dir(dir)
each_abspath(dir) do |abspath|
if ruby?(abspath)
do_preload_file(abspath)
elsif dir?(abspath)
do_preload_dir(abspath)
end
end
end
# @param file [String]
# @return [Boolean]
def do_preload_file(file)
logger.call("preloading #{file}") if logger
require file
end
# @param parent [Module]
# @param cname [String]
# @return [String]
def cpath(parent, cname)
parent.equal?(Object) ? cname : "#{parent.name}::#{cname}"
end
# @param dir [String]
# @yieldparam path [String]
# @return [void]
def each_abspath(dir)
Dir.foreach(dir) do |entry|
next if entry.start_with?(".")
abspath = File.join(dir, entry)
yield abspath unless ignored.member?(abspath)
end
end
# @param path [String]
# @return [Boolean]
def ruby?(path)
path.end_with?(".rb")
end
# @param path [String]
# @return [Boolean]
def dir?(path)
File.directory?(path)
end
# @param paths [<String, Pathname, <String, Pathname>>]
# @return [<String>]
def expand_paths(paths)
Array(paths).flatten.map { |path| File.expand_path(path) }
end
end
end

146
lib/zeitwerk/registry.rb Normal file
View File

@ -0,0 +1,146 @@
# frozen_string_literal: true
module Zeitwerk
module Registry # :nodoc: all
class << self
# Keeps track of all loaders. Useful to broadcast messages and to prevent
# them from being garbage collected.
#
# @private
# @return [<Zeitwerk::Loader>]
attr_reader :loaders
# Registers loaders created with `for_gem` to make the method idempotent
# in case of reload.
#
# @private
# @return [{String => Zeitwerk::Loader}]
attr_reader :loaders_managing_gems
# Maps real 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
# @return [{String => Zeitwerk::Loader}]
attr_reader :autoloads
# This hash table addresses an edge case in which an autoload is ignored.
#
# For example, let's suppose we want to autoload in a gem like this:
#
# # lib/my_gem.rb
# loader = Zeitwerk::Loader.new
# loader.push_dir(__dir__)
# loader.setup
#
# module MyGem
# end
#
# if you require "my_gem", as Bundler would do, this happens while setting
# up autoloads:
#
# 1. Object.autoload?(:MyGem) returns `nil` because the autoload for
# the constant is issued by Zeitwerk while the same file is being
# required.
# 2. The constant `MyGem` is undefined while setup runs.
#
# Therefore, a directory `lib/my_gem` would autovivify a module according to
# the existing information. But that would be wrong.
#
# To overcome this fundamental limitation, we keep track of the constant
# paths that are in this situation ---in the example above, "MyGem"--- and
# take this collection into account for the autovivification logic.
#
# Note that you cannot generally address this by moving the setup code
# below the constant definition, because we want libraries to be able to
# use managed constants in the module body:
#
# module MyGem
# include MyConcern
# end
#
# @private
# @return [{String => (String, Zeitwerk::Loader)}]
attr_reader :inceptions
# Registers a loader.
#
# @private
# @param loader [Zeitwerk::Loader]
# @return [void]
def register_loader(loader)
loaders << 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
# @param root_file [String]
# @return [Zeitwerk::Loader]
def loader_for_gem(root_file)
loaders_managing_gems[root_file] ||= begin
Loader.new.tap do |loader|
loader.inflector = Zeitwerk::GemInflector.new(root_file)
loader.push_dir(File.dirname(root_file))
end
end
end
# @private
# @param loader [Zeitwerk::Loader]
# @param realpath [String]
# @return [void]
def register_autoload(loader, realpath)
autoloads[realpath] = loader
end
# @private
# @param realpath [String]
# @return [void]
def unregister_autoload(realpath)
autoloads.delete(realpath)
end
# @private
# @param cpath [String]
# @param realpath [String]
# @param loader [Zeitwerk::Loader]
# @return [void]
def register_inception(cpath, realpath, loader)
inceptions[cpath] = [realpath, loader]
end
# @private
# @param cpath [String]
# @return [String, nil]
def inception?(cpath)
if pair = inceptions[cpath]
pair.first
end
end
# @private
# @param path [String]
# @return [Zeitwerk::Loader, nil]
def loader_for(path)
autoloads[path]
end
# @private
# @param loader [Zeitwerk::Loader]
# @return [void]
def on_unload(loader)
autoloads.delete_if { |_path, object| object == loader }
inceptions.delete_if { |_cpath, (_path, object)| object == loader }
end
end
@loaders = []
@loaders_managing_gems = {}
@autoloads = {}
@inceptions = {}
end
end

5
lib/zeitwerk/version.rb Normal file
View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Zeitwerk
VERSION = "1.0.0.beta"
end

View File

@ -0,0 +1,30 @@
require "test_helper"
class TestGemInflector < LoaderTest
def with_setup
files = [
["lib/my_gem.rb", "Zeitwerk::Loader.for_gem.setup; module MyGem; end"],
["lib/my_gem/foo.rb", "MyGem::Foo = true"],
["lib/my_gem/version.rb", "MyGem::VERSION = '1.0.0'"],
["lib/my_gem/ns/version.rb", "MyGem::Ns::Version = true"]
]
with_files(files) do
loader.push_dir("lib")
loader.setup
yield
end
end
test "the constant for my_gem/version.rb is inflected as VERSION" do
with_setup { assert_equal "1.0.0", MyGem::VERSION }
end
test "other possible version.rb are inflected normally" do
with_setup { assert MyGem::Ns::Version }
end
test "works as expected for other files" do
with_setup { assert MyGem::Foo }
end
end

View File

@ -0,0 +1,19 @@
require "test_helper"
class TestInflector < Minitest::Test
def camelize(str)
Zeitwerk::Inflector.new.camelize(str, nil)
end
test "capitalizes the first letter" do
assert_equal "User", camelize("user")
end
test "camelizes snake case basenames" do
assert_equal "UsersController", camelize("users_controller")
end
test "knows nothing about acronyms" do
assert_equal "HtmlParser", camelize("html_parser")
end
end

View File

@ -0,0 +1,28 @@
require "test_helper"
class TestAncestors < LoaderTest
test "autoloads a constant from an ancestor" do
files = [
["a.rb", "class A; end"],
["a/x.rb", "class A::X; end"],
["b.rb", "class B < A; end"],
["c.rb", "class C < B; end"]
]
with_setup(files) do
assert C::X
end
end
test "autoloads a constant from an ancenstor, even if present above" do
files = [
["a.rb", "class A; X = :A; end"],
["b.rb", "class B < A; end"],
["b/x.rb", "class B; X = :B; end"],
["c.rb", "class C < B; end"]
]
with_setup(files) do
assert_equal :A, A::X
assert_equal :B, C::X
end
end
end

View File

@ -0,0 +1,28 @@
require "test_helper"
class TestAutovivification < LoaderTest
test "autoloads a simple constant in an autovivified module" do
files = [["admin/x.rb", "Admin::X = true"]]
with_setup(files) do
assert Admin::X
end
end
test "autovivifies several levels in a row" do
files = [["foo/bar/baz/woo.rb", "Foo::Bar::Baz::Woo = true"]]
with_setup(files) do
assert Foo::Bar::Baz::Woo
end
end
test "autoloads several constants from the same namespace" do
files = [
["app/models/admin/hotel.rb", "class Admin::Hotel; end"],
["app/controllers/admin/hotels_controller.rb", "class Admin::HotelsController; end"]
]
with_setup(files, dirs: %w(app/models app/controllers)) do
assert Admin::Hotel
assert Admin::HotelsController
end
end
end

View File

@ -0,0 +1,55 @@
require "test_helper"
class TestCallbacks < LoaderTest
test "autoloading a file triggers on_file_loaded" do
def loader.on_file_loaded(file)
if file == File.realpath("x.rb")
$on_file_loaded_called = true
end
super
end
files = [["x.rb", "X = true"]]
with_setup(files) do
$on_file_loaded_called = false
assert X
assert $on_file_loaded_called
end
end
test "requiring an autoloadable file triggers on_file_loaded" do
def loader.on_file_loaded(file)
if file == File.realpath("y.rb")
$on_file_loaded_called = true
end
super
end
files = [
["x.rb", "X = true"],
["y.rb", "Y = X"]
]
with_setup(files, load_path: ".") do
$on_file_loaded_called = false
require "y"
assert Y
assert $on_file_loaded_called
end
end
test "autoloading a directory triggers on_dir_loaded" do
def loader.on_dir_loaded(dir)
if dir == File.realpath("m")
$on_dir_loaded_called = true
end
super
end
files = [["m/x.rb", "M::X = true"]]
with_setup(files) do
$on_dir_loaded_called = false
assert M::X
assert $on_dir_loaded_called
end
end
end

View File

@ -0,0 +1,20 @@
require "test_helper"
class TestConcurrency < LoaderTest
test "constant definition is synchronized" do
files = [["m.rb", <<-EOS]]
module M
sleep 0.5
def self.works?
true
end
end
EOS
with_setup(files) do
t = Thread.new { M }
assert M.works?
t.join
end
end
end

View File

@ -0,0 +1,117 @@
require "test_helper"
class TestEagerLoad < LoaderTest
test "eager loads indepent files" do
loaders = [loader, Zeitwerk::Loader.new]
$tel0 = $tel1 = false
files = [
["lib0/app0.rb", "module App0; end"],
["lib0/app0/foo.rb", "class App0::Foo; $tel0 = true; end"],
["lib1/app1/foo.rb", "class App1::Foo; end"],
["lib1/app1/foo/bar/baz.rb", "class App1::Foo::Bar::Baz; $tel1 = true; end"]
]
with_files(files) do
loaders[0].push_dir("lib0")
loaders[0].setup
loaders[1].push_dir("lib1")
loaders[1].setup
Zeitwerk::Loader.eager_load_all
assert $tel0
assert $tel1
end
end
test "eager loads dependent loaders" do
loaders = [loader, Zeitwerk::Loader.new]
$tel0 = $tel1 = false
files = [
["lib0/app0.rb", <<-EOS],
module App0
App1
end
EOS
["lib0/app0/foo.rb", <<-EOS],
class App0::Foo
$tel0 = App1::Foo
end
EOS
["lib1/app1/foo.rb", <<-EOS],
class App1::Foo
App0
end
EOS
["lib1/app1/foo/bar/baz.rb", <<-EOS]
class App1::Foo::Bar::Baz
$tel1 = App0::Foo
end
EOS
]
with_files(files) do
loaders[0].push_dir("lib0")
loaders[0].setup
loaders[1].push_dir("lib1")
loaders[1].setup
Zeitwerk::Loader.eager_load_all
assert $tel0
assert $tel1
end
end
test "we can opt-out of entire root directories, and still autoload" do
$test_eager_load_eager_loaded_p = false
files = [["foo.rb", "Foo = true; $test_eager_load_eager_loaded_p = true"]]
with_setup(files) do
loader.do_not_eager_load(".")
loader.eager_load
assert !$test_eager_load_eager_loaded_p
assert Foo
end
end
test "we can opt-out of sudirectories, and still autoload" do
$test_eager_load_eager_loaded_p = false
files = [
["db_adapters/mysql_adapter.rb", <<-EOS],
module DbAdapters::MysqlAdapter
end
$test_eager_load_eager_loaded_p = true
EOS
["foo.rb", "Foo = true"]
]
with_setup(files) do
loader.do_not_eager_load("db_adapters")
loader.eager_load
assert Foo
assert !$test_eager_load_eager_loaded_p
assert DbAdapters::MysqlAdapter
end
end
test "we can opt-out of files, and still autoload" do
$test_eager_load_eager_loaded_p = false
files = [
["foo.rb", "Foo = true"],
["bar.rb", "Bar = true; $test_eager_load_eager_loaded_p = true"]
]
with_setup(files) do
loader.do_not_eager_load("bar.rb")
loader.eager_load
assert Foo
assert !$test_eager_load_eager_loaded_p
assert Bar
end
end
end

View File

@ -0,0 +1,17 @@
require "test_helper"
class TestExceptions < LoaderTest
test "raises NameError if the expected constant is not defined" do
files = [["typo.rb", "TyPo = 1"]]
with_setup(files) do
assert_raises(NameError) { Typo }
end
end
test "raises if the file does" do
files = [["raises.rb", "Raises = 1; raise 'foo'"]]
with_setup(files, rm: false) do
assert_raises(RuntimeError, "foo") { Raises }
end
end
end

View File

@ -0,0 +1,77 @@
require "test_helper"
class TestForGem < LoaderTest
test "sets things correctly" do
files = [
["my_gem.rb", <<-EOS],
$for_gem_test_loader = Zeitwerk::Loader.for_gem
$for_gem_test_loader.setup
class MyGem
end
EOS
["my_gem/foo.rb", "class MyGem::Foo; end"],
["my_gem/foo/bar.rb", "class MyGem::Foo::Bar; end"]
]
with_files(files) do
with_load_path(".") do
assert require "my_gem" # what bundler is going to do
assert MyGem::Foo::Bar
$for_gem_test_loader.unload
assert !Object.const_defined?(:MyGem)
$for_gem_test_loader.setup
assert MyGem::Foo::Bar
end
end
end
test "is idempotent" do
files = [
["my_gem.rb", <<-EOS],
$for_gem_test_zs << Zeitwerk::Loader.for_gem
$for_gem_test_zs.last.setup
class MyGem
end
EOS
["my_gem/foo.rb", "class MyGem::Foo; end"]
]
with_files(files) do
with_load_path(".") do
$for_gem_test_zs = []
assert require "my_gem" # what bundler is going to do
assert MyGem::Foo
$for_gem_test_zs.first.unload
assert !Object.const_defined?(:MyGem)
$for_gem_test_zs.first.setup
assert MyGem::Foo
assert_equal 2, $for_gem_test_zs.size
assert_same $for_gem_test_zs.first, $for_gem_test_zs.last
end
end
end
test "configures the gem inflector by default" do
files = [
["my_gem.rb", <<-EOS],
$for_gem_test_loader = Zeitwerk::Loader.for_gem
$for_gem_test_loader.setup
class MyGem
end
EOS
["my_gem/foo.rb", "class MyGem::Foo; end"]
]
with_files(files) do
with_load_path(".") do
require "my_gem"
assert_instance_of Zeitwerk::GemInflector, $for_gem_test_loader.inflector
end
end
end
end

View File

@ -0,0 +1,101 @@
require "test_helper"
require "set"
class TestIgnore < LoaderTest
test "ignored root directories are ignored" do
files = [["x.rb", "X = true"]]
with_files(files) do
loader.push_dir(".")
loader.ignore(".")
assert_empty loader.autoloads
assert_raises(NameError) { ::X }
end
end
test "ignored files are ignored" do
files = [
["x.rb", "X = true"],
["y.rb", "Y = true"]
]
with_files(files) do
loader.push_dir(".")
loader.ignore("y.rb")
loader.setup
assert_equal 1, loader.autoloads.size
assert ::X
assert_raises(NameError) { ::Y }
end
end
test "ignored directories are ignored" do
files = [
["x.rb", "X = true"],
["m/a.rb", "M::A = true"],
["m/b.rb", "M::B = true"],
["m/c.rb", "M::C = true"]
]
with_files(files) do
loader.push_dir(".")
loader.ignore("m")
loader.setup
assert_equal 1, loader.autoloads.size
assert ::X
assert_raises(NameError) { ::M }
end
end
test "ignored files are not eager loaded" do
files = [
["x.rb", "X = true"],
["y.rb", "Y = true"]
]
with_files(files) do
loader.push_dir(".")
loader.ignore("y.rb")
loader.setup
loader.eager_load
assert_equal 1, loader.autoloads.size
assert ::X
assert_raises(NameError) { ::Y }
end
end
test "ignored directories are not eager loaded" do
files = [
["x.rb", "X = true"],
["m/a.rb", "M::A = true"],
["m/b.rb", "M::B = true"],
["m/c.rb", "M::C = true"]
]
with_files(files) do
loader.push_dir(".")
loader.ignore("m")
loader.setup
loader.eager_load
assert_equal 1, loader.autoloads.size
assert ::X
assert_raises(NameError) { ::M }
end
end
test "supports several arguments" do
a = "#{Dir.pwd}/a.rb"
b = "#{Dir.pwd}/b.rb"
loader.ignore(a, b)
assert_equal [a, b].to_set, loader.ignored
end
test "supports an array" do
a = "#{Dir.pwd}/a.rb"
b = "#{Dir.pwd}/b.rb"
loader.ignore([a, b])
assert_equal [a, b].to_set, loader.ignored
end
end

View File

@ -0,0 +1,82 @@
require "test_helper"
class TestLogging < LoaderTest
def setup
super
loader.logger = method(:print)
end
test "logs loaded files" do
files = [["x.rb", "X = true"]]
with_files(files) do
with_load_path(".") do
assert_output(/constant X loaded from file #{File.realpath("x.rb")}/) do
loader.push_dir(".")
loader.setup
assert X
end
end
end
end
test "logs required managed files" do
files = [["x.rb", "X = true"]]
with_files(files) do
with_load_path(".") do
assert_output(/constant X loaded from file #{File.realpath("x.rb")}/) do
loader.push_dir(".")
loader.setup
assert require "x"
end
end
end
end
test "logs autovivified modules" do
files = [["admin/user.rb", "class Admin::User; end"]]
with_files(files) do
with_load_path(".") do
assert_output(/module Admin autovivified from directory #{File.realpath("admin")}/) do
loader.push_dir(".")
loader.setup
assert Admin
end
end
end
end
test "logs autoload configured for files" do
files = [["x.rb", "X = true"]]
with_files(files) do
assert_output("autoload set for X, to be loaded from #{File.realpath("x.rb")}") do
loader.push_dir(".")
loader.setup
end
end
end
test "logs autoload configured for directories" do
files = [["admin/user.rb", "class Admin::User; end"]]
with_files(files) do
assert_output("autoload set for Admin, to be autovivified from #{File.realpath("admin")}") do
loader.push_dir(".")
loader.setup
end
end
end
test "logs preloads" do
files = [["x.rb", "X = true"]]
with_files(files) do
loader.push_dir(".")
loader.preload("x.rb")
assert_output(/preloading #{File.realpath("x.rb")}/) do
loader.setup
end
end
end
end

View File

@ -0,0 +1,57 @@
require "test_helper"
class TestMultiple < LoaderTest
test "multiple independent loaders" do
loaders = [loader, Zeitwerk::Loader.new]
files = [
["lib0/app0.rb", "module App0; end"],
["lib0/app0/foo.rb", "class App0::Foo; end"],
["lib1/app1/foo.rb", "class App1::Foo; end"],
["lib1/app1/foo/bar/baz.rb", "class App1::Foo::Bar::Baz; end"]
]
with_files(files) do
loaders[0].push_dir("lib0")
loaders[1].push_dir("lib1")
loaders.each(&:setup)
assert App0::Foo
assert App1::Foo::Bar::Baz
end
end
test "multiple dependent loaders" do
loaders = [loader, Zeitwerk::Loader.new]
files = [
["lib0/app0.rb", <<-EOS],
module App0
App1
end
EOS
["lib0/app0/foo.rb", <<-EOS],
class App0::Foo
App1::Foo
end
EOS
["lib1/app1/foo.rb", <<-EOS],
class App1::Foo
App0
end
EOS
["lib1/app1/foo/bar/baz.rb", <<-EOS]
class App1::Foo::Bar::Baz
App0::Foo
end
EOS
]
with_files(files) do
loaders[0].push_dir("lib0")
loaders[1].push_dir("lib1")
loaders.each(&:setup)
assert App0::Foo
assert App1::Foo::Bar::Baz
end
end
end

View File

@ -0,0 +1,66 @@
require "test_helper"
class TestNamespaces < LoaderTest
test "directories without explicitly defined namespaces autovivify a module" do
files = [["admin/user.rb", "class Admin::User; end"]]
with_setup(files) do
assert_kind_of Module, Admin
assert Admin::User
end
end
test "directories with a matching defined namespace do not autovivfy" do
files = [
["app/models/hotel.rb", "class Hotel; X = 1; end"],
["app/models/hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_setup(files, dirs: "app/models") do
assert_kind_of Class, Hotel
assert Hotel::X
assert Hotel::Pricing
end
end
test "already existing namespaces are not reset" do
files = [
["lib/active_storage.rb", "module ActiveStorage; end"],
["app/models/active_storage/blob.rb", "class ActiveStorage::Blob; end"]
]
with_files(files) do
with_load_path("lib") do
begin
require "active_storage"
loader.push_dir("app/models")
loader.setup
assert ActiveStorage::Blob
loader.unload
assert ActiveStorage
ensure
delete_loaded_feature("lib/active_storage.rb")
Object.send(:remove_const, :ActiveStorage)
end
end
end
end
test "sudirectories in the root directories do not autovivify modules" do
files = [["app/models/concerns/pricing.rb", "module Pricing; end"]]
with_setup(files, dirs: %w(app/models app/models/concerns)) do
assert_raises(NameError) { Concerns }
end
end
test "subdirectories in the root directories are ignored even if there is a matching file" do
files = [
["app/models/hotel.rb", "class Hotel; include GeoLoc; end"],
["app/models/concerns/geo_loc.rb", "module GeoLoc; end"],
["app/models/concerns.rb", "module Concerns; end"]
]
with_setup(files, dirs: %w(app/models app/models/concerns)) do
assert Concerns
assert Hotel
end
end
end

View File

@ -0,0 +1,53 @@
require "test_helper"
class TestPreload < LoaderTest
def preloads
@preloads ||= ["a.rb", "m/n"]
end
def assert_preload
$a_preloaded = $b_preoloaded = $c_preloaded = $d_preloaded = false
files = [
["a.rb", "A = 1; $a_preloaded = true"],
["m/n/b.rb", "M::N::B = 1; $b_preloaded = true"],
["m/c.rb", "M::C = 1; $c_preloaded = true"],
["d.rb", "D = 1; $d_preloaded = true"]
]
with_files(files) do
loader.push_dir(".")
yield # preload here
loader.setup
assert $a_preloaded
assert $b_preloaded
assert !$c_preloaded
assert !$d_preloaded
end
end
test "preloads files and directories (multiple args)" do
assert_preload do
loader.preload(*preloads)
end
end
test "preloads files and directories (array)" do
assert_preload do
loader.preload(preloads)
end
end
test "preloads files and directories (multiple calls)" do
assert_preload do
loader.preload(preloads.first)
loader.preload(preloads.last)
end
end
test "preloads files after setup too" do
assert_preload do
loader.setup
loader.preload(preloads)
end
end
end

View File

@ -0,0 +1,20 @@
require "test_helper"
require "pathname"
class TesPushDir < LoaderTest
test "accepts dirs as strings and stores their absolute paths" do
loader.push_dir(".")
assert loader.dirs == { Dir.pwd => true }
end
test "accepts dirs as pathnames and stores their absolute paths" do
loader.push_dir(Pathname.new("."))
assert loader.dirs == { Dir.pwd => true }
end
test "raises on non-existing directories" do
dir = File.expand_path("non-existing")
e = assert_raises(ArgumentError) { loader.push_dir(dir) }
assert_equal "the root directory #{dir} does not exist", e.message
end
end

View File

@ -0,0 +1,200 @@
require "test_helper"
class TestRequireInteraction < LoaderTest
def assert_required(str)
assert_equal true, require(str)
end
def assert_not_required(str)
assert_equal false, require(str)
end
test "our decorated require returns true or false as expected" do
files = [["user.rb", "class User; end"]]
with_files(files) do
with_load_path(".") do
assert_required "user"
assert_not_required "user"
delete_loaded_feature("user.rb")
Object.send(:remove_const, :User)
end
end
end
test "autoloading makes require idempotent even with a relative path" do
files = [["user.rb", "class User; end"]]
with_setup(files, load_path: ".") do
assert User
assert_not_required "user"
end
end
test "a required top-level file is still detected as autoloadable" do
files = [["user.rb", "class User; end"]]
with_setup(files, load_path: ".") do
assert_required "user"
loader.unload
assert !Object.const_defined?(:User, false)
loader.setup
assert User
end
end
test "a required top-level file is still detected as autoloadable (require_relative)" do
files = [["user.rb", "class User; end"]]
with_setup(files) do
assert_equal true, require_relative("../../tmp/user")
loader.unload
assert !Object.const_defined?(:User, false)
loader.setup
assert User
end
end
test "require autovivifies as needed" do
files = [
["app/models/admin/user.rb", "class Admin::User; end"],
["app/controllers/admin/users_controller.rb", "class Admin::UsersController; end"]
]
dirs = %w(app/models app/controllers)
with_setup(files, dirs: dirs, load_path: dirs) do
assert_required "admin/user"
assert_equal 3, loader.autoloads.size
assert Admin::User
assert Admin::UsersController
loader.unload
assert !Object.const_defined?(:Admin)
end
end
test "require works well with explicit namespaces" do
files = [
["hotel.rb", "class Hotel; X = true; end"],
["hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_setup(files, load_path: ".") do
assert_required "hotel/pricing"
assert Hotel::Pricing
assert Hotel::X
end
end
test "you can autoload yourself in a required file" do
files = [
["my_gem.rb", <<-EOS],
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
loader.setup
module MyGem; end
EOS
["my_gem/foo.rb", "class MyGem::Foo; end"]
]
with_files(files) do
with_load_path(Dir.pwd) do
assert_required "my_gem"
end
end
end
test "does not autovivify while loading an explicit namespace, constant is not yet defined - file first" do
files = [
["hotel.rb", <<-EOS],
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
loader.setup
Hotel.name
class Hotel
end
EOS
["hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_files(files) do
iter = ->(dir, &block) do
if dir == Dir.pwd
block.call("hotel.rb")
block.call("hotel")
end
end
Dir.stub :foreach, iter do
e = assert_raises(NameError) do
with_load_path(Dir.pwd) do
assert_required "hotel"
end
end
assert_match %r/Hotel/, e.message
end
end
end
test "does not autovivify while loading an explicit namespace, constant is not yet defined - file last" do
files = [
["hotel.rb", <<-EOS],
loader = Zeitwerk::Loader.new
loader.push_dir(__dir__)
loader.setup
Hotel.name
class Hotel
end
EOS
["hotel/pricing.rb", "class Hotel::Pricing; end"]
]
with_files(files) do
iter = ->(dir, &block) do
if dir == Dir.pwd
block.call("hotel")
block.call("hotel.rb")
end
end
Dir.stub :foreach, iter do
e = assert_raises(NameError) do
with_load_path(Dir.pwd) do
assert_required "hotel"
end
end
assert_match %r/Hotel/, e.message
end
end
end
test "symlinks in autoloaded files set by Zeitwerk" do
files = [["real/app/models/user.rb", "class User; end"]]
with_files(files) do
FileUtils.ln_s("real", "symlink")
loader.push_dir("symlink/app/models")
loader.setup
with_load_path("symlink/app/models") do
assert User
assert_not_required "user"
loader.reload
assert_required "user"
end
end
end
test "symlinks in autoloaded files resolved by Ruby" do
files = [["real/app/models/user.rb", "class User; end"]]
with_files(files) do
FileUtils.ln_s("real", "symlink")
loader.push_dir("symlink/app/models")
loader.setup
with_load_path("symlink/app/models") do
assert_required "user"
loader.reload
assert_required "user"
end
end
end
end

View File

@ -0,0 +1,183 @@
require "test_helper"
class TestRubyCompatibility < LoaderTest
test "autoload calls Kernel#require" do
files = [["x.rb", "X = true"]]
with_files(files) do
loader.push_dir(".")
loader.setup
$trc_require_has_been_called = false
$trc_autoload_path = File.expand_path("x.rb")
begin
Kernel.module_eval do
alias_method :trc_original_require, :require
def require(path)
$trc_require_has_been_called = true if path == $trc_autoload_path
trc_original_require(path)
end
end
assert X
assert $trc_require_has_been_called
ensure
Kernel.module_eval do
remove_method :require
define_method :require, instance_method(:trc_original_require)
remove_method :trc_original_require
end
end
end
end
test "directories are not included in $LOADED_FEATURES" do
with_files([]) do
FileUtils.mkdir("admin")
loader.push_dir(".")
loader.setup
assert Admin
assert !$LOADED_FEATURES.include?(File.realpath("admin"))
end
end
test "an autoload can be overridden" do
files = [
["x0/x.rb", "X = 0"],
["x1/x.rb", "X = 1"]
]
with_files(files) do
Object.autoload(:X, File.expand_path("x0/x.rb"))
Object.autoload(:X, File.expand_path("x1/x.rb"))
assert_equal 1, X
end
Object.send(:remove_const, :X)
end
test "const_defined? is true for autoloads and does not load the file" do
files = [["x.rb", "$const_defined_does_not_trigger_autoload = false; X = true"]]
with_files(files) do
$const_defined_does_not_trigger_autoload = true
Object.autoload(:X, File.expand_path("x.rb"))
assert Object.const_defined?(:X, false)
assert $const_defined_does_not_trigger_autoload
assert_nil Object.send(:remove_const, :X)
end
end
test "remove_const does not trigger an autoload" do
files = [["x.rb", "$remove_const_does_not_trigger_autoload = false; X = 1"]]
with_files(files) do
$remove_const_does_not_trigger_autoload = true
Object.autoload(:X, File.expand_path("x.rb"))
Object.send(:remove_const, :X)
assert $remove_const_does_not_trigger_autoload
end
end
test "autoloads remove the autoload configuration in the parent" do
files = [["x.rb", "X = true"]]
with_files(files) do
Object.autoload(:X, File.expand_path("x.rb"))
assert Object.autoload?(:X)
assert X
assert !Object.autoload?(:X)
assert Object.send(:remove_const, :X)
assert delete_loaded_feature("x.rb")
end
end
test "autoload configuration can be deleted with remove_const" do
files = [["x.rb", "X = true"]]
with_files(files) do
Object.autoload(:X, File.expand_path("x.rb"))
assert Object.autoload?(:X)
Object.send(:remove_const, :X)
assert !Object.autoload?(:X)
end
end
test "remove_const works on constants with an autoload even if the file did not define them" do
files = [["foo.rb", "NOT_FOO = 1"]]
with_files(files) do
with_load_path(Dir.pwd) do
begin
Object.autoload(:Foo, "foo")
assert_raises(NameError) { Foo }
Object.send(:remove_const, :Foo)
Object.send(:remove_const, :NOT_FOO)
delete_loaded_feature("foo.rb")
end
end
end
end
test "an autoload on yourself is ignored" do
files = [["foo.rb", <<-EOS]]
Object.autoload(:Foo, __FILE__)
$trc_inception = !Object.autoload?(:Foo)
Foo = 1
EOS
with_files(files) do
loader.push_dir(".")
loader.setup
with_load_path do
$trc_inception = false
require "foo"
end
assert $trc_inception
end
end
test "an autoload on a file being required at some point up in the call chain is also ignored" do
files = [
["foo.rb", <<-EOS],
require 'bar'
Foo = 1
EOS
["bar.rb", <<-EOS]
Bar = true
Object.autoload(:Foo, File.realpath('foo.rb'))
$trc_inception = !Object.autoload?(:Foo)
EOS
]
with_files(files) do
loader.push_dir(".")
loader.setup
with_load_path do
$trc_inception = false
require "foo"
end
assert $trc_inception
end
end
# This why we issue a lazy_subdirs.delete call in the tracer block.
test "tracing :class calls you back on creation and on reopening" do
traced = []
tracer = TracePoint.trace(:class) do |tp|
traced << tp.self
end
2.times do
class C; end
module M; end
end
assert_equal [C, M, C, M], traced
tracer.disable
self.class.send(:remove_const, :C)
self.class.send(:remove_const, :M)
end
end

View File

@ -0,0 +1,77 @@
require "test_helper"
# Rails applications are expected to preload tree leafs in STIs. Using requires
# is the old school way to address this and it is somewhat tricky. Let's have a
# test to make sure the circularity works.
class TestOldSchoolWorkaroundSTI < LoaderTest
def files
[
["a.rb", <<-EOS],
class A
require 'b'
end
$test_sti_loaded << 'A'
EOS
["b.rb", <<-EOS],
class B < A
require 'c'
end
$test_sti_loaded << 'B'
EOS
["c.rb", <<-EOS],
class C < B
require 'd1'
require 'd2'
end
$test_sti_loaded << 'C'
EOS
["d1.rb", "class D1 < C; end; $test_sti_loaded << 'D1'"],
["d2.rb", "class D2 < C; end; $test_sti_loaded << 'D2'"]
]
end
def with_setup
original_verbose = $VERBOSE
$VERBOSE = nil # To avoid circular require warnings.
$test_sti_loaded = []
super(files, load_path: ".") do
yield
end
ensure
$VERBOSE = original_verbose
end
def assert_all_loaded
assert_equal %w(A B C D1 D2), $test_sti_loaded.sort
end
test "loading the root loads everything" do
with_setup do
assert A
assert_all_loaded
end
end
test "loading a root child loads everything" do
with_setup do
assert B
assert_all_loaded
end
end
test "loading an intermediate descendant loads everything" do
with_setup do
assert C
assert_all_loaded
end
end
test "loading a leaf loads everything" do
with_setup do
assert D1
assert_all_loaded
end
end
end

View File

@ -0,0 +1,67 @@
require "test_helper"
class TestTopLevel < LoaderTest
test "autoloads a simple constant in a top-level file" do
files = [["x.rb", "X = true"]]
with_setup(files) do
assert X
end
end
test "autoloads a simple class in a top-level file" do
files = [["app/models/user.rb", "class User; end"]]
with_setup(files, dirs: "app/models") do
assert User
end
end
test "autoloads several top-level classes" do
files = [
["app/models/user.rb", "class User; end"],
["app/controllers/users_controller.rb", "class UsersController; User; end"]
]
with_setup(files, dirs: %w(app/models app/controllers)) do
assert UsersController
end
end
test "autoloads only the first of multiple occurrences" do
files = [
["app/models/user.rb", "User = :model"],
["app/decorators/user.rb", "User = :decorator"],
]
with_setup(files, dirs: %w(app/models app/decorators)) do
assert_equal :model, User
end
end
test "does not autoload if the constant is already defined" do
::X = 1
files = [["x.rb", "X = 2"]]
with_setup(files) do
assert_equal 1, ::X
loader.reload
assert_equal 1, ::X
Object.send(:remove_const, :X)
end
end
test "anything other than Ruby and visible directories is ignored" do
files = [
["x.txt", ""], # Programmer notes
["x.lua", ""], # Lua files for Redis
["x.yaml", ""], # Included configuration
["x.json", ""], # Included configuration
["x.erb", ""], # Included template
["x.jpg", ""], # Included image
["x.rb~", ""], # Emacs auto backup
["#x.rb#", ""], # Emacs auto save
[".filename.swp", ""], # Vim swap file
["4913", ""], # May be created by Vim
[".idea/workspace.xml", ""] # RubyMine
]
with_setup(files) do
assert_empty loader.autoloads
end
end
end

View File

@ -0,0 +1,68 @@
require "test_helper"
class TestUnload < LoaderTest
test "unload removes all autoloaded constants" do
files = [
["user.rb", "class User; end"],
["admin/root.rb", "class Admin::Root; end"]
]
with_setup(files) do
assert User
assert Admin::Root
admin = Admin
loader.unload
assert !Object.const_defined?(:User)
assert !Object.const_defined?(:Admin)
assert !admin.const_defined?(:Root)
end
end
test "unload removes non-executed autoloads" do
files = [["x.rb", "X = true"]]
with_setup(files) do
# This does not autolaod, see the compatibility test.
assert Object.const_defined?(:X)
loader.unload
assert !Object.const_defined?(:X)
end
end
test "unload clears internal caches" do
files = [
["user.rb", "class User; end"],
["admin/root.rb", "class Admin::Root; end"]
]
with_setup(files) do
assert User
assert Admin::Root
assert !loader.autoloads.empty?
assert !loader.lazy_subdirs.empty?
loader.unload
assert loader.autoloads.empty?
assert loader.lazy_subdirs.empty?
end
end
test "unload disables the tracer" do
files = [["x.rb", "X = true"]]
with_setup(files) do
assert loader.tracer.enabled?
loader.unload
assert !loader.tracer.enabled?
end
end
test "unload does not assume autoloaded constants are still there" do
files = [["x.rb", "X = true"]]
with_setup(files) do
assert X
assert Object.send(:remove_const, :X) # user removed by hand
loader.unload # should not raise
end
end
end

View File

@ -0,0 +1,5 @@
module DeleteLoadedFeature
def delete_loaded_feature(path)
$LOADED_FEATURES.delete(File.realpath(path))
end
end

View File

@ -0,0 +1,56 @@
class LoaderTest < Minitest::Test
TMP_DIR = File.expand_path("../tmp", __dir__)
attr_reader :loader
def setup
@loader = Zeitwerk::Loader.new
end
def teardown
Zeitwerk::Registry.loaders.each(&:unload)
Zeitwerk::Registry.loaders.clear
Zeitwerk::Registry.loaders_managing_gems.clear
end
def mkdir_test
FileUtils.rm_rf(TMP_DIR)
FileUtils.mkdir_p(TMP_DIR)
end
def with_files(files, rm: true)
mkdir_test
Dir.chdir(TMP_DIR) do
files.each do |fname, contents|
FileUtils.mkdir_p(File.dirname(fname))
File.write(fname, contents)
end
begin
yield
ensure
mkdir_test if rm
end
end
end
def with_load_path(dirs = loader.dirs.keys)
Array(dirs).each { |dir| $LOAD_PATH.push(dir) }
yield
ensure
Array(dirs).each { |dir| $LOAD_PATH.delete(dir) }
end
def with_setup(files, dirs: ".", load_path: nil, rm: true)
with_files(files, rm: rm) do
Array(dirs).each { |dir| loader.push_dir(dir) }
loader.setup
if load_path
with_load_path(load_path) { yield }
else
yield
end
end
end
end

View File

@ -0,0 +1,6 @@
module TestMacro
def test(description, &block)
method_name = "test_#{description}".gsub(/\W/, "_")
define_method(method_name, &block)
end
end

16
test/test_helper.rb Normal file
View File

@ -0,0 +1,16 @@
require "minitest/autorun"
require "minitest/focus"
require "minitest/reporters"
Minitest::Reporters.use!(Minitest::Reporters::DefaultReporter.new)
require "zeitwerk"
require "support/test_macro"
require "support/delete_loaded_feature"
require "support/loader_test"
Minitest::Test.class_eval do
extend TestMacro
include DeleteLoadedFeature
end

21
zeitwerk.gemspec Normal file
View File

@ -0,0 +1,21 @@
require_relative "lib/zeitwerk/version"
Gem::Specification.new do |spec|
spec.name = "zeitwerk"
spec.summary = "Efficient and thread-safe constant autoloader"
spec.description = <<-EOS
Zeitwerk implements constant autoloading with Ruby semantics. Each gem
and application may have their own independent autoloader, with its own
configuration, inflector, and logger. Supports autoloading, preloading,
reloading, and eager loading.
EOS
spec.author = "Xavier Noria"
spec.email = 'fxn@hashref.com'
spec.license = "MIT"
spec.homepage = "https://github.com/fxn/zeitwerk"
spec.files = Dir.glob("lib/**/*.rb") + ["README.md"]
spec.version = Zeitwerk::VERSION
spec.required_ruby_version = ">= 2.4.4"
end