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

The CLI

The whole toolchain is one binary. vilan <command> --help prints each command’s flags; this page adds the behavior the one-line help can’t carry. One rule up front: vilan upgrade is the only command that touches the network. Everything else (builds, dependency resolution, tests) works offline (git dependencies are fetched once by the first build that needs them, then served from the cache forever). That rule is about the network; locally, build and run execute the manifest’s own build hooks with your own privileges, without prompting — a dependency’s are never executed, and one that declares them is named in a note: line rather than passed over silently.

For the guided on-ramp, see Hello Vilan; for --watch, HMR, and the manifest keys that shape the dev loop, see the dev loop.

One flag stands outside the commands: vilan --print-keywords prints the language’s keyword table as JSON, {"keywords": ["Self", "as", "async", …], "contextual": ["Self", "as", …]}, and exits. keywords is every word with a keyword reading; contextual is the subset that is a keyword only in one position and an ordinary name everywhere else (with, own, lazy, as, …); the rest are reserved. It is the list for a tool that highlights or escapes Vilan outside this toolchain to read instead of keeping its own copy, which drifts: the playground’s editor on this site reads it, and vilan bindgen escapes exactly the reserved words, and only where a reserved word cannot stand: a free function, a parameter or a type named css binds as css_, one named lazy as lazy. A MEMBER may be any word, so a TypeScript method or property named type or css binds as written (event.type()); only a member named self keeps its _.

vilan init [name]

Scaffolds a ready-to-run project: a manifest, sources that compile, and a .gitignore. With name, creates that directory (it must not exist or must be empty); without, scaffolds into the current directory (which must not already hold a vilan.toml). It never overwrites a file, and it creates no git repository; git init stays yours.

--template <name> picks the shape; omitted, an interactive prompt asks (without a terminal that is a clean error, never a hang):

TemplateWhat you get
nodea package that runs on Node, with a sibling module and a test
browsera reactive browser app (a counter, an index.html)
fullstackone package, two entries: a browser client and a Node server

The templates are compiled and run by Vilan’s own test suite, so a scaffold that stops working fails Vilan’s build before it reaches yours.

vilan build [file]

Compiles to JavaScript. The path may be a .vl file, a project directory, or omitted; then the nearest vilan.toml decides what to build. A single-entry package writes the bundle beside the entry; a multi-entry package writes one into dist/ per entry. The extension is the target’s: .mjs on a process runtime (Node/Deno/Bun), so the host classifies the emitted ESM without inspecting it, and .js on the browser, whose <script type="module"> already declares it. Assets emitted at compile time (the styling system’s CSS) land beside the output. Build hooks execute first — through your shell, with your privileges, each command printed before it runs; a failing hook fails the build. Every [build] run command runs on every build; a named [[build.hook]] that declared inputs/outputs is skipped while none of them has moved, and says Fresh <name> instead (the dev loop). A dependency that declares hooks gets a note: line and no execution.

A file named by path is a file of its package. The nearest vilan.toml above it supplies its source root, dependencies, prelude and build options, exactly as addressing the directory would — so vilan check src/main.vl answers about that file what vilan check . answers. What it does not do is run the package’s [build] hooks: naming one file asks for that file, not for the package’s build pipeline. A file with no vilan.toml above it compiles on its own, with the default prelude and no dependencies. A file of a [library] is a module of it (no main is asked for), rooted at the library’s layer for pkg:: paths, and imports the library’s [library.dependencies] as the editor does.

std is not checked as an entry. Address a file inside the toolchain’s own std/src/ and about one in six of them (twelve of seventy-one at v0.47.0) reports errors that a program importing std never sees: the files that declare compiler intrinsics as bodyless external funs (context.vl, process.vl, random.vl, shared.vl, web/dom.vl) are refused for naming no [extern(..)] binding, the platform-layer files (browser/web/ui.vl, process/web/ui.vl, web/document.vl, web/prelude.vl, web/router.vl) cannot reach their base layer’s modules through pkg::, which has one root in file mode, and reactive/store.vl and rpc/mirror.vl read an [internal] field that only std’s own code may. None of those is a defect in std. Std is checked through the programs and tests that import it (vilan check . in any package, vilan test), never one file at a time.

  • --stdout: print the JavaScript instead of writing a file.
  • --rerun-hooks: run every [[build.hook]] even if it is fresh — the escape for a hook that reads something it did not declare. (rm -rf dist is the bigger hammer: the freshness record lives there.)
  • --platform <p>: node, deno, bun, browser, or none; overrides the package’s target (--target is an accepted alias). none checks against no platform’s layers, the strictest reading.
  • --watch: rebuild whenever a watched source changes (Ctrl-C stops).
  • -d, --debug: also emit a dump per pipeline stage beside the source, for poking at the compiler’s view of your code. .parse-raw.out is the tree the parser produced; .parse.out is that tree after the desugars hooked at every parse entry (the css block, element syntax, ? lifting) — the one analysis actually receives, so the pair brackets the desugars and a node in one and not the other is something they added or removed. .analyze.out is the analyzed program and .callgraph.out the call graph its post-analysis passes shared.
  • --print-chunks: report the route-chunk plan — what a split = true browser leg would load lazily per route, with function counts and a byte estimate, plus a verdict: line measuring what splitting would actually cost the first load (the entry is emitted both ways and compared). Analysis only; the emitted JavaScript is unchanged, so this is how to measure a leg before opting it in (the dev loop).
  • --explain: after the build, print where every output came from — see below.
  • --source-maps: write a source map beside each script whatever the preset says (main.mjs.map, named by the script’s last line), and the debugger’s sidecar with it (main.mjs.vdbg.json: each function’s bindings by vilan name and type). The debug preset writes both already; this is [build] source-maps = true for one build. See debugging.

A dbg(..) or dbg_stack() call prints in a debug build, which is the default. A release build refuses one — “dbg left in a release build”, at the call — unless vilan.toml says what to do with it:

[build]
preset = "release"
dbg = "strip"    # dbg(x) is x and dbg_stack() nothing; "keep" prints in release too
  • --backend <js|rust>: which emitter runs. js is the default. rust emits the program as Rust, writes a cargo project under dist/native/ and builds it with the host’s cargo — in debug, because rustc is the inner loop from there on (--release is yours to run over the generated project). It is a first cut whose scope is structs, enums, Option/Result, str, List, HashMap/HashSet, closures, impls, print and panic; anything outside that — a generic function, a module-level binding, async, any host binding — is refused by name rather than mis-compiled. Native binaries has the whole list. vilan run --backend rust runs the binary it built; it takes no --watch.

Every build of a browser entry writes <name>.chunks.json, the leg’s build manifest — what it emitted, for std::build::build_of to read. A browser entry with [entry.<name>] split = true additionally writes one file per route arm, lists them in that manifest, and warns when the split cost the first load more than it deferred. The leg’s chunk files belong to its last build: a build that writes none removes any a previous one left, and rewrites the manifest to say the leg emitted none. vilan run ignores split — the dev loop swaps whole bundles — and emits the leg as one file.

vilan build --explain

Where did this file come from? The compile-time asset channel scatters its contributions across files on purpose — that scatter is import-driven composition — so a stylesheet in dist/ is the sum of however many emit calls the program reached, and finding them was a grep. --explain asks the build instead. It builds first, exactly as vilan build does, and then prints what it wrote:

output  dist/client.css
  role         emitted kind `css`
  emitted      src/client.vl:14
  emitted      src/theme.vl:9

output  dist/brand/logo.svg
  role         bundled copy
  source       src/static/logo.svg
  named        src/client.vl:17 (asset::bundle_as)

output  dist/client.js
  role         compiled bundle
  leg          client

output  src/icons.vl
  role         hook output
  hook         icons (Fresh)

input   src/static/logo.svg
  read         src/client.vl:17 (asset::bundle_as)
  invalidates  dist/brand/logo.svg
  invalidates  dist/client.js

One block per file the build wrote, headed output and its path, then a fixed role and whatever the build knows about it:

roleWhat follows
emitted kind <k>one emitted line per const site that contributed to it
bundled copythe source file, and a named line per const site that named it, with the spelling (asset::bundle / asset::bundle_as)
compiled bundlethe leg whose JavaScript it is
build manifestthe leg it describes
route chunkthe leg and the route arm
hook outputthe hook that declares it, and this build’s verdict — (ran) or (Fresh)

Then one block per tracked input, headed input: a read line per const site that touched it (with the call — asset::read, asset::read_dir, asset::read_dir_all, asset::digest, or a bundle’s source read), a declared line per [[build.hook]] that names it in inputs, and an invalidates line per output a change to it would move. “Invalidates” is read off the same records, not guessed: a const input moves its leg’s compiled bundle (the channel’s inputs are sources to the compile, which is why editing one starts a --watch round) plus the flushes and copies of the sites that read it; a hook input moves that hook’s declared outputs. The build manifest is deliberately absent — every build of a browser leg rewrites it, so naming it under every input would be true and would say nothing.

Three things worth knowing:

  • It builds. Explaining a tree without building it would describe whatever the last build happened to leave, which is the one answer that is never useful. Under --watch, every round prints its own report.
  • A site is a const expression, not the emit call inside it. That is the granularity the compiler keeps: the const evaluator runs a lowered tree whose frames carry function names and no spans, so the const site is what a const-eval error points at too, and the two locations are counted from one set of bytes.
  • --stdout is refused with it. --stdout prints a bundle, not a build: it writes no output directory at all, so there would be nothing to explain, and the report would corrupt the one stream the flag exists to produce.

The shape is line-oriented on purpose — a fixed key per line, paths spelled exactly as the build’s own Compiled / Emitted / Bundled lines spell them — so grep '^output' lists a dist/, grep invalidates answers “what does this file feed”, and two builds diff cleanly.

vilan check [file]

build without the output: type-checks and reports diagnostics, writes nothing, and runs no [build] run hooks. Same path forms and flags (--platform, --watch, -d). In a multi-entry package it checks every entry, each under its own platform. Exit is non-zero when diagnostics were reported.

Writes nothing means nothing. A check remembers its macro expansions across processes — that is what makes a second check of an unchanged package compile no macro world at all — and that memory lives under ~/.vilan/check-cache/, keyed by the package’s path, not in the package. dist/.cache belongs to vilan build, which has a dist/ because it has artifacts; a check has neither, and creating one would make a read-only command mutate the tree it was pointed at. The root holds itself to a bound — tables untouched for thirty days go, and past 256 tables or 256 MiB the oldest go first — and vilan cache clean empties it; vilan cache prune sweeps the check tables beside the std trees. VILAN_CHECK_CACHE names a different root.

In a multi-entry package the entries are checked at the same time, one thread each, and their diagnostics are printed in entry order, exactly as a one-by-one round would print them (VILAN_SEQUENTIAL_CHECK=1 runs them one by one).

--explain-cost [N] prints, after checking, the N declarations (default 20) of your own package whose type inference cost the solver the most work: the constraint attempts, inferences, impl selections, type slots and impl rows its body’s constraints took, each a count — never a time — so the same program ranks the same on every machine. Closures count toward the function that writes them, generated code toward the attribute that generated it, and std’s declarations are left out.

vilan check --explain-cost 5

--fix is the one exception to “writes nothing”, and it is asked for by name. Before checking, it applies the fixes the diagnostics carry to the package’s own files (never to std or a dependency), analyzes again, and repeats until a round finds nothing more to fix; then it checks as usual and reports what is left. The edits are the editor’s quick fixes, computed by the same functions, so the two never disagree. It cannot be combined with --watch. It fixes four kinds of diagnostic:

  • A moved std path (v0.44.0, where std’s modules were grouped under namespaces): every import std::dom::… becomes import std::web::dom::…, a brace list keeps its names (std::{ delta::SeqOp } becomes std::{ reactive::delta::SeqOp }), and prelude = "std::web" in vilan.toml becomes prelude = "std::web::prelude" — only that value changes, the file’s comments and layout stay. A module imported as a module keeps the name it bound: import std::web; (the old web prelude, read as web::Signal) becomes import std::web::prelude as web;, and a self in a brace list under that path becomes prelude as web. One run migrates a package, even one whose old imports are its only errors. A file that vilan fmt left canonical is kept canonical (a moved import can sort to a new place). Two things are left, and named: a file under the package’s generated root, which the project regenerates — change what generates it — and a brace list under the old web-prelude path holding a reach marker, an impl selector or a nested group (std::web::{ #Signal, .. }), which no one edit rewrites correctly.

    vilan check --fix
    
  • A numeric mismatch (the release that moved std’s positions, lengths and counts to usize): the .as_*() conversion the message names, or : usize on a counter bound by a bare literal (Convert with .as_usize(), Declare at a usize). What it cannot decide — a -1 “not found”, a for i >= 0 loop, signed arithmetic that should convert once at its end — stays a diagnostic, for a person. An ascription to another width (n as f64, which names a type and never converts) is rewritten to the conversion in its place, n.as_f64().

  • A stale or unfilled auto (auto annotations): the written type is rewritten to what the item now infers, and a bare auto is filled, spelled the shortest way the file can name the type — by name, or through a module it imports. It never adds an import: a type the file cannot name is left, with the import that would let it. A rewrite that changes what callers read re-checks them in the next round, so a caller’s own auto, or a conversion a caller now needs, follows it.

It prints what it did before the check’s own report: for moved paths, a total and then one line per file with its count, and a line for each path it left; then, always, the numeric line; then, when it wrote any, a line counting the auto annotations.

fixed 4 moved std paths in 3 files
  src/main.vl: 2
  src/views.vl: 1
  vilan.toml: 1
fixed 0 numeric mismatches in 0 files

A second run finds nothing, writes nothing, and says fixed 0 numeric mismatches in 0 files.

One thing it does that build does not: when the file has a syntax error, check reports it and then type-checks the rest of the file anyway. The parser recovers at the next statement or item boundary, so a half-written statement no longer hides the type errors above and below it — which is the state a file is in most of the time it is being edited. build stops at the syntax errors, because a recovered file is not something to emit from. Diagnostics that are consequences of the skipped statement — a function body that lost its result, a name whose declaration did not parse — are reported too, beside the syntax error that explains them.

Opt-in warnings: [lints]. A few warnings are off unless a package asks for them, in its own vilan.toml:

[lints]
internal_use = "warn"

internal_use warns at every use of an [internal("reason")] item outside the module that declares it (an import line alone is not a use) — `anchor` is internal: place against it, never through it. The label on its own only changes what the editor shows (the name is hidden from completion, dimmed, and its hover leads with the reason); a package that wants the terminal to say so too sets the key. Each lint is "allow" (the default) or "warn", and a lint name or a level the section does not have is a manifest error rather than a silent no-op. The section is read from the entry package’s manifest; std’s own uses, and a dependency’s, are their authors’ and never warn.

Keeping auto types: [check] auto. A package can ask vilan check to keep its inferred types written as auto annotations:

[check]
auto = "exported"

Under "exported" each return and module binding reachable from outside its module — export-marked, in an export *; file, or an inherent method of an exported type — whose type is inferred gets a warning carrying its : auto T, which vilan check --fix (and the editor’s on-save action) writes; "all" covers every return and module binding; "off" is the default. A void return and a local binding are never asked for, and a type the file cannot name without a new import is left for a person. Like [lints], the section is read from the entry package’s manifest.

vilan run [file] [args…]

Builds and runs. Anything after the file is forwarded to the program. Reach it with import std::process; and process::args() — both lines: the qualified std::process::args() is a namespace path, not an expression. Under --watch it rebuilds and restarts on every save; in a project with a browser leg, hot module replacement is on by default: the page swaps changed code in place instead of reloading (see the dev loop).

Stdout is the program’s: what the build itself reports on the way (the Bundled line for each resource it carries) goes to stderr, so a program prints the same bytes under either backend.

  • --watch: place it before the file, ahead of any program args.
  • --no-hmr: plain restart-the-server watching, no dev channel.
  • --hmr-port <port>: the 127.0.0.1 port for the HMR channel (0 means an OS-assigned one).
  • --entry <name>: in a package (or workspace) with more than one runnable entry, which one to launch. The others still compile; they don’t run. The manifest’s default-entry makes the flag unnecessary; with neither, the error names both ways to choose.
  • --inspect[=[host:]port]: run under node’s inspector (default 127.0.0.1:9229) with the program’s source map, so a debugger that attaches sets breakpoints in .vl files. --inspect-brk also holds the program before its first line until the debugger resumes it. Neither takes --watch or --backend rust. See debugging.

A program vilan run starts runs with node’s --enable-source-maps whenever its build wrote a map (the debug preset’s default), so an uncaught panic’s stack names .vl files and lines.

vilan fmt [paths…]

Formats source files in place; directories are walked, and the default is the current directory. Formatting is conservative and a fixed point:

  • A statement over 100 columns whose expression is a method chain splits (subject on the statement’s line, one .link(…) per line below it), and the rule applies per line, recursively: a nested chain, list literal or struct literal that still overflows splits one level further in. A chain that fits stays on (or collapses back to) one line.
  • A list literal, a struct literal or an import’s brace set over the budget breaks one entry per line, with a trailing comma after every one — the last included, so adding an entry is a one-line diff. One that fits stays inline without a trailing comma, so the comma marks a split and nothing else.
  • A struct literal’s field written long where the shorthand says the same thing — A { x = x } — is printed as the shorthand A { x }. The two are one construct with two spellings, and the formatter picks one; a field whose value is any other expression, A { x = y } included, is left as written.
  • Width is measured on a line, not on a statement: a construct that opens a line and continues below it — a block-bodied closure, a match, a block — is judged by the line it opens, and its body lines are measured where they are printed. So view(…)…when(cond, || { … }) splits its chain like any other; only what shares the opening line counts toward that line’s width.
  • A chain also splits regardless of width when a link that is not its last spans lines — when a }) would be followed by more chain on the same line. A chain that ends at its spanning link is left alone, so the trailing-closure shape self.cleanups.write().push(|| { … }); stays as written.
  • A list or struct literal also splits regardless of width when one of its elements spans lines, because its closing } or ] — and usually a ) and ; after it — would otherwise pile onto that element’s last line. Unlike a chain, the last element counts: a composite has no position where a spanning element leaves a clean line.
  • A comment you write inside one of these constructs keeps it split, and attaches to the element it precedes — the link, element, field, imported name or parameter below it. A construct that collapsed would have no line to keep the comment on, which is why the comment decides the layout. A comment inside an element (a closure body a link carries, say) is that body’s own and changes nothing.
  • A fun signature over the budget breaks its parameter list the same way, one parameter per line, with the return type, a borrows clause and the body’s { (or a bodyless ;) riding the closing ). An empty parameter list never breaks, so a signature pushed over by its name stays long. A closure’s parameters are never broken.
  • A block-bearing HEAD over the budget breaks at its own layout site: an if or for condition, a for … in iterable, a match subject. Each of those lines — if <cond> {, for <name> in <iterable> { — carries one thing with a layout of its own, so that is where the break goes, at the lowest-precedence operator and operator-leading. The permission stops at the head: a loop body is a fresh statement list and a match’s legs earn their own breaks from their own lines.
  • A chain with a type ascription on MORE than one stage splits regardless of width, one stage per line, each as T closing its stage’s line: let n = words as List<str> on the first line, then .map(f) as List<usize> and .len() as usize below it. A single ascription follows the ordinary chain rule. A closure type after as is printed parenthesized — f as (|i32| i32) — so its greedy return type never has to be found by eye, and a spaced generic list after as is written tight (as List<i32>), the spelling the grammar reads as one.
  • Parenthesized groups you wrote are kept, even where the grammar doesn’t need them: a redundant paren is usually there for clarity.
  • A call’s argument list is never wrapped, but the split reaches the last argument, so a statement whose only breakable construct sits there still breaks it — list.push(T { … }) splits the literal. A long earlier argument still leaves a long line: layout hangs off the final argument. This is deliberately not symmetric with the parameter rule above — an argument list sits inside an expression, where the builder convention decides layout, while a parameter list is a declaration’s own contract and has no shape but one-per-line.
  • An element HEAD’s items are put in a canonical ORDER: id, name, type, for, href, src lead, every other undotted attribute follows alphabetically, then every on: handler alphabetically among themselves. A DOTTED link (.class(…), .styled(…), .child(…), one of your own) is a barrier — it holds its position absolutely and items sort only within the runs between barriers — because a link may write any slot it likes and the formatter knows nothing about what yours writes. Two items naming the same slot keep their written order, so a last-wins pair still wins the same way, and a head with a comment anywhere inside it is left exactly as written. What the reorder cannot change is what the element BUILDS: every moved item fills a slot named by its own first argument, and an HTML start tag reads its attributes as a set.
  • The order the attribute VALUES run in is not preserved, and that is the one thing to know about the rule above. An attribute’s value is an arbitrary expression, so moving title(a()) past id(b()) moves when a() and b() are called. In practice a value is a literal or a signal read and this cannot be observed — it is the same bargain the style() order below ships with, and the same one an argument list has always had. Where it can be observed, the side effect is the smell rather than the sorter: lift it into a let above the element, where its order is written down and a reader can see it.
  • A style() builder chain’s links are put in a canonical ORDER — the element head’s sibling, reordering your code rather than re-laying it out, and taking the same bargain about when its arguments run. The order is Tailwind CSS’s category sequence (layout, flexbox/grid, spacing, sizing, typography, backgrounds, borders, effects, filters, tables, transitions/animation, transforms, interactivity, svg, accessibility), with every condition combinator after every property method, in the axis order the selector nests them: md, then the relation (within, children, divide), then attribute, then the pseudo-classes. Two rules keep it safe, because a chain merges last-wins per property slot. A method the formatter does not know — one of your own impl Style extensions, or an escape hatch whose slot is an argument (raw, with_length, with_color, with_border) — is a barrier: links sort only within the runs between barriers, and nothing crosses one, so a chain of your own methods is left exactly as written. And methods whose slots are entangled — the same property, or a CSS shorthand over it (padding over padding_x over padding_left, size over width and height, border over border_color) — keep their written order, because padding then padding_x means something the reverse does not. Only independent slots ever cross, so the rendered stylesheet is byte-identical before and after. Style + Style operands are never reordered: that merge’s order is yours.
  • A file the formatter cannot yet print faithfully is left byte-for-byte untouched, never half-formatted — and said out loud, not silently. One declined <file>:<line> line names it and what the formatter met: a construct the printer has no rule for yet, a reprint its own safety net threw away, a reprint that is not a Vilan file, or a file that does not lex or parse. The safety net is two checks, not one: the reprint must carry the source’s tokens, and it must read back as a Vilan file — the formatter parses its own output before it writes anything. The second check is not implied by the first, because the token comparison deliberately ignores an insignificant trailing comma, so a printer that wrote one in the wrong place would satisfy it with output nothing can parse. When the token check is the one that fails, the line it names is where the reprint and your file stop agreeing — found by bisecting the two token streams, not by naming the file’s first declaration.

--check reports the files that would change and exits 1 if any (the CI spelling). Nothing is rewritten.

Comments are left exactly as you typed them, unless you ask. The formatter lays code out to a width and leaves prose alone, so a // line runs as far as its author took it and a paragraph edited in the middle keeps its ragged lines. A package can opt into re-filling them:

[fmt]
wrap_comments = true
comment_width = 84

It re-fills a paragraph — a run of // (or ///) lines with nothing but a newline between them — to comment_width columns, at that comment’s own indentation. A blank // line is a paragraph break and stays one. Both keys are read from the nearest vilan.toml above the file, each on its own, so a workspace can set the width once and a member turn the wrapping off; unset means off, and with it off vilan fmt is byte-for-byte what it was.

comment_width is prose’s own width, and it defaults to the code width (100). Code has one canonical layout and no knob; comments are yours, and a tree whose prose is written narrower than its code — std’s is written to 84 — would otherwise be re-filled to a width nobody chose the day it opted in. The key is read only when wrap_comments is on: with the knob off nothing is re-laid-out at any width.

Ten classes are never re-filled, because re-wrapping them destroys something: fenced code blocks; list items (an item is a line, and joining two makes one); tables; headings; block quotes; a line with an interior run of two or more spaces (aligned columns, an ASCII drawing); section banners and horizontal rules (// --- Placement ---); toolchain directives such as // witness:; commented-out code; and license headers. A trailing comment after code is not re-filled either — it is not a paragraph, it is a note on a line. A URL or a `code span` longer than the width is never broken — it takes a line of its own and runs over, the way any unbreakable word does. No character is ever substituted: the fill moves whitespace between words and does nothing else, and it checks that afterwards. If a re-fill ever came out with different words, vilan fmt declines the file (exit 2) and names the comment rather than writing it.

So wrap_comments = true does not mean “no comment over the budget.” A paragraph in any of those ten classes keeps every column its author gave it, a hanging-indent list stays as deep and as long as it was written, and an unbreakable word runs past the width on a line of its own. The key says the formatter may re-fill ordinary prose, not that a grep for long lines will come back empty.

Three outcomes, three exit codes. 0 is clean. 1 is “this tree is not formatted” — --check found files that would change, or a write failed. 2 is “the formatter could not format a file”, the declined lines above it saying which and why, and it holds in both modes because a run that skipped a file wrote nothing for it either. 2 outranks 1: a run that met a file it could not format has established nothing about the rest of the tree. Before this, every one of those ways out answered the file’s own bytes, so --check reported a file the printer had bailed on as already-formatted.

Generated sources are skipped. A package that declares [package] generated = "…" is saying that directory holds products — files a build hook writes, not files anyone authored — and vilan fmt leaves every one of them byte-identical, in --check too. It prints one dim note: line per run saying how many it skipped and which root they were under; the exit code doesn’t move, because skipping a product is the right answer rather than a degraded one.

Directory symlinks are walked, because a link is ordinary project layout — src/icons pointing at a generator’s output tree, src/shared at a sibling. Two limits, both about the walk and neither about the link: a directory already walked under another name is not walked again (so a cycle terminates and every file is reported once), and a link whose target resolves outside the project is not followed — vilan fmt prints one dim note: naming it, since that is where this command’s scope ends. Format that tree where it lives. A generated root declared through a link is still the package’s products, and is skipped as such.

The exclusion holds however the file is reached, naming it on the command line included, and your editor’s format-on-save honors it through the same rule. That is the point of it: formatting a generated module rewrites bytes the hook that wrote them digests, so the hook goes stale, regenerates the file unformatted, and the two undo each other on every round — quietly, since neither tool is doing anything wrong. If a file should be formatted, it isn’t a product: move it out of the root, or drop the key.

vilan test [path]

Runs *_test.vl files: the given file, a directory of tests, or every test in the project. A test file lives beside the code it tests and compiles as a file of its package: pkg:: siblings and dependencies resolve exactly as they do for the rest of the package. Each test passes by exiting 0; a failed assert panics, which fails it. Tests run on Node whatever the package’s target says. --watch re-runs on save.

vilan bindgen <file.d.ts>

Generates external bindings from a TypeScript declaration file, so a third-party JavaScript library can be reached from Vilan without hand-writing an external struct and an [extern(…)] external fun per member.

It is not a build step. You run it by hand, once, and the .vl it writes is yours: review it, edit it, commit it. Nothing in build, check, or run reaches it, no manifest key turns it on, and nothing regenerates it behind your back. Treat the output the way you treat vilan init’s scaffold — a starting point you own from the moment it lands, not a cache entry.

  • --platform <p>: required. node, deno, bun, browser, or @process. Every generated binding is stamped [platform("<p>")]. There is no default and no sniffing of the .d.ts: generated bindings land in your code, which is unconstrained by platform unless it is fenced, so an unfenced browser-only binding in a Node project would compile clean and fail at runtime. A library genuinely used on both gets two runs, into two files.
  • --only <Type>: emit only this declaration and everything reachable from its signatures — base types it extends, member types, parameter and return types, generic arguments. Repeatable, and the flags compose into one closure. Omitted: the whole file. A name the file does not declare is an error, so a typo fails instead of quietly writing less than you asked for. Use it when a declaration file is far larger than the slice you need; see Filtering.
  • -o, --output <path>: where to write. Omitted: <stem>.vl beside the input (leaflet.d.ts → leaflet.vl).
  • --stdout: print instead of writing.
  • --stats: also report coverage — how many declarations and members bound, and which TypeScript constructs did not.

Attribute order in the output is not stylistic: the parser reads [extern(…)] before [platform(…)], and the other way round is a parse error. Keep the pair in that order if you edit a generated file.

One thing a .d.ts cannot tell bindgen is whether the host keeps an argument past the call — a listener it registers, a callback it queues, a value it stashes. It generates no retains flag, so add the trailing retains to [extern(…)] yourself on any binding that hands the host something it holds on to ([extern(method, "on", retains)] for the Marker.on below): without it the compiler is free to destroy the argument’s binding at its last use while the host is still reading it. See Externs and retention for what the flag promises.

What you get

declare function greet(name: string, loudly?: boolean): string;

interface Marker {
    readonly id: string;
    title: string;
    move(dx: number, dy: number): void;
    on(event: string, handler: (x: number) => void): void;
}

vilan bindgen marker.d.ts --platform node writes bindings in exactly the dialect std is written in:

[extern("greet")]
[platform("node")]
external fun greet(name: str): str;

[extern("greet")]
[platform("node")]
external fun greet_with_loudly(name: str, loudly: bool): str;

external struct Marker;

impl Marker {
	[extern(get, "id")]
	[platform("node")]
	external fun id(self): str;

	[extern(get, "title")]
	[platform("node")]
	external fun title(self): str;

	[extern(set, "title")]
	[platform("node")]
	external fun set_title(self, value: str): void;

	[extern(method, "move")]
	[platform("node")]
	external fun move_(self, dx: f64, dy: f64): void;

	[extern(method, "on")]
	[platform("node")]
	external fun on(self, event: str, handler: |f64| void): void;
}

fun main() { }

Names become snake_case (the extern keeps the exact JS spelling), a readonly property gets only a getter, and every number becomes f64, because a .d.ts cannot say whether a number is meant as an integer. Narrowing to i32 is a human edit.

An optional parameter becomes one binding per call arity. TypeScript optionals are trailing, so greet(name) and greet(name, loudly) are two real host calls and become two real bindings of the same symbol — the short one keeps the plain name. (std does the same by hand: append and append_text both bind appendChild.)

Constructors

TypeScript splits a host class in two: an interface for what an instance has, and a declare var of the same name whose object type carries the new(…) signature — the static side. bindgen reads the pair back together, so a construct signature becomes a real constructor on the type it yields:

interface Marker {
    readonly id: string;
}

declare var Marker: {
    prototype: Marker;
    new(id: string): Marker;
    readonly count: number;
};
external struct Marker;

impl Marker {
	[extern(new, "Marker")]
	[platform("node")]
	external fun new(id: str): Marker;

	[extern(get, "id")]
	[platform("node")]
	external fun id(self): str;

	[extern("Marker.count")]
	[platform("node")]
	external fun count(): f64;
}

fun main() { }

new is Marker::new(…); the statics beside it become dotted globals reached the same way, Marker::count(). prototype is the idiom’s marker, not a binding, and is dropped. A construct signature that names a different type — declare var Image: { new(): HTMLImageElement } — binds on that type instead, as HTMLImageElement::new_image(…).

An object-typed global with no construct signature is not a class and gets a TODO rather than an invented type; so does a global whose constructor object is typed by a named interface rather than written inline.

Filtering a large declaration file

A declaration file is often far larger than the part you use, and extends flattening multiplies it: Vilan has no struct inheritance, so every base member is copied into each derived type. --only cuts the output to a named type and its transitive closure:

vilan bindgen lib.dom.d.ts --platform browser --only HTMLCanvasElement -o canvas.vl

What “reachable” means is what survives the mapping table, not what the TypeScript text mentions. An open union widens to any, so RenderingContext = CanvasRenderingContext2D | WebGLRenderingContext | … pulls in none of its members; T | null binds as T, so T is kept. A base type is followed for the members flattened out of it, but its own name is not emitted unless some signature names it.

Cycles are fine — a Node has an ownerDocument and a Document has a documentElement — each declaration is visited once.

A caveat worth knowing before reaching for it on the DOM specifically: the browser’s element types are one strongly-connected component, so --only HTMLCanvasElement against the real lib.dom.d.ts still keeps about 900 declarations. The filter is doing its job; the type graph is simply that dense. For a third-party library — bindgen’s actual target — the closure is usually a handful of types.

Absence, and why there is no Option

Nothing bindgen emits is an Option<T>, and that is deliberate. Vilan has no null, and Option is a tagged array at runtime — Some(v) is [0, v], None is [1] — which a third-party host neither produces nor reads. Both directions break:

  • reading, a host returning "hello" is tested as value[0] == 0, which is "h" == 0, so a present value arrives as None;
  • writing, None reaches the host as the array [1] — and for an optional boolean argument, [1] is truthy.

So a type admitting null/undefined binds as the bare type, and the binding carries a /// note saying the value may be missing. Guarding is yours. (std does use Option across external boundaries, but only ones it owns — compiler intrinsics and its own runtime helpers, which know the representation.)

// TODO(bindgen)

Nothing is ever dropped silently. Every construct the generator cannot express becomes a comment naming it and saying why, in place:

// TODO(bindgen): numeric index signature `[index: number]: string` —
// this is an array-LIKE shape, NOT a JS array …

A generated file with TODOs is reviewable; one with silent gaps is a landmine. The ones you will meet most:

TypeScriptWhy it can’t map
declare const x: T (a plain global value)Every [extern(…)] form binds a call or a receiver’s property; none reads a bare global as a value. Bind its members as dotted globals: [extern("x.member")]. A global that is a constructor object is different — see Constructors.
{ [key: string]: T }, { [index: number]: T }, Record<K, V>An open keyed or array-like host object has no Vilan type at a host boundary — see below.
overloadsVilan has one signature per name. The first wins; the rest are quoted so you can hand-split them into distinct names.
A | B (open unions), intersectionsNo union or structural types in Vilan; widened to any.
namespace, declare module, declare global, conditional/mapped types, keyofOut of v1 scope.

Why every interface becomes an external struct

A Vilan struct is a positional array at runtime: struct Point { x: f64 } is [x], and p.x is p[0]. A host object {x: 1} read through one yields undefined, silently. Only an external struct — an opaque handle whose fields are reached through [extern(get/set, …)] — survives the crossing. The same fact rules out three tempting mappings:

  • a TS discriminated union is a tagged object, while a Vilan enum is [tag, …payload] — unless it is a backed enum, which is the bare backing value and does cross (that is what a closed string set maps to, below);
  • HashMap<str, T> is a Vilan struct over a hashed native map, not a plain host object;
  • List<T> is a real JS array, and an array-like ({[index: number]: T} — numeric keys and length, no Symbol.iterator) is not one: iterating it throws. Convert at the boundary with Array.from and bind the result as List<T>.

A T[]/Array<T> in a .d.ts is a real array and does map to List<T>; a TS tuple [A, B] maps to (A, B), since a Vilan tuple is a JS array too.

Closed string sets

A named union of string literals becomes a backed enum — each variant carries the host string it stands for, so the enum is that string at runtime and crosses the boundary unchanged. Every position takes it directly, with no wrapper and no forwarder: a parameter, a return, a property’s getter and setter, a List<Align>, a callback’s own parameter. bindgen emits signatures only; there is never a generated body.

enum Align {
	Start = "start",
	End = "end",
}

external struct Chart;

impl Chart {
	[extern(set, "align")]
	[platform("node")]
	external fun set_align(self, value: Align): void;

	[extern(get, "align")]
	[platform("node")]
	external fun align(self): Align;

	[extern(method, "onAlign")]
	[platform("node")]
	external fun on_align(self, handler: |Align| void): void;
}

fun main() { }

Nothing checks the read direction at the boundary — the host can answer with a string that is none of the variants — but nothing has to: an exhaustive match on a backed enum traps and names the value rather than returning a confident wrong variant. Where an unrecognized value is an answer you expect rather than a bug, hand-edit the binding to the guarded shape: bind the raw str under a name the module does not export and forward through Align::parse, which returns Option<Align>. The generated file is ordinary source, and that edit is one of the reasons it is yours to keep.

An inline "left" | "right" is widened to str instead — safe and exact, since that is what the host takes; only a union the library author bothered to name earns a type.

Scope

bindgen targets the third-party library std doesn’t wrap. It is not for regenerating std::browser::dom, which is a deliberately curated subset reviewed line by line, and it does not resolve types across files: a name declared in another .d.ts widens to any with a TODO.

vilan upgrade

Replaces this binary (and vilan-lsp beside it) with the newest release, downloading for your platform and swapping the pair atomically (vilan-lsp first, so the two are never newer-cli/older-lsp). The licenses and third-party notices travel along. --check reports whether a newer release exists and changes nothing.

The VS Code extension comes along. After the swap — and when the toolchain is already the newest release but the installed extension is not its version — vilan upgrade runs the installers’ extension step: code --install-extension vilan-lang.vilan from the Marketplace or Open VSX first (an install VS Code keeps updated from then on), and the release’s own vilan-vscode.vsix, verified against its checksum, when the gallery cannot be reached. The editor is found as the installers find it: code on your PATH, else a VS Code Server’s own CLI. Its last line says which it did; an editor that refuses the extension is reported, never a failed upgrade. --no-vscode (or VILAN_NO_VSCODE=1) leaves the editor alone.

vilan upgrade also prunes the std cache described below while it has ~/.vilan open.

vilan cache prune

Deletes materialized std trees no binary can use any more, and the macro expansion tables vilan check keeps beside them.

An installed vilan carries its standard library inside the binary and writes it out once, to ~/.vilan/std-cache/<content hash>/, so the compiler and your editor read ordinary files. The directory is keyed by the std’s content, so each build of the toolchain gets its own — which is invisible if you install releases, and adds up quickly if you build vilan from source.

Two things prune it for you: writing a new tree sweeps the root it just grew, and vilan upgrade sweeps while it is there. Both keep anything created in the last seven days, because a compile reads std files lazily and a young tree may belong to one that is still running. This command is the same sweep, on demand:

vilan cache prune            # entries older than seven days
vilan cache prune --dry-run  # print what would go, with sizes; delete nothing
vilan cache prune --all      # every entry, guard and all

The tree this binary itself uses is never deleted, --all included: the next command would write it straight back.

Two roots are swept, and each reports its own line. ~/.vilan/std-cache/ is the one above. ~/.vilan/check-cache/ holds one macro expansion table per package a vilan check has warmed — the same content-keyed, stamped file vilan build keeps in dist/.cache, put where a command that emits nothing can keep memory without writing into your tree. Deleting a table costs a recompile of that package’s macro worlds and nothing else; the same seven-day guard applies, because a check running right now is holding its own table open.

A fourth root is ~/.vilan/cache/worlds/ (or the directory VILAN_WORLD_CACHE names): the persisted std prefix — std analyzed once, and a program’s dependencies with it, written by the language server and by vilan check under VILAN_STD_PREFIX=on, and loaded by every later process. Each file’s header names the build of the analyzer that wrote it, so prune takes a file another build wrote (past the seven-day guard, or all of them with --all) and never one this build wrote. The root also holds itself to 256 MiB as it is written (VILAN_WORLD_CACHE_BYTES sets another budget), the least recently used file first.

vilan cache clean

Deletes every macro expansion table vilan check keeps in ~/.vilan/check-cache/ and every persisted std prefix in ~/.vilan/cache/worlds/, whatever their age. Each one is re-created by the next check of its package — or the next analysis that looks for its prefix — so nothing is lost but the warm start.

vilan cache clean            # every check table and every persisted prefix
vilan cache clean --dry-run  # print what would go, with sizes; delete nothing

vilan cache warm

Writes the persisted std prefix for a program — checks it once with the prefix on — so the next process (a CI step, an editor opening the project) loads std, and the program’s dependencies, instead of analyzing them again. It takes what vilan check takes: a file, a project directory, or nothing for the nearest vilan.toml.

vilan cache warm             # the project here
vilan cache warm src/main.vl # one program

A check also holds that root to a bound by itself, at most once a day: every table untouched for thirty days goes, and past 256 tables or 256 MiB the oldest go first — never the package being checked, and never a table younger than ten minutes, which a check running right now may be reading.