language

Direction: guest calls into the host through it · Capability: none (pure data / dispatch) · Worlds: language-plugin (imports)

LG.3c: plugin-contributed languages.

A plugin ships a tree-sitter grammar compiled to WebAssembly and the queries that go with it. The language lands in the SAME registry the bundled ones live in, so Lang::detect_from_path selects it by extension, :describe-buffer names it, highlighting, folding, indenting and incremental reparse all run through the ordinary paths — with no host kind-branch anywhere.

The host still owns the parse loop

The guest ships the grammar; it does not run it. WasmStore::load_language turns the bytes into an ordinary tree_sitter::Language, and from that point nothing downstream can tell where the grammar came from. There is no guest call on the keystroke path at all — the plugin is consulted once, at load.

This preserves the rejection recorded in plugin-treesitter-seam.md §9: a text-only seam where the guest re-parses would duplicate the host's live incremental tree. What changes here is only where the grammar comes from, never who runs it.

Data, not a callback

A language is a static description. Nothing about the guest needs to be alive once the bytes and query sources are across, so the store is dropped when registration returns — the help seam's shape and reasoning, not dashboard's live sections.

Where it runs

Once per load, on the loader's off-boot-thread task. Compiling a grammar costs ~100 ms (Cranelift), which is exactly why it happens here and once, rather than on first open of a matching file.

Functions (1)

register-language

register-language: func(spec: language-spec) -> result<_, string>

Register one language.

err when the grammar fails to load or a query fails to compile, carrying a reason that names the language and the offending query. Never a trap, and never a half-registered language: a rejected language costs itself and nothing else, so the plugin's other contributions — including its other languages — still register and the load still succeeds.

Example — Register a language whose grammar wasm the plugin embeds at build time (spec is the language-spec example) · crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs

// The real one: registered as `lg3c-md`, loaded from the grammar's
// own `tree_sitter_markdown` export via `grammar-name`.
let _ = register_language(&spec("lg3c-md", &["lg3cmd"], GRAMMAR.to_vec()));

Types (2)

record conceal-rule

record conceal-rule {
    pattern: string,
    hide: list<u32>,
    slot: option<string>,
}

H.2: one display-time elision rule.

The host compiles the pattern once at registration and matches it against each display line during a matrix rebuild — never per frame, and never for a language that declares none. See docs/dev/architecture/conceal.md.

The engine is RE2-style and cannot backtrack. That is a property, not an implementation note: these patterns come from a plugin and run over every rebuilt line, so an engine with a pathological input would be a plugin's ability to freeze the renderer. Lookaround and backreferences are therefore unavailable, and a pattern using them is refused at registration rather than being slow later.

Fields

  • pattern: string — Regex matched against a single line. Anchoring is the rule's business; the host adds none.

  • hide: list<u32> — 1-based capture-group indices whose spans are hidden.

    Group 0 (the whole match) is REFUSED — a rule that hides its entire match is a deletion rather than a concealment, and is almost always a pattern that forgot its capture parentheses. An index the pattern has no group for is refused too, at registration, because checking it per line would log at rebuild rate.

  • slot: option<string> — OL.1: a capture or theme-element NAME for what stays VISIBLE in each match. none — every rule before this — conceals only.

    Per-RULE, because conceal is a general mechanism and most rules hide punctuation that means nothing on its own. Only a rule whose REMAINDER is a thing — an org link's description — declares a style, so adding a conceal rule for something else cannot accidentally paint it.

    "What survives concealment" is the styling unit rather than a named group, and that is what lets one declaration serve both org link forms: [[t][d]] hides the brackets and target and styles d; [[t]] hides the brackets and styles t. The mechanism that decides where a link IS also decides what to paint — two mechanisms could disagree about that, and a link that renders as prose is a link nobody can see is under the cursor.

    Resolved at REGISTRATION, through the same path a highlights.scm capture name takes, so a concealed link follows a colourscheme swap exactly as a heading does. A name that resolves to nothing conceals without painting rather than failing the rule.

record language-spec

record language-spec {
    name: string,
    extensions: list<string>,
    grammar-name: option<string>,
    grammar: list<u8>,
    highlights: option<string>,
    folds: option<string>,
    injections: option<string>,
    indents: option<string>,
    textobjects: option<string>,
    conceal-rules: list<conceal-rule>,
}

Everything the host needs to make a language real.

Fields

  • name: string — The grammar's tree-sitter name — org for tree_sitter_org. Becomes the language id :set filetype and :describe-buffer report, and the key its queries are cached under. MUST match the grammar's exported entry point or the module loads with nothing to call.

    A name that collides with a bundled language is REFUSED, not shadowed: a plugin silently replacing rust would be a miserable thing to debug. Claiming a bundled extension is allowed but never wins, because the native table is consulted first.

  • extensions: list<string> — ["org"] — extensions that select this language, matched case-insensitively, with or without a leading dot. A language with none can never be selected and is refused as a manifest mistake.

  • grammar-name: option<string> — The grammar's own entry-point name, when it differs from name.

    load_language finds a grammar by its tree_sitter_<x> export, and that is not always what users call the language. lattice's own bundled sql is the case in point: the grammar is tree-sitter-sequel and exports tree_sitter_sequel, while the language everyone types is sql. Absent means "same as name", which is the common case.

  • grammar: list<u8> — The grammar, compiled to wasm. tree-sitter build --wasm produces one; so does scripts/build-wasm-grammar.sh with nothing but clang and a rustup toolchain.

  • highlights: option<string> — Tree-sitter queries, as source. Absent means that feature is simply unavailable for this language — never an error.

    All of these compile at REGISTRATION, not first use. A malformed query is the plugin author's mistake and must surface at load with the offending query named; compiling lazily would turn a typo in folds.scm into "folding silently does nothing", which is indistinguishable from the feature not existing and surfaces days later.

  • folds: option<string>

  • injections: option<string>

  • indents: option<string>

  • textobjects: option<string>

  • conceal-rules: list<conceal-rule> — H.2: what to hide when rendering this language, empty for most languages.

    A refused rule is dropped, not fatal — deliberately unlike a malformed query above, which rejects the whole language. The asymmetry is the proportionality: a broken folds.scm means the language cannot fold at all, and silence there is indistinguishable from the feature not existing; a broken conceal rule means one pattern does not hide, the others are unaffected, and the language is otherwise entirely usable. Losing a language over a typo in a cosmetic regex would cost far more than it protects.

Example — Describe a language: grammar wasm built by the plugin, its extensions and a highlights query · crates/lattice-plugin-host/tests/fixtures/language-guest/src/lib.rs

fn spec(name: &str, exts: &[&str], grammar: Vec<u8>) -> LanguageSpec {
    LanguageSpec {
        name: name.to_string(),
        // The fixture's grammar exports `tree_sitter_markdown`, but `markdown`
        // is a BUNDLED language name and is refused. Splitting the two is
        // exactly what `grammar-name` is for, and the same split lattice's own
        // `sql`-on-`sequel` needs.
        grammar_name: Some("markdown".to_string()),
        extensions: exts.iter().map(|s| (*s).to_string()).collect(),
        grammar,
        highlights: Some(HIGHLIGHTS.to_string()),
        folds: None,
        injections: None,
        indents: None,
        textobjects: None,
        conceal_rules: vec![],
    }
}