• Stars
    star
    114
  • Rank 308,031 (Top 7 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 2 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Isolated Module Autoloader for Ruby

Im

Gem Version Build Status

Introduction

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_added2. 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.

Synopsis

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

File paths match constant paths under loader

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.

Root directories

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.

Relative and absolute cpaths

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.

Usage

(TODO)

Motivation

(TODO)

Related

  • Demo Rails app using Im to isolate the application under one namespace

License

Released under the MIT License, Copyright (c) 2023 Chris Salzberg and 2019โ€“ฯ‰ Xavier Noria.

Footnotes

  1. https://bugs.ruby-lang.org/issues/6210 โ†ฉ

  2. https://bugs.ruby-lang.org/issues/17881 โ†ฉ