Configuring lattice with `init.rs`
Configuring lattice in Rust/WASM with init.rs: where it lives, how it loads and reloads, and worked examples for event handlers, custom grammar (commands / motions / text-objects / operators / ex-commands), keybindings, and options.
lattice has one extension substrate — WebAssembly. There is no vimscript, no Lua, no elisp. Your configuration is a small Rust program, init.rs, compiled to a wasm32-wasip2 component and loaded at boot. It registers keymaps, custom commands, new motions and text objects, options, and event handlers through the same seams a plugin uses — your config is simply the first plugin the editor loads.
TOML covers static option values (ui.nerd_fonts = true); anything programmable — a keybind, a custom :command, a new motion, an event hook — lives in init.rs. Static settings stay declarative; logic stays code; one toolchain.
Prerequisite: this is the how-to for users. The full plugin substrate (the capability/fuel/crash-isolation model, every seam, the introspection commands) is documented in
pluginsand the authoring guide. If you've written a plugin,init.rsis the same thing pointed at your config directory.
Where it lives and how it loads
Your compiled config is a plugin directory at:
<config>/lattice/init/
├── plugin.toml # the manifest (id = "init", provides = [...])
└── init.wasm # your init.rs, compiled to a component
<config> is ~/.config on both Linux and macOS (honoring $XDG_CONFIG_HOME), and %APPDATA% on Windows — a consistent ~/.config/lattice/ tree across Unix, not the macOS-native Application Support. Your on-disk plugins live alongside it at ~/.config/lattice/plugins/.
- Loaded at boot, after the built-in vim grammar and modes register (so your keymaps, commands, and options layer on top of the defaults — your
<leader>fsits above the builtin grammar; your:mycmdjoins the same command registry as:write) but before the plugins in~/.config/lattice/plugins/, so yourPrePluginLoaded/PluginLoadedhandlers are subscribed and ready when those plugins load (see Configuring plugins that load after you). - Loaded with boot capabilities —
init.rsis your own trusted config, so it gets the pre-granted (Bundled) trust tier, not the consent-prompted tier a downloaded plugin gets. - Reload without restarting with
:reload-config— it unloads the old config (reversing every keymap, command, option, and subscription it added) and re-instantiatesinit.wasmfrom disk with a fresh, clean sandbox. Edit, rebuild,:reload-config, done.
An absent <config>/lattice/init/ is the normal "no custom config" case — the editor boots with defaults, silently.
The model: config is a plugin, each capability is a seam
Every kind of configuration maps to a seam — a typed interface mirroring a native editor subsystem:
| You want to… | Seam | WIT world you implement |
|---|---|---|
bind a key (<leader>f → a command) | keymap | keymap-plugin |
add a :command, motion, text object, operator | grammar | grammar-plugin |
declare a typed option (:set my.opt=…) | config | config-plugin |
| observe editor events (save, mode change, …) | events | events-plugin |
| define a minor mode | modes | modes-plugin |
One component implements one seam world today. A WebAssembly component has a single world type, so a single
init.wasmtargets onewit_bindgen::generate!world. If your config spans several seams (say, custom commands and event handlers), ship the extra concerns as additional plugins under<data>/lattice/plugins/<name>/— each a one-seam component — alongside theinit/dir. A unifiedinitworld that lets one component register across every seam at once is planned; until then, one component = one seam.The good news: commands, motions, text objects, operators, and ex-commands are all the same seam (
grammar), so a single grammar component can define all of them together (the big example below).
plugin.toml declares which seam(s) the component provides:
id = "init" # required; the :reload-config target
provides = ["grammar"] # the seam this component implements
doc = "My lattice config."
Everything else in the manifest (capabilities, editor_capabilities) is the sandbox grant — see the safety model and plugins.
Event handlers
Observe editor state transitions — a document saved, the modal mode changed, a major mode entered. This is the :autocmd / add-hook equivalent, unified into one typed event bus.
Handlers act via APIs, not command strings — but only the APIs their world imports. A standalone
events-pluginimportsevents+host-services
logging, so its handler canwalkthe fs, emit its own plugin event, or log — nothing more. To callenable-mode/set-option/register-bindingfrom a handler, your world must also importmodes/config/keymap— a combined world (see the complete annotated example). A handler never runs a:command string or returns an effect (the standingevent-handlers-call-apis-not-commandsrule::commands are user-facing front-ends; a handler calls the underlying API). Direct buffer mutation and before-class veto (a handler that rewrites content or aborts a save) are still deferred.
You implement the events-plugin world: subscribe your handlers in register_events, then dispatch them in on_event.
The event catalog
Subscribe with an EventFilter — any combination of event kinds, path globs, and major-modes (all None ⇒ every event):
EventKind | Payload | Fires when |
|---|---|---|
DocumentOpened | { id, path?, version, text } | a buffer opens (path None for scratch) |
DocumentClosed | id | a buffer closes |
BeforeSave | { id, path } | just before a write |
DocumentSaved | { id, path } | after a write |
DocumentChanged | { id, path?, version } | buffer content changed |
SelectionsChanged | { … } | cursor/selection moved |
ModalModeChanged | { from_state, to_state } | Normal↔Insert↔Visual↔… |
BeforeQuit | — | :q on the last window |
OptionChanged | { name, old?, new_value } | a :set landed |
MajorEntered / MajorExiting | { buffer, mode } | major mode lifecycle |
MinorActivated / MinorDeactivated | { buffer, mode } | minor mode lifecycle |
PrePluginLoaded | { name } | a plugin is about to run its load-time code — the hook for that plugin's options (see below). The load waits for your handler |
PluginLoaded / PluginUnloaded | { name, id } | a plugin finished loading / was unloaded — the hook for deferred plugin config (see below) |
Plugin | { name, payload } | a plugin-defined event (filter by name yourself) |
A worked event-handler config
// init.rs — an events-plugin. plugin.toml: provides = ["events"]
wit_bindgen::generate!({ world: "events-plugin", path: "wit" });
use lattice::plugin_host::events;
use lattice::plugin_host::host_services;
use lattice::plugin_host::types::{EventFilter, EventKind};
struct Component;
// Handler ids are yours to choose — they route deliveries in `on_event`. Give
// them names so the dispatch reads clearly.
const ON_SAVE: u32 = 1;
const ON_MODE: u32 = 2;
const ON_RUST_OPEN: u32 = 3;
impl Guest for Component {
// Called once at load. Subscribe each handler with a filter.
fn register_events() {
// Every save, any file.
events::subscribe(
&EventFilter { kinds: Some(vec![EventKind::DocumentSaved]), path_globs: None, major_modes: None },
ON_SAVE,
);
// Modal transitions (Normal↔Insert↔…).
events::subscribe(
&EventFilter { kinds: Some(vec![EventKind::ModalModeChanged]), path_globs: None, major_modes: None },
ON_MODE,
);
// A document opening, narrowed to Rust files by a path glob.
events::subscribe(
&EventFilter {
kinds: Some(vec![EventKind::DocumentOpened]),
path_globs: Some(vec!["**/*.rs".into()]),
major_modes: None,
},
ON_RUST_OPEN,
);
// Declare a plugin-defined event other plugins can observe (optional).
host_services::register_event("myconfig.saved", "Fired after every save.");
}
// Deliver one matching event. Dispatch on the handler id you registered.
fn on_event(handler: u32, ev: Event) {
match handler {
ON_SAVE => {
if let Event::DocumentSaved(doc) = ev {
log(&format!("saved {}", doc.path));
// Fan out a plugin event carrying the path (any consumer of
// "myconfig.saved" — another plugin — receives it).
host_services::emit_event("myconfig.saved", doc.path.as_bytes());
}
}
ON_MODE => {
if let Event::ModalModeChanged(m) = ev {
log(&format!("mode {} -> {}", m.from_state, m.to_state));
}
}
ON_RUST_OPEN => {
if let Event::DocumentOpened(doc) = ev {
log(&format!("opened rust file (version {})", doc.version));
}
}
_ => {}
}
}
}
// The only side effect a handler can have without extra capabilities: write to
// its own private, always-writable `/data` dir. (An `fs:read:<prefix>` grant in
// plugin.toml also lets a handler read outside it via the gated `walk`.)
fn log(line: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/data/events.log") {
let _ = writeln!(f, "{line}");
}
}
export!(Component);
A handler that traps (panics, overruns its fuel budget) is quarantined — the host logs it, skips that one delivery, and every other subscriber is untouched. Return early on the arms you don't handle; never panic to signal "not for me."
Core plugins: configure, don't enable
Core plugins ship with lattice and are on by default — you don't enable them. auto-pair is a core plugin: auto-pair-mode is active out of the box, gated by a bool option auto-pair.enabled (default true). To configure or disable it, set options at the top level of init.rs — the option exists as soon as the core plugin loads (before your other config runs). (You can also flip the mode live on a single buffer with :auto-pair-mode — the toggle command every registered mode gets; auto-pair.enabled is the editor-wide default.)
fn setup() {
// auto-pair is ON by default. Turn it off:
config::set_option("auto-pair.enabled", "false");
// …or keep it on and switch to the manual close-key style:
config::set_option("auto-pair.style", "manual");
config::set_option("auto-pair.close-key", "<C-l>");
}
You can equally set these in lattice.toml (auto-pair.enabled = false) or live with :set. See core-plugins for the full list and each plugin's options.
Configuring user plugins that load after you
Your init.rs loads first — before the user plugins in ~/.config/lattice/plugins/. So config that targets a user plugin (enable its mode, set its options, bind keys to its commands) can't run at the top level: the plugin isn't there yet. Instead, subscribe and react when it arrives — the with-eval-after-load / lazy-autocmd pattern. There are two moments, and which one you want depends on what you are doing:
fn on_event(handler: u32, ev: Event) {
// OPTIONS — before the plugin has read any of them.
if let (1, Event::PrePluginLoaded(name)) = (handler, &ev) {
if name == "my-plugin" {
config::set_option("my-plugin.option", "value");
}
}
// EVERYTHING ELSE — once the plugin is fully loaded.
if let (2, Event::PluginLoaded(p)) = (handler, ev) {
if p.name == "my-plugin" {
modes::enable_mode("my-plugin-mode");
}
}
}
PrePluginLoaded for options, PluginLoaded for everything else. A plugin may read its own options while it is loading — org builds a theme element and a highlight-query rule per TODO keyword out of org.todo-keywords before its load finishes — and by PluginLoaded those reads have already happened. So an option set there is set too late, silently: the plugin keeps its default and nothing reports a problem. PrePluginLoaded fires after the plugin has declared its options and before it reads any, and the load waits for your handler, so the value is in place when the plugin looks for it. Conversely enable-mode cannot go there — the mode is not registered yet.
Both fire for every plugin, so check the name.
Why a handler at all, rather than a top-level enable_mode(...):
- User-plugin minor modes are available-but-off. A user plugin provides a mode; you enable it — the plugin author doesn't turn it on for you (the emacs global-minor-mode model).
enable-modeflips it on globally and activates it on your open buffers immediately. (Core plugins differ: their<id>.enabledgate turns their default mode on for you — see above.) - The target must exist when you configure it.
PluginLoadedfires after the plugin's seams all registered, soenable-mode/set-optionalways hit a present target. - Graceful by construction. If a plugin never loads (not installed, failed to compile), its
PluginLoadednever fires and your handler never runs — no error, no guard to write. Config for a plugin you removed simply goes dormant.
Plugins still load asynchronously (they never block startup), so an enabled mode becomes active a frame or two after that plugin's cold-start finishes — the same "absent, then present" you feel on emacs/vim startup, never a flicker of wrong state.
Custom grammar: commands, motions, text objects, operators, ex-commands
The grammar seam is where you extend the editing language itself. A motion you add composes with operators (d + your-motion), repeats with counts, and is recordable in macros — because a plugin-contributed motion is registered into the same CommandRegistry a builtin lives in and is indistinguishable to the dispatcher.
You implement two halves of the grammar-plugin world:
register_grammar— declare each contribution (name, doc, spec) with a callback id you choose;- the
grammar-callbacksexports — the behavior, dispatched by that callback id.
Grammar is the one synchronous seam. A motion/operator/text-object
applyruns on the keystroke (sod{motion}stays atomic and dot-repeat works). Keep it cheap arithmetic over the projected context — it runs under a tight fuel budget, and a runaway call is trapped. No I/O, no long loops.
One config with several grammar contributions
// init.rs — a grammar-plugin. plugin.toml: provides = ["grammar"]
wit_bindgen::generate!({ world: "grammar-plugin", path: "wit" });
use exports::lattice::plugin_host::grammar_callbacks::Guest as Callbacks;
use lattice::plugin_host::grammar;
use lattice::plugin_host::types::{
ActionContext, Args, EchoLevel, EchoPayload, Effect, ExCommandContext, LatencyClass,
MotionContext, MotionResult, MotionSpec, OperatorContext, Position, Range, SurfaceForm,
TextObjectContext, TextObjectSpec, OperatorSpec, ActionSpec, ExCommandSpec,
};
struct Component;
// Callback ids — your own dispatch keys, one per contribution.
const MOT_DOWN5: u32 = 1;
const TXO_TO_BOL: u32 = 2;
const OP_SHOUT: u32 = 3;
const ACT_HELLO: u32 = 4;
const EXC_GREET_PARSE: u32 = 5;
const EXC_GREET_APPLY: u32 = 6;
impl Guest for Component {
fn register_grammar() {
// 1. A MOTION: `gld` jumps 5 lines down. Composes with operators — `dgld`
// deletes 5 lines. `jump: true` records it in the jump list.
grammar::register_motion(
"leap-down",
"Jump five lines down",
&MotionSpec { jump: true, exclusive: false, args_schema: Vec::new() },
MOT_DOWN5,
);
// 2. A TEXT OBJECT: "to beginning of line" — usable as an operator target
// (`d{txo}`), resolves to a range the operator consumes.
grammar::register_text_object(
"to-bol",
"From the cursor back to the start of the line",
&TextObjectSpec { args_schema: Vec::new() },
TXO_TO_BOL,
);
// 3. An OPERATOR: acts on a range (from a following motion/text object)
// and returns Effects. `repeatable: true` ⇒ dot-repeatable.
grammar::register_operator(
"shout",
"Uppercase the operated range (demo: echoes instead)",
&OperatorSpec { repeatable: true, args_schema: Vec::new(), blockwise_per_row: false },
OP_SHOUT,
);
// 4. An ACTION: a standalone command bound to a chord/`:` with no motion —
// it just runs and returns Effects.
grammar::register_action(
"say-hello",
"Echo a greeting",
&ActionSpec { args_schema: Vec::new() },
ACT_HELLO,
);
// 5. An EX-COMMAND: `:greet <name>` — two callbacks, one to parse the
// argument string into typed Args, one to apply.
grammar::register_ex_command(
"greet",
"Greet someone: :greet <name>",
&ExCommandSpec {
latency_class: LatencyClass::Reflex,
accepts_bang: false,
accepts_range: false,
args_schema: Vec::new(),
surface_form: SurfaceForm::Keyword,
},
EXC_GREET_PARSE,
EXC_GREET_APPLY,
);
}
}
impl Callbacks for Component {
// A motion returns the target position (+ whether it's linewise). Pure
// arithmetic over the projected context — cursor is `ctx.from`, count is
// `ctx.count` (honor `2gld` → 10 lines).
fn apply_motion(callback: u32, ctx: MotionContext) -> Result<MotionResult, String> {
match callback {
MOT_DOWN5 => Ok(MotionResult {
target: Position { line: ctx.from.line + 5 * ctx.count.max(1), byte: 0 },
linewise: true,
}),
other => Err(format!("no motion {other}")),
}
}
// A text object returns the range it resolved (here: line start → cursor).
fn apply_text_object(callback: u32, ctx: TextObjectContext) -> Result<Range, String> {
match callback {
TXO_TO_BOL => Ok(Range {
start: Position { line: ctx.at.line, byte: 0 },
end: ctx.at,
}),
other => Err(format!("no text object {other}")),
}
}
// An operator receives the resolved `ctx.range` and returns Effects. (This
// demo just echoes; a real one would emit edit Effects over the range.)
fn apply_operator(
callback: u32,
ctx: OperatorContext,
_doc: &Document,
) -> Result<Vec<Effect>, String> {
match callback {
OP_SHOUT => Ok(vec![Effect::Echo(EchoPayload {
level: EchoLevel::Info,
text: format!("SHOUT over {} lines", ctx.range.end.line - ctx.range.start.line + 1),
})]),
other => Err(format!("no operator {other}")),
}
}
fn apply_action(callback: u32, _ctx: ActionContext) -> Result<Vec<Effect>, String> {
match callback {
ACT_HELLO => Ok(vec![Effect::Echo(EchoPayload {
level: EchoLevel::Info,
text: "hello from init.rs".into(),
})]),
other => Err(format!("no action {other}")),
}
}
// Parse `:greet <rest>` — the raw string after the command word — into Args.
fn parse_ex_args(callback: u32, rest: String, _bang: bool) -> Result<Args, String> {
match callback {
EXC_GREET_PARSE => Ok(Args::String(rest.trim().to_string())),
other => Err(format!("no parser {other}")),
}
}
// Apply `:greet` — read the parsed Args from the context, return Effects.
fn apply_ex_command(callback: u32, ctx: ExCommandContext) -> Result<Vec<Effect>, String> {
match callback {
EXC_GREET_APPLY => {
let who = match ctx.args { Args::String(s) if !s.is_empty() => s, _ => "world".into() };
Ok(vec![Effect::Echo(EchoPayload { level: EchoLevel::Info, text: format!("Hello, {who}!") })])
}
other => Err(format!("no ex-command {other}")),
}
}
}
export!(Component);
An apply that returns Err(..) is a graceful no-op (logged, the dispatcher commits nothing) — distinct from a trap. Return errors as values.
Bind your new motion/action to a key with a companion keymap config (next), or invoke a command directly (:greet Dhruva).
Keybindings
The keymap seam binds a chord to an existing command (a builtin, or one your grammar config added), landing in the user keymap layer — above the builtin vim grammar, so your bindings win.
// init.rs — a keymap-plugin. plugin.toml: provides = ["keymap"]
wit_bindgen::generate!({ world: "keymap-plugin", path: "wit" });
use lattice::plugin_host::keymap;
use lattice::plugin_host::keymap::BindingMode;
struct Component;
impl Guest for Component {
fn register_keymap() {
// <C-s> in Normal → :write. The command is named by its registry name.
keymap::register_binding(BindingMode::Normal, "<C-s>", "ex:write");
// <leader>g → the :greet command a grammar config added.
keymap::register_binding(BindingMode::Normal, "<leader>g", "greet");
}
}
export!(Component);
A binding to an unregistered command, or an unparseable chord, is skipped (logged) — it binds nothing rather than mis-binding.
Options
The config seam does two things: register-option declares a new typed option into the same registry :set, :describe-option, and :customize read (no special-casing), and set-option(name, value) overrides an existing option's value — exactly what :set name=value does (the value is type-coerced + validated, and OptionChanged fires). So init.rs is a value-setting config front-end alongside lattice.toml: lattice.toml for static values, init.rs for values you compute or set conditionally (per filetype, on a plugin loading). set-option returns false (a logged no-op) for an unknown option or an invalid value — it never mis-sets.
// init.rs — a config-plugin. plugin.toml: provides = ["config"]
wit_bindgen::generate!({ world: "config-plugin", path: "wit" });
use lattice::plugin_host::config;
use lattice::plugin_host::config::OptionType;
struct Component;
impl Guest for Component {
fn register_options() {
// Now `:set myconfig.greeting=hi` works, and :describe-option shows it.
config::register_option("myconfig.greeting", OptionType::String, "hello", "Default greeting.");
config::register_option("myconfig.max-items", OptionType::Integer, "50", "Cap on items.");
}
}
export!(Component);
A complete annotated init.rs
A realistic config that uses several seams at once. Because it's multi-seam, you declare one combined world with the seams you use (the same pattern a bundled plugin like auto-pair uses) — put this in your own wit/init.wit:
// wit/init.wit
package my:init;
world init {
// Seams you contribute into (you implement their register-* export):
export register-options: func(); // config
export register-keymap: func(); // keymap
export register-events: func(); // events
export on-event: func(handler: u32, ev: event);
// Host functions you call — `config`/`keymap`/`events` you both provide AND
// call; `modes` you only CALL (`enable-mode`), so it's imported, not provided:
import config; import keymap; import events; import modes;
use lattice:plugin-host/types.{event};
}
plugin.toml: provides = ["config", "keymap", "events"] — the seams you register into. (modes is not listed: you don't declare a mode, you only call enable-mode on one another plugin declared.) Read the guest top to bottom — immediate config runs when init.rs loads; a plugin's options run from the PrePluginLoaded handler; the rest of its deferred config runs from the PluginLoaded one.
wit_bindgen::generate!({ world: "init", path: "wit" });
use lattice::plugin_host::keymap::BindingMode;
use lattice::plugin_host::types::{Event, EventFilter, EventKind};
use lattice::plugin_host::{config, events, keymap, modes};
struct Component;
impl Guest for Component {
// ── IMMEDIATE: override option values (the config front-end; also settable
// in lattice.toml, but here you might compute them) ──────────────────────
fn register_options() {
config::set_option("tabstop", "4");
config::set_option("plugin.trace-level", "info");
// CORE plugins (auto-pair) are on by default — configure them here at the
// top level; their options exist as soon as the core plugin loads.
config::set_option("auto-pair.style", "manual"); // manual close-key pairing
// config::set_option("auto-pair.enabled", "false"); // …or turn it off
// set-option returns false (a logged no-op) for an unknown option or an
// invalid value — it never mis-sets.
}
// ── IMMEDIATE: keybindings above the builtin grammar ─────────────────────
fn register_keymap() {
// <C-s> in Normal → :write ; gd → an LSP command (if lsp is loaded).
keymap::register_binding(BindingMode::Normal, "<C-s>", "ex:write");
keymap::register_binding(BindingMode::Normal, "gd", "lsp-definition");
}
// ── Subscribe the DEFERRED + event-flow hooks ────────────────────────────
fn register_events() {
events::subscribe(&kind(EventKind::PrePluginLoaded), 1); // a plugin's OPTIONS
events::subscribe(&kind(EventKind::PluginLoaded), 2); // everything else
events::subscribe(&kind(EventKind::DocumentOpened), 3); // options by filetype
}
fn on_event(handler: u32, ev: Event) {
match (handler, &ev) {
// OPTIONS: a plugin's options exist by now and it has not read any
// of them yet — and the load WAITS for this handler, so a value set
// here reaches an option the plugin consumes while loading.
(1, Event::PrePluginLoaded(name)) => {
if name == "my-linter" {
config::set_option("my-linter.strict", "true");
}
}
// DEFERRED: what needs the plugin fully loaded. `enable-mode` needs
// the mode to be REGISTERED, which has not happened above. (Core
// plugins like auto-pair are on by default — configure them in
// `register_options`, not here.)
(2, Event::PluginLoaded(p)) => {
if p.name == "my-linter" {
modes::enable_mode("my-linter-mode");
}
}
// EVENT FLOW: set options as buffers open (e.g. wrap for markdown).
(3, Event::DocumentOpened(d)) => {
if d.path.as_deref().is_some_and(|p| p.ends_with(".md")) {
config::set_option("wrap", "on");
}
}
_ => {}
}
}
}
fn kind(k: EventKind) -> EventFilter {
EventFilter { kinds: Some(vec![k]), path_globs: None, major_modes: None }
}
export!(Component);
The pattern to internalize, in three tiers: immediate config for what exists at load (option values via set-option, builtin/LSP keymaps via register-binding); PrePluginLoaded for a plugin's own options; and PluginLoaded for anything that needs the plugin fully loaded — enable-mode above all. Handlers call APIs (enable-mode, set-option), never : command strings.
Why the options tier is separate: a plugin may READ its own options while it is still loading. org builds one theme element and one highlight-query rule per TODO keyword out of org.todo-keywords, both at load time — so a PluginLoaded handler setting that option changes a value that has already been used, and the keywords render unstyled. PrePluginLoaded fires in the window between "the plugin declared its options" and "the plugin read them", and the load waits for your handler, so the value is there when the plugin looks. Set options there; do things there is nothing yet to do them to on PluginLoaded.
Both carry the plugin's name, and you must check it — the events fire for every plugin, not only yours. Add a custom command by also provide-ing grammar (see Custom grammar).
Building and installing
Scaffold it: lattice --scaffold-init
The fastest start — let lattice write a buildable starter config for you:
lattice --scaffold-init
This creates ~/.config/lattice/init/ with a complete WASM-component config crate — Cargo.toml, plugin.toml, src/lib.rs (a minimal config: an option, a keybinding, an event handler), and a wit/ copy of this editor's API (so it builds with no separate checkout, matched to your version). It refuses to overwrite an existing config. Then build + install as below (the command prints these steps too):
rustup target add wasm32-wasip2 # once
cd ~/.config/lattice/init
cargo build --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/lattice_init.wasm init.wasm
Edit src/lib.rs, rebuild, and :reload-config. The rest of this section is the manual setup, if you'd rather assemble it yourself.
Manual setup
init.rs is a standalone crate compiled to a component. A minimal setup:
# Cargo.toml — a standalone [workspace] so it doesn't inherit an outer toolchain
[package]
name = "lattice-init"
version = "0.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # a component, not an rlib
[dependencies]
wit-bindgen = "0.58"
[profile.release]
opt-level = "s"
strip = true
[workspace]
Build and install:
rustup target add wasm32-wasip2 # once
cargo build --target wasm32-wasip2 --release
cp target/wasm32-wasip2/release/lattice_init.wasm \
~/.config/lattice/init/init.wasm # place beside plugin.toml
Then in a running editor, :reload-config — no restart. (Or start the editor; it loads init/ at boot.)
Point wit_bindgen::generate!(path: …) at wherever you keep the wit/ package (a copy in your config repo, or a checkout path). Browse the exact signatures for any seam with :describe-plugin-api <seam>, or dump scaffolding with :export-plugin-api markdown.
The safety model
init.rs runs in the same sandbox as any plugin, at the pre-granted Bundled tier (it's your own config):
- Capabilities are deny-by-default. No filesystem beyond your private
/datadir unlessplugin.tomlrequests anfs:prefix; no network, no process spawn. - Fuel + deadline bounded. Every call has a budget; a runaway loop is trapped, not hung. The synchronous
grammarseam has the tightest budget — keepapplycheap. - Crash-isolated. A trap quarantines the config and fires one
PluginCrashedevent; the editor never stalls or crashes. Return errors as values (Err(..)), don't panic.
See plugins for the full model.
Reference
plugins— the plugin substrate, security model, introspection commands.- authoring guide — the per-seam surface in depth, building/testing guests.
:describe-plugin-api <seam>/:list-plugin-apis/:export-plugin-api— the live, self-documenting API in the editor.:reload-config— reloadinit.rswithout restarting.