require in Spinel

August 26, 2026 · View on GitHub

Spinel is a subset of Ruby: a program that compiles and runs under Spinel should behave the same under CRuby. One place Spinel used to be a superset instead was require. Some stdlib that CRuby gates behind a requireStringIO, IO#winsize, and friends — was always available in Spinel, so code that forgot the require ran under Spinel but raised NameError / NoMethodError under CRuby. The require-gate closes that gap.

This document describes how require works today, which stdlib needs which require, and how features are resolved. The planned package system is sketched under Planned at the end.

require vs require_relative

They are different mechanisms and stay distinct:

formresolves towhen
require_relative "path"a project-local file, spliced into the programalways; a missing file is a compile error
require "name"a named feature (bundled stdlib, native, or — planned — a package)the feature must exist, else a compile error

require is not a runtime file load. Spinel is a whole-program ahead-of-time compiler, so every require is known at compile time and there is no $LOAD_PATH to mutate at runtime. A require that names something Spinel cannot provide is reported when you compile, not when you run.

The require-gate

The require-gate makes require-gated stdlib unavailable unless you require it, matching CRuby. It is currently opt-in: pass --require-gate, or set the environment variable SPINEL_REQUIRE_GATE=1, when compiling. With it off (the default today) the old always-available behaviour is preserved. The gate is expected to become the default at a future release boundary.

spinel --require-gate myprogram.rb
SPINEL_REQUIRE_GATE=1 spinel myprogram.rb   # the same switch

spin build always compiles with the gate on: inside a resolved dependency set the universe is known, so a require that resolves to nothing is a bug rather than something to warn past. spin flags hands the flag to a build driven from outside spin, which is why it has a flag spelling at all — an environment assignment cannot ride inside a flag string.

Require-gated stdlib

These are provided by Spinel but, like CRuby, only after their require:

requirewhat it enableswithout the require (gate on)
require "stringio"StringIOuninitialized constant
require "strscan"StringScanneruninitialized constant
require "json"JSON.generate, JSON.dumpuninitialized constant
require "csv"CSV (with CSV::Row / CSV::Table)uninitialized constant
require "monitor"Monitor (#synchronize)NameError (uninitialized constant)
require "socket"TCPServer, TCPSocket, UDPSocket, UNIXServer, UNIXSocket, Socket, AddrinfoNameError (uninitialized constant)
require "pathname"Pathnameuninitialized constant
require "io/console"IO#winsizeNoMethodError
require "time"Time#iso8601NoMethodError

socket is the strictest of these: its require is mandatory even with the gate off, because CRuby itself only defines TCPServer / TCPSocket after the require, and growing the constants unconditionally would diverge from CRuby's NameError. What the two classes actually cover is in limitations.md.

A library loads itself and nothing else. CRuby's stdlib files often require others as an implementation detail (csv pulls in stringio there, so a program that requires csv can name StringIO without saying so), and Spinel's version of the same library has no reason to make the same internal choice. Require what the program actually uses.

Two shapes, which set what the failure looks like:

  • A feature that defines a class/module (stringio, strscan, json, monitor): without the require the constant is undefined.
  • A feature that extends a core class (io/console adds IO#winsize, time adds Time#iso8601): without the require the method is undefined. The rest of IO and Time are core and always available — only the gated method needs the require.
# gate on, no require:
StringIO.new("x")     # error: uninitialized constant StringIO
STDOUT.winsize        # error: undefined method 'winsize'

# correct:
require "stringio"
require "io/console"
StringIO.new("x").read
STDOUT.winsize

Unsatisfiable requires

A require that Spinel cannot satisfy at all — a stdlib Spinel does not implement (date, pp, securerandom, ...) or an unknown name — is a compile error under the gate, the same as a missing require_relative:

spinel: cannot load such file -- date (require "date")

This is honest about the subset: if Spinel cannot provide date, the program genuinely cannot run, and you learn it when you compile rather than via a confusing failure later. With the gate off this is a warning instead.

A few requires name a capability Spinel already provides as core, and are tolerated as a no-op (modern CRuby treats them as already-loaded too):

  • require "thread" (Thread, Mutex, Queue are core)
  • require "enumerator" (Enumerator is core)
  • require "fiber" (Fiber is core)

Pre-installed packages (the carved-out stdlib)

Some stdlib ships with Spinel as Ruby source and is spliced when required — set, forwardable, optparse, erb, csv, pathname, digest, base64 (plus the stringio/strscan/json marker shims for their C-backed features). net/http and uri are there, and so is openssl -- the one that is conditional: it is glue over the system libssl, so it exists only where those headers did at build time, and require "openssl" is otherwise the unsatisfiable require it is for any library Spinel does not carry. Each lives as an ordinary spinelgem under packages/<name>/ beside the compiler (packages/set/set.rb with its spin.toml); lib/ holds only the C runtime. The require pulls in the package's file like any other package — pre-installed just means no fetch.

Providing your own feature

You can provide a feature yourself and have require "name" resolve it, through the same mechanism as bundled stdlib. Pass -I <dir> (like ruby -I) to add a feature search root, then a require resolves against it:

spinel -I mylibs main.rb

A feature name is a path, looked up in each -I root in two forms:

  • single filerequire "thing"mylibs/thing.rb (the CRuby form);
  • colocated directoryrequire "thing"mylibs/thing/thing.rb, so a feature's sources (.rb, later its .c/.rbs) share one directory. require "my/thing"mylibs/my/thing.rb or mylibs/my/thing/thing.rb.

Pure Ruby needs only the .rb: it is spliced into the whole-program compile and Spinel infers types from it like your own code, so no manifest or .rbs is required (an .rbs is optional, to pin the public surface). A require that no root satisfies is the compile error from the previous section.

A spin package is already in the colocated form. A package named curses lives at <pkgs>/curses/curses.rb, so -I <pkgs> resolves require "curses" against it — with no project file and no spin involved — and one root serves every package under it. Its carried C reaches the link line through the compiler's repeatable --link:

spinel -I spin/packages --link ~/.cache/spin/native/curses-0.1.0-cc/sp_curses.o app.rb

The root has to be the directory the package sits in. -I . at a repository root whose packages live under spin/packages/ looks for ./curses.rb and ./curses/curses.rb, finds neither, and the require is ignored with a warning (or refused, under the gate). Working out which roots and which objects a dependency set implies is what spin flags does for you.

Planned

  • C in a feature is reached through FFI today (wrapping an existing library); a planned in-TU mode will let a feature's own C be inlined without a link boundary.
  • A default vendored root and a project manifest will sit on top of -I, so packages resolve without passing -I by hand.
  • Feature directories (a -I root, a vendored package) are read-only inputs: Spinel never writes build artifacts next to a feature's sources. A C-backed feature's compiled objects go to a separate build cache, so a feature tree can be shared or mounted read-only.
  • Spinel's own require-gated stdlib will eventually be carved out of the compiler into ordinary feature packages, resolved exactly like a third-party one.