anvilforge-spark 0.3.4

Spark — Livewire-equivalent reactive components baked into Anvilforge. Signed snapshots, partial re-render, real-time push via Bellows.
Documentation
//! Component registry — inventory-driven lookup tables.
//!
//! Each `#[spark::component]`-annotated struct emits a `ComponentEntry` that
//! maps class names → constructors / hydrators. The runtime looks up by class
//! name (FQN) when decoding a snapshot, or by short suffix (e.g. `"counter"`)
//! when mounting a fresh component from a forge directive.

use std::collections::HashMap;
use std::pin::Pin;

use futures::Future;
use once_cell::sync::Lazy;
use parking_lot::RwLock;

use crate::component::{Ctx, MountProps, PropertyWrite};
use crate::error::{Error, Result};

/// Object-safe runtime handle for a component instance. The vtable holds all
/// the methods Spark calls from outside the component itself. Generated by the
/// `#[spark::component]` derive (or hand-written for `impl Trait`-shaped wrappers).
pub trait DynComponent: Send + Sync {
    fn as_any_mut(&mut self) -> &mut (dyn std::any::Any + Send + Sync);
    fn snapshot_data(&self) -> serde_json::Value;
    fn render(&self) -> Result<String>;
    fn apply_writes<'a>(
        &'a mut self,
        writes: &'a [PropertyWrite],
        ctx: &'a mut Ctx,
    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
    fn dispatch_call<'a>(
        &'a mut self,
        method: &'a str,
        args: Vec<serde_json::Value>,
        ctx: &'a mut Ctx,
    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
}

/// Boxed runtime handle. `class` and `view` are pulled from the registry entry
/// so they're available without re-querying.
pub struct BoxedComponent {
    pub class: &'static str,
    pub view: &'static str,
    pub state: Box<dyn DynComponent>,
}

/// Inventory record. Each `#[spark::component]` submits one of these via
/// `inventory::submit!`.
pub struct ComponentEntry {
    pub class: &'static str,
    pub view: &'static str,
    pub listeners: fn() -> Vec<String>,
    pub mount: fn(MountProps) -> BoxedComponent,
    pub load: fn(&serde_json::Value) -> Result<BoxedComponent>,
}

inventory::collect!(ComponentEntry);

/// Type-erased dispatch function: invoked by the runtime to forward an action
/// call into the user's `async fn` on the component. The lifetime contract
/// ties the returned future to the borrowed component, method name, and ctx.
pub type DispatchFn = for<'a> fn(
    component: &'a mut (dyn std::any::Any + Send + Sync),
    method: &'a str,
    args: Vec<serde_json::Value>,
    ctx: &'a mut crate::component::Ctx,
) -> Pin<Box<dyn futures::Future<Output = Result<()>> + Send + 'a>>;

/// Dispatch table entry — submitted by `#[spark_actions]`. Each component class
/// has at most one. The dispatch function downcasts the type-erased component
/// reference and matches the method name to the user's `fn`s.
pub struct DispatchEntry {
    pub class: &'static str,
    pub dispatch: DispatchFn,
}

inventory::collect!(DispatchEntry);

/// Listeners table entry. Submitted by `#[spark_actions]` listing every
/// `#[spark_on("event")]` method's event name. Empty if the component has none.
pub struct ListenerEntry {
    pub class: &'static str,
    pub events: fn() -> Vec<String>,
}

inventory::collect!(ListenerEntry);

/// Mount factory entry. Submitted by `#[spark_actions]` when a `#[spark_mount]`
/// method is present, otherwise the component falls back to `Default::default()`.
///
/// The factory returns a type-erased boxed instance of the concrete component
/// type — the caller (the component's generated `default_then_props`) downcasts
/// back to `Self` using `Box::downcast::<Self>()`. This keeps the entry type
/// non-generic so `inventory::collect!` works.
pub struct MountEntry {
    pub class: &'static str,
    pub mount: fn(crate::component::MountProps) -> Box<dyn std::any::Any + Send>,
}

inventory::collect!(MountEntry);

/// Look up the dispatcher for a class, if any.
pub fn dispatcher_for(class: &str) -> Option<&'static DispatchEntry> {
    inventory::iter::<DispatchEntry>
        .into_iter()
        .find(|e| e.class == class)
}

/// Look up the registered listeners for a class, returning the empty list if none.
pub fn listeners_for(class: &str) -> Vec<String> {
    for entry in inventory::iter::<ListenerEntry> {
        if entry.class == class {
            return (entry.events)();
        }
    }
    Vec::new()
}

/// Look up the registered `#[spark_mount]` factory for a class, if any.
pub fn mount_factory_for(
    class: &str,
) -> Option<fn(crate::component::MountProps) -> Box<dyn std::any::Any + Send>> {
    for entry in inventory::iter::<MountEntry> {
        if entry.class == class {
            return Some(entry.mount);
        }
    }
    None
}

static REGISTRY: Lazy<RwLock<HashMap<&'static str, &'static ComponentEntry>>> = Lazy::new(|| {
    let mut map = HashMap::new();
    for entry in inventory::iter::<ComponentEntry> {
        map.insert(entry.class, entry);
    }
    RwLock::new(map)
});

/// Lookup a component entry by exact class FQN.
pub fn lookup(class: &str) -> Option<&'static ComponentEntry> {
    REGISTRY.read().get(class).copied()
}

/// Lookup by short name (the typical `@spark("counter", ...)` form). Picks the
/// first registered class whose FQN ends with `::<short>` (case-insensitive).
pub fn lookup_by_short_name(short: &str) -> Option<&'static ComponentEntry> {
    let map = REGISTRY.read();

    let suffix = format!("::{}", short);
    if let Some(entry) = map.values().find(|e| e.class.ends_with(&suffix)) {
        return Some(*entry);
    }

    if let Some(entry) = map.values().find(|e| e.class == short) {
        return Some(*entry);
    }

    let lower = short.to_ascii_lowercase();
    map.values()
        .find(|e| e.class.to_ascii_lowercase().ends_with(&lower))
        .copied()
}

/// Resolve a mount name to a registered entry, returning a clear error otherwise.
pub fn resolve(name: &str) -> Result<&'static ComponentEntry> {
    lookup(name)
        .or_else(|| lookup_by_short_name(name))
        .ok_or_else(|| Error::UnknownComponent(name.to_string()))
}

/// List every registered component class name (handy for diagnostics).
pub fn classes() -> Vec<&'static str> {
    REGISTRY.read().keys().copied().collect()
}