Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

18. The module system

  • Status: Accepted
  • Date: 2026-09-03

Context

Every Korrin program so far has been a single file. Real programs are not: they split into pieces that import each other, and they reach for a standard library. Milestone 2 adds both. This ADR covers the system — how a module is named, found, loaded, and used; ADR 0019 covers the built-in modules that ride on it.

The decisions in play:

  • What is a module? A file? A namespace object? A set of names spliced into the importer?
  • How is one named — by path, by a registered name, by package?
  • What does import bind — the module, or names out of it?
  • Isolation — does an imported module see the importer's globals?
  • Cycles — allowed (with partially-initialised modules, like Python), or an error?

The user chose: path + module object. import "./utils" binds utils; members are reached as utils.foo(). import "./a/config" as cfg handles a name collision. Top-level names are public; _name is private by convention. A circular import is an error. A bare import "math" (no ./) is a built-in module.

Decision

Syntax

import "<specifier>"            # binds the specifier's final component
import "<specifier>" as <name>  # binds <name>

import is a statement and may appear anywhere a statement may (it binds its alias under the normal scoping rules — at the top level that means a global). The specifier is a plain string — no interpolation holes. When there is no as, the alias is the specifier's last path component with any extension removed ("./a/config"config, "math"math); if that is not a valid identifier, as is required (E0108).

Resolution

  • A specifier starting with ./ or ../ is a file, resolved relative to the directory of the file doing the importing (not the process's working directory, and not the entry file). .kor is appended if the specifier has no extension. The path is canonicalised; a file that cannot be found or read is E0314.
  • Any other specifier is a built-in module (ADR 0019). An unknown one is E0314.

Relative imports need a file on disk to resolve against, so they only work when the program was started from a file (korrin run file.kor, or korrin::run_file for an embedder). In-memory execution (korrin::run) has no directory: a relative import from it is E0314, while built-in imports still work.

The module object

import evaluates the module's file (or builds the built-in) once and binds a module objecttype() reports "module". Loading is cached by canonical path (or built-in name), so importing the same module from two places yields the same object (== is identity).

A module's file runs in its own scope: a fresh global with only the builtins and Error, then the module's top level. It does not see the importing program's names, and the importer does not see the module's names except through the object.

module.foo reads foo from the module's top-level bindings.

  • A name that is not bound is E0309 ("module x has no member foo").
  • A name starting with _ is private: reading it through the module is E0309, even though it exists. Code inside the module uses its _-names normally; privacy is only enforced at the boundary.

There is no way to enumerate, re-export, or splice a module's names. import binds exactly one object.

Cycles

While a module is being loaded its path is marked "in progress". An import that resolves to a module already in progress is E0315 — Korrin does not expose half-initialised modules. Break the cycle by moving the shared thing into a third module both import.

Implementation

  • Value::Module(Rc<ModuleObject>) where ModuleObject holds the name, origin (for diagnostics), and the module's scope Environment.
  • Interpreter holds a ModuleRegistry — a directory stack (for ./ resolution), a cache, and an in-progress set. korrin::run_file seeds the stack with the entry file's directory; each nested file import pushes its own.
  • Compile errors inside an imported module surface as a single E0314 at the import site, carrying the module's own error text. Per-file diagnostic rendering (a caret into the imported file) is a known gap, tracked for a later milestone.

Consequences

  • A Korrin codebase can span files, which is the point.
  • Module objects (rather than name-splicing) keep every name's origin visible at the use site: geometry.area(r) says where area came from. No from x import * ambiguity, ever.
  • The "fresh globals per module" rule means a module cannot accidentally depend on a name the importer happened to define. It also means every module re-pays for its own builtin bindings (cheap) and that two modules' top-level _helpers never collide.
  • Cycles being an error is stricter than Python. It rules out a real (if awkward) pattern, in exchange for never handing code a module that isn't finished. Consistent with Korrin's "fail loud" stance.
  • Imported-module diagnostics are worse than single-file ones until multi-file rendering lands. Acceptable for M2; noted.
  • import anywhere (not just the top level) is permissive. It costs nothing and a top-level-only rule would be one more thing to explain.

Alternatives considered

  • Name-splicing (from "./x" import foo). Familiar, but it detaches a name from its source and invites collisions and import *. The module object gives the same access with the origin always visible.
  • Cycles allowed, with partial modules. Python's model. Powerful for a few patterns, a source of confusing bugs for many more (a name is sometimes there, sometimes not, depending on load order). Rejected for predictability.
  • A package manifest / registry. Way beyond M2. Path-based resolution needs no configuration and is enough to build with; a package story can come later without changing import's surface.
  • Resolving ./ against the process CWD (like a shell). Surprising — moving where you run korrin from would change which files a program sees. Relative to the importing file is what every modern language does.
  • export keyword / explicit public list. More ceremony. The _ prefix convention (already Korrin's style for "internal") extended to a hard boundary rule covers it with no new syntax.