6: Module Resolution


Contents


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:

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

  1. Split the path at .. Segments form a filesystem path relative to the source root, except the final segment(s) which may be symbol names.
  2. Given use a.b.c.Foo, the compiler checks in order:
    • Check 1: src/a/b/c.leaf — importing symbol Foo
    • Check 2: src/a/b/c/Foo.leaf — importing the module as a namespace
  3. Check 1 is attempted first. If both exist, Check 1 wins and Check 2 is ignored. If neither exists, compile error.
  4. The compiler resolves to .leaf files only.
  5. 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 @):

  1. The segment after @ is the package name (e.g., use @json.parser → package name is json).
  2. The build tool configuration (package manifest, lock file, or equivalent) maps each package name to its interface files.
  3. The compiler frontend reads interface declarations to understand available symbols.
  4. After the package name, remaining segments resolve as module paths and symbol names within that package's interface tree.
  5. Only pub symbols are visible to external importers.
  6. 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):

  1. @std is resolved by the compiler to its built-in standard library path. No package manifest entry is required.
  2. After @std, segments resolve as module paths within the standard library (e.g., use @std.io.File resolves to the File type in the io module).
  3. 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:

Unconstructable Types (Compile Errors)

The following patterns create unconstructable types and must be rejected at compile time:

  1. Direct type alias cycles:
       type A = B
       type B = A
       
    The compiler cannot resolve what these types represent. This is a compile error even if the cycle passes through generic types like Option:
       type A = Option[B]    # Still an error: no concrete base type
       type B = Option[A]
       
  2. 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
       
  3. Cycles through Tuple types:
       struct A
           pub field: Tuple[B, int]
       end
       struct B
           pub field: Tuple[A, str]    # Error: tuples use direct containment
       end
       
    Tuples do not provide indirection — they directly contain their elements.
  4. 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 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
Link copied to clipboard!