lex-extension-host 0.17.1

Runtime for the Lex extension system: registry, transports, trust gate, sandboxing
Documentation

Runtime for the Lex extension system.

This crate hosts the registry, schema loader, namespace URI resolver, transport adapters (subprocess, future WASM), and trust gate that turn a set of [lex_extension::Schema]s plus [lex_extension::LexHandler] implementations into a dispatch fabric the lexd CLI, lex-lsp server, lex-core (for built-in lex.* resolvers), and Rust embedders all share.

Pre-1.0 the public API surface is unstable per Cargo convention. The crate is published so that downstream crates in the lex toolchain — especially lex-core, which carries the lex.include resolver as the first built-in LexHandler — can depend on it. Handler authors should depend on lex-extension, not this crate.

Writing a handler — the unified registration pattern (#615)

Extension authors register one [lex_extension::Schema] per label, attach the lifecycle hooks that label participates in, and provide one [lex_extension::LexHandler] implementation per namespace. The Registry routes each hook to the right method by namespace + label:

use lex_extension::{LexHandler, Format, RenderOut, WireNode};
use lex_extension::handler::HandlerError;
use lex_extension::wire::LabelCtx;
use lex_extension::schema::{HookSet, RenderHook, Schema};
use lex_extension_host::Registry;

struct AcmeHandler;
impl LexHandler for AcmeHandler {
    // IR-construction lifecycle: hydrate verbatim payloads
    // (`:: acme.table ::`, `:: acme.image ::`) into typed wire
    // nodes the host's IR builder consumes.
    fn on_ir_build(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
        match ctx.label.as_str() {
            "acme.thing" => Ok(Some(WireNode::Verbatim { /* ... */ })),
            _ => Ok(None),
        }
    }
    // Pre-serialisation lifecycle: emit the format-specific
    // representation (markdown, HTML, ...). One handler can
    // participate in both IR-build and render against the same
    // schema — a single registration, both lifecycles.
    fn on_render(&self, ctx: &LabelCtx, fmt: Format) -> Result<Option<RenderOut>, HandlerError> {
        /* ... */
        Ok(None)
    }
}

let registry = Registry::new();
registry.register_namespace(
    "acme",
    vec![Schema {
        schema_version: 1,
        label: "acme.thing".into(),
        hooks: HookSet {
            ir_build: true,                              // declare IR-build participation
            render: vec![RenderHook::new("html")],       // declare render participation
            ..HookSet::default()
        },
        /* ... rest of Schema ... */
#       description: None, params: Default::default(), attaches_to: vec![],
#       body: Default::default(), verbatim_label: false,
#       capabilities: Default::default(), handler: None, diagnostics: Vec::new(),
    }],
    Box::new(AcmeHandler),
).expect("registration ok");

Lifecycle hooks

Three hook surfaces, each on its own lifecycle phase:

Hook Lifecycle phase Dispatch entry point Built-in example
on_resolve AST substitution [Registry::dispatch_resolve] lex.include
on_ir_build IR construction [Registry::dispatch_ir_build] lex.tabular.table, lex.media.*
on_render Pre-serialisation [Registry::dispatch_render] doc.title, doc.author, ...

on_resolve and on_ir_build have the same shape (Result<Option<WireNode>, HandlerError>); they're separate hooks because they fire at different lifecycle phases and have different consumer contracts. on_resolve returns a wire node spliced into the host AST; on_ir_build returns a wire node consumed by the IR builder. Pre-#615 these were a single overloaded hook (on_resolve); the unified registry surface separates them so extension authors can declare exactly the lifecycle phase they participate in.

What's in this crate

  • [Registry] — namespace registration, label lookup, and dispatch helpers wrapping every hook event with HandlerError folding and panic catch.
  • [schema::SchemaLoader] — YAML schema loader + post-deserialise validator.
  • [transport::native] — the trivial transport: a registered Box<dyn LexHandler> is its own transport, no adapter required.
  • [transport::subprocess] (behind the subprocess feature) — spawn a handler binary and dispatch over LSP-framed JSON-RPC.
  • [trust::TrustGate] — decides whether a handler is allowed to run, per the β/γ-correct policy in the master tracking issue (subprocess always prompts; native trusted by linkage).
  • [sandbox::Sandbox] — OS-level enforcement facade. The plumbing-PR default is [sandbox::NullSandbox] (no enforcement, available() == false). Per-OS implementations land in follow-up PRs (12a Linux, 12b macOS, 12c Windows); the trust matrix flip (PR 12d) consumes [Sandbox::available] to auto-trust declared-pure handlers under enforced sandboxing.

Coming in later PRs:

  • PR 12a/b/c: per-OS sandbox enforcement.
  • PR 12d: trust matrix flip (auto-trust pure handlers under enforced sandbox).