Skip to main content
v0.13.0
rfc-0030

Module System Redesign

Summary

Replaces RFC-0009 and RFC-0029 with a revised module system that addresses the ergonomic shortcomings of the Rust-inspired design. The core problems with the previous design were: the required two-step mod + use pattern for every imported module, the name/mod.mln directory module convention, and pub use as the re-export mechanism. This RFC resolves all three with minimal added complexity.

Supersedes: RFC-0009 (Module System), RFC-0029 (Module System — Gaps and Clarifications)
Target: v0.5.0

:

Motivation

The RFC-0009 design required two separate declarations to use a module:

mod parser; // declares the module exists (loads the file)
use parser::{Ast, Token}; // brings names into scope

Both steps are mandatory. Skipping mod means the file is never loaded. Skipping use means you can only access names via fully-qualified paths. This double-declaration pattern was the primary ergonomic complaint.

Additionally, pub use reads as a mechanism (pub + use) rather than an intent (export), and name/mod.mln as the directory module entry point directly imports a Rust convention that has no other motivation.

Design

import replaces both mod and use

A single import declaration both loads the module file and brings names into the current scope:

import parser::{Ast, Token}; // loads parser.mln, brings Ast and Token into scope
import std::math; // loads std/math, brings math into scope as a module handle
import root::lexer::Token as Tok; // absolute path with alias
import parser::*; // glob import — all public names from parser.mln

There is no mod keyword and no use keyword for module imports. import is the only form.

Import forms:

FormEffect
import path::Name;imports Name
import path::Name as Alias;imports Name under Alias
import path::{A, B, C};imports multiple names from one path
import path::{A as X, B};imports with per-item aliases
import path::*;imports all public names from the module
import path::module;imports module as a module handle; module::item is then valid

export replaces pub use

Re-exporting names from submodules uses an explicit export declaration:

// parser.mln — facade module for the parser namespace
export ast::Ast;
export lexer::{Token, Span};
export ast::ParseError as Error;

export and import share the same path and tree syntax. export re-exports into the current module's public API; the exported names are then accessible as if defined directly in the re-exporting module.

pub on declarations continues to mark individual items as externally accessible. pub and export serve different roles:

KeywordPurpose
pubMarks a declaration in this file as externally accessible
export path::Name;Re-exports a name from a submodule into this module's public API

File-to-module mapping

:: maps directly to / in the filesystem. There is no special directory module file.

ImportFile resolved
import parser::Ast;parser.mln
import parser::ast::Ast;parser/ast.mln
import root::a::b::c::T;a/b/c.mln relative to the root file

A directory module with a public facade is expressed by placing name.mln alongside the name/ directory. The two coexist without ambiguity — they are different paths:

src/
main.mln ← import parser::Ast; import parser::lexer::Token;
parser.mln ← export ast::Ast; export lexer::Token;
parser/
ast.mln ← pub struct Ast { ... }
lexer.mln ← pub struct Token { ... }

parser.mln is the facade. Files in parser/ form the namespace. There is no parser/mod.mln convention.

Module visibility

Modules do not have their own visibility annotation. Module-level access control is handled entirely by pub on individual items. If an item is pub, its full path is accessible to any importer. If it is private, it is not.

To hide the internal file structure from importers, a parent module uses export to expose only the names it chooses:

// parser.mln
export ast::Ast; // Ast is accessible as root::parser::Ast
export lexer::Token; // Token is accessible as root::parser::Token
// parser/ast.mln and parser/lexer.mln paths remain accessible
// but callers are expected to use the facade

There is no equivalent of pub mod / private mod from RFC-0009. This simplification is intentional for v0.5.0. Path-level module privacy is deferred.

Paths

Path roots are unchanged from RFC-0029:

RootMeaning
root::The selected root module for the current program
std::The bundled standard library root
self::The current module
super::The parent module; invalid from the root module
imported module handleA module brought into scope by import path::module;

Fully-qualified paths are valid anywhere a name is expected without a preceding import:

let p: root::parser::Ast := root::parser::Ast::new();

import is a local binding convenience, not the only access mechanism.

File header ordering

(import | export)* declaration*

import and export declarations may appear in any order relative to each other, but all must precede any other declarations. import and export are not valid inside blocks.

Import conflicts

Explicit import conflicts (two import statements binding the same local name) are a compile error at the second import.

Glob imports use a softer rule:

  • Local declarations beat glob imports.
  • Explicit imports beat glob imports.
  • Two glob imports may name the same item without an immediate error; using that name is an error only if the reference is ambiguous.

Circular imports

Circular imports are a compile error. The error message includes the full import chain.

Module graph loading

The module graph is built from import declarations. The loader:

  1. Parses the root file.
  2. Collects all import declarations; resolves each to a file path via ::/ mapping.
  3. Recursively loads each referenced file, detecting cycles.
  4. Only files reachable via at least one import declaration are loaded.

export declarations are processed after the graph is fully loaded. They do not affect which files are loaded.

std::core auto-import

Unchanged from RFC-0029: std::core is auto-imported into every file as if import std::core::*; appeared implicitly. The auto-import is lowest priority; any explicit import beats it. A local declaration shadows the auto-import in its declaring module only.

Single-file compatibility

A .mln file with no import or export declarations is a complete program. Fully preserved.

:

Grammar changes

file ::= header-decl* declaration*
header-decl ::= import-decl | export-decl
import-decl ::= 'import' import-path ';'
export-decl ::= 'export' import-path ';'
import-path ::= path-root '::' import-tree
| path-root
path-root ::= 'root' | 'std' | 'self' | 'super' | identifier
import-tree ::= import-item
| '{' import-item (',' import-item)* '}'
| '*'
| identifier '::' import-tree
import-item ::= identifier ('as' identifier)?
pub-ann ::= 'pub' -- unchanged; valid on struct, enum, fun, let, mut, linear struct, linear enum, aspect

mod, use, and pub use are removed from the grammar.

Changes from RFC-0009 / RFC-0029

RFC-0009/0029RFC-0030
mod name; declares a submoduleremoved — import builds the module graph
pub mod name; makes a submodule publicremoved — no module-level visibility annotation
use path::Name; brings a name into scopeimport path::Name;
pub use path::Name; re-exports a nameexport path::Name;
name/mod.mln as directory module entry pointremoved — name.mln alongside name/ directory
File header: mod* use* declaration*(import|export)* declaration*
Glob import use path::*;import path::*; — same conflict rules
use path::Name as Alias;import path::Name as Alias;
super::, self:: path rootsunchanged
root:: path rootunchanged
Circular import is a compile errorunchanged
pub on declarationsunchanged
std::core auto-importunchanged
Single-file compatibilityunchanged

:

Open Questions

None — all questions from RFC-0009 and RFC-0029 are either resolved by this RFC or unchanged.

Decision

Outcome: Accepted
Target: v0.5.0

The Rust-inspired mod + use two-step was the primary ergonomic shortcoming of RFC-0009. Collapsing both into import eliminates the pattern without adding complexity elsewhere. export as an explicit re-export keyword is cleaner than pub use. Dropping name/mod.mln removes a Rust convention with no independent motivation.

The removal of module-level visibility (pub mod) is the most significant simplification. Item-level pub is sufficient for v0.5.0; path-level module privacy can be added later if the need arises in practice.

Coverage Checklist (added 2026-08-19, not part of the original RFC)

Retroactive breakdown of this RFC's distinct, fixture-testable normative claims (expanded 2026-08-19: added item 12, missed in the original pass), as headed sections for citation purposes only. The document above is unchanged and remains the historical record. Deliberately excludes claims that aren't independently observable from a program's behavior -- implementation strategy, design rationale, or internal architecture discussion belongs in the RFC's own prose, not here.

1. Import both loads a module and introduces selected names

An import declaration loads the referenced module and brings its selected public names into the importing module's scope. Named, grouped, aliased, glob, and module-handle imports are accepted forms.

2. Export re-exports names through a module's public API

An export declaration makes a name from a submodule available through the current module, including under an alias. Importers may use that re-export as if the name were declared directly by the facade module.

3. Module paths map directly to .mtl source paths

:: path segments resolve to directories and .mtl files, so a::b::Thing resolves through a/b.mtl. A facade name.mtl may coexist with name/; no special name/mod.mtl entry file is used.

4. Only public items are accessible across a module boundary

Declarations are module-private unless marked public; modules themselves have no separate visibility modifier. An import or qualified reference to a private item is rejected with the visibility error T0009.

5. Header imports and exports precede declarations

At file scope, import and export declarations may be interleaved but must come before ordinary declarations. They are not valid inside blocks.

6. Module roots and qualified paths resolve in source code

root::, std::, self::, and super:: are supported path roots in their valid contexts, and fully qualified paths may be used wherever a name is expected. super:: from the root module is rejected.

7. Import conflicts follow explicit and glob priority rules

Two explicit imports of the same local name are an immediate T0011 error. Explicit imports and local declarations win over glob imports, while a name from two user glob imports is rejected only if it is used ambiguously.

8. Circular and missing imports are load errors

An import cycle is rejected with its dependency chain, and an import whose module file is absent is an error rather than a silently ignored dependency.

9. A bare export also loads its referenced module

An export path::Name; with no corresponding import of path still resolves and loads path's module file into the graph. (Resolved 2026-08-19, metel-core#749/#664: this RFC's original text claimed the opposite -- "export declarations are processed after the graph is fully loaded. They do not affect which files are loaded" -- but that design was found to be a real bug, not a stated intent worth keeping: without loading the export's target, a name reachable only through a re-export resolved nowhere in the compiled program, so the re-export itself was permanently broken. Fixed deliberately in metel-core#664 -- export and import now build the module graph together. docs/public/reference/spec/modules.md's "Module Graph Loading" section, which repeated this RFC's original claim, is corrected to match.)

10. Single-file programs remain valid

A .mtl program with no import or export declarations runs as a complete single-module program.

11. std::core names are available in every module

Every module receives the std::core surface at the lowest import-priority tier. An explicit import or a local declaration takes precedence over an auto-imported name.

12. Legacy mod and use declarations are rejected

The former module declarations mod, use, and pub use are not valid Metel syntax; imports and re-exports use import and export instead.