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):
| Template | What you get |
|---|---|
node | a package that runs on Node, with a sibling module and a test |
browser | a reactive browser app (a counter, an index.html) |
fullstack | one 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 distis the bigger hammer: the freshness record lives there.)--platform <p>:node,deno,bun,browser, ornone; overrides the package’starget(--targetis an accepted alias).nonechecks 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.outis the tree the parser produced;.parse.outis that tree after the desugars hooked at every parse entry (thecssblock, 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.outis the analyzed program and.callgraph.outthe call graph its post-analysis passes shared.--print-chunks: report the route-chunk plan — what asplit = truebrowser leg would load lazily per route, with function counts and a byte estimate, plus averdict: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). Thedebugpreset writes both already; this is[build] source-maps = truefor 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.jsis the default.rustemits the program as Rust, writes a cargo project underdist/native/and builds it with the host’scargo— in debug, because rustc is the inner loop from there on (--releaseis 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,printandpanic; 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 rustruns 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:
role | What follows |
|---|---|
emitted kind <k> | one emitted line per const site that contributed to it |
bundled copy | the source file, and a named line per const site that named it, with the spelling (asset::bundle / asset::bundle_as) |
compiled bundle | the leg whose JavaScript it is |
build manifest | the leg it describes |
route chunk | the leg and the route arm |
hook output | the 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
constexpression, not theemitcall 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 theconstsite is what a const-eval error points at too, and the two locations are counted from one set of bytes. --stdoutis refused with it.--stdoutprints 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::…becomesimport std::web::dom::…, a brace list keeps its names (std::{ delta::SeqOp }becomesstd::{ reactive::delta::SeqOp }), andprelude = "std::web"invilan.tomlbecomesprelude = "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 asweb::Signal) becomesimport std::web::prelude as web;, and aselfin a brace list under that path becomesprelude as web. One run migrates a package, even one whose old imports are its only errors. A file thatvilan fmtleft canonical is kept canonical (a moved import can sort to a new place). Two things are left, and named: a file under the package’sgeneratedroot, 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: usizeon a counter bound by a bare literal (Convert with.as_usize(), Declareatausize). What it cannot decide — a-1“not found”, afor i >= 0loop, 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(autoannotations): the written type is rewritten to what the item now infers, and a bareautois 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 ownauto, 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>: the127.0.0.1port for the HMR channel (0means 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’sdefault-entrymakes the flag unnecessary; with neither, the error names both ways to choose.--inspect[=[host:]port]: run under node’s inspector (default127.0.0.1:9229) with the program’s source map, so a debugger that attaches sets breakpoints in.vlfiles.--inspect-brkalso holds the program before its first line until the debugger resumes it. Neither takes--watchor--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 shorthandA { 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. Soview(…)…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 shapeself.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
funsignature over the budget breaks its parameter list the same way, one parameter per line, with the return type, aborrowsclause 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
iforforcondition, afor … initerable, amatchsubject. 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 amatch’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 Tclosing its stage’s line:let n = words as List<str>on the first line, then.map(f) as List<usize>and.len() as usizebelow it. A single ascription follows the ordinary chain rule. A closure type afterasis printed parenthesized —f as (|i32| i32)— so its greedy return type never has to be found by eye, and a spaced generic list afterasis 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,srclead, every other undotted attribute follows alphabetically, then everyon: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())pastid(b())moves whena()andb()are called. In practice a value is a literal or a signal read and this cannot be observed — it is the same bargain thestyle()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 aletabove 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), thenattribute, 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 ownimpl Styleextensions, 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 (paddingoverpadding_xoverpadding_left,sizeoverwidthandheight,borderoverborder_color) — keep their written order, becausepaddingthenpadding_xmeans something the reverse does not. Only independent slots ever cross, so the rendered stylesheet is byte-identical before and after.Style + Styleoperands 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 itextends, 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>.vlbeside 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 asvalue[0] == 0, which is"h" == 0, so a present value arrives asNone; - writing,
Nonereaches the host as the array[1]— and for an optionalbooleanargument,[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:
| TypeScript | Why 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. |
| overloads | Vilan 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), intersections | No union or structural types in Vilan; widened to any. |
namespace, declare module, declare global, conditional/mapped types, keyof | Out 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
enumis[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 andlength, noSymbol.iterator) is not one: iterating it throws. Convert at the boundary withArray.fromand bind the result asList<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.