Skip to main content

lattice_protocol/
event_registry.rs

1// `linkme`'s distributed-slice expansion uses `#[link_section]`
2// declarations, which the workspace's `unsafe_code = "deny"`
3// lint flags. Same shape `lattice-config::core_options` uses --
4// scope-limited opt-in for the macro expansion site.
5#![allow(unsafe_code)]
6
7//! The open, typed half of the event system: feature crates and plugins
8//! declare their own event types here, and introspection lists them.
9//!
10//! Typed-event surface (mode-architecture §5.10 follow-up).
11//!
12//! The legacy [`crate::Event`] enum is the closed catalogue of
13//! editor-core events (DocumentOpened, DocumentChanged, etc.).
14//! Feature crates (`lattice-lsp`, `lattice-completion`, future
15//! plugins) need to declare and own *their* events without
16//! editing the central enum -- the same ownership model
17//! `lattice-mode` uses for `Mode` declarations.
18//!
19//! This module adds:
20//!
21//! - [`Event`] trait -- marker every concrete event type
22//!   implements (`Any + Debug + Send + Sync + 'static`).
23//! - [`EventTypeId`] -- thin wrapper over `std::any::TypeId`
24//!   keyed against an interned name; the registry surfaces the
25//!   name for introspection (`:describe-events`).
26//! - [`EventDescriptor`] -- per-event metadata (name, doc,
27//!   source crate). Aggregated process-wide via
28//!   [`EVENT_DESCRIPTORS`] (a `linkme` distributed slice; same
29//!   mechanism `lattice-config` uses for typed options).
30//! - [`register_event!`](crate::register_event) macro -- single declaration
31//!   site that pushes the descriptor and implements [`Event`].
32//! - A runtime registry for plugin-defined events, which cannot be in a
33//!   link-time slice: [`register_runtime_event`] / [`unregister_runtime_event`],
34//!   with [`all_events`] / [`event_info_by_name`] as the merged
35//!   built-in ∪ runtime view ([`EventInfo`]).
36//!
37//! The runtime's `lattice_runtime::EventBus` (M.5.3.a follow-
38//! up) accepts both shapes: legacy enum publishes via
39//! `publish` / `subscribe`, typed events via
40//! `publish_typed::<T>` / `subscribe_typed::<T>`. Built-in
41//! events stay on the legacy path until a future cleanup slice
42//! migrates them; new events (LSP and beyond) declare via the
43//! typed path.
44
45use std::any::TypeId;
46use std::fmt::Debug;
47
48/// Marker trait every concrete event type implements. Required
49/// supertraits give the bus enough to box, store, and
50/// downcast: `Any` for the cast, `Send + Sync + 'static` for
51/// cross-thread shipping, `Debug` for diagnostic logs.
52pub trait Event: std::any::Any + Debug + Send + Sync + 'static {
53    /// The type-id under which this event is registered.
54    /// Implementors typically delegate to a `static` to avoid
55    /// re-allocating the metadata on every call. The macro
56    /// `register_event!` generates this for you.
57    fn event_type_id(&self) -> EventTypeId;
58}
59
60/// Stable identifier for an event type. Pairs Rust's
61/// `std::any::TypeId` (the bus's downcast key) with a string
62/// name (what `:describe-events` prints). Hash / Eq use the
63/// `TypeId` only -- two registrations with the same struct but
64/// different names would collide on the bus side, which is the
65/// behaviour we want. Nothing detects such a duplicate: registering the same
66/// type twice with `register_event!` fails to compile (conflicting `Event`
67/// impls), but two *types* sharing a name both land in
68/// [`EVENT_DESCRIPTORS`], and the by-name lookups return whichever the linker
69/// placed first.
70///
71/// # Examples
72///
73/// ```
74/// use std::any::TypeId;
75/// use lattice_protocol::event_registry::EventTypeId;
76///
77/// struct Indexed;
78/// let a = EventTypeId::of::<Indexed>("my-plugin.indexed");
79/// let b = EventTypeId::new(TypeId::of::<Indexed>(), "another-name");
80/// assert_eq!(a, b); // identity is the Rust type, not the name
81/// assert_eq!(a.name(), "my-plugin.indexed");
82/// assert_eq!(a.rust_type_id(), TypeId::of::<Indexed>());
83/// ```
84#[derive(Debug, Clone, Copy)]
85pub struct EventTypeId {
86    type_id: TypeId,
87    name: &'static str,
88}
89
90impl EventTypeId {
91    /// Pair an already-computed `TypeId` with its display name.
92    pub const fn new(type_id: TypeId, name: &'static str) -> Self {
93        Self { type_id, name }
94    }
95
96    /// The id of event type `T`, displayed as `name`.
97    pub fn of<T: 'static>(name: &'static str) -> Self {
98        Self {
99            type_id: TypeId::of::<T>(),
100            name,
101        }
102    }
103
104    /// The Rust `TypeId` — the bus's downcast and equality key.
105    pub fn rust_type_id(&self) -> TypeId {
106        self.type_id
107    }
108
109    /// The user-facing event name (`"lsp.buffer-attached"`).
110    pub fn name(&self) -> &'static str {
111        self.name
112    }
113}
114
115impl PartialEq for EventTypeId {
116    fn eq(&self, other: &Self) -> bool {
117        self.type_id == other.type_id
118    }
119}
120
121impl Eq for EventTypeId {}
122
123impl std::hash::Hash for EventTypeId {
124    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
125        self.type_id.hash(state);
126    }
127}
128
129/// Per-event metadata aggregated into [`EVENT_DESCRIPTORS`].
130/// `:describe-events` walks this slice; subscriber tooling
131/// (future "wait-for-event" debuggers, plugin host) can also
132/// enumerate it.
133///
134/// `name` is the user-facing identifier (`"lsp.buffer-attached"`,
135/// `"document.changed"`); convention: lowercase,
136/// dot-separated, namespaced by feature.
137#[derive(Debug, Clone, Copy)]
138pub struct EventDescriptor {
139    /// User-facing identifier (`"lsp.buffer-attached"`).
140    pub name: &'static str,
141    /// One-line summary `:describe-events` prints.
142    pub doc: &'static str,
143    /// The crate that declared the event (`"lattice-lsp"`).
144    pub source_crate: &'static str,
145    /// Returns the `TypeId` of the concrete event type. Stored
146    /// as a fn pointer (rather than the `TypeId` directly)
147    /// because `TypeId::of::<T>()` is non-const today; the
148    /// distributed-slice entries call this once at registry
149    /// build time.
150    pub type_id: fn() -> TypeId,
151}
152
153/// Process-wide distributed slice every `register_event!` call
154/// pushes into. `linkme` aggregates entries across crates at
155/// link time -- same mechanism `lattice-config`'s typed-option
156/// registry uses.
157#[linkme::distributed_slice]
158pub static EVENT_DESCRIPTORS: [EventDescriptor];
159
160/// Walk every registered event descriptor. Order is
161/// link-determined (not sorted); callers that need a stable
162/// presentation should sort by `name` themselves.
163pub fn registered_events() -> impl Iterator<Item = &'static EventDescriptor> {
164    EVENT_DESCRIPTORS.iter()
165}
166
167/// Look up a descriptor by exact name. Returns `None` when no
168/// event is registered under that name.
169pub fn descriptor_by_name(name: &str) -> Option<&'static EventDescriptor> {
170    EVENT_DESCRIPTORS.iter().find(|d| d.name == name)
171}
172
173/// Look up a descriptor by `TypeId`. Used by the bus to format
174/// "unknown subscriber for event X" diagnostics and by
175/// `:describe-events` when invoked off a publisher's
176/// `event_type_id()`.
177pub fn descriptor_by_type_id(type_id: TypeId) -> Option<&'static EventDescriptor> {
178    EVENT_DESCRIPTORS.iter().find(|d| (d.type_id)() == type_id)
179}
180
181/// Owned, source-tagged view of an event descriptor. Merges the two registries:
182/// the compile-time [`EVENT_DESCRIPTORS`] linkme slice (built-in events) and the
183/// runtime registry (plugin-defined events, PH7.8b). Introspection + completion
184/// (`:describe-events`, `:describe-event`, `gen:events`) read this unified view
185/// so a plugin's custom event surfaces exactly like a built-in one.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct EventInfo {
188    /// User-facing identifier (`lsp.buffer-attached`, `my-plugin.file-indexed`).
189    pub name: String,
190    /// Short summary shown by `:describe-event(s)`.
191    pub doc: String,
192    /// Who owns the declaration — a source crate for built-ins, or the plugin
193    /// provenance string (e.g. `plugin:git-gutter`) for runtime events.
194    pub source: String,
195    /// `true` = compile-time (linkme) built-in; `false` = runtime plugin event.
196    pub builtin: bool,
197}
198
199/// Process-wide registry of RUNTIME (plugin-defined) events. Parallels the
200/// compile-time [`EVENT_DESCRIPTORS`] linkme slice — `linkme` is link-time
201/// constant, so a runtime-loaded plugin needs this to declare its own events.
202/// Keyed by name; a `RwLock` gives the interior mutability a post-boot plugin
203/// load needs. The Phase-8 loader / the `host-services register-event` seam
204/// (PH7.8b.2) is the populator.
205fn runtime_events() -> &'static std::sync::RwLock<std::collections::BTreeMap<String, EventInfo>> {
206    static RUNTIME_EVENTS: std::sync::OnceLock<
207        std::sync::RwLock<std::collections::BTreeMap<String, EventInfo>>,
208    > = std::sync::OnceLock::new();
209    RUNTIME_EVENTS.get_or_init(|| std::sync::RwLock::new(std::collections::BTreeMap::new()))
210}
211
212/// Register a runtime (plugin-defined) event. Idempotent by name (a re-register
213/// overwrites — a plugin reload refreshes its doc). Returns `false` and records
214/// nothing if the name collides with a BUILT-IN event: a plugin must not shadow
215/// a native event (its subscribers would be ambiguous). Also returns `false`
216/// if the registry lock is poisoned.
217///
218/// The registry is process-wide, so it is shared by every editor instance
219/// (and every test) in the process.
220///
221/// # Examples
222///
223/// ```
224/// use lattice_protocol::event_registry::{
225///     all_events, event_info_by_name, register_runtime_event, unregister_runtime_event,
226/// };
227///
228/// assert!(register_runtime_event(
229///     "demo-plugin.file-indexed",
230///     "A file finished indexing.",
231///     "plugin:demo-plugin",
232/// ));
233/// let info = event_info_by_name("demo-plugin.file-indexed").unwrap();
234/// assert!(!info.builtin);
235/// assert_eq!(info.source, "plugin:demo-plugin");
236/// assert!(all_events().iter().any(|e| e.name == "demo-plugin.file-indexed"));
237///
238/// unregister_runtime_event("demo-plugin.file-indexed"); // plugin unload
239/// assert!(event_info_by_name("demo-plugin.file-indexed").is_none());
240/// ```
241pub fn register_runtime_event(
242    name: impl Into<String>,
243    doc: impl Into<String>,
244    source: impl Into<String>,
245) -> bool {
246    let name = name.into();
247    if descriptor_by_name(&name).is_some() {
248        return false;
249    }
250    if let Ok(mut map) = runtime_events().write() {
251        map.insert(
252            name.clone(),
253            EventInfo {
254                name,
255                doc: doc.into(),
256                source: source.into(),
257                builtin: false,
258            },
259        );
260        true
261    } else {
262        false
263    }
264}
265
266/// Remove a runtime event (plugin unload / reload). No-op for an unknown name.
267pub fn unregister_runtime_event(name: &str) {
268    if let Ok(mut map) = runtime_events().write() {
269        map.remove(name);
270    }
271}
272
273/// Every event — built-in (linkme) ∪ runtime (plugin) — as owned [`EventInfo`],
274/// sorted by name. The unified view introspection + completion read.
275pub fn all_events() -> Vec<EventInfo> {
276    let mut out: Vec<EventInfo> = registered_events()
277        .map(|d| EventInfo {
278            name: d.name.to_string(),
279            doc: d.doc.to_string(),
280            source: d.source_crate.to_string(),
281            builtin: true,
282        })
283        .collect();
284    if let Ok(map) = runtime_events().read() {
285        out.extend(map.values().cloned());
286    }
287    out.sort_by(|a, b| a.name.cmp(&b.name));
288    out
289}
290
291/// Look up any event (built-in or runtime) by exact name.
292pub fn event_info_by_name(name: &str) -> Option<EventInfo> {
293    if let Some(d) = descriptor_by_name(name) {
294        return Some(EventInfo {
295            name: d.name.to_string(),
296            doc: d.doc.to_string(),
297            source: d.source_crate.to_string(),
298            builtin: true,
299        });
300    }
301    runtime_events().read().ok()?.get(name).cloned()
302}
303
304/// Declare and register an event type. Generates:
305///
306/// 1. An impl of [`Event`] for `$ty` returning the registered
307///    [`EventTypeId`].
308/// 2. A `linkme`-aggregated [`EventDescriptor`] entry pushed
309///    into [`EVENT_DESCRIPTORS`].
310///
311/// Conventions:
312/// - `$name` is the user-facing identifier
313///   (`"lsp.buffer-attached"`); lowercase, dot-separated,
314///   namespaced by feature.
315/// - `$doc` is the short summary `:describe-events` prints.
316/// - `$source_crate` records who owns the declaration; useful
317///   for `:describe-events --by-crate` and plugin host
318///   tooling.
319///
320/// # Examples
321///
322/// ```
323/// use lattice_protocol::event_registry::{Event, descriptor_by_name};
324/// use lattice_protocol::register_event;
325///
326/// #[derive(Debug)]
327/// pub struct IndexFinished {
328///     pub files: usize,
329/// }
330/// register_event!(
331///     IndexFinished,
332///     "demo.index-finished",
333///     "Fired when the demo indexer finishes a pass.",
334///     "demo-crate",
335/// );
336///
337/// let event = IndexFinished { files: 3 };
338/// assert_eq!(event.event_type_id().name(), "demo.index-finished");
339/// let desc = descriptor_by_name("demo.index-finished").unwrap();
340/// assert_eq!(desc.source_crate, "demo-crate");
341/// assert_eq!((desc.type_id)(), std::any::TypeId::of::<IndexFinished>());
342/// ```
343///
344/// The macro can't be invoked from within a `cfg(test)` module
345/// alone -- `linkme` requires the slice entry at the top level
346/// of a binary's link graph. For tests that need a registered
347/// event, declare it at module scope.
348///
349/// **Caller crates must add `linkme` as a direct dependency**
350/// (proc-macro attribute paths can't route through re-exports).
351/// Same constraint `lattice-config` callers face.
352#[macro_export]
353macro_rules! register_event {
354    ($ty:ty, $name:literal, $doc:literal, $source_crate:literal $(,)?) => {
355        impl $crate::event_registry::Event for $ty {
356            fn event_type_id(&self) -> $crate::event_registry::EventTypeId {
357                $crate::event_registry::EventTypeId::of::<$ty>($name)
358            }
359        }
360
361        const _: () = {
362            #[linkme::distributed_slice($crate::event_registry::EVENT_DESCRIPTORS)]
363            static DESCRIPTOR: $crate::event_registry::EventDescriptor =
364                $crate::event_registry::EventDescriptor {
365                    name: $name,
366                    doc: $doc,
367                    source_crate: $source_crate,
368                    type_id: || std::any::TypeId::of::<$ty>(),
369                };
370        };
371    };
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    // Declare a test event at module scope (linkme requires
379    // top-level for the slice entry to land in the link graph).
380    #[derive(Debug, Clone)]
381    pub struct TestEvent {
382        pub _n: u32,
383    }
384
385    register_event!(
386        TestEvent,
387        "test.event",
388        "Test event used to validate the registry surface.",
389        "lattice-protocol-tests",
390    );
391
392    #[test]
393    fn registered_event_appears_in_descriptors_slice() {
394        let found = registered_events().any(|d| d.name == "test.event");
395        assert!(found, "test.event should appear in EVENT_DESCRIPTORS");
396    }
397
398    /// PH7.8b: a runtime (plugin-defined) event registers, surfaces in the
399    /// unified view beside built-ins, resolves by name, and unregisters. It may
400    /// NOT shadow a built-in event name.
401    #[test]
402    fn runtime_events_register_surface_and_unregister() {
403        let name = "test.ph78b-runtime-only";
404        assert!(register_runtime_event(
405            name,
406            "a plugin event",
407            "plugin:demo"
408        ));
409
410        // Appears in the unified view, tagged non-builtin, beside `test.event`.
411        let all = all_events();
412        assert!(all.iter().any(|e| e.name == name && !e.builtin));
413        assert!(all.iter().any(|e| e.name == "test.event" && e.builtin));
414
415        let info = event_info_by_name(name).expect("resolves by name");
416        assert_eq!(info.doc, "a plugin event");
417        assert_eq!(info.source, "plugin:demo");
418        assert!(!info.builtin);
419
420        // A plugin must not shadow a built-in event.
421        assert!(
422            !register_runtime_event("test.event", "hijack", "plugin:demo"),
423            "runtime registration must not shadow a built-in event"
424        );
425
426        unregister_runtime_event(name);
427        assert!(event_info_by_name(name).is_none());
428    }
429
430    #[test]
431    fn descriptor_by_name_finds_test_event() {
432        let d = descriptor_by_name("test.event").expect("registered");
433        assert_eq!(d.name, "test.event");
434        assert_eq!(d.source_crate, "lattice-protocol-tests");
435    }
436
437    #[test]
438    fn descriptor_by_type_id_round_trips() {
439        let tid = std::any::TypeId::of::<TestEvent>();
440        let d = descriptor_by_type_id(tid).expect("registered");
441        assert_eq!(d.name, "test.event");
442    }
443
444    #[test]
445    fn event_trait_returns_registered_type_id() {
446        let e = TestEvent { _n: 42 };
447        let etid = e.event_type_id();
448        assert_eq!(etid.name(), "test.event");
449        assert_eq!(etid.rust_type_id(), std::any::TypeId::of::<TestEvent>());
450    }
451}