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}