Skip to main content
v0.13.0

Modules

Files and Modules

Sincev0.5.0

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.

ImportFile 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
Legality Rule №1

Each non-prelude module path maps directly to its .mtl file path. A facade file and the same-named directory are distinct paths; name/mod.mtl has no special meaning.

Referenced by: rfc-0030

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
Legality Rule №1

At file scope, imports and exports may be interleaved but must precede ordinary declarations; neither declaration form is valid in a block.

Referenced by: rfc-0030

Paths

Paths use :: separators.

Path roots are:

RootMeaning
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 handleA 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
Legality Rule №1

root, std, self, and super resolve as path roots in their valid module contexts. A qualified path is valid wherever its resolved name is valid; super is invalid in the root module.

Referenced by: rfc-0030

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:

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
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

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

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

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

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

public and export serve different roles:

KeywordPurpose
publicMarks 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

Sincev0.6.1

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)

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:

TierSourcePriority
StdAuto-inserted by the runtime (e.g. std::core)Lowest
UserExplicit import path::* in sourceHigher

Conflict rules:

  • Local declarations beat all glob imports.
  • Explicit imports beat all glob imports.
  • A User glob silently wins over a Std glob for the same name (no error).
  • Two User globs 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.

Legality Rule №2

A collision between two user glob imports is rejected with T0011 only when code refers to the ambiguous name.

Legality Rule №3

An explicit import takes precedence over a glob-imported binding of the same name.

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)
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

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)
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

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)
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

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)

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):

  1. The root file is parsed.
  2. All import and export declarations are collected; each is resolved to a file path via the ::/ mapping.
  3. Each referenced file is loaded recursively; cycles are detected and rejected.
  4. Only files reachable via at least one import or export declaration 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

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

Tested by (3)

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
Legality Rule №1

A source file with no import or export declarations is a complete single-module program.

Referenced by: rfc-0030

Removed Module Keywords

mod, use, and pub use are not module declarations in Metel. Module loading and re-export use import and export.

Formal rules
Legality Rule №1

mod, use, and pub use are rejected by the grammar; module declarations use import and export instead.

Referenced by: rfc-0030