initial commit
This commit is contained in:
commit
60e6918790
|
@ -0,0 +1,2 @@
|
|||
test/tmp
|
||||
*.gem
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
source 'https://rubygems.org'
|
||||
|
||||
gemspec
|
||||
|
||||
gem "rake"
|
||||
gem "minitest"
|
||||
gem "minitest-focus"
|
||||
gem "minitest-reporters"
|
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
bundle exec rake
|
||||
else
|
||||
bundle exec rake TEST="$1"
|
||||
fi
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Zeitwerk
|
||||
VERSION = "1.0.0.beta"
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
module DeleteLoadedFeature
|
||||
def delete_loaded_feature(path)
|
||||
$LOADED_FEATURES.delete(File.realpath(path))
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
module TestMacro
|
||||
def test(description, &block)
|
||||
method_name = "test_#{description}".gsub(/\W/, "_")
|
||||
define_method(method_name, &block)
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue