Im is a thread-safe code loader for anonymous-rooted namespaces in Ruby. It allows you to share any nested, autoloaded set of code without polluting or in any way touching the global namespace.
To do this, Im leverages code autoloading, Zeitwerk conventions around file
structure and naming, and two features added in Ruby 3.2: Kernel#load
with a module argument1 and Module#const_added
2. Since these Ruby
features are essential to its design, Im is not usable with earlier versions
of Ruby.
Im started its life as a fork of Zeitwerk and has a very similar interface. Im and Zeitwerk can be used alongside each other provided there is no overlap between file paths managed by each gem.
Im is in active development and should be considered experimental until the eventual release of version 1.0. Versions 0.1.6 and earlier of the gem were part of a different experiment and are unrelated to the current gem.
Im's public interface is in most respects identical to that of Zeitwerk. The
central difference is that whereas Zeitwerk loads constants into the global
namespace (rooted in Object
), Im loads them into anonymous namespaces rooted
on the loader itself. Im::Loader
is a subclass of Module
, and thus each
loader instance can define its own namespace. Since there can be arbitrarily
many loaders, there can also be arbitrarily many autoloaded namespaces.
Im's gem interface looks like this:
# lib/my_gem.rb (main file)
require "im"
loader = Im::Loader.for_gem
loader.setup # ready!
module loader::MyGem
# ...
end
loader.eager_load # optionally
The generic interface is identical to Zeitwerk's:
loader = Zeitwerk::Loader.new
loader.push_dir(...)
loader.setup # ready!
Other than gem names, the only difference here is in the definition of MyGem
under the loader namespace in the gem code. Unlike Zeitwerk, with Im the gem
namespace is not defined at toplevel:
Object.const_defined?(:MyGem)
#=> false
In order to prevent leakage, the gem's entrypoint, in this case
lib/my_gem.rb
, must not define anything at toplevel, hence the use of
module loader::MyGem
.
Once the entrypoint has been required, all constants defined within the gem's file structure are autoloadable from the loader itself:
# lib/my_gem/foo.rb
module MyGem
class Foo
def hello_world
"Hello World!"
end
end
end
foo = loader::MyGem::Foo
# loads `Foo` from lib/my_gem/foo.rb
foo.new.hello_world
#=> "Hello World!"
Constants under the loader can be given permanent names that are different from the one defined in the gem itself:
Bar = loader::MyGem::Foo
Bar.new.hello_world
#=> "Hello World!"
Like Zeitwerk, Im keeps a registry of all loaders, so the loader objects won't
be garbage collected. For convenience, Im also provides a method, Im#import
,
to fetch a loader for a given file path:
require "im"
require "my_gem"
extend Im
my_gem = import "my_gem"
#=> my_gem::MyGem is autoloadable
Reloading works like Zeitwerk:
loader = Im::Loader.new
loader.push_dir(...)
loader.enable_reloading # you need to opt-in before setup
loader.setup
...
loader.reload
You can assign a permanent name to an autoloaded constant, and it will be reloaded when the loader is reloaded:
Foo = loader::Foo
loader.reload # Object::Foo is replaced by an autoload
Foo #=> autoload is triggered, reloading loader::Foo
Like Zeitwerk, you can eager-load all the code at once:
loader.eager_load
Alternatively, you can broadcast eager_load
to all loader instances:
Im::Loader.eager_load_all
File structure is identical to Zeitwerk, again with the difference that constants are loaded from the loader's namespace rather than the root one:
lib/my_gem.rb -> loader::MyGem
lib/my_gem/foo.rb -> loader::MyGem::Foo
lib/my_gem/bar_baz.rb -> loader::MyGem::BarBaz
lib/my_gem/woo/zoo.rb -> loader::MyGem::Woo::Zoo
Im inherits support for collapsing directories and custom inflection, see Zeitwerk's documentation for details on usage of these features.
Internally, each loader in Im can have one or more root directories from which
it loads code onto itself. Root directories are added to the loader using
Im::Loader#push_dir
:
loader.push_dir("#{__dir__}/models")
loader.push_dir("#{__dir__}/serializers"))
Note that concept of a root namespace, which Zeitwerk uses to load code under a given node of the global namespace, is absent in Im. Custom root namespaces are likewise not supported. These features were removed as they add complexity for little gain given Im's flexibility to anchor a namespace anywhere in the global namespace.
Im uses two types of constant paths: relative and absolute, wherever possible defaulting to relative ones. A relative cpath is a constant name relative to the loader in which it was originally defined, regardless of any other names it was later assigned. Whereas Zeitwerk uses absolute cpaths, Im uses relative cpaths for all external loader APIs (see usage for examples).
To understand these concepts, it is important first to distinguish between two types of names in Ruby: temporary names and permanent names.
A temporary name is a constant name on an anonymous-rooted namespace, for example a loader:
my_gem = import "my_gem"
my_gem::Foo
my_gem::Foo.name
#=> "#<Im::Loader ...>::Foo"
Here, the string "#<Im::Loader ...>::Foo"
is called a temporary name. We can
give this module a permanent name by assigning it to a toplevel constant:
Bar = my_gem::Foo
my_gem::Foo.name
#=> "Bar"
Now its name is "Bar"
, and it is near impossible to get back its original
temporary name.
This property of module naming in Ruby is problematic since cpaths are used as keys in Im's internal registries to index constants and their autoloads, which is critical for successful autoloading.
To get around this issue, Im tracks all module names and uses relative naming
inside loader code. Internally, Im has a method, relative_cpath
, which can
generate any module name under a module in the loader namespace:
my_gem.send(:relative_cpath, loader::Foo, :Baz)
#=> "Foo::Baz"
Using relative cpaths frees Im from depending on Module#name
for
registry keys like Zeitwerk does, which does not work with anonymous
namespaces. All public methods in Im that take a cpath
take the relative
cpath, i.e. the cpath relative to the loader as toplevel, regardless of any
toplevel-rooted constant a module may have been assigned to.
(TODO)
(TODO)
- Demo Rails app using Im to isolate the application under one namespace
Released under the MIT License, Copyright (c) 2023 Chris Salzberg and 2019โฯ Xavier Noria.