lattice_plugin_host/events_host.rs
1//! The event/hook guest world (PH7.8b).
2//!
3//! An event-observing plugin implements the `events-plugin` world: it **imports**
4//! the `events` subscription API (host-provided) and `host-services`, and
5//! **exports** `register-events` (the host calls it once to drive subscription
6//! registration) and `on-event` (host→guest delivery). This module holds the
7//! **fifth `bindgen!`** (after `plugin`, `picker-source-plugin`,
8//! `completion-source-plugin`, `grammar-plugin`) for that world — the
9//! shared-types trick (`with:` points
10//! `types` + `host-services` at the `plugin` world's generated modules so a
11//! crossed `event` value is the SAME Rust type `WitBoundary` round-trips,
12//! `boundary_event.rs`; PH7.3d precedent).
13//!
14//! **Async (unlike grammar).** Event delivery is OFF the keystroke path: the host
15//! owns an mpsc and pushes each `event` to `on-event` on the plugin's own task
16//! (§5.10.4). So the `bindgen!` sets `exports: { default: async }` — an `on-event`
17//! call suspends the guest stack, never pins the caller's thread, and a slow
18//! handler can never freeze a keystroke or another subscriber (paramount #4). The
19//! per-plugin actor that drains the bus channel and drives `on-event` is the
20//! `event_task` bridge (PH7.8c).
21//!
22//! Registration flow (the grammar `register-grammar` precedent): the host calls
23//! the guest's `register-events` export; the guest calls the imported
24//! `events.subscribe(filter, handler)` host function; that records the
25//! declaration into the Store's [`EventContributions`] (via the `events::Host`
26//! impl on `PluginState`, `lib.rs`); after the export returns, the host drains the
27//! subscriptions and wires each to the native `EventBus` (PH7.8c).
28
29use crate::lattice::plugin_host::types::EventFilter as WitEventFilter;
30
31pub(crate) mod bindings {
32 wasmtime::component::bindgen!({
33 world: "events-plugin",
34 path: "../lattice-wit/wit",
35 // Event delivery (`on-event`) is async — off the keystroke path, a
36 // delivery suspends the guest, never pins the caller's thread.
37 exports: { default: async },
38 with: {
39 // Reuse the `plugin` world's generated mirrors so a crossed value is
40 // the same Rust type `WitBoundary` round-trips; `host-services` reuses
41 // the already-wired `Host` impl (the completion/picker precedent).
42 "lattice:plugin-host/types": crate::lattice::plugin_host::types,
43 "lattice:plugin-host/host-services": crate::lattice::plugin_host::host_services,
44 "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
45 },
46 });
47}
48
49/// One subscription a plugin declared through `events.subscribe`, recorded
50/// verbatim (the guest-chosen `handler` id + the WIT `filter`). The host drains
51/// these after `register-events` returns and wires each to the native
52/// `EventBus` (PH7.8c); the filter projects to native at wire time via
53/// [`boundary_event::project_event_filter`](crate::boundary_event::project_event_filter).
54pub struct RecordedSubscription {
55 /// The guest's own dispatch key — passed back to `on-event(handler, ev)`.
56 pub handler: u32,
57 /// The declarative filter the plugin subscribed with (WIT form; projected to
58 /// the native `EventFilter` when wired to the bus).
59 pub filter: WitEventFilter,
60}
61
62/// The per-plugin accumulator the `events::Host` impl records into during
63/// `register-events` (`lib.rs`). Held in `PluginState`; drained by the host
64/// after the registration export returns (PH7.8c). `record` is the sync
65/// host-func body (it only pushes — it cannot trap), factored here (the
66/// [`GrammarContributions`](crate::grammar_host::GrammarContributions) precedent)
67/// so recording is unit-testable without a `PluginState` / guest.
68#[derive(Default)]
69pub struct EventContributions {
70 recorded: Vec<RecordedSubscription>,
71}
72
73impl EventContributions {
74 /// Record a subscription (the `events.subscribe` host-func body).
75 pub fn record(&mut self, filter: WitEventFilter, handler: u32) {
76 self.recorded.push(RecordedSubscription { handler, filter });
77 }
78
79 /// How many subscriptions were recorded.
80 pub fn len(&self) -> usize {
81 self.recorded.len()
82 }
83
84 /// True when the plugin subscribed to nothing (the degenerate case — a
85 /// non-observing plugin, or one whose `register-events` is empty).
86 pub fn is_empty(&self) -> bool {
87 self.recorded.is_empty()
88 }
89
90 /// Drain the recorded subscriptions, leaving the accumulator empty. Called by
91 /// the host after `register-events` returns (PH7.8c) to wire the bus.
92 pub fn take(&mut self) -> Vec<RecordedSubscription> {
93 std::mem::take(&mut self.recorded)
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 #![allow(clippy::unwrap_used, clippy::panic)]
100
101 use super::*;
102 use crate::lattice::plugin_host::types::EventKind as WitEventKind;
103
104 fn filter(kind: WitEventKind) -> WitEventFilter {
105 WitEventFilter {
106 kinds: Some(vec![kind]),
107 path_globs: None,
108 major_modes: None,
109 minor_modes: None,
110 }
111 }
112
113 #[test]
114 fn records_subscriptions_and_preserves_handler_ids() {
115 let mut e = EventContributions::default();
116 assert!(e.is_empty());
117
118 e.record(filter(WitEventKind::DocumentSaved), 1);
119 e.record(filter(WitEventKind::BeforeQuit), 7);
120 assert_eq!(e.len(), 2);
121
122 let drained = e.take();
123 assert!(e.is_empty(), "take() leaves the accumulator empty");
124 assert_eq!(drained.len(), 2);
125 assert_eq!(drained[0].handler, 1);
126 assert!(matches!(
127 drained[0].filter.kinds.as_deref(),
128 Some([WitEventKind::DocumentSaved])
129 ));
130 assert_eq!(drained[1].handler, 7);
131 }
132}