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"
| Value | Meaning |
|---|---|
| omitted | std’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 |
false | no 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 nopreludekey 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_windowwrites 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. implblocks carry the bit too. A block a consumer cannot see contributes no methods to that consumer, so a module that exports per-item writesexport 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.