Skip to main content

spark/
registry.rs

1//! Component registry — inventory-driven lookup tables.
2//!
3//! Each `#[spark::component]`-annotated struct emits a `ComponentEntry` that
4//! maps class names → constructors / hydrators. The runtime looks up by class
5//! name (FQN) when decoding a snapshot, or by short suffix (e.g. `"counter"`)
6//! when mounting a fresh component from a forge directive.
7
8use std::collections::HashMap;
9use std::pin::Pin;
10
11use futures::Future;
12use once_cell::sync::Lazy;
13use parking_lot::RwLock;
14
15use crate::component::{Ctx, MountProps, PropertyWrite};
16use crate::error::{Error, Result};
17
18/// Object-safe runtime handle for a component instance. The vtable holds all
19/// the methods Spark calls from outside the component itself. Generated by the
20/// `#[spark::component]` derive (or hand-written for `impl Trait`-shaped wrappers).
21pub trait DynComponent: Send + Sync {
22    fn as_any_mut(&mut self) -> &mut (dyn std::any::Any + Send + Sync);
23    fn snapshot_data(&self) -> serde_json::Value;
24    fn render(&self) -> Result<String>;
25    fn apply_writes<'a>(
26        &'a mut self,
27        writes: &'a [PropertyWrite],
28        ctx: &'a mut Ctx,
29    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
30    fn dispatch_call<'a>(
31        &'a mut self,
32        method: &'a str,
33        args: Vec<serde_json::Value>,
34        ctx: &'a mut Ctx,
35    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
36}
37
38/// Boxed runtime handle. `class` and `view` are pulled from the registry entry
39/// so they're available without re-querying.
40pub struct BoxedComponent {
41    pub class: &'static str,
42    pub view: &'static str,
43    pub state: Box<dyn DynComponent>,
44}
45
46/// Inventory record. Each `#[spark::component]` submits one of these via
47/// `inventory::submit!`.
48pub struct ComponentEntry {
49    pub class: &'static str,
50    pub view: &'static str,
51    pub listeners: fn() -> Vec<String>,
52    pub mount: fn(MountProps) -> BoxedComponent,
53    pub load: fn(&serde_json::Value) -> Result<BoxedComponent>,
54}
55
56inventory::collect!(ComponentEntry);
57
58/// Type-erased dispatch function: invoked by the runtime to forward an action
59/// call into the user's `async fn` on the component. The lifetime contract
60/// ties the returned future to the borrowed component, method name, and ctx.
61pub type DispatchFn = for<'a> fn(
62    component: &'a mut (dyn std::any::Any + Send + Sync),
63    method: &'a str,
64    args: Vec<serde_json::Value>,
65    ctx: &'a mut crate::component::Ctx,
66) -> Pin<Box<dyn futures::Future<Output = Result<()>> + Send + 'a>>;
67
68/// Dispatch table entry — submitted by `#[spark_actions]`. Each component class
69/// has at most one. The dispatch function downcasts the type-erased component
70/// reference and matches the method name to the user's `fn`s.
71pub struct DispatchEntry {
72    pub class: &'static str,
73    pub dispatch: DispatchFn,
74}
75
76inventory::collect!(DispatchEntry);
77
78/// Listeners table entry. Submitted by `#[spark_actions]` listing every
79/// `#[spark_on("event")]` method's event name. Empty if the component has none.
80pub struct ListenerEntry {
81    pub class: &'static str,
82    pub events: fn() -> Vec<String>,
83}
84
85inventory::collect!(ListenerEntry);
86
87/// Mount factory entry. Submitted by `#[spark_actions]` when a `#[spark_mount]`
88/// method is present, otherwise the component falls back to `Default::default()`.
89///
90/// The factory returns a type-erased boxed instance of the concrete component
91/// type — the caller (the component's generated `default_then_props`) downcasts
92/// back to `Self` using `Box::downcast::<Self>()`. This keeps the entry type
93/// non-generic so `inventory::collect!` works.
94pub struct MountEntry {
95    pub class: &'static str,
96    pub mount: fn(crate::component::MountProps) -> Box<dyn std::any::Any + Send>,
97}
98
99inventory::collect!(MountEntry);
100
101/// Look up the dispatcher for a class, if any.
102pub fn dispatcher_for(class: &str) -> Option<&'static DispatchEntry> {
103    inventory::iter::<DispatchEntry>
104        .into_iter()
105        .find(|e| e.class == class)
106}
107
108/// Look up the registered listeners for a class, returning the empty list if none.
109pub fn listeners_for(class: &str) -> Vec<String> {
110    for entry in inventory::iter::<ListenerEntry> {
111        if entry.class == class {
112            return (entry.events)();
113        }
114    }
115    Vec::new()
116}
117
118/// Look up the registered `#[spark_mount]` factory for a class, if any.
119pub fn mount_factory_for(
120    class: &str,
121) -> Option<fn(crate::component::MountProps) -> Box<dyn std::any::Any + Send>> {
122    for entry in inventory::iter::<MountEntry> {
123        if entry.class == class {
124            return Some(entry.mount);
125        }
126    }
127    None
128}
129
130static REGISTRY: Lazy<RwLock<HashMap<&'static str, &'static ComponentEntry>>> = Lazy::new(|| {
131    let mut map = HashMap::new();
132    for entry in inventory::iter::<ComponentEntry> {
133        map.insert(entry.class, entry);
134    }
135    RwLock::new(map)
136});
137
138/// Lookup a component entry by exact class FQN.
139pub fn lookup(class: &str) -> Option<&'static ComponentEntry> {
140    REGISTRY.read().get(class).copied()
141}
142
143/// Lookup by short name (the typical `@spark("counter", ...)` form). Picks the
144/// first registered class whose FQN ends with `::<short>` (case-insensitive).
145pub fn lookup_by_short_name(short: &str) -> Option<&'static ComponentEntry> {
146    let map = REGISTRY.read();
147
148    let suffix = format!("::{}", short);
149    if let Some(entry) = map.values().find(|e| e.class.ends_with(&suffix)) {
150        return Some(*entry);
151    }
152
153    if let Some(entry) = map.values().find(|e| e.class == short) {
154        return Some(*entry);
155    }
156
157    let lower = short.to_ascii_lowercase();
158    map.values()
159        .find(|e| e.class.to_ascii_lowercase().ends_with(&lower))
160        .copied()
161}
162
163/// Resolve a mount name to a registered entry, returning a clear error otherwise.
164pub fn resolve(name: &str) -> Result<&'static ComponentEntry> {
165    lookup(name)
166        .or_else(|| lookup_by_short_name(name))
167        .ok_or_else(|| Error::UnknownComponent(name.to_string()))
168}
169
170/// List every registered component class name (handy for diagnostics).
171pub fn classes() -> Vec<&'static str> {
172    REGISTRY.read().keys().copied().collect()
173}