Download

11. Modules

Module declaration#

Every .vader file declares its module first: the keyword module, then the module's name as a string literal.

#!/usr/bin/env -S vader run     // optional shebang
// optional leading comments

module "std/string"             // mandatory

Only a shebang line and comments may precede it. The following are errors:

  • a file without a module declaration, or with an import or a declaration before it;
  • a second module declaration in the same file.

Module name grammar#

module-name ::= segment ('/' segment)*
segment     ::= [a-z] [a-z0-9_]*

Valid names: main, std/string, std/string/utf8, vader/front/lexer, my_app/util_2.

Invalid names: Std/String (uppercase), std/1string (a segment starts with a digit), std//string (an empty segment), std/string/ (a trailing slash), ./relative (other characters).

A / is an ordinary character of the name.

Granularity and location#

A module is the set of .vader files directly inside one folder, and its name decides where that folder is: import "a/b/c" looks for the folder a/b/c under each search root in turn (Include paths, below).

  • A folder holds at most one module: files of one folder that declare different names are an error. Its sub-folders hold other modules.
  • A folder at a module's location that declares another name does not provide that module.
  • Modules are independent of each other: std/string and std/string/utf8 are unrelated. A shared prefix gives no parent-child relation, no implicit re-export and no visibility.

Imports#

import "std/string"                                 // wildcard: every export, unqualified
str :: import "std/string"                          // namespace, reached as `str.trim(...)`
import "std/string" { trim as strip, split as _ }   // wildcard + modifiers: rename / exclude

An import takes one of three forms:

  • Wildcard, import "path": every export of the module (functions, types, structs, enums, traits and constants) is visible unqualified in the importing file, as if declared there. It is the usual form.
  • Namespace, name :: import "path": binds the alias and nothing else. Every member is reached through it: str.trim, str.Builder, and is str.Type in a test or a match arm.
  • Wildcard with modifiers, import "path" { x as y, z as _ }: the wildcard of path, with a few names changed. x as y renames x to y, and z as _ leaves z out.

An alias names a module, not a value: x :: str and f(str) are errors. A member the module does not export is an error, as the same name would be unqualified; the module's own imports are not members.

There is no implicit binding of the path's last segment, and no as after the path.

Resolution rules#

// file: app/report.vader, with app/geo and app/paint of §6 *Import requirement*
module "app"

import "app/geo"
import "app/paint" { area as border }

both_areas :: fn(s: Square) -> f64 = area(s) + border(s)   // 21.0 for a side of 3.0
import "./geo"                          // error: an import names a module, never a file
import "app"                            // error: a file of `app` imports its own module
import "app/geo"
import "app/geo" { area as _ }          // error: `app/geo` is imported twice in this file
import "app/paint" { area }             // error: the wildcard already brings `area`
  • The string of an import is a module name, resolved against the search roots (Include paths, below).
  • Imports are file-scoped: each file of a module declares its own, and sees none of its siblings'.
  • A type or a constant that two wildcard imports export is an error only where it is used unqualified, and the error names every module that provides it. A declaration of the module or a rename binding of that name shadows the wildcards, and a name the prelude or a builtin provides always refers to that one. A namespace import, a rename or an exclusion settles an ambiguity.
  • Functions of one name overload instead, across wildcard imports and with the module's own (§6 Function overloading). Named without being called, a function that two wildcard imports export is still an error.
  • A rename or a namespace alias cannot take the name of a type or constant the module declares, in any of its files. It can take a function's: the module's function keeps the unqualified name, and the imported one stays reachable by UFCS (§6 Function overloading); with the same signature, the two are a redefinition, an error. Either error is reported on the import. Two files of a module may import the same name.
  • There are no re-exports: a module publishes only what it declares. A module that fronts another wraps each binding it republishes.

Details.

  • An import cycle, direct or through other modules, is an error.
  • A path is imported once per file, in any form: one import holds all its modifiers.
  • Two rename modifiers that bind one name in one file (import "a" { x as p } and import "b" { y as p }) are an error.
  • import "p" {} is the bare wildcard: the compiler warns, toward import "p".

Prelude#

std/core is an implicit prelude: every export of std/core is in scope in every other module without an import, so Range, Field, Display, Iterator and the rest are usable directly. A declaration of the module, or a rename in an import (import "std/string" { trim as Range }), hides a prelude name of the same spelling. The prelude does not apply inside std/core itself. An explicit import "std/core" in another module is an error.

Visibility#

A top-level declaration has one of two levels:

  • private, the default (no keyword): visible to every file of the module without an import, and invisible to any other module. There is no file-private level.
  • export: visible to the modules that import this one. main is always public, with or without export.
helper :: fn(x: i32) -> i32 = x + 1           // visible to every file of this module, invisible outside

export api :: fn(x: i32) -> i32 = helper(x)   // visible to every module that imports this one

Intra-module collisions#

The files of a module share its namespace:

  • Two structs, type aliases, constants, enums or traits with the same name are an error, in one file or across files.
  • Functions with the same name join one overload set, across files as within one file (§6 Function overloading). An ambiguity shows at a call where their parameters overlap.

Exported declarations cannot expose a non-exported type#

An exported declaration cannot name a non-exported type in the parameter or return types of a function, or the fields of an exported struct. The right-hand side of a type alias and the type of an exported constant are not checked.

Secret :: struct { value: i32 }          // private to this module

export reveal :: fn() -> Secret { ... }   // error: Secret is not exported

Configuration: vader.json#

A project is a folder described by its vader.json or by its build.vader (Programmable build API, below); a file can also be compiled without one. Which project a command takes is described in docs/TOOLING.md Projects.

{
  "name": "myapp",
  "version": "0.1.0",
  "includePaths": ["lib"],
  "entries": { "myapp": "src/main.vader" },
  "assets": ["sprite.png"]
}
Key Meaning
includePaths Extra module roots, each a folder relative to the project root or under $VADER_HOME (Include paths, below).
entries The programs of the project, each binary's name mapped to its entry file.
assets The files and folders copied beside the binaries.
name, version A description of the project, which no build uses.

The paths of entries and assets are relative to the project root. An asset path that is absolute, or that leaves the project through .., is an error, and so is any other key, or a key of the wrong shape.

An entry's name is its binary's file name, so it must be one on every platform: ASCII letters, digits, _ and -, not starting with -, and none of the names Windows reserves for a device (con, prn, aux, nul, com0 to com9, lpt0 to lpt9), whatever its case. Any other name is an error, and so are two names that differ only in case: a file system that ignores case gives them one binary.

Include paths#

// file: shop/cli/main.vader     the entry: its namespace `shop` is rooted at shop/
module "shop/cli"

import "shop/cart"               // shop/cart/
import "shop/cli/args"           // shop/cli/args/
import "std/string"              // the library root
import "tools/log"               // error without a project: tools/ lies beside shop/
// file: tool/main.vader         a single segment: `app` is rooted at tool/
module "app"

import "app/util"                // tool/util/

An import is resolved against an ordered list of module roots. The first root whose folder at the module's location declares that module provides it; a later folder that declares the same name is ignored.

  • The entry's namespace comes first: the first segment of its declared name, rooted at the folder that segment names, and no folder beside it.
  • When the entry's folder does not end with its name, or the name has a single segment, the name is rooted at the entry's own folder.
  • The command's roots, the library root (Library root, below) and the project's roots follow, in the order docs/TOOLING.md Search roots gives. Generated modules come last, and extend a module instead of shadowing it (Generated modules, below).

Library root#

std/ lives under the toolchain's library root, a module root that holds every namespace the toolchain ships. How a command finds it is described in docs/TOOLING.md Toolchain.

External packages are planned.

Resolution#

A program is resolved from its entry outward: its modules are the entry's module and the modules it imports, directly or not. A module that nothing imports is never read.

What appears at the top level of a .vader file#

module "shapes"                                  // first

import "std/io"

Circle :: struct { radius: f64 }                 // a struct
Square :: struct { side: f64 }
Figure :: Circle | Square                        // a type
Shape :: trait { area :: fn(self) -> f64 }       // a trait
PI :: 3.14159                                    // a constant

@displayable                                     // a decorator
Unit :: enum { Metre, Inch }                     // an enum

Circle implements Shape {                        // a trait implementation
    area :: fn(self) -> f64 = PI * self.radius * self.radius
}

@assert(PI > 3.0)                                // a compile-time check (§12)

square_area :: fn(s: Square) -> f64 = s.side * s.side    // a function

println("loaded")                                // error: a statement belongs in a function body

Nothing else appears at the top level: code with an effect lives in a function body, usually main. Importing a module runs no code.

Generated modules#

A module's files live in one folder: the first root that provides it wins (Include paths). The exception is the generated modules of a project, which its driver writes (Generating code, below): a generated file joins a module that an earlier root provides instead of shadowing it.

A driver's ctx.emit_module("app", ...) can therefore extend the project's own app. The generated file becomes one more file of the module: it sees the module's declarations, and they see its own, with no import either way. The module's identity remains its primary folder.

Programmable build API: build.vader#

A project drives its own build with a build.vader at its root, which the command compiles and runs (docs/TOOLING.md Build drivers). The driver decides what to compile: the compiler is part of its import closure. Its folder is the project of the entries it adds.

build.vader declares module "build" and build :: fn(args: string[]) -> i32. The name is reserved, as main is in a program. A build.vader that declares another module is an ordinary source file of its folder, not a driver.

module "build"

import "toolchain/build"

build :: fn(args: string[]) -> i32 {
    ctx :: new_build_context(args)
    ctx.add_entry("myapp", "src/main.vader")
    return ctx.emit_native()
}

What a driver declares#

A driver describes in code what a vader.json declares:

  • ctx.add_entry(name, path) adds a program, as a key of entries does, its name under the same rule;
  • ctx.add_asset(path) adds a file or folder to copy beside the binaries, as assets does;
  • module search roots go in ctx.include_paths, and are resolved and ranked as includePaths are.

An entry or an asset that breaks its rule is an error. When the driver starts draining ctx.messages() with an error already reported, one of its own reports included, nothing is described or compiled: the stream yields only a CompleteMessage of code 1.

The end of the stream prints the diagnostics of the build, the driver's reports included, and an error among them makes the build's code 1. What a driver reports once the build is over, or when it leaves the loop before the end, prints when build returns, and fails the build the same way.

What the command decides#

What the command asked reaches the driver in args, and the driver does not change it. ctx.mode() names it: .Build, .Check, .Prepare or .Describe (docs/TOOLING.md Build drivers). Only .Build writes outputs: otherwise ctx.emit_native() links nothing and returns 0. A step after the build, such as installing or packaging, first checks ctx.mode() == .Build.

The message stream#

A driver reaches the compiler through toolchain/build, and names AST types through toolchain/ast. Draining ctx.messages() is the build: it yields each module's typed declarations, then the phase boundaries, and last a CompleteMessage whose code is the build's exit code. Phase.BeforeEmit is the last point where leaving the loop writes nothing, which is how a driver that only lints declines to build.

Plugins#

A plugin is a type that implements BuildPlugin. ctx.use(p) plugs it into the build: it hears each message of the stream, in the order the plugins were plugged, before the driver's own loop does. Its members do nothing unless it overrides them:

  • on_module(ctx, msg) receives the typed declarations of one module;
  • on_phase(ctx, phase) receives a phase boundary;
  • on_complete(ctx, code) receives the end of the build, where a report still fails it.
module "build"

import "std/string"
import "toolchain/build"
AST :: import "toolchain/ast"

StructNames :: struct {}

StructNames implements BuildPlugin {
    on_module :: fn(self, ctx: BuildContext, msg: TypecheckedMessage) -> void {
        if msg.module_name != "app" {
            return
        }
        for d in msg.declarations {
            if d is AST.StructDecl && d.name[0..<1].to_upper() != d.name[0..<1] {
                ctx.report(d.name_span, "struct `${d.name}` should be PascalCase")
            }
        }
    }
}

build :: fn(args: string[]) -> i32 {
    ctx :: new_build_context(args)
    ctx.add_entry("myapp", "src/main.vader")
    ctx.use(StructNames {})
    return ctx.emit_native()    // 1 when a struct breaks the rule
}
  • A plugin reports and generates as a driver does.
  • A library can ship a plugin: ctx.use(JsonDerive { .modules = ["app"] }), from json/derive, gives each struct of app a to_json.
  • Plugins are read when the stream starts, like the rest of the configuration.
  • In .Describe mode the stream builds nothing, and the plugins hear nothing.
  • Starting a build from inside a plugin is an error.

Generating code#

// file: build.vader, in `build`, with AST :: import "toolchain/ast"
for msg in ctx.messages() {
    if msg is TypecheckedMessage && msg.module_name == "app" {
        for d in msg.declarations {
            if d is AST.StructDecl {          // once per build, however many rounds run
                text :: "describe :: fn(v: ${d.name}) -> string = \"a ${d.name}\""
                ctx.emit_module("app", text)  // joins `app`
            }
        }
    }
}

// what it writes for Point:
describe :: fn(v: Point) -> string = "a Point"

// file: src/main.vader, with Point of §4 *Structs*
main :: fn() -> i32 {
    println(describe(Point { .x = 1, .y = 2 }))   // a Point
    return 0
}
  • ctx.emit_module(name, source) writes source as an ordinary .vader file of the project (docs/TOOLING.md Search roots), compiled like a hand-written module: it has real spans, can be read and formatted, and every command finds it.
  • name is a new module that the project imports, or an existing one, which the file joins (Generated modules, above).
  • The program is then checked again and the generated declarations come back through the stream, so generation can be transitive; each declaration is still delivered once per build.
  • Generation stops at a fixed point, a round that writes nothing new. A driver whose output never settles is an error.
  • A generated module that no round writes is an error: one emitted after the last Phase.BeforeEmit, or before the driver leaves the loop there.
  • A failure of the driver contract is a build-driver diagnostic (docs/TOOLING.md Diagnostics). A driver reports the violations of its own rules through ctx.report.