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

Projects and dependencies

Hello Vilan covers the two basic manifests: an application ([package]) and a library ([library]). This page is the rest of the manifest’s vocabulary — external dependencies, pre-build commands, and workspaces — for when a project grows into it.

Git dependencies

Beyond path dependencies, a dependency can live in a git repository, pinned to one exact point (a tag or a commit, never a branch):

[package.dependencies]
shapes = { git = "https://github.com/someone/shapes", tag = "v1.2.0" }
pinned = { git = "https://github.com/someone/other", rev = "9f2c1ab" }

The repository must be a [library] with its vilan.toml at the root. vilan build (or check, or run) fetches it once into ~/.vilan/git-deps/ and reuses that checkout forever after, so builds work offline and a moved tag cannot change what you already built. Nothing else fetches: the editor uses the cache when it’s there and never reaches the network.

The prelude key

prelude names the module whose exports are in scope in every one of this package’s files with no import. It sits on [package] and on [library] alike:

[package]
name = "app"
prelude = "std::web::prelude"
ValueMeaning
omittedstd’s base set: print, Iterator, Option/Some/None, Result/Ok/Err
"std::web::prelude"the base set plus Signal, SignalCell, CollPipe, SetPipe, view, View, the six slot values (when, when_some, swap, each, each_values, each_by), and the modules style and ui
"pkg::my_prelude"your own module — its exports are the ambient names
"some_dep::their_prelude"a dependency’s module
falseno prelude at all

A custom prelude is an ordinary module of re-exports, and it replaces std’s rather than extending it — extension is spelled by re-exporting what you want to keep:

export import std::io::print;
export import std::option::Option::{ self, Some, None };
export import std::reactive::Signal;
export import std::web::style;                 // a whole MODULE, as `style::…`
export import std::web::style::Length::rem as rem;   // a type's STATIC, bare

Three rules make the key safe to use:

  • Per package, never inherited. A dependency resolves under the prelude its manifest declares. You cannot change what a dependency’s source means, and it cannot inject names into yours — not even through a [project] workspace root, which has no prelude key at all. Each member states its own.
  • The weakest scope. A local declaration or an explicit import of a prelude name wins, silently. Adding a prelude cannot break code that compiles today.
  • "std" is not a value. It names the package root, not a prelude module; the manifest refuses it and points at "std::prelude" (the default) or "std::web::prelude".

A prelude that re-exports a platform-layered name makes your package’s ambient scope platform-dependent. Nothing special happens — platform coloring still reports at the point the code becomes reachable — but “my prelude broke my server build” is a confusing way to learn it.

The standard library declares prelude = false: 741 declarations across 63 files, where “which module is this from” has to be answerable by reading the file.

What a module exports

A package is a tree of modules, and a module’s items are its own until it says otherwise. export is the marker:

export *;

fun helper(): i32 {
	7
}

export *; is the whole-module form — one bare statement below the file’s imports, saying “everything here is surface”. Per-item is the other form, and it is what a library grows into:

export struct Session {
	token: str,
}

export fun open(token: str): Session {
	Session { token = token }
}

// Not exported: the module's own machinery.
fun refresh_window(): i32 {
	300
}

Two things follow, and the second is the one that surprises people.

  • Nothing is blocked. An importer who needs refresh_window writes the reach — import pkg::session::{ #refresh_window }; — and it compiles. What the marker changes is completion, the add-import fix and the “import it first” steer: a private item is not offered. A plain import of it from another file of the same package warns and names the reach spelling; from a dependency, nothing is said at all.
  • impl blocks carry the bit too. A block a consumer cannot see contributes no methods to that consumer, so a module that exports per-item writes export impl Session { … } for the blocks it means to publish. A module with no marker anywhere is uncurated and offers everything — which is why a package that has never thought about visibility keeps compiling exactly as it did.

New packages are uncurated, and that is a fine place to stay. Reach for the marker when the package has consumers: export *; at the top of each module preserves today’s behaviour exactly, and per-item curation is the step after, when the surface is worth naming. std itself is curated (548 of its 856 declarations are exported) and is the worked example.

Reserved names

Four names are refused wherever a manifest names a package: std, pkg, macro_std, and vilan. The first three are the import roots the toolchain owns — std:: is always the standard library, pkg:: is always your own package, macro_std:: is always the macro world’s std — so a dependency declared under one of them could only shadow the root or vanish behind it. vilan is the language’s own name, held for official packages to come. The dependency key is yours to choose, so pick any other name; the library it points at keeps its own.

Pre-build commands and default-entry

Two more keys matter once a project grows. [build] run is a command line (or a list of them) for your shell, run before each build and each --watch round: an asset pipeline, a codegen sidecar. It runs with your privileges and Vilan doesn’t prompt — the manifest is yours, and this is the trust cargo build and npm run already take. A step too expensive to repeat gets a [[build.hook]] instead: a name, and the inputs and outputs that decide whether it needs to run at all. A hook that writes Vilan modules also wants generated, naming the directory it writes them into, so vilan fmt leaves them alone instead of rewriting bytes the hook is watching. And default-entry names the entry vilan run should drive when a package has several. All are covered in the dev loop.

Workspaces

A workspace groups several packages so they build together with one vilan build . at the root. You need it less often than you might expect, since a client + server app is one package with two entries (see the full-stack shape). Reach for a workspace when members want their own manifests and dependency sets:

[project]
packages = ["common", "client", "server"]

[project.dependencies]
shapes = { git = "https://github.com/someone/shapes", tag = "v1.2.0" }

Dependencies declared at the workspace root are there for the members to share, so the version lives in one file. A member takes one by name:

[package.dependencies]
shapes = { project = true }

That is the whole form: project = true and nothing else, once per dependency you want. It is opt-in on purpose: a member that stays silent gets nothing, so adding a dependency at the root can never change what another package sees. A path written at the root is relative to the root, since that is where you wrote it. And a member that declares its own shapes = { path = "…" } uses that one. There is no shadowing rule to remember, because inheritance only happens where you ask for it.