6: Module Resolution
Contents
- 6.1: Modules
- 6.2: Project Structure
- 6.3: Import Syntax
- 6.3.1: Project-Local Imports
- 6.3.2: External Package Imports (
@) - 6.3.3: Standard Library Imports (
@std) - 6.3.4: Re-Exports (
pub useandpkg use) - 6.4: Path Resolution Rules
- 6.5: Circular Dependencies
- 6.6: Visibility and Exports
6.1: Modules
A module is a single .leaf source file. There is no separate module declaration syntax — if a file exists on the filesystem, it is a module and can be imported. The filesystem layout is the module tree.
A module's name is its filename without the extension. A file at math/vector.leaf defines the module math.vector.
6.2: Project Structure
A Leaf project has a project root directory:
myProject/
src/
main.leaf # binary entry point (defines fn main())
lib.leaf # library entry point (pub exports)
math/
vector.leaf # library module
matrix.leaf # library module
utils.leaf # library module
Entry points:
src/main.leaf— the binary entry point. Must definefn main().src/lib.leaf— the library entry point. Contains or re-exports the public API of the package.- Both can coexist — the project is both a runnable binary and an importable library.
All .leaf files under src/ are part of the project.
6.3: Import Syntax
Leaf uses a single use keyword for all imports. The path prefix determines where the import resolves from:
| Path form | Resolves to |
|---|---|
use foo.bar |
Project-local: src/foo/bar.leaf |
use @pkg.foo |
External package pkg |
use @std.io |
Standard library module io |
Top-level imports appear at the top of the file, before any declarations. These are visible throughout the entire file.
Scoped imports may also appear inside any block (function bodies, if/else branches, for/while loops, match arms, etc.). A scoped import is visible only within the enclosing block and follows the same shadowing rules as local variable bindings — an inner scope's import shadows an outer scope's import or binding of the same name. Closures may reference names introduced by a scoped import, following normal capture semantics.
Re-exports (pub use, pkg use) are only allowed at the top level of a file. Scoped imports must be bare use without a visibility modifier.
Enum variant imports use wildcard syntax to bring all variants of an enum into scope unqualified:
use Direction.*
This brings North, South, East, and West into scope as bare names. Variant imports work at both file scope and block scope. If two wildcard imports introduce the same name, it is a compile error. A local variable binding or inner-scope import shadows a variant import following normal shadowing rules.
Wildcard imports work with any path form — local enums, external package enums, and re-exported enums:
use @std.io.IoError.* # brings Eof, Other into scope
use mymodule.Color.* # brings Red, Green, Blue into scope
6.3.1: Project-Local Imports
Bare paths (no @ prefix) resolve from the project's src/ directory:
use math.vector.Vector # imports `Vector` from src/math/vector.leaf
use math.vector.[Vector, dot] # multi-import
use utils.helpers.clamp # imports `clamp`
Importing a module as a namespace:
use math.vector # imports the module itself
v = vector.Vector.new(1.0, 2.0)
Renaming imports:
use math.vector.Vector as Vec
use math.matrix as mat
Any pub or pkg top-level symbol can be imported: functions, structs, enums, interfaces, type aliases, and constants.
use config.limits.MAX_LENGTH # imports a constant
use can import both pub and pkg symbols (since the importing module is within the same package). Module-private symbols cannot be imported.
6.3.2: External Package Imports (@)
Paths beginning with @ resolve to external packages — dependencies that are not part of the current project's source tree:
use @json.parse # from the `json` package
use @json.[parse, stringify] # multi-import
use @json as j # namespace import
The segment immediately after @ is the package name (e.g., @json). External packages provide their interface through .leaf files. The build tool configuration maps package names to their interface files.
The @ prefix makes it immediately clear that a symbol comes from outside the project. External imports support the same forms as project-local imports (multi-import, renaming, namespace import).
External imports can only access pub symbols. pkg symbols in external packages are not visible.
6.3.3: Standard Library Imports (@std)
The standard library is accessed through the @std prefix. It follows the same rules as external packages — the standard library is simply a well-known package that the compiler provides without requiring build tool configuration:
use @std.io # import the io module as a namespace
use @std.io.[readFile, writeFile] # multi-import from io
use @std.io.readFile as read # renaming
The compiler resolves @std to its built-in standard library path. No entry in the package manifest is needed.
Only pub symbols are accessible from standard library modules, same as any external package.
6.3.4: Re-Exports (pub use and pkg use)
A module can re-export symbols it imports, making them part of its own API. Both pub and pkg visibility modifiers can be applied to use:
pub use math.vector.Vector
pub use utils.helpers.[clamp, lerp]
pub use re-exports symbols as fully public — importable by any module, including external packages.
pkg use math.internal.normalize
pkg use math.internal.[cross, project]
pkg use re-exports symbols with package-level visibility — importable by any module within the same package, but not visible to external consumers.
pkg use follows the same rules as pub use in all other respects: it supports multi-imports, renaming (pkg use foo.bar as baz), and the same path resolution. The only difference is the visibility of the re-exported symbol.
Re-exports work with all path forms — project-local, external (@), and standard library (@std):
pub use @json.parse # re-export an external symbol
pub use @std.io.readFile # re-export a stdlib function
Typical use: pub use in lib.leaf to define the package's public API; pkg use in internal modules to provide convenient access to shared internals without exposing them externally.
6.4: Path Resolution Rules
Project-local resolution (bare paths without @):
- Split the path at
.. Segments form a filesystem path relative to the source root, except the final segment(s) which may be symbol names. - Given
use a.b.c.Foo, the compiler checks in order: - Check 1:
src/a/b/c.leaf— importing symbolFoo - Check 2:
src/a/b/c/Foo.leaf— importing the module as a namespace - Check 1 is attempted first. If both exist, Check 1 wins and Check 2 is ignored. If neither exists, compile error.
- The compiler resolves to
.leaffiles only. - All paths are absolute from the project source root. Relative imports are not supported — all paths are written from the root:
# In src/math/matrix.leaf: use math.vector.Vector # always from the root
External package resolution (paths beginning with @):
- The segment after
@is the package name (e.g.,use @json.parser→ package name isjson). - The build tool configuration (package manifest, lock file, or equivalent) maps each package name to its interface files.
- The compiler frontend reads interface declarations to understand available symbols.
- After the package name, remaining segments resolve as module paths and symbol names within that package's interface tree.
- Only
pubsymbols are visible to external importers. - The backend handles actual linking and how to reference external code at runtime. This is outside the scope of the frontend specification.
Standard library resolution (@std):
@stdis resolved by the compiler to its built-in standard library path. No package manifest entry is required.- After
@std, segments resolve as module paths within the standard library (e.g.,use @std.io.Fileresolves to theFiletype in theiomodule). - Otherwise follows the same rules as external package resolution.
No ambiguity between local and external: Because external paths always require the @ prefix, a project-local module can never shadow an external package or standard library module. The path use io always means src/io.leaf; the standard library io module is always use @std.io.
6.5: Circular Dependencies
Circular module imports are allowed. Module A can import module B while module B imports module A (directly or transitively). Because Leaf has no top-level code execution (all executable code lives inside functions), import order does not affect module behavior. This is similar to JavaScript ES modules.
Circular type dependencies are allowed as long as the involved types are constructable — they can be instantiated without requiring infinite memory or infinite type resolution.
Constructable Types
A type is constructable if there exists at least one way to create a value of that type without requiring an infinite chain of nested values. Circular type references are constructable when the cycle passes through at least one terminator — a type that can be instantiated without requiring the recursive type:
Option[T]— terminate withNoneResult[T, E]— terminate withErr(...)using a non-cyclic error type, orOk(...)if the success type provides a terminatorList[T]— terminate with[]Map[K, V]— terminate with{}- Enums — terminate using a non-recursive variant (if one exists)
- User-defined structs — constructable if at least one configuration of their fields terminates the cycle (may require deep analysis of field types)
Unconstructable Types (Compile Errors)
The following patterns create unconstructable types and must be rejected at compile time:
- Direct type alias cycles:
The compiler cannot resolve what these types represent. This is a compile error even if the cycle passes through generic types liketype A = B type B = AOption:type A = Option[B] # Still an error: no concrete base type type B = Option[A] - Direct struct containment cycles:
struct Node pub next: Node # Error: requires infinite memory end struct A pub field: B end struct B pub field: A # Error: A contains B contains A... end - Cycles through
Tupletypes:
Tuples do not provide indirection — they directly contain their elements.struct A pub field: Tuple[B, int] end struct B pub field: Tuple[A, str] # Error: tuples use direct containment end - Enum with all variants recursive:
enum InfiniteTree Branch(left: InfiniteTree, right: InfiniteTree) # Error: no non-recursive variant exists end
Valid Circular Type Dependencies
Common constructable patterns include:
# Linked list node
struct Node
pub value: int
pub next: Option[Node] # Terminates with None
end
# Tree structure
struct Tree
pub value: int
pub children: List[Tree] # Terminates with []
end
# Binary tree enum
enum BinaryTree
Leaf(value: int) # Non-recursive variant terminates
Branch(left: BinaryTree, right: BinaryTree)
end
# Mutually recursive structs with indirection
struct Expression
pub kind: ExprKind
end
enum ExprKind
Literal(value: int)
Binary(op: str, left: Expression, right: Expression)
end
Compiler Requirements
Implementations must perform constructability analysis on type definitions. This may require traversing the full type dependency graph to determine whether at least one terminating path exists for each type involved in a cycle. The specific algorithm is implementation-defined, but the semantics of what constitutes a constructable type are specified above.
6.6: Visibility and Exports
A module's exports are determined by visibility modifiers on its top-level symbols. All top-level declarations — structs, enums, interfaces, type aliases, functions, and constants — follow the same visibility rules:
pub— importable by any module.pkg— importable within the same package only.- Unmarked — module-private, cannot be imported.
pub MAX_LENGTH = 1024 # importable by anyone
pub struct Vector # importable by anyone
pub x: float
pub y: float
end
pkg fn internalNormalize(v: Vector) -> Vector
return Vector.new(0.0, 0.0)
end
fn helper() -> float # module-private
return 0.0
end
pub fn zero() -> Vector
return Vector.new(0.0, 0.0)
end