Skip to main content

lattice_plugin_sdk/
lib.rs

1//! The guest-side Rust SDK for lattice plugins: typed event payloads, typed
2//! options and typed configuration shapes layered over the plugin-host WIT
3//! wire. Compiled INTO plugins (Rust today; other component-model languages
4//! use the WIT directly), never into the host.
5//!
6//! ## What it owns
7//!
8//! The WIT is the plugin API; this crate adds **zero** capability that is not
9//! on the wire — only ergonomics a Rust author would otherwise hand-write. The
10//! plugin-host `emit-event` / `register-event` host-services (PH7.8b.2) carry
11//! `name: string` + `payload: list<u8>` — opaque MessagePack the host never
12//! interprets. That is deliberate (the boundary discipline the whole host rests
13//! on), but raw bytes are a poor author API. This crate adds the type-safe layer:
14//!
15//!   - [`PluginEvent`] — a trait pairing a compile-time `NAME` + `DOC` with
16//!     MessagePack `encode` / `decode`.
17//!   - `#[derive(PluginEvent)]` — derives all four from a serde struct: `DOC`
18//!     from the struct's `///` doc-comment (the doc-comment IS the event doc),
19//!     `NAME` from `#[event(name = "...")]` or the kebab-cased type name.
20//!   - [`try_decode`] — the subscriber-side helper: name-gate + decode in one.
21//!   - [`PluginOption`] + `#[derive(PluginOption)]` + [`parse_option`] — the
22//!     same shape for scalar options (`bool` / `i64` / `String`).
23//!   - [`shape`] — [`shape::ConfigShape`] + `#[derive(ConfigShape)]`: a Rust
24//!     struct as a structured config schema and value, and the arena
25//!     flattening ([`shape::flatten_schema`], [`shape::flatten_value`],
26//!     [`shape::unflatten_value`]) the WIT seam needs.
27//!
28//! ## What it must not depend on
29//!
30//! No `lattice-*` runtime crate and no `wit-bindgen` bindings — only `serde`,
31//! `rmp-serde` and its own derive. Two structural reasons: it is published and
32//! versioned for out-of-tree plugin authors, so it cannot drag the editor in;
33//! and it must compose with EVERY plugin world, which it can only do by naming
34//! none of their generated types. It is a separate crate because it is the one
35//! piece of lattice that compiles into guests.
36//!
37//! ## WIT-agnostic by design (approach A)
38//!
39//! This crate touches **no** plugin-host bindings — it is pure serde + a derive.
40//! The host calls stay plugin-side one-liners using the derived constants
41//! (`host_services` below stands in for a plugin's generated bindings):
42//!
43//! ```
44//! use lattice_plugin_sdk::{DecodeError, PluginEvent};
45//! use serde::{Deserialize, Serialize};
46//! # mod host_services {
47//! #     pub fn register_event(_name: &str, _doc: &str) {}
48//! #     pub fn emit_event(_name: &str, _payload: &[u8]) {}
49//! # }
50//!
51//! /// The indexer finished scanning a file.
52//! #[derive(Debug, PartialEq, Serialize, Deserialize, PluginEvent)]
53//! #[event(name = "indexer.file-scanned")]
54//! struct FileScanned {
55//!     path: String,
56//!     symbols: u32,
57//! }
58//!
59//! // at register-events:
60//! host_services::register_event(FileScanned::NAME, FileScanned::DOC);
61//! // to emit:
62//! let ev = FileScanned { path: "src/lib.rs".into(), symbols: 42 };
63//! let payload = ev.encode();
64//! host_services::emit_event(FileScanned::NAME, &payload);
65//!
66//! // in another plugin's on-event(name, payload):
67//! # fn on_event(name: &str, payload: &[u8]) -> Result<Option<FileScanned>, DecodeError> {
68//! if let Some(ev) = lattice_plugin_sdk::try_decode::<FileScanned>(name, payload) {
69//!     let ev = ev?; // a real FileScanned
70//!     return Ok(Some(ev));
71//! }
72//! # Ok(None)
73//! # }
74//! assert_eq!(on_event("indexer.file-scanned", &payload), Ok(Some(ev)));
75//! assert_eq!(FileScanned::DOC, "The indexer finished scanning a file.");
76//! ```
77//!
78//! Because the SDK is world-agnostic it composes with EVERY plugin world (events,
79//! grammar, completion, …) unchanged — it is the seed the other SDK seams reuse.
80//! A fuller `ctx.emit(ev)` / `on_event::<E>()` sugar can layer on once a real
81//! multi-world plugin exists to shape the host-call binding.
82//!
83//! ## Cross-plugin contracts
84//!
85//! Because a `PluginEvent` type is just a serde struct, plugin A can publish its
86//! event types in a shared crate and plugin B can depend on it — a
87//! compile-checked, versioned event contract (the coordinating-plugins use case).
88//!
89//! ## Design
90//!
91//! - `docs/dev/architecture/plugin-host.md` — the host the wire talks to, and
92//!   the events / config seams this crate types.
93//! - `docs/dev/architecture/typed-configuration.md` — [`shape`] and the arena
94//!   encoding.
95//! - `docs/dev/guides/plugin-authoring.md` — end-to-end plugin authoring.
96
97#![warn(missing_docs)]
98
99// So the derive's generated `::lattice_plugin_sdk::..` paths resolve inside this
100// crate's own tests (the `lattice-config` / serde precedent for a crate that
101// consumes its own derive).
102extern crate self as lattice_plugin_sdk;
103
104pub use lattice_plugin_sdk_derive::{ConfigShape, PluginEvent, PluginOption};
105
106pub mod shape;
107
108/// A plugin-defined event: a typed view over the opaque `emit-event` /
109/// `on-event` wire (PH7.8b.2). Implement via `#[derive(PluginEvent)]` on a
110/// serde-serializable struct; hand-implementing is possible but rarely needed.
111///
112/// `NAME` is the wire identifier (matched by subscribers, registered via
113/// `register-event`); `DOC` is the human summary surfaced in `:describe-event`.
114/// `encode` / `decode` round-trip the payload as MessagePack.
115///
116/// The derive requires the struct to implement serde's `Serialize` and
117/// `Deserialize`; its `NAME` defaults to the kebab-cased type name
118/// (`MyCustomEvent` → `my-custom-event`, acronyms degrade per letter), so real
119/// plugins namespace it explicitly with `#[event(name = "plugin.event")]`.
120///
121/// # Examples
122///
123/// ```
124/// use lattice_plugin_sdk::PluginEvent;
125/// use serde::{Deserialize, Serialize};
126///
127/// /// Kebab-name fallback event.
128/// #[derive(Debug, PartialEq, Serialize, Deserialize, PluginEvent)]
129/// struct MyCustomEvent {
130///     value: i64,
131/// }
132///
133/// assert_eq!(MyCustomEvent::NAME, "my-custom-event");
134/// assert_eq!(MyCustomEvent::DOC, "Kebab-name fallback event.");
135///
136/// let bytes = MyCustomEvent { value: 7 }.encode();
137/// assert_eq!(MyCustomEvent::decode(&bytes), Ok(MyCustomEvent { value: 7 }));
138/// // A payload for some other type is a typed error, never a panic.
139/// assert!(MyCustomEvent::decode(&[0xc0]).is_err());
140/// ```
141pub trait PluginEvent: Sized {
142    /// The event's wire name — the identifier crossed to `emit-event` and
143    /// matched by subscribers (e.g. `"git.hunks-changed"`).
144    const NAME: &'static str;
145    /// The human-facing doc (from the struct's `///` comment), shown by
146    /// `:describe-event` once registered.
147    const DOC: &'static str;
148
149    /// Serialize to the opaque MessagePack payload `emit-event` carries.
150    ///
151    /// # Panics
152    ///
153    /// The derived impl panics only if the type's `Serialize` impl itself
154    /// errors, which a plain derived serde struct never does; a hand-written
155    /// `Serialize` that can fail is treated as a programming bug.
156    fn encode(&self) -> Vec<u8>;
157
158    /// Deserialize from a payload received on `on-event`. A malformed / mistyped
159    /// payload is a typed [`DecodeError`], never a panic.
160    fn decode(bytes: &[u8]) -> Result<Self, DecodeError>;
161}
162
163/// A failed [`PluginEvent::decode`] — the payload was not valid MessagePack for
164/// the target type (wrong event type, version skew, corruption). Carries the
165/// underlying decoder message; opaque and stable (it hides the serde impl).
166///
167/// `Display` renders `plugin event decode failed: <message>`.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct DecodeError(
170    /// The decoder's own message. Diagnostic text for logs, not a stable
171    /// format to match on.
172    pub String,
173);
174
175impl std::fmt::Display for DecodeError {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        write!(f, "plugin event decode failed: {}", self.0)
178    }
179}
180
181impl std::error::Error for DecodeError {}
182
183/// Subscriber-side helper: if `name` names event `E`, decode `payload` into it;
184/// otherwise `None` (the event is for a different subscriber). Folds the
185/// name-gate the guest would otherwise write by hand in `on-event` into one call.
186///
187/// The name match is exact (case-sensitive, no prefix matching); when it fails
188/// the payload is not looked at.
189///
190/// # Examples
191///
192/// ```
193/// use lattice_plugin_sdk::{PluginEvent, try_decode};
194/// use serde::{Deserialize, Serialize};
195///
196/// /// A project finished indexing.
197/// #[derive(Debug, PartialEq, Serialize, Deserialize, PluginEvent)]
198/// #[event(name = "indexer.indexed")]
199/// struct Indexed { files: u32 }
200///
201/// let payload = Indexed { files: 3 }.encode();
202/// match try_decode::<Indexed>("indexer.indexed", &payload) {
203///     Some(Ok(ev)) => assert_eq!(ev.files, 3),
204///     Some(Err(e)) => panic!("our event, but a bad payload: {e}"),
205///     None => panic!("not our event"),
206/// }
207/// // Some other plugin's event: not decoded at all.
208/// assert_eq!(try_decode::<Indexed>("git.hunks-changed", &payload), None);
209/// // Our name, garbage bytes: a typed error.
210/// assert!(matches!(try_decode::<Indexed>("indexer.indexed", &[0xc1]), Some(Err(_))));
211/// ```
212pub fn try_decode<E: PluginEvent>(name: &str, payload: &[u8]) -> Option<Result<E, DecodeError>> {
213    (name == E::NAME).then(|| E::decode(payload))
214}
215
216/// A plugin-defined scalar option — a typed view over the `config`
217/// register/read wire (slice PH7.10b). Implement via `#[derive(PluginOption)]`
218/// on a newtype over `bool` / `i64` / `String`; `#[option(default = "...")]` is
219/// required, `#[option(name = "...")]` defaults to the kebab-cased type name.
220/// For structured (record / list / enum) options use [`shape::ConfigShape`]
221/// instead.
222///
223/// It is **WIT-agnostic** (approach A): the derive only supplies these constants
224/// plus the value type. The plugin makes the `config.register-option` /
225/// `config.get-option` WIT calls itself, mapping [`OptionKind`] to the generated
226/// `option-type` (`config` below stands in for a plugin's generated bindings):
227///
228/// ```
229/// use lattice_plugin_sdk::{OptionKind, PluginOption, parse_option};
230/// # mod config {
231/// #     pub enum OptionType { Boolean, Integer, String }
232/// #     pub fn register_option(_: &str, _: OptionType, _: &str, _: &str) {}
233/// #     pub fn get_option(_: &str) -> Option<String> { Some("5".into()) }
234/// # }
235///
236/// /// How many things the plugin tracks.
237/// #[derive(PluginOption)]
238/// #[option(name = "myplugin.count", default = "3")]
239/// struct Count(i64);
240///
241/// // The one per-plugin mapping to the generated WIT enum.
242/// fn wit_ty(kind: OptionKind) -> config::OptionType {
243///     match kind {
244///         OptionKind::Boolean => config::OptionType::Boolean,
245///         OptionKind::Integer => config::OptionType::Integer,
246///         OptionKind::String => config::OptionType::String,
247///     }
248/// }
249///
250/// config::register_option(Count::NAME, wit_ty(Count::KIND), Count::DEFAULT, Count::DOC);
251/// let value: i64 = parse_option::<Count>(&config::get_option(Count::NAME).unwrap()).unwrap();
252///
253/// assert_eq!(value, 5);
254/// assert_eq!(Count::NAME, "myplugin.count");
255/// assert_eq!(Count::KIND, OptionKind::Integer);
256/// assert_eq!(Count::DOC, "How many things the plugin tracks.");
257/// ```
258pub trait PluginOption {
259    /// The option's registry name (matched by `:set`, shown in `:describe-option`).
260    const NAME: &'static str;
261    /// The human-facing doc (from the struct's `///` comment).
262    const DOC: &'static str;
263    /// The initial value as a string (parsed host-side via the native `OptionType`).
264    const DEFAULT: &'static str;
265    /// The value type — maps to the WIT `option-type` when registering.
266    const KIND: OptionKind;
267    /// The Rust value type (`bool` / `i64` / `String`), parsed from a
268    /// `get-option` string via [`parse_option`].
269    type Value: std::str::FromStr;
270}
271
272/// The value type of a plugin option — the WIT-agnostic mirror of the `config`
273/// interface's `option-type` enum. The plugin maps this to the generated
274/// `option-type` at the `register-option` call site (the SDK can't name the
275/// per-world WIT type — approach A).
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum OptionKind {
278    /// A `bool` option; the derive picks it for a `bool` field.
279    Boolean,
280    /// A signed integer option; the derive picks it for an `i64` field.
281    Integer,
282    /// A free-form string option; the derive picks it for a `String` field.
283    String,
284}
285
286/// Parse a `get-option` result string into the option's typed value (slice
287/// PH7.10b). `get-option` returns the value formatted by the native
288/// `OptionType`; this reads it back into `O::Value` via its `FromStr`.
289///
290/// # Errors
291///
292/// A malformed string is a typed [`OptionParseError`] carrying the `FromStr`
293/// error's message, never a panic.
294///
295/// # Examples
296///
297/// ```
298/// use lattice_plugin_sdk::{PluginOption, parse_option};
299///
300/// /// Whether long lines wrap.
301/// #[derive(PluginOption)]
302/// #[option(default = "true")]
303/// struct WrapLines(bool);
304///
305/// assert_eq!(WrapLines::NAME, "wrap-lines");
306/// assert_eq!(parse_option::<WrapLines>("false"), Ok(false));
307/// assert!(parse_option::<WrapLines>("yes").is_err());
308/// ```
309pub fn parse_option<O: PluginOption>(s: &str) -> Result<O::Value, OptionParseError>
310where
311    <O::Value as std::str::FromStr>::Err: std::fmt::Display,
312{
313    s.parse::<O::Value>()
314        .map_err(|e| OptionParseError(e.to_string()))
315}
316
317/// A failed [`parse_option`] — the `get-option` string didn't parse for the
318/// option's value type. Carries the underlying parser message.
319///
320/// `Display` renders `plugin option parse failed: <message>`.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct OptionParseError(
323    /// The value type's `FromStr` error message, e.g. `invalid digit found in
324    /// string`. Diagnostic text, not a stable format to match on.
325    pub String,
326);
327
328impl std::fmt::Display for OptionParseError {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        write!(f, "plugin option parse failed: {}", self.0)
331    }
332}
333
334impl std::error::Error for OptionParseError {}
335
336/// Implementation detail used by the generated `#[derive(PluginEvent)]` code so a
337/// consumer depends only on `lattice-plugin-sdk` (the SDK owns the `rmp-serde`
338/// dependency, not every plugin). Not part of the stable API.
339#[doc(hidden)]
340pub mod __private {
341    use super::DecodeError;
342
343    /// MessagePack-encode a derived event. Infallible for the derived case (a
344    /// plain serde struct never errors on serialize); a hand-rolled `Serialize`
345    /// that errors is a programming bug surfaced as a panic, not silent data loss.
346    pub fn encode<T: serde::Serialize>(value: &T) -> Vec<u8> {
347        rmp_serde::to_vec(value)
348            .expect("PluginEvent MessagePack encoding is infallible for derived structs")
349    }
350
351    /// MessagePack-decode a derived event, mapping any decoder error to the
352    /// SDK's opaque [`DecodeError`].
353    pub fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, DecodeError> {
354        rmp_serde::from_slice(bytes).map_err(|e| DecodeError(e.to_string()))
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    // The `PluginOption` marker newtypes carry their value type for the derive
361    // but the tests read only the derived constants, so the field is unused.
362    #![allow(clippy::unwrap_used, clippy::panic, dead_code)]
363
364    use super::*;
365    use serde::{Deserialize, Serialize};
366
367    /// A file the indexer finished scanning.
368    ///
369    /// Second doc line.
370    #[derive(Debug, PartialEq, Serialize, Deserialize, PluginEvent)]
371    #[event(name = "indexer.file-scanned")]
372    struct FileScanned {
373        path: String,
374        symbols: u32,
375    }
376
377    /// Kebab-name fallback event.
378    #[derive(Debug, PartialEq, Serialize, Deserialize, PluginEvent)]
379    struct MyCustomEvent {
380        value: i64,
381    }
382
383    #[test]
384    fn explicit_name_and_doc_come_from_the_attrs() {
385        assert_eq!(FileScanned::NAME, "indexer.file-scanned");
386        // The multi-line doc-comment is captured, joined, and trimmed.
387        assert_eq!(
388            FileScanned::DOC,
389            "A file the indexer finished scanning.\n\nSecond doc line."
390        );
391    }
392
393    #[test]
394    fn name_defaults_to_the_kebab_cased_type_name() {
395        assert_eq!(MyCustomEvent::NAME, "my-custom-event");
396        assert_eq!(MyCustomEvent::DOC, "Kebab-name fallback event.");
397    }
398
399    #[test]
400    fn encode_decode_round_trips() {
401        let ev = FileScanned {
402            path: "src/lib.rs".into(),
403            symbols: 42,
404        };
405        let bytes = ev.encode();
406        let back = FileScanned::decode(&bytes).unwrap();
407        assert_eq!(ev, back, "MessagePack round-trips the struct");
408    }
409
410    #[test]
411    fn try_decode_gates_on_the_event_name() {
412        let ev = FileScanned {
413            path: "a.rs".into(),
414            symbols: 1,
415        };
416        let payload = ev.encode();
417
418        // Matching name → Some(Ok(..)).
419        let got = try_decode::<FileScanned>("indexer.file-scanned", &payload);
420        assert_eq!(got, Some(Ok(ev)));
421
422        // Different name → None (not this subscriber's event; no decode attempted).
423        assert_eq!(try_decode::<FileScanned>("other.event", &payload), None);
424    }
425
426    #[test]
427    fn decode_of_a_bad_payload_is_a_typed_error() {
428        // Garbage bytes that are not valid MessagePack for the struct.
429        let err = FileScanned::decode(&[0xff, 0x00, 0x01]).unwrap_err();
430        assert!(
431            format!("{err}").contains("decode failed"),
432            "decode surfaces a typed error, never a panic: {err}"
433        );
434    }
435
436    /// How wide a tab is rendered.
437    #[derive(PluginOption)]
438    #[option(name = "editor.tab-width", default = "8")]
439    struct TabWidth(i64);
440
441    /// Whether long lines wrap.
442    #[derive(PluginOption)]
443    #[option(default = "true")]
444    struct WrapLines(bool);
445
446    #[test]
447    fn option_derive_captures_name_doc_default_and_kind() {
448        assert_eq!(TabWidth::NAME, "editor.tab-width");
449        assert_eq!(TabWidth::DOC, "How wide a tab is rendered.");
450        assert_eq!(TabWidth::DEFAULT, "8");
451        assert_eq!(TabWidth::KIND, OptionKind::Integer);
452        // NAME defaults to the kebab-cased type name; KIND from the field type.
453        assert_eq!(WrapLines::NAME, "wrap-lines");
454        assert_eq!(WrapLines::KIND, OptionKind::Boolean);
455    }
456
457    #[test]
458    fn parse_option_reads_typed_values_and_errors_typed() {
459        assert_eq!(parse_option::<TabWidth>("7").unwrap(), 7_i64);
460        assert!(parse_option::<WrapLines>("true").unwrap());
461        // A malformed string is a typed error, never a panic.
462        let err = parse_option::<TabWidth>("not-a-number").unwrap_err();
463        assert!(format!("{err}").contains("parse failed"));
464    }
465}