Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spec §11 — The platform model & manifests

A platform is a host a build targets: node (the default), deno, bun, or browser. The first three form the @process family. One package may build for several platforms at once (§11.4’s entries); the compiler proves, per entry, that no reachable code requires a capability its platform lacks.

11.1 Layers

The standard library is one platform-neutral base in which a module that needs a platform declares it in its own file (§11.3):

  • the browser modules (std::web::dom, std::web::router, std::web::storage, std::web::dev) lead with [platform("browser")] mod self;: browser builds only;
  • the process modules (std::fs, std::http, std::db, std::process, std::build, std::watch, std::web::document, std::rpc::server) lead with [platform("@process")] mod self;: @process builds only;
  • one module, std::web::ui, is a twin: one import path served by a different file per platform — the browser layer’s (live DOM) and the process layer’s (an HTML string tree) — and each build resolves the file its platform admits.

A library may declare the same shape for itself: a file’s own [platform(..)] mod self;, or, for a module that needs a different FILE per platform, per-platform overlay roots ([library.layer], §11.4) beside a neutral root.

11.2 Coloring and the reachability check

Every function is colored with the platform requirement it implies: seeded by the layer its externs and std calls live in, flowing callee-to-caller through the call graph (the same inference shape as asyncness, §7.3), including through generic instantiations: a generic function’s requirement is judged per instantiation, so save<T> colors process-only only for the Ts whose code actually reaches a process capability.

The check is on reachable code, not imports: importing a module is free; each entry is checked along the call paths that start at its main (and its reachable initializers). A path that crosses onto a platform the entry does not build for is a compile error naming the chain from the entry to the crossing. Module-level initializers obey the same rule: a binding’s initializer is analyzed, colored, and bundled only if something reachable references the binding. const initializers evaluate at build time (§9) and never color anything.

11.3 Fences

[platform("browser")] (one platform, a family like "@process", or several) on a function declares the platforms it promises to run on. The promise is checked on every compile of the package that declares it, whatever the build’s entries: if code the fenced function reaches requires a layer one of the fenced platforms lacks, the error lands at the fence with the offending chain, not at some distant entry in a dependent build. A package that only depends on the fenced code (std, or a dependency) does not re-check the promise; what its own entries reach is checked as always. Fences add no runtime behavior; they are checked declarations.

A fence is also the platform its body is analyzed under. One analysis types a file under one platform — and so under one std twin, which is what a body over std::web::ui’s Region reads its fields from — so a file is analyzed under a platform every fence and label in it admits, whenever the colour its package gives it (§11.2, the default-entry’s, inference’s) admits none of them. Declarations that share no platform make the file a pair of twins; the file’s colour stands.

A file’s own platform. [platform("browser")] mod self; — the attribute on mod self;, the file’s own module (§4.1), as the file’s first statement — declares the platform of the whole file. Everything the file declares requires that platform, exactly as a layer module’s items require their layer’s: a call into it from an entry of another platform is the chain error above (`when_value` requires the `browser` platform its file declares and cannot run on `node`, with the path from main). It is also a fence on each of the file’s functions, checked the same way. And it is the platform the file is analyzed under: it outranks inference and the default-entry colour outright, and of the entries that reach the file only those it admits type-check it — reaching it from another is the error. A build its declaration excludes still loads the file, so an import of it is legal and a call into it is that one error at the caller, but reports nothing from inside it: one program has one platform, so a twin the file imports (std::web::ui) binds the BUILD’s side there, not the side the file was written against. A module a library layer serves to other platforms only is the same promise and is treated the same way. The same attribute on an impl block does this for the block’s members; on a struct, an enum or a trait it takes part in choosing the platform the file is analyzed under. A type has no platform of its own (§11.2’s requirement is on code), so a label on one requires nothing.

Twins. Two items of one identity — two module-level functions of one name, or two impls of one trait for one subject — may stand in one file when both are fenced and their fences share no platform. They are twins, and each build compiles the one its platform admits:

import std::io::print;

[platform("browser")]
fun where_am_i(): str {
	"the browser"
}

[platform("@process")]
fun where_am_i(): str {
	"a server"
}

fun main() {
	print(where_am_i());
}

Each twin is analyzed only under a platform its fence admits, so a body reads its own platform’s std twin (a browser twin’s View has an element, a @process twin’s has attributes), and a build collects only its own: the other twin is not type-checked, registered or emitted by it. Twins whose fences overlap are refused, naming a platform both admit; so is an unfenced item beside a fenced one — there is no default with a platform override, the default is the complement fence written out. Twin functions agree on their written signature (parameters, return type, generics). A platform no twin admits simply has none: reaching the name from it is a miss that names the twins there are. A file whose mod self; declares a platform holds its twins to it — a twin that admits none of the file’s platforms is refused, since nothing could compile it. The twin trait impls and functions are the whole of it: nominals and inherent impls have no twins.

This bends §11.2’s “unreachable code is still type-checked” for twins alone, and the rule that replaces it is: every item is type-checked under at least one platform it admits, and vilan check checks a file under each platform its twins name as well as under its colour — so a single-platform vilan build checks its own twins and no others, and vilan check is the answer for all of them.

11.4 Manifests (vilan.toml)

The manifest declares what a directory builds. Sections:

  • [package]: an application or plain package: name, description, root (source root; default src/), generated (below), entry (the entry file, when there is exactly one), target (a platform; default node), and dependencies (name → { path = "…" } for a local directory, or { git = "…", tag | rev = "…" } for a [library] repository pinned to exactly one of a tag or a commit; registry dependencies are future work).
  • Generated sources: [package] generated (and [library] generated) names the directory holding the package’s generated sources, relative to the manifest. It must be a relative path inside the package, must not name the package directory, and must not be the source root; each is a manifest error naming the key. The directory need not exist. Its effect is on the formatter: vilan fmt — and any editor formatting that goes through the same toolchain — leaves every source file under it byte-identical, however that file is reached, and reports once per run that it did. A file the formatter rewrote would re-digest as changed and so re-stale the [[build.hook]] that declared it as an output, which would regenerate it unformatted; nothing else in the toolchain reads the key, and module resolution is unaffected by it.
  • [entry.<name>]: one build entry per table: path (default <root>/<name>.vl), target (default node), and split (default false; §11.5). A package with entries builds each for its own platform; reachability (§11.2) is what lets one source tree serve several. [package] default-entry names the entry vilan run executes when several are runnable.
  • [library]: a dependency-only package: name, description, root, generated, dependencies, and [library.layer.<name>] overlays (root, platform = ["…"]) for per-platform sources.
  • [project]: a workspace: packages = ["member", …] (paths); building the project builds every member against its own manifest. default-entry names the member vilan run executes when several are runnable. Its own dependencies are declared once for the members to share: a member writes dep = { project = true } to take that declaration (paths in it resolve against the project root). Inheritance is per dependency and opt-in (nothing is inherited implicitly), and project = true combines with no other key.
  • [build]: run and [[build.hook]], plus codegen options: preset ("debug" | "release") and the per-feature overrides indent, spaces, debug-names. The codegen options never change program semantics (§7.6), only the emitted text; the hooks are not among them. dbg ("keep" | "strip") says what a dbg(..) or dbg_stack() call does: the debug preset keeps it (it prints), the release preset refuses the build at each call, "strip" makes the call its argument and prints nothing, and "keep" prints in release too. source-maps (a bool) writes a source map beside each script: on under debug, off under release; it changes no byte of the script but the comment that names the map. run is a command line (or a list of them) executed through the host shell before each build (each --watch round included), in the manifest’s directory, in order; a non-zero exit fails the build. It runs with the invoking user’s privileges and environment — no sandbox, no allowlist, no timeout, no prompt — and the implementation prints each command before spawning it. Only the manifest being built contributes hooks: a dependency’s are never executed, whatever they declare, and a dependency that declares one is reported rather than passed over in silence. vilan check builds nothing and runs none. [[build.hook]] is the same execution with a name and, optionally, declared inputs and outputs: file or directory paths, relative to the manifest, matched literally (there is no glob matching, and a pattern is an error). Such a hook is skipped when it has a recorded stamp and every declared input and output hashes, by content, to the value recorded when it last ran, with the command string unchanged; a declared input that was absent is recorded as absent, so its appearance invalidates. A hook declaring neither inputs nor outputs is never fresh and always runs. Skipping is a cost optimization over code already trusted to run, never a security property. Where the stamp is recorded, and its format, are implementation-defined; that it is inside the project — so deleting the build output directory re-runs every hook — is not.
  • Dependency trust: a dependency declaration takes build-hooks, a boolean granting that one dependency permission to run its own build hooks. Absent means no. Nothing runs a dependency’s hooks in this version, granted or not; the key records the grant so it is a reviewable line before there is a mechanism behind it.
  • [macro]: the compile-time interpreter budget: fuel (steps per macro/const run) and depth (nested expansion), §9.3/§10.4.

std, pkg, macro_std, and vilan are reserved package names. The first three name the import roots themselves (§4.2), so a [package] name or a key in any dependencies table claiming one is a manifest error — a dependency under such a key could only shadow the root or be unreachable behind it. vilan is the language’s own name, held for its official namespace; the same claim is refused in the same places. [library] name is exempt: the standard library itself is the [library] named std (likewise macro_std), and a library’s own name, unlike a dependency key, never binds an import root.

11.5 Build products

Each entry emits dist/<name>.<ext> for its platform (browser entries first, so a server that ships bundles finds them fresh), plus dist/<name>.css when const evaluation emitted style assets (§9.2). The extension states the module kind to the host: .mjs on the process platforms, whose runtimes classify a file as ESM or CommonJS before running it, and .js on the browser, where the loading <script type="module"> tag classifies it instead.

Any other emitted kind (§9.2’s channel takes an arbitrary kind) lands the same way: dist/<name>.<kind>, holding the kind’s deduplicated lines in a kind-specific deterministic order — css sorts in cascade order (base rules before @media blocks, media blocks by ascending min-width), and every other kind sorts lexically by line — so a kind’s bytes are a function of the set of contributions, never of the order const evaluation reached them. A kind that stops being emitted stops shipping: the build records which kind files each entry’s flush wrote (dist/.vilan-asset-kinds) and removes a recorded file when a later build emits nothing for its kind. Only recorded files are ever removed — a file the record does not name is never the build’s to touch.

A browser entry declaring split = true emits route chunks instead of one file: dist/<name>.js carries every module-level binding (so §7.6’s initialization order is untouched) and every function two or more route arms can reach, and dist/<name>.<arm>.js carries the functions exactly one arm of the entry’s route match can reach. Every build of a browser entry writes dist/<name>.chunks.json, the leg’s build manifest: its bundle’s file name, the style sidecar’s file name or null, whether the bundle must be loaded as a classic script, and the chunks it emitted (empty unless it split). Chunks are fetched when a navigation first reaches their arm, and the route value does not advance until one arrives; which functions land where is implementation-defined beyond that rule. Overlapping navigations resolve by order of DEPARTURE, not of arrival: a chunk that lands after a later navigation began does not advance the route value. A fetch that fails leaves the route value where it was and is not remembered, so a later navigation to that arm fetches again. split on a non-browser entry is a manifest error. vilan run builds all entries and starts one @process entry: the only one, the designated default-entry, or the one --entry names; vilan check checks every entry, always. The emitted text beyond §7.6’s guarantees is implementation-defined.