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;:@processbuilds 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; defaultsrc/),generated(below),entry(the entry file, when there is exactly one),target(a platform; defaultnode), anddependencies(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 sourceroot; 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(defaultnode), andsplit(defaultfalse; §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-entrynames the entryvilan runexecutes 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-entrynames the membervilan runexecutes when several are runnable. Its owndependenciesare declared once for the members to share: a member writesdep = { 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), andproject = truecombines with no other key.[build]:runand[[build.hook]], plus codegen options:preset("debug"|"release") and the per-feature overridesindent,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 adbg(..)ordbg_stack()call does: thedebugpreset keeps it (it prints), thereleasepreset 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 underdebug, off underrelease; it changes no byte of the script but the comment that names the map.runis a command line (or a list of them) executed through the host shell before each build (each--watchround 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 checkbuilds nothing and runs none.[[build.hook]]is the same execution with anameand, optionally, declaredinputsandoutputs: 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 neitherinputsnoroutputsis 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) anddepth(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.