Modules
Files and Modules
Every .mtl source file is a module. There is no mod declaration — the module graph is built entirely from import declarations.
The root file passed to the toolchain is the root module:
metel src/main.mtl
In that example, root:: refers to src/main.mtl.
File-to-Module Mapping
:: maps directly to / in the filesystem. There is no special directory module file.
| Import | File resolved |
|---|---|
import parser::Ast; | parser.mtl |
import parser::ast::Ast; | parser/ast.mtl |
import root::a::b::c::T; | a/b/c.mtl relative to the root file |
A directory module with a public facade is expressed by placing name.mtl alongside the name/ directory. The two coexist without ambiguity — they are different paths:
src/
main.mtl ← import parser::Ast; import parser::lexer::Token;
parser.mtl ← export ast::Ast; export lexer::Token;
parser/
ast.mtl ← public struct Ast { ... }
lexer.mtl ← public struct Token { ... }
parser.mtl is the facade. Files in parser/ form the namespace. There is no name/mod.mtl convention.
Formal rules
File Header Ordering
At file scope, import and export declarations must precede all other declarations:
(import | export)* declaration*
import and export are not valid inside blocks.
Formal rules
Paths
Paths use :: separators.
Path roots are:
| Root | Meaning |
|---|---|
root:: | The selected root module for the current program |
std:: | The bundled standard library root; std::core is always available |
self:: | The current module |
super:: | The parent module; invalid from the root module |
| imported module handle | A module brought into scope by import path::module; |
Reserved namespaces
The std top-level namespace is reserved for the standard library. User module
paths may not begin with std — a module file at std.mtl or anywhere under
std/ in the project tree is a compile error:
error: module path `std::…` is reserved for the standard library
std is also a reserved keyword and cannot appear as an identifier. Both
restrictions are consistent: std is not a valid name for user code at any
level.
No other top-level names are currently reserved.
Fully-qualified paths are valid anywhere a name is expected:
// src/main.mtl
import root::parser::Token;
fun main() -> i64 {
let token: root::parser::Token := root::parser::Token { value = 42 };
return token.value;
}
// src/parser.mtl
public struct Token {
public value: i64,
}
Formal rules
Imports
import loads the referenced module file and declares which names from it are in scope for the current module:
// src/main.mtl
import parser::*;
import root::lexer::Token as Tok;
fun main() -> i64 {
let ast := Ast { token = Token { value = 1 } };
let tok: Tok := dbg(Tok { value = 2 });
return ast.token.value + tok.value + parse(ast.token);
}
// src/parser.mtl
export ast::Ast;
export ast::parse;
export lexer::Token;
// src/parser/ast.mtl
import super::lexer::Token;
public struct Ast { public token: Token }
public fun parse(token: Token) -> i64 { token.value }
// src/lexer.mtl
public struct Token { public value: i64 }
import parser::*; brings in Ast, parse, and the re-exported Token all at
once; Tok — an alias for that same Token, reached via a second,
root-qualified import — works as both a type annotation and a struct
constructor, and unifies with the un-aliased name since both name the same
declaration.
Import forms:
| Form | Effect |
|---|---|
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 |
Formal rules
Legality Rule №1
A module may use its own declarations and public declarations brought into scope by an import. Loading another module alone does not make that module's names available.
Referenced by: rfc-0031
Tested by
Legality Rule №2
An aliased import binds only its alias locally. The alias may be used wherever the imported declaration's kind permits, including as a value, type, or constructor.
Referenced by: rfc-0031
Tested by (2)
Legality Rule №3
A qualified use resolves through an imported binding; an unresolved qualified path is a name-resolution error and is not retried as an arbitrary bare name.
Referenced by: rfc-0031
Tested by
Legality Rule №4
An import loads its referenced module and introduces the selected public names or module handle into the importing module's scope.
Referenced by: rfc-0030
Tested by
Re-exports
export re-exports names from submodules into the current module's public API:
// parser.mtl — facade module for the parser namespace
export ast::Ast;
export lexer::{Token, Span};
export ast::ParseError as Error;
fun main() -> i64 {
return 0;
}
export and import share the same path and tree syntax. Re-exported names are indistinguishable from names defined directly in the re-exporting module.
Formal rules
Legality Rule №1
A re-export may expose only a declaration that is public in its source module. Re-exporting
a private source declaration is a T0009 visibility error.
Referenced by: rfc-0031
Tested by
Legality Rule №2
A re-export makes a public source declaration available through the current module's public API, including under an alias; importers may use it as a declaration of the facade.
Referenced by: rfc-0030
Tested by
public and export serve different roles:
| Keyword | Purpose |
|---|---|
public | Marks a declaration in this file as externally accessible |
export path::Name; | Re-exports a name from a submodule into this module's public API |
Since v0.12.1 (metel-core#664), a bare export path::Name; also loads path's module file, exactly as an import of the same path would — otherwise a name reachable only through a re-export, with no import anywhere pulling its module in directly, would never actually resolve: it would exist nowhere in the compiled program for the re-export to point at. export and import therefore build the module graph together; an export is not merely a post-load renaming step over files import already loaded.
std::core Auto-Import
Every module automatically has std::core glob-imported at the lowest priority tier. This means Perhaps, Result, Display, Iterable, From, and all built-in functions are available in every module without any explicit import statement.
// No import needed — Perhaps and Result are always in scope
fun maybe_parse(s: String) -> Perhaps<i64> {
if (s == "1") { return Some { value = 1 }; }
return None;
}
fun main() -> i64 {
match (maybe_parse("1")) {
Some { value } => value,
None => 0,
}
}
You can still write import std::core::Perhaps; or import std::core::*; explicitly — the result is the same. If a local declaration or explicit import shadows a std::core name, the local binding wins silently.
std::core is a virtual module — it has no physical .mtl file and cannot be listed or enumerated. Its contents are seeded by the runtime.
Formal rules
Legality Rule №1
Every module has the std::core names available without an import; the same names may also
be named through their explicit std::core:: paths.
Referenced by: rfc-0030, rfc-0057
Tested by (2)
1// Integration Test 8 — Core type completeness: Perhaps and Result (v0.6.0)
2//
3// Feature coverage:
4// Perhaps<T> construction, matching, and chaining
5// Result<T,E> construction, matching, ? propagation, and From coercion
6// Generic functions over Perhaps and Result
7// Interaction: function returning Perhaps used in Result context via map
8// Regression: ensure #133 (TypeDefinitionRegistry) did not break these paths
9
10// ── Result helpers ────────────────────────────────────────────────────────────
11
12struct ParseError { msg: String }
13struct MathError { msg: String }
14
15extend MathError: From<ParseError> {
16 fun from(value: ParseError) -> MathError {
17 MathError { msg = "parse: " + value.msg }
18 }
19}
20
21fun parse_positive(s: String) -> Result<i64, ParseError> {
22 if (s == "1") { Result::Ok { value = 1 } }
23 else if (s == "2") { Result::Ok { value = 2 } }
24 else if (s == "10") { Result::Ok { value = 10 } }
25 else if (s == "42") { Result::Ok { value = 42 } }
26 else { Result::Err { error = ParseError { msg = "bad input: " + s } } }
27}
28
29fun double_parsed(s: String) -> Result<i64, ParseError> {
30 let n := parse_positive(s)?;
31 Result::Ok { value = n * 2 }
32}
33
34fun add_parsed(a: String, b: String) -> Result<i64, MathError> {
35 // cross-type match + as-cast (? cross-type deferred to #13)
36 let x := match (parse_positive(a)) {
37 Result::Ok { value } => value,
38 Result::Err { error } => { return Result::Err { error = error as MathError }; },
39 };
40 let y := match (parse_positive(b)) {
41 Result::Ok { value } => value,
42 Result::Err { error } => { return Result::Err { error = error as MathError }; },
43 };
44 Result::Ok { value = x + y }
45}
46
47// ── Perhaps helpers ───────────────────────────────────────────────────────────
48
49fun find_in(arr: i64[], target: i64) -> Perhaps<i64> {
50 var i := 0;
51 while (i < arr.len()) {
52 if (arr[i as u64] == target) { return Perhaps::Some { value = i }; }
53 i += 1;
54 }
55 None
56}
57
58fun map_some(p: Perhaps<i64>, factor: i64) -> Perhaps<i64> {
59 match (p) {
60 Perhaps::Some { value } => Perhaps::Some { value = value * factor },
61 None => None,
62 }
63}
64
65fun perhaps_to_result(p: Perhaps<i64>, error: String) -> Result<i64, String> {
66 match (p) {
67 Perhaps::Some { value } => Result::Ok { value = value },
68 None => Result::Err { error = error },
69 }
70}
71
72fun main() {
73 // ── Basic Result matching ─────────────────────────────────────────────────
74
75 let r1 := parse_positive("42");
76 match (r1) {
77 Result::Ok { value } => assert(value == 42),
78 Result::Err { error } => assert(false),
79 };
80
81 let r2 := parse_positive("bad");
82 match (r2) {
83 Result::Ok { value } => assert(false),
84 Result::Err { error } => assert(error.msg == "bad input: bad"),
85 };
86
87 // ── ? propagation (same-type) ─────────────────────────────────────────────
88
89 let d1 := double_parsed("10");
90 match (d1) {
91 Result::Ok { value } => assert(value == 20),
92 Result::Err { error } => assert(false),
93 };
94
95 let d2 := double_parsed("nope");
96 match (d2) {
97 Result::Ok { value } => assert(false),
98 Result::Err { error } => assert(error.msg == "bad input: nope"),
99 };
100
101 // ── ? propagation with From coercion (ParseError → MathError) ─────────────
102
103 let a1 := add_parsed("1", "2");
104 match (a1) {
105 Result::Ok { value } => assert(value == 3),
106 Result::Err { error } => assert(false),
107 };
108
109 let a2 := add_parsed("1", "bad");
110 match (a2) {
111 Result::Ok { value } => assert(false),
112 Result::Err { error } => assert(error.msg == "parse: bad input: bad"),
113 };
114
115 let a3 := add_parsed("x", "2");
116 match (a3) {
117 Result::Ok { value } => assert(false),
118 Result::Err { error } => assert(error.msg == "parse: bad input: x"),
119 };
120
121 // ── Perhaps: find_in ──────────────────────────────────────────────────────
122
123 let arr := [10, 20, 30, 40, 50];
124
125 let f1 := find_in(arr, 30);
126 match (f1) {
127 Perhaps::Some { value } => assert(value == 2),
128 None => assert(false),
129 };
130
131 let f2 := find_in(arr, 99);
132 match (f2) {
133 Perhaps::Some { value } => assert(false),
134 None => assert(true),
135 };
136
137 // ── Perhaps chaining via map_some ─────────────────────────────────────────
138
139 let m1 := map_some(Perhaps::Some { value = 5 }, 3);
140 match (m1) {
141 Perhaps::Some { value } => assert(value == 15),
142 None => assert(false),
143 };
144
145 let m2 := map_some(None, 3);
146 match (m2) {
147 Perhaps::Some { value } => assert(false),
148 None => assert(true),
149 };
150
151 // map_some of a find_in result
152 let mapped := map_some(find_in(arr, 20), 10);
153 match (mapped) {
154 Perhaps::Some { value } => assert(value == 10), // index 1, multiplied by 10
155 None => assert(false),
156 };
157
158 // ── perhaps_to_result bridge ──────────────────────────────────────────────
159
160 let p2r_ok := perhaps_to_result(Perhaps::Some { value = 7 }, "not found");
161 match (p2r_ok) {
162 Result::Ok { value } => assert(value == 7),
163 Result::Err { error } => assert(false),
164 };
165
166 let p2r_err := perhaps_to_result(None, "not found");
167 match (p2r_err) {
168 Result::Ok { value } => assert(false),
169 Result::Err { error } => assert(error == "not found"),
170 };
171
172 // Combine find_in (returns Perhaps) with perhaps_to_result (converts to Result)
173 let bridge := perhaps_to_result(find_in(arr, 40), "missing");
174 match (bridge) {
175 Result::Ok { value } => assert(value == 3), // index 3
176 Result::Err { error } => assert(false),
177 };
178
179 let bridge_miss := perhaps_to_result(find_in(arr, 0), "missing");
180 match (bridge_miss) {
181 Result::Ok { value } => assert(false),
182 Result::Err { error } => assert(error == "missing"),
183 };
184}
passes
Import Conflicts
Two explicit imports that bind the same local name in the same module are a compile-time error at the second import.
Glob imports use a priority tier system:
| Tier | Source | Priority |
|---|---|---|
Std | Auto-inserted by the runtime (e.g. std::core) | Lowest |
User | Explicit import path::* in source | Higher |
Conflict rules:
- Local declarations beat all glob imports.
- Explicit imports beat all glob imports.
- A
Userglob silently wins over aStdglob for the same name (no error). - Two
Userglobs exporting the same name are a conflict error (T0011) only if that name is actually referenced.
Formal rules
Legality Rule №1
Two explicit imports that bind the same local name are rejected with T0011 at import
time.
Tested by
1import a::foo;
2import b::foo;
3fun main() -> i64 { return foo(); }
typecheck errorT0011
Legality Rule №2
A collision between two user glob imports is rejected with T0011 only when code refers
to the ambiguous name.
Tested by
1import a::*;
2import b::*;
3fun main() -> i64 { return foo(); }
typecheck errorT0011
Legality Rule №3
An explicit import takes precedence over a glob-imported binding of the same name.
Tested by
Legality Rule №4
Import conflicts follow their binding kind: duplicate explicit imports fail immediately, ambiguous user-glob names fail when referenced, and an explicit import disambiguates a glob-provided name.
Referenced by: rfc-0030, rfc-0031
Tested by (3)
Visibility
Declarations are module-private by default. A declaration is accessible from outside its module only if it is annotated with publicL1.
public struct Token { public kind: i64, span: i64 }
struct InternalState { count: i64 }
public fun parse(tokens: Token[]) -> i64 { return tokens.len(); }
fun helper(token: Token) -> boolean { return token.kind == 0; }
fun main() -> i64 {
let token := Token { kind = 0, span = 1 };
let state := InternalState { count = 2 };
if (helper(token)) { return parse([token]) + state.count; }
return 0;
}
public is valid on struct, enum, fun, and aspect declarations. Top-level let and var bindings are always module-private; public value exports are not supported in the current version.
Struct field visibility is independent from the struct's own visibility. Fields are module-private by default; add public on each field that should be accessible outside the declaring moduleL1.
public struct Token {
public kind: i64,
span: i64,
}
From outside the declaring module, Token is nameable, token.kind is accessible, and
reading or assigning token.span is a T0009 visibility errorL3;
the declaring module retains access to all of its own fields.
Constructing Token directly outside its declaring module also requires visibility to
every named fieldL4, so private fields force
construction through module-local helpers or constructors instead. Marking a field
public on a struct that is not itself public doesn't expose that field to any other
module — the compiler warns on this combinationL5,
since the field can never actually be reached across a module boundary through a private
type. Pattern-matching Token outside its declaring module follows the same rule as
constructionL7 — a private field may not be named in
the pattern; a .. rest pattern (see §Struct patterns)
must be used to omit it instead.
Within a module, all names defined in that module are accessible without qualification, including private names.
Modules do not have their own visibility annotation. Module-level access control is handled entirely by public on individual items.
Formal rules
Legality Rule №1
Only declarations marked public are accessible from outside their declaring module; a
struct field is accessible outside that module only when both the field itself and its
enclosing struct are public. A public field on a struct that is not itself public
never becomes reachable across a module boundary, regardless of how a value of that
struct's type was obtained (e.g. returned from a public function that never names the
struct type itself).
Referenced by: rfc-0030, rfc-0031, rfc-0032, rfc-0098
Tested by (3)
1// #776: `public` on a field is conditional on the enclosing struct's own
2// visibility, not an independent grant. `Secret` is not `public`, so its
3// `public value: i64` field must stay unreachable across a module boundary
4// even once a `Secret` is obtained via `make()`, a public function that
5// never names `Secret` itself.
6import token::make;
7fun main() -> i64 {
8 let s := make();
9 return s.value;
10}
typecheck errorT0009
Legality Rule №2
A public function declaration must carry the explicit type annotations required for its
public API; an omitted required annotation is T0010.
Referenced by: rfc-0031
Tested by
Legality Rule №3
Reading or assigning a private struct field from outside its declaring module is
rejected with T0009. The declaring module retains access to all of its own fields,
including private ones.
Referenced by: rfc-0032
Tested by (3)
1import token::make;
2fun main() -> i64 { return make().offset; }
typecheck errorT0009
1import token::make;
2fun main() -> i64 { var t := make(); t.offset := 9; return t.kind; }
typecheck errorT0009
Legality Rule №4
Constructing a struct literal outside its declaring module is rejected with T0009 if
it names any private field. A module-local constructor or helper function may still
construct the value.
Referenced by: rfc-0032
Tested by
1import token::Token;
2fun main() { let t := Token { kind = 1, offset = 7 }; print(t.kind); }
typecheck errorT0009
Legality Rule №5
Declaring a field public on a struct that is not itself public produces a compiler
warning: the field cannot be reached across a module boundary through a private type,
so the public marker on it has no effect from outside the declaring module.
Referenced by: rfc-0032
Tested by (2)
1struct Thing {
2 public value: i64,
3}
4
5fun main() {
6 let t := Thing { value = 1 };
7 println(t.value.to_string());
8}
passes
1// #776: `public` on a field is conditional on the enclosing struct's own
2// visibility, not an independent grant. `Secret` is not `public`, so its
3// `public value: i64` field must stay unreachable across a module boundary
4// even once a `Secret` is obtained via `make()`, a public function that
5// never names `Secret` itself.
6import token::make;
7fun main() -> i64 {
8 let s := make();
9 return s.value;
10}
typecheck errorT0009
Legality Rule №6
Named fields of an enum struct-like variant follow the same visibility rules as an
ordinary struct's fields: constructing a variant literal outside the enum's declaring
module and naming a private field is rejected with T0009, the same as for a struct.
Referenced by: rfc-0032
Tested by
1import token::Token;
2fun main() { let t := Token::Inner { kind = 1, offset = 7 }; }
typecheck errorT0009
Legality Rule №7
Naming a private field in a struct pattern from outside the struct's declaring module is
rejected with T0009. The pattern must either omit that field with a trailing .., or
be written inside the declaring module, where private fields remain nameable.
Referenced by: rfc-0032
Tested by (2)
1import token::Token;
2import token::make_token;
3
4fun main() {
5 let t := make_token(1, 2);
6 let x := match (t) {
7 Token { kind, offset } => kind + offset,
8 };
9 println(x);
10}
typecheck errorT0009at 7
Circular Imports
Circular imports are a compile errorL2. The error message includes the full import chain.
Module Graph Loading
The module graph is built from both import and export declarations — a re-export
needs its target module loaded exactly as much as an ordinary import does, since a
name that resolves nowhere can't be re-exported (metel-core#664):
- The root file is parsed.
- All
importandexportdeclarations are collected; each is resolved to a file path via the::→/mapping. - Each referenced file is loaded recursively; cycles are detected and rejected.
- Only files reachable via at least one
importorexportdeclaration are loaded.
An export still differs from an import in what it does with the resolved name —
import brings it into local scope, export re-exports it through the current
module's public API without making it locally visible — but both equally decide
which files enter the module graph at all.
Formal rules
Legality Rule №1
Every non-prelude import must resolve to a loadable module. A missing module is a load error rather than an import that contributes an empty scope.
Referenced by: rfc-0031
Tested by
Legality Rule №2
Imports and re-exports both contribute module-graph edges. Missing modules and circular dependencies are load errors, and a bare re-export loads its target module.
Referenced by: rfc-0030
Single-File Compatibility
A .mtl file with no import or export declarations is a complete program. Existing single-file programs remain valid without modification.
Formal rules
Removed Module Keywords
mod, use, and pub use are not module declarations in Metel. Module loading and
re-export use import and export.