larvaedocs
GitHub

Writing a worm in Rust

The larvae-worm crate, the wasm macros, and the native pipe protocol

updated Aug 19, 20264 min read

Depend on larvae-worm. The crate is the guest side of the worm system and does not pull in larvae, because larvae is the host, not a library. It gives a Rust author both shipping forms:

  • frontend! and rules!, macros for the wasm ABI. Compile to wasm32-unknown-unknown and ship the .wasm.
  • a native module with a Handler trait and serve(), for the native pipe protocol. Compile an ordinary binary.

The wire types are the same on every transport, so a handler returns the same values whichever form ships, and moving a worm from wasm to native is a packaging change rather than a rewrite.

The wasm route works for anything that compiles to wasm32, so Zig and C are equally valid. Rust is documented here because that is where the helper crate is.

The wasm form

rust
larvae_worm::frontend!(|source: &str, config: &str| -> anyhow::Result<String> {
    my_compiler::compile(source, config)
});

larvae_worm::rules! {
    "strip_debug" => |node: larvae_worm::Node| {
        if node.kind() == "CallExpr" && node.text().starts_with("dprint") {
            node.remove();
        }
    },
}

Node answers the same surface Luau gets, so nothing on the node API is form specific. Declare each capability in worm.toml, and set form = "wasm" with entry pointing at the .wasm.

wasm is the default recommendation for a published worm: it runs sandboxed, and one artifact serves every platform.

The macros are not the only door

larvae_worm::abi is public and documented, so a worm doing something unusual can export the raw functions without vendoring a macro. Under the hood, wasm has no strings: text crosses as an offset and a length into the module's own memory. None of that is visible through the macros.

api is a pinned contract

A .wasm is compiled against a host ABI, which is why worm.toml carries api. A worm built for api = 1 loaded by a larvae speaking api = 2 is rejected at load with a sentence, rather than discovered as a trap mid build.

The native form

Implement the Handler trait and call serve() from main. larvae starts the binary and speaks to it over a pipe. The trade is stated plainly on what a worm is: native speed and no sandbox, so the form is opt in, for code a project trusts.

Rules change shape on native. A pipe crossing costs about 24 µs, so per node dispatch would spend everything on the boundary; instead larvae sends one batched message per file. A batched rule reads the matched node and the source, and navigates no tree. Format and lint cross once per file on every transport, so they cost the same whichever form you pick.

Shipping a native worm

Two channels fit the native form:

  • crates.io. x = { cargo = "crate", version = "1.0.0" } in the user's config runs cargo install, so the worm builds from source on the user's machine and one published crate serves every platform. The binary must carry its own worm.toml and return it over the pipe when larvae asks, because cargo install ships no data files.
  • A GitHub release with one zip per platform. larvae tries <name>-worm-<arch>-<os>.zip first, so that is the name to publish under. larvae makes the entry executable at install.

See using worms for the resolution order and caching.

Answering the editor

Both forms can answer code actions and Luau type definitions for the language server: on wasm the exports are larvae_actions and larvae_definitions, on native the actions and definitions ops. All are optional. A worm that leaves them out answers with nothing rather than an error, the opposite of format and lint, where the manifest promises the capability and a missing export is a broken promise worth a message. A worm that fails is passed over, because one broken worm must not take the lightbulb from the others.

Actions speak in byte offsets, and larvae turns those into protocol positions. The title carries the worm's name, and an action that names the lint it repairs is grouped under that diagnostic. Definitions are Luau definition text, the answer to larvae/definitions: a worm that makes a data file requirable knows the type of require("./items.json"). The user's side is on using worms.

Pick the right form

shape of the work form
many small node visits Luau, the cheapest crossing
heavy compute behind few crossings, shipped anywhere wasm, the default recommendation
native speed for code the project trusts native, batched rules over a pipe

wasm is not the faster form for a node heavy rule: crossings cost roughly six times what they cost in Luau, and that is interpreter tax landing on the boundary rather than on your logic. A front-end is one call per file by definition, which is why wasm suits a compiler and Luau suits a tidy up rule. The measurements are on what a worm costs.

Developing one

larvae worm does not care which form it is looking at:

shell
larvae worm run myworm app.mk          # plain transform, print the result
larvae worm run myworm app.mk --fmt    # format it, and report if a second pass changes it
larvae worm run myworm app.mk --lint   # report findings at the manifest defaults
larvae worm info myworm                # what the manifest declares, without running it

info prints the form and the api a manifest declares, which is the fastest way to check it is what you meant. Point a real project at the worm with path = for the full pipeline. See the CLI reference.