ADR-0026: Module System
Status
Stable (no longer requires --preview module_types)
Summary
Introduce a module system for Gruel that prioritizes fast compilation through lazy semantic analysis, uses a simple "file = struct" model inspired by Zig, and provides straightforward pub/private visibility. This design supersedes the flat namespace from ADR-0023 (multi-file compilation) while preserving forward compatibility with future package management.
Context
Current State
ADR-0023 introduced multi-file compilation with a flat global namespace—all functions, structs, and enums are globally visible across files. This was explicitly a stepping stone:
"The UX is admittedly awkward (
gruel a.gruel b.gruel c.gruel -o out), but this is a stepping stone, not the final design."
We now need a proper module system that provides:
- Namespacing — Avoid symbol collisions as codebases grow
- Encapsulation — Hide implementation details
- Fast compilation — The primary design constraint
Design Goals
- Compilation speed is paramount — Inspired by Zig's lazy analysis approach
- Simplicity over flexibility — Avoid Rust's module system complexity
- Files are the unit of organization — No separate "module declaration" concept
- Forward-compatible with packages — Don't box out future package management
Research Summary
We analyzed module systems from several languages:
| Language | Key Insight |
|---|---|
| Zig | Files are structs; lazy analysis skips unreferenced code; simple pub/private |
| Rust | Explicit mod/use creates cognitive overhead; fine-grained visibility rarely needed |
| Hylo | Intra-module visibility is automatic; pub only affects cross-module |
| Swift | Multiple visibility levels add complexity without proportional benefit |
| Go | Directory = package; implicit file discovery; simple and fast |
Key takeaways:
- Zig's lazy analysis enables dramatically faster builds by only analyzing referenced code
- Rust's module system is the #2 complaint after borrow checking—too many concepts (
mod,use,pub use,extern crate, visibility modifiers) - Simple pub/private (Zig) or pub/internal/private (Hylo) covers 99% of use cases
Decision
Core Principle: Files Are Structs
Every .gruel file is implicitly a struct. Importing a file returns a struct type containing all pub declarations from that file.
// math.gruel
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn sub(a: i32, b: i32) -> i32 { a - b }
fn helper() -> i32 { 42 } // private, not exported
// main.gruel
const math = @import("math.gruel");
fn main() -> i32 {
math.add(1, 2) // OK
// math.helper() // Error: `helper` is private
}
Import Syntax
Following Zig's model, @import is a builtin that returns a struct type containing all pub declarations from the imported file:
// @import returns a struct type
const math = @import("math.gruel");
math.add(1, 2)
// You can alias to any name
const m = @import("math.gruel");
m.add(1, 2)
// Access nested items directly
const add = @import("math.gruel").add;
add(1, 2)
The key insight: @import("foo.gruel") is equivalent to a struct containing the file's contents:
// If math.gruel contains:
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn sub(a: i32, b: i32) -> i32 { a - b }
fn helper() -> i32 { 42 } // private
// Then @import("math.gruel") returns something like:
// struct {
// pub fn add(a: i32, b: i32) -> i32 { ... }
// pub fn sub(a: i32, b: i32) -> i32 { ... }
// // helper is not visible - it's private
// }
Resolution order for @import("foo"):
- Local file
foo.gruel(simple file module) - Local file
_foo.gruelwith directoryfoo/(directory module) - (Future) Dependency named
fooingruel.toml
Directory Modules
A directory becomes a module when it contains a _ prefixed file with the same name:
src/
main.gruel
math.gruel # const math = @import("math.gruel");
_utils.gruel # const utils = @import("utils"); — module root for utils/
utils/
strings.gruel # Submodule
internal.gruel # Submodule
The _utils.gruel file controls what the directory exports using Zig's pub const pattern for re-exports:
// _utils.gruel — the module root for utils/
// Re-export the entire strings module
pub const strings = @import("utils/strings.gruel");
// Re-export a specific function from internal
pub const helper = @import("utils/internal.gruel").helper;
// internal module itself is not re-exported, so users can't access
// @import("utils").internal — they'd have to import it directly
Usage:
// main.gruel
const utils = @import("utils");
// Access re-exported submodule
utils.strings.format("hello")
// Access re-exported function directly
utils.helper()
Why _ prefix?
- Sorts first — In file listings,
_utils.gruelappears beforeutils/, making the module entry point immediately visible - Unambiguous —
_foo.gruelis always a directory module root;foo.gruelis always a standalone file module - No dual-file confusion — Rust's
foo.rs+foo/pattern requires remembering that both exist; here the_makes it explicit
This follows matklad's suggestion from "Notes on Module System" for improving discoverability.
No mod declarations needed. Unlike Rust, there's no need to write mod strings; to declare that strings.gruel exists. The filesystem is the source of truth — if the file exists, it can be imported. Re-exports are explicit pub const bindings, following Zig's pattern.
Visibility: Simple pub/private
Only two visibility levels:
| Modifier | Meaning |
|---|---|
pub | Visible to all importers |
| (none) | Private to this module (directory) |
Rationale: Rust's pub(crate), pub(super), pub(in path) are rarely used and add cognitive overhead. If we later need package-level visibility, we can add pub(pkg) without breaking existing code.
Intra-module visibility: Files within the same directory can access each other's non-pub items. This matches Hylo's model where pub only affects cross-module (cross-directory) visibility.
// utils/strings.gruel
fn internal_helper() { ... } // Private
// utils/parser.gruel
const strings = @import("strings.gruel");
fn parse() {
strings.internal_helper() // OK - same directory
}
// main.gruel
const utils = @import("utils");
fn main() {
// utils.strings.internal_helper() // Error - different directory
}
Lazy Semantic Analysis
The key to fast compilation. The compiler only analyzes code that is actually referenced from entry points.
// broken.gruel
pub fn works() -> i32 { 42 }
pub fn broken() -> i32 { "not an int" } // Type error!
// main.gruel
const broken = @import("broken.gruel");
fn main() -> i32 {
broken.works() // Only this is analyzed
// broken.broken() is never called, so its error is NOT reported
}
Trade-off: Errors in unreferenced code are silently ignored. This is the same trade-off Zig makes, and it's deliberate:
- Faster builds (don't analyze unused code)
- Smaller binaries (unused code isn't codegen'd)
- IDE tooling may need separate "check all" mode
Implementation: Semantic analysis starts at main() and follows references. Each declaration is analyzed at most once, with results cached.
Entry Points
- A program must have exactly one
pub fn main() - The file containing
main()is the entry point for analysis - Libraries (future) will have different entry point rules
Standard Library
The standard library is not implicitly imported (unlike Hylo). Users must explicitly import what they need:
const io = @import("std").io;
const Vec = @import("std").collections.Vec;
Rationale: Explicit imports make dependencies clear and don't pollute the namespace. This also makes the prelude smaller and compilation faster.
Circular Imports
Circular imports between files are allowed at the type level but not at the value level:
// a.gruel
const b = @import("b.gruel");
pub struct Foo { b: b.Bar } // OK - type reference
// b.gruel
const a = @import("a.gruel");
pub struct Bar { a: a.Foo } // OK - type reference
// a.gruel
const b = @import("b.gruel");
pub const X: i32 = b.Y + 1; // Error - circular value dependency
// b.gruel
const a = @import("a.gruel");
pub const Y: i32 = a.X + 1; // Error - circular value dependency
The compiler detects cycles during lazy analysis and reports clear errors.
Relationship to Multi-File Compilation (ADR-0023)
This ADR supersedes the flat namespace from ADR-0023. The compilation model remains similar:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ main.gruel │ │ math.gruel │ │ utils/ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Lazy Semantic Analysis │
│ (starts at main, follows imports on demand) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────┐
│ Codegen │
└─────────────┘
Key change: Instead of merging all symbols into a flat namespace, we now:
- Start analysis at
main() - When encountering
@import("foo.gruel"), lazily analyzefoo.gruel - Only analyze declarations that are actually referenced
Future: Package Management
This module system is designed to be forward-compatible with packages. A future gruel.toml would map package names to sources:
# gruel.toml (future)
[]
= { = "https://...", = "sha256:..." }
= { = "../json-lib" }
The resolution order becomes:
- Local file
foo.gruel(simple file module) - Local file
_foo.gruelwithfoo/directory (directory module) - Dependency
foofromgruel.toml
The import syntax (@import("foo")) remains unchanged—the package manager just adds another resolution step.
Implementation Phases
All phases are complete. 40 spec tests pass.
Phase 1: Basic Module Imports ✓
Goal: @import("foo.gruel") imports foo.gruel from the same directory.
-
@importparses asIntrinsicCall - Resolve relative file paths from importing file
- Load and parse imported files on demand
- Create Module type for imported file
- Stabilized (preview gate removed)
Phase 2: Module Member Access ✓
Goal: Access module members via module.symbol qualified syntax.
- Handle FieldGet on Module types → lookup in module's exports
- Visibility checking (only
pubdeclarations accessible) - Type checking for module member access
- Codegen for qualified function calls
Phase 3: Directory Modules ✓
Goal: @import("foo") can import _foo.gruel which has submodules in foo/.
- Directory module resolution (
_foo.gruel+foo/pattern) - Re-exports via
pub const - Intra-directory visibility rules
Phase 4: Lazy Analysis ✓
Goal: Only analyze referenced code.
- Sema starts from entry point (main)
- On-demand declaration analysis
- Caching for analyzed declarations
- Referenced function tracking
Phase 5: Standard Library Structure ✓
Goal: Organize std as proper modules.
-
std/directory with_std.gruelroot -
@import("std")resolution -
std.mathsubmodule with abs, min, max, clamp
Consequences
Positive
- Fast compilation — Lazy analysis skips unreferenced code
- Simple mental model — Files are structs,
pubor private - No boilerplate — No
moddeclarations, noextern crate - Forward-compatible — Package system can layer on top
Negative
- Silent errors in unused code — Trade-off for speed
- Less encapsulation than Rust — No
pub(crate)equivalent (yet) - Different from Rust — Users familiar with Rust need to unlearn some habits
Neutral
- Directory structure matters — File layout determines module structure
- No implicit prelude — More explicit, but more typing for common imports
Open Questions
- IDE support for lazy analysis — Should we provide a
gruel check --allmode that analyzes everything regardless of reachability? (Not needed immediately, but maybe someday.)
Future Work
- Package management —
gruel.toml, dependency resolution, content-addressed packages - Package visibility —
pub(pkg)for package-internal APIs if needed - Incremental compilation — Build on lazy analysis for file-level caching
- Conditional compilation —
#[cfg(...)]attributes for platform-specific code
References
- ADR-0023: Multi-File Compilation — Superseded flat namespace
- ADR-0025: Comptime — Related compile-time evaluation
- Notes on Module System — matklad's analysis of module system design principles (key inspiration for filesystem-based modules, no
moddeclarations) - Zig Module System — "Files are structs" inspiration
- Zig Sema: Lazy Analysis — Mitchell Hashimoto's explanation
- Zig and Rust Comparison — matklad's analysis
- Rust Module System Criticism — What to avoid
- Hylo Modules — Intra-module visibility inspiration `