zeitwerk

package module
v0.0.0-...-df4c9c6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 6 Imported by: 0

README

go-ruby-zeitwerk/zeitwerk

zeitwerk — go-ruby-zeitwerk

Docs License Go Coverage

A pure-Go (no cgo) model of the engine of Ruby's Zeitwerk autoloader — the code loader behind Rails and countless gems — faithful to the observable behaviour of the zeitwerk gem's default configuration on MRI 4.0.5. It mirrors Zeitwerk's snake_case→CamelCase inflection (with acronym overrides), its push_dir / ignore / collapse / namespace configuration, and the way setup turns a directory tree into the bidirectional map between constant paths and the files or directories that define them — the map that then drives eager_load, reload, unload, and the on_load / on_setup / on_unload callbacks — without any Ruby runtime.

It is the Zeitwerk backend for go-embedded-ruby, but is a standalone, reusable module with no dependency on the Ruby runtime — a sibling of go-ruby-set and go-ruby-connection-pool.

MRI-faithful, not Composition-Oriented. This is the gem's loader — every mapping decision matches what MRI 4.0.5 + zeitwerk does, verified by a differential oracle that runs the real gem's Zeitwerk::Inflector and Zeitwerk::Loader#all_expected_cpaths side by side with this package.

Engine, not runtime — the two seams

Zeitwerk does two very different kinds of work. Most of it is pure logic: scanning the managed directories and computing which constant each .rb file and each subdirectory should define, honouring the configured namespaces, collapsed directories, and ignored paths. That logic is what this package reimplements, and it needs no Ruby.

The rest touches the Ruby runtime — registering a Module#autoload, running require, removing a constant on unload. Those are left to the host through two function seams, so require "zeitwerk" in go-embedded-ruby plugs the real VM in here while everything else stays pure Go:

// DefineAutoload registers an autoload (Ruby's Module#autoload); Setup calls it
// once per managed constant. isDir marks an implicit namespace module.
type DefineAutoloadFunc func(cpath, filePath string, isDir bool)

// Load actually loads a managed file (Ruby's require); EagerLoad calls it once
// per managed file and aborts on the first error it returns.
type LoadFunc func(filePath string) error

Constant removal on unload is the third runtime touch-point; it rides the on_unload callback, which the host uses to drop the constant.

The filesystem is a seam too

The directory scan reads through an injectable FS (default: the real filesystem), so a host that manages a virtual tree — or a test that builds one in a temp dir — plugs its own in:

type FS interface {
	ReadDir(dir string) ([]DirEntry, error)
}

Inflection

Inflector is a faithful port of Zeitwerk::Inflector, the default inflector. It is deliberately basic: it upcases only the first letter of each underscored word, so html_parser becomes HtmlParser until you register an acronym.

in := zeitwerk.NewInflector()
in.Camelize("users_controller")           // => "UsersController"
in.Camelize("html_parser")                // => "HtmlParser"  (default)

in.Inflect(map[string]string{"html_parser": "HTMLParser"})
in.Camelize("html_parser")                // => "HTMLParser"  (override)

Usage

loader := zeitwerk.NewLoader()
loader.Inflector().Inflect(map[string]string{"html_parser": "HTMLParser"})

loader.PushDir("/app/models", "")          // top-level namespace
loader.Ignore("/app/models/legacy.rb")
loader.Collapse("/app/models/concerns")    // not a namespace; promote children

loader.SetDefineAutoload(func(cpath, path string, isDir bool) { /* Module#autoload */ })
loader.SetLoad(func(path string) error { /* require */ return nil })
loader.OnLoad("ANY", func(cpath, path string) { /* after each constant loads */ })

loader.Setup()        // scan → build the constant map → register autoloads
loader.EagerLoad()    // load every managed file, firing on_load
// ... later, after files change:
loader.EnableReloading()   // (before Setup, in practice)
loader.Reload()            // unload + setup again

Autoloads() returns the computed map (sorted by constant path); PathAt and CpathAt resolve it in either direction.

API

// Inflector — models Zeitwerk::Inflector (the default_inflector)
func NewInflector() *Inflector
func (in *Inflector) Inflect(m map[string]string)          // inflect
func (in *Inflector) Camelize(basename string) string      // camelize

// Loader — models Zeitwerk::Loader
func NewLoader() *Loader
func (l *Loader) Inflector() *Inflector                     // inflector
func (l *Loader) SetInflector(in *Inflector)                // inflector=
func (l *Loader) SetFS(fs FS)                               // (filesystem seam)
func (l *Loader) SetDefineAutoload(fn DefineAutoloadFunc)   // (Module#autoload seam)
func (l *Loader) SetLoad(fn LoadFunc)                       // (require seam)
func (l *Loader) EnableReloading()                          // enable_reloading
func (l *Loader) PushDir(dir, namespace string) error       // push_dir(path, namespace:)
func (l *Loader) Ignore(globs ...string)                    // ignore
func (l *Loader) Collapse(globs ...string)                  // collapse
func (l *Loader) OnLoad(cpath string, fn func(cpath, filePath string)) // on_load(:ANY | "Cpath")
func (l *Loader) OnSetup(fn func())                         // on_setup
func (l *Loader) OnUnload(fn func(cpath, filePath string))  // on_unload
func (l *Loader) Setup() error                              // setup
func (l *Loader) EagerLoad() error                          // eager_load
func (l *Loader) Reload() error                             // reload
func (l *Loader) Unload()                                   // unload
func (l *Loader) Autoloads() []Autoload                     // the computed map
func (l *Loader) PathAt(cpath string) (Autoload, bool)      // constant -> path
func (l *Loader) CpathAt(filePath string) (Autoload, bool)  // path -> constant

// Errors — model the gem's hierarchy
type Error struct{ Msg string }          // Zeitwerk::Error
type NameError struct{ Msg string }      // Zeitwerk::NameError
type SetupRequired struct{}              // Zeitwerk::SetupRequired

Semantics

  • The map. Setup walks each root and records, for every non-hidden .rb file and every subdirectory, the fully-qualified constant it defines. A subdirectory is an implicit namespace module (admin/Admin); a file and a same-named directory (admin.rb + admin/) form an explicit namespace where the file is the definer and the directory contributes its children. Hidden entries (dotfiles) and non-.rb files are skipped.
  • Namespaces. PushDir(dir, "Admin") maps the root's children under Admin; an empty namespace (or "Object") means the top level.
  • Ignore / collapse. Ignore drops matching paths (and their descendants); Collapse marks a directory as not a namespace, promoting its children into the parent. Both take glob patterns matched against absolute paths with path.Match semantics.
  • Lifecycle. EagerLoad loads every managed file and fires on_load; Unload fires on_unload and clears the map; Reload (only after EnableReloading) is Unload + Setup. EagerLoad / Reload return a *SetupRequired before Setup has run.

Fidelity vs the zeitwerk gem

The default inflector is ported exactly, including its one quirk: the reference

inflections[basename] || basename.split("_").each_with_object("".dup) { |word, s|
  s << (inflections[word] || word[0].upcase << word[1..-1]) }

only upcases the first character of each word (so html_parserHtmlParser, not HTMLParser, until an override is registered) — this package does the same, and the differential oracle asserts it against a real Zeitwerk::Inflector. The constant map is checked against the gem's own Zeitwerk::Loader#all_expected_cpaths on an identical directory tree covering nested namespaces, a collapsed directory, and an ignored file. Glob matching uses Go's path.Match (a faithful subset of Dir.glob; ** is not expanded), and empty inflection words degrade gracefully where MRI would raise on nil[0].

Tests & coverage

go test -race ./...

The suite holds 100% line coverage — inflection (defaults, per-word and whole-basename overrides, acronyms, empty-word edges), push_dir / ignore / collapse / namespaces, setup-map correctness on real and injected trees, explicit-namespace collisions, eager_load, the callbacks, and every error branch — and cross-compiles on all six supported 64-bit targets (amd64, arm64, riscv64, loong64, ppc64le, s390x, including the big-endian s390x). A differential MRI oracle runs the real zeitwerk gem side by side with this package on the lanes where Ruby and the gem are present; it skips itself elsewhere, so the deterministic, Ruby-free tests alone drive the coverage gate.

License

BSD-3-Clause — see LICENSE. Copyright the go-ruby-zeitwerk/zeitwerk authors.

WebAssembly

Being pure Go (CGO=0), this library also compiles to WebAssembly — both GOOS=js GOARCH=wasm (browser / Node.js) and GOOS=wasip1 GOARCH=wasm (WASI). CI builds both targets on every push, alongside the six 64-bit native/qemu arches.

GOOS=js     GOARCH=wasm go build ./...   # browser / Node
GOOS=wasip1 GOARCH=wasm go build ./...   # WASI (wasmtime, wasmer, wasmedge, …)

Documentation

Overview

Package zeitwerk is a pure-Go (no cgo) model of the engine of Ruby's Zeitwerk autoloader, faithful to the observable behaviour of the zeitwerk gem's default configuration on MRI 4.0.5.

It reimplements the part of Zeitwerk::Loader that is pure logic — scanning the managed directory trees and computing the bidirectional mapping between constant paths (e.g. "Admin::UsersController") and the file or directory that defines them, honouring namespaces, collapsed directories, and ignored paths, and driving the setup / eager-load / reload / unload lifecycle and its callbacks. Everything that actually touches a Ruby runtime — defining a constant, requiring a file, removing a constant — is left to the host through small function seams (DefineAutoload, Load, and the on_unload callback), so this package has no dependency on any Ruby runtime and is the reusable engine a go-embedded-ruby binding plugs into.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Autoload

type Autoload struct {
	Cpath string // fully-qualified constant path, e.g. "Admin::UsersController"
	Cname string // final segment only, e.g. "UsersController"
	Path  string // absolute file or directory path that defines it
	IsDir bool   // true for an implicit namespace module (a managed directory)
}

Autoload is one entry of the computed constant map: the constant a managed path defines.

type DefineAutoloadFunc

type DefineAutoloadFunc func(cpath, filePath string, isDir bool)

DefineAutoloadFunc is the seam the host uses to register an autoload, standing in for Ruby's `Module#autoload`. Setup calls it once per managed constant with the fully-qualified constant path and the file or directory that defines it (isDir true for an implicit namespace module autovivified from a directory). It is optional; a nil seam means "engine only", useful for tests that just assert the computed map.

type DirEntry

type DirEntry struct {
	Name  string
	IsDir bool
}

DirEntry is one child of a directory as reported by an FS: its basename and whether it is itself a directory. It is the minimal shape the loader's directory scan needs.

type Error

type Error struct{ Msg string }

Error is the base error type of the loader, mirroring Zeitwerk::Error (a StandardError subclass in the gem). PushDir on a directory that does not exist, and Reload without reloading enabled, both surface as an *Error, just as the gem raises Zeitwerk::Error for those conditions.

func (*Error) Error

func (e *Error) Error() string

type FS

type FS interface {
	// ReadDir lists the children of dir. The returned entries need not be
	// sorted; the loader orders constants deterministically itself.
	ReadDir(dir string) ([]DirEntry, error)
}

FS is the filesystem seam the loader scans. The default implementation reads the real filesystem, so PushDir takes ordinary absolute paths; tests (and any host that manages a virtual tree) inject their own by calling SetFS. Only directory listing is required — the loader never reads file contents itself, since loading a file is the host's Load seam.

type Inflector

type Inflector struct {
	// contains filtered or unexported fields
}

Inflector models Zeitwerk::Inflector, the default inflector that maps a file or directory basename to the constant name it defines. It is a faithful port of the gem's default_inflector: a very basic snake_case -> CamelCase conversion, honouring hard-coded overrides configured with Inflect.

The reference Ruby is:

def camelize(basename, _abspath)
  inflections[basename] || basename.split("_").each_with_object("".dup) do |word, camelized|
    camelized << (inflections[word] || word[0].upcase << word[1..-1])
  end
end

So Camelize first consults a whole-basename override, and otherwise splits on "_" and, for each word, consults a per-word override or upcases just the first character while leaving the rest of the word untouched. This is why the default turns "html_parser" into "HtmlParser" (not "HTMLParser") until an acronym override is registered.

func NewInflector

func NewInflector() *Inflector

NewInflector returns a default Zeitwerk inflector with no overrides.

func (*Inflector) Camelize

func (in *Inflector) Camelize(basename string) string

Camelize maps a snake_case basename to the constant name it defines, applying the same rules as Zeitwerk::Inflector#camelize. The abspath argument the gem accepts is not needed by the default inflector and is therefore omitted.

func (*Inflector) Inflect

func (in *Inflector) Inflect(m map[string]string)

Inflect registers hard-coded basename/word -> constant-name overrides, mirroring Zeitwerk::Inflector#inflect. Keys may be whole basenames ("html_parser" => "HTMLParser") or individual words ("html" => "HTML"); both are consulted by Camelize. Later calls merge into and override earlier ones.

type LoadFunc

type LoadFunc func(filePath string) error

LoadFunc is the seam the host uses to actually load a managed file, standing in for Ruby's `require`. EagerLoad calls it once per managed file; returning a non-nil error aborts the eager load with that error (the host may return a *NameError when the file does not define its expected constant).

type Loader

type Loader struct {
	// contains filtered or unexported fields
}

Loader models Zeitwerk::Loader: a registry of root directories and the constant map computed from them. The zero value is not usable; construct one with NewLoader.

func NewLoader

func NewLoader() *Loader

NewLoader returns a loader with the default Zeitwerk inflector and the real filesystem. Configure it with PushDir / Ignore / Collapse and the seams, then call Setup.

func (*Loader) Autoloads

func (l *Loader) Autoloads() []Autoload

Autoloads returns the computed constant map as a slice sorted by constant path, a stable snapshot suitable for assertions and for a host that wants to enumerate the managed constants.

func (*Loader) Collapse

func (l *Loader) Collapse(globs ...string)

Collapse registers glob patterns of directories that do not represent a namespace: a collapsed directory's children are promoted into its parent namespace, mirroring `Zeitwerk::Loader#collapse`. Matching uses the same path.Match semantics as Ignore.

func (*Loader) CpathAt

func (l *Loader) CpathAt(filePath string) (Autoload, bool)

CpathAt returns the constant a given file or directory path defines, the inverse direction of the map (path -> constant). The boolean is false if the path is not managed.

func (*Loader) EagerLoad

func (l *Loader) EagerLoad() error

EagerLoad loads every managed file and fires the on_load callbacks for every managed constant, mirroring `Zeitwerk::Loader#eager_load`. Constants are processed in sorted order so parent namespaces precede their children. It returns a *SetupRequired if Setup has not run, and propagates the first error the Load seam returns.

func (*Loader) EnableReloading

func (l *Loader) EnableReloading()

EnableReloading permits Reload, mirroring `Zeitwerk::Loader#enable_reloading`. It must be called before Setup; Reload on a loader without it returns an *Error, as the gem raises Zeitwerk::Error.

func (*Loader) Ignore

func (l *Loader) Ignore(globs ...string)

Ignore registers glob patterns whose matching files and directories are excluded from the managed tree, mirroring `Zeitwerk::Loader#ignore`. Patterns are matched against absolute paths with path.Match semantics (a pattern with no metacharacters matches that exact path).

func (*Loader) Inflector

func (l *Loader) Inflector() *Inflector

Inflector returns the loader's inflector so callers can register overrides, e.g. Loader.Inflector().Inflect(map[string]string{"html_parser": "HTMLParser"}).

func (*Loader) OnLoad

func (l *Loader) OnLoad(cpath string, fn func(cpath, filePath string))

OnLoad registers a callback fired when a managed constant is loaded (during EagerLoad here, since this engine has no lazy autoload trigger of its own), mirroring `Zeitwerk::Loader#on_load`. An empty cpath (or "ANY") fires for every constant; a specific cpath fires only for that one. This matches the gem, where `on_load(:ANY)` runs for all and `on_load("Foo")` for just Foo.

func (*Loader) OnSetup

func (l *Loader) OnSetup(fn func())

OnSetup registers a callback fired at the end of every Setup (and thus after each Reload), mirroring `Zeitwerk::Loader#on_setup`.

func (*Loader) OnUnload

func (l *Loader) OnUnload(fn func(cpath, filePath string))

OnUnload registers a callback fired for each managed constant when it is unloaded, mirroring `Zeitwerk::Loader#on_unload`. It is the host's seam for removing the constant, since removal touches the Ruby runtime.

func (*Loader) PathAt

func (l *Loader) PathAt(cpath string) (Autoload, bool)

PathAt returns the managed path a given constant path maps to (constant -> path). The boolean is false if the constant is not managed.

func (*Loader) PushDir

func (l *Loader) PushDir(dir, namespace string) error

PushDir registers a root directory whose children map into namespace, mirroring `Zeitwerk::Loader#push_dir(path, namespace:)`. An empty namespace (or "Object") means the top level. The directory must exist; otherwise PushDir returns an *Error, as the gem raises Zeitwerk::Error for a missing root directory.

func (*Loader) Reload

func (l *Loader) Reload() error

Reload unloads and sets up again, picking up filesystem changes, mirroring `Zeitwerk::Loader#reload`. It returns an *Error if reloading was not enabled with EnableReloading (the gem raises Zeitwerk::Error), and a *SetupRequired if Setup has not run yet.

func (*Loader) SetDefineAutoload

func (l *Loader) SetDefineAutoload(fn DefineAutoloadFunc)

SetDefineAutoload installs the DefineAutoload seam (see DefineAutoloadFunc).

func (*Loader) SetFS

func (l *Loader) SetFS(fs FS)

SetFS replaces the filesystem seam the loader scans (default: the real filesystem).

func (*Loader) SetInflector

func (l *Loader) SetInflector(in *Inflector)

SetInflector replaces the inflector, mirroring `Zeitwerk::Loader#inflector=`.

func (*Loader) SetLoad

func (l *Loader) SetLoad(fn LoadFunc)

SetLoad installs the Load seam (see LoadFunc).

func (*Loader) Setup

func (l *Loader) Setup() error

Setup scans the registered root directories and builds the constant map, registering each managed constant through the DefineAutoload seam, mirroring `Zeitwerk::Loader#setup`. It then fires the on_setup callbacks. Setup is idempotent: calling it again while already set up is a no-op.

func (*Loader) Unload

func (l *Loader) Unload()

Unload fires the on_unload callback for every managed constant and clears the computed map, requiring a fresh Setup afterwards, mirroring `Zeitwerk::Loader#unload`.

type NameError

type NameError struct{ Msg string }

NameError mirrors Zeitwerk::NameError (a ::NameError subclass in the gem). It models the failure raised when a managed file or directory does not define the constant its path maps to; the host binding raises it from its Load / DefineAutoload seam and it is surfaced here so callers can match on the type.

func (*NameError) Error

func (e *NameError) Error() string

type SetupRequired

type SetupRequired struct{}

SetupRequired mirrors Zeitwerk::SetupRequired. EagerLoad and Reload return it when the loader has not been set up yet, exactly as the gem raises Zeitwerk::SetupRequired from eager_load and friends before setup has run.

func (*SetupRequired) Error

func (e *SetupRequired) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL