Skip to main content

gpui_rhai/
component.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::{Arc, Mutex};
3
4use rhai::{Dynamic, Map};
5use semver::{Version, VersionReq};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::{
10    AssetId, ComponentStateSchema, Length, ModuleId, ObjectField, RUNTIME_API_VERSION,
11    SchemaValidationError, ScriptCallback, ScriptGeneration, Style, UiEventHandler, UiNode,
12    UiValue, UiValueError, ValueSchema,
13};
14
15const HEADER_START: &str = "/* gpui-rhai\n";
16const HEADER_END: &str = "\n*/";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
19pub struct RuntimeApiRange {
20    pub min_inclusive: u32,
21    pub max_exclusive: u32,
22}
23
24impl RuntimeApiRange {
25    #[must_use]
26    pub const fn new(min_inclusive: u32, max_exclusive: u32) -> Self {
27        Self {
28            min_inclusive,
29            max_exclusive,
30        }
31    }
32
33    #[must_use]
34    pub const fn contains(self, version: u32) -> bool {
35        self.min_inclusive <= version && version < self.max_exclusive
36    }
37
38    #[must_use]
39    pub const fn is_valid(self) -> bool {
40        self.min_inclusive < self.max_exclusive
41    }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
45pub struct ComponentMetadata {
46    pub id: ModuleId,
47    pub export: String,
48    pub version: Version,
49    pub runtime_api: RuntimeApiRange,
50    #[serde(default)]
51    pub dependencies: BTreeSet<ModuleId>,
52    #[serde(default)]
53    pub capabilities: BTreeMap<String, VersionReq>,
54    #[serde(default)]
55    pub assets: BTreeSet<String>,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
59pub struct ComponentSchema {
60    #[serde(default)]
61    pub props: BTreeMap<String, crate::ObjectField>,
62    #[serde(default)]
63    pub state: ComponentStateSchema,
64    #[serde(default)]
65    pub events: BTreeMap<String, EventSchema>,
66    #[serde(default)]
67    pub slots: BTreeMap<String, SlotSchema>,
68    #[serde(default)]
69    pub parts: BTreeSet<String>,
70    #[serde(default)]
71    pub effects: BTreeSet<String>,
72}
73
74#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75pub struct EventSchema {
76    pub payload: ValueSchema,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
80pub struct SlotSchema {
81    #[serde(default)]
82    pub required: bool,
83    #[serde(default)]
84    pub multiple: bool,
85}
86
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88pub struct ComponentDefinition {
89    pub metadata: ComponentMetadata,
90    pub schema: ComponentSchema,
91}
92
93impl ComponentDefinition {
94    /// Construct and validate a formal component definition.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`ComponentError`] when names, compatibility ranges, defaults,
99    /// event callbacks, slots, parts, or capabilities violate the component
100    /// contract.
101    pub fn new(
102        metadata: ComponentMetadata,
103        mut schema: ComponentSchema,
104    ) -> Result<Self, ComponentError> {
105        install_standard_style_props(&mut schema)?;
106        validate_metadata(&metadata)?;
107        validate_schema(&schema)?;
108        Ok(Self { metadata, schema })
109    }
110
111    /// Validate and normalize invocation props, applying declared defaults.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`ComponentError::MissingKey`] for an unkeyed stateful instance,
116    /// or [`ComponentError::InvalidProps`] when props violate the schema.
117    pub fn invoke(
118        &self,
119        key: Option<String>,
120        props: Map,
121    ) -> Result<ComponentInvocation, ComponentError> {
122        self.invoke_with_generation(key, props, ScriptGeneration::default())
123    }
124
125    pub(crate) fn invoke_with_generation(
126        &self,
127        key: Option<String>,
128        mut props: Map,
129        generation: ScriptGeneration,
130    ) -> Result<ComponentInvocation, ComponentError> {
131        if !self.schema.state.is_empty() && key.is_none() {
132            return Err(ComponentError::MissingKey {
133                component: self.metadata.id.clone(),
134            });
135        }
136
137        for (name, field) in &self.schema.props {
138            if !props.contains_key(name.as_str())
139                && let Some(default) = &field.default
140            {
141                props.insert(name.clone().into(), default.clone().into_dynamic());
142            }
143        }
144
145        ValueSchema::object(self.schema.props.clone())
146            .validate(&Dynamic::from_map(props.clone()))
147            .map_err(|source| ComponentError::InvalidProps {
148                component: self.metadata.id.clone(),
149                source,
150            })?;
151
152        if let Some(part_styles) = props.get("part_styles") {
153            let part_styles = part_styles.clone_cast::<Map>();
154            if let Some(part) = part_styles
155                .keys()
156                .find(|part| !self.schema.parts.contains(part.as_str()))
157            {
158                return Err(ComponentError::UnknownStylePart {
159                    component: self.metadata.id.clone(),
160                    part: part.to_string(),
161                });
162            }
163        }
164
165        let retained_props =
166            ComponentProps::from_validated(&self.schema.props, &props, generation)?;
167        Ok(ComponentInvocation {
168            component: self.metadata.id.clone(),
169            export: self.metadata.export.clone(),
170            key,
171            props,
172            retained_props,
173        })
174    }
175
176    /// Validate a declared semantic event payload.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`ComponentError::UnknownEvent`] or
181    /// [`ComponentError::InvalidEventPayload`].
182    pub fn validate_event(&self, event: &str, payload: &Dynamic) -> Result<(), ComponentError> {
183        let event_schema =
184            self.schema
185                .events
186                .get(event)
187                .ok_or_else(|| ComponentError::UnknownEvent {
188                    component: self.metadata.id.clone(),
189                    event: event.to_owned(),
190                })?;
191        event_schema.payload.validate(payload).map_err(|source| {
192            ComponentError::InvalidEventPayload {
193                component: self.metadata.id.clone(),
194                event: event.to_owned(),
195                source,
196            }
197        })
198    }
199
200    /// Confirm that the source header describes this exported definition.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ComponentError::HeaderMismatch`] when any metadata differs.
205    pub fn validate_header(&self, header: &ComponentMetadata) -> Result<(), ComponentError> {
206        if &self.metadata == header {
207            Ok(())
208        } else {
209            Err(ComponentError::HeaderMismatch {
210                header: Box::new(header.clone()),
211                exported: Box::new(self.metadata.clone()),
212            })
213        }
214    }
215}
216
217fn install_standard_style_props(schema: &mut ComponentSchema) -> Result<(), ComponentError> {
218    match schema.props.get("key") {
219        Some(field) if !matches!(field.schema, ValueSchema::String { .. }) => {
220            return Err(ComponentError::InvalidStandardKeyProp);
221        }
222        Some(_) => {}
223        None => {
224            schema.props.insert(
225                "key".to_owned(),
226                ObjectField::optional(ValueSchema::string()),
227            );
228        }
229    }
230    let standard = [
231        ("style", ObjectField::optional(ValueSchema::Style)),
232        (
233            "part_styles",
234            ObjectField::optional(ValueSchema::Map {
235                values: Box::new(ValueSchema::Style),
236            }),
237        ),
238    ];
239    for (name, expected) in standard {
240        match schema.props.get(name) {
241            Some(actual) if actual != &expected => {
242                return Err(ComponentError::InvalidStandardStyleProp(name.to_owned()));
243            }
244            Some(_) => {}
245            None => {
246                schema.props.insert(name.to_owned(), expected);
247            }
248        }
249    }
250    Ok(())
251}
252
253#[derive(Clone, Debug)]
254pub struct ComponentInvocation {
255    pub component: ModuleId,
256    pub export: String,
257    pub key: Option<String>,
258    pub props: Map,
259    pub retained_props: ComponentProps,
260}
261
262#[derive(Clone, Debug, PartialEq)]
263pub enum ComponentPropValue {
264    Data(UiValue),
265    Array(Vec<ComponentPropValue>),
266    Map(BTreeMap<String, ComponentPropValue>),
267    Node(Box<UiNode>),
268    Nodes(Vec<UiNode>),
269    Callback(UiEventHandler),
270    Style(Box<Style>),
271    Styles(BTreeMap<String, Style>),
272    Length(Length),
273    Asset(AssetId),
274    Signal(crate::NativeSignal),
275    Collection(crate::NativeCollection),
276    Document(crate::NativeTextDocument),
277    Ref(crate::ElementRef),
278}
279
280#[derive(Clone, Debug, Default, PartialEq)]
281pub struct ComponentProps(BTreeMap<String, ComponentPropValue>);
282
283impl ComponentProps {
284    fn from_validated(
285        schema: &BTreeMap<String, ObjectField>,
286        props: &Map,
287        generation: ScriptGeneration,
288    ) -> Result<Self, ComponentError> {
289        let values = schema
290            .iter()
291            .filter_map(|(name, field)| {
292                props.get(name.as_str()).cloned().map(|value| {
293                    convert_component_prop(&field.schema, value, generation)
294                        .map(|value| (name.clone(), value))
295                        .map_err(|source| ComponentError::PropConversion {
296                            prop: name.clone(),
297                            source,
298                        })
299                })
300            })
301            .collect::<Result<_, _>>()?;
302        Ok(Self(values))
303    }
304
305    #[must_use]
306    pub fn get(&self, name: &str) -> Option<&ComponentPropValue> {
307        self.0.get(name)
308    }
309
310    pub fn iter(&self) -> impl Iterator<Item = (&str, &ComponentPropValue)> {
311        self.0.iter().map(|(name, value)| (name.as_str(), value))
312    }
313
314    /// Compare props for component-render reuse.
315    ///
316    /// Node-valued props are deliberately never reusable. A node or slot can
317    /// carry callbacks and component ownership whose structural equality does
318    /// not prove that retaining the previous subtree is semantically safe.
319    pub(crate) fn reusable_eq(&self, other: &Self) -> bool {
320        self.0.len() == other.0.len()
321            && self.0.iter().all(|(name, value)| {
322                other
323                    .0
324                    .get(name)
325                    .is_some_and(|other| value.reusable_eq(other))
326            })
327    }
328}
329
330impl ComponentPropValue {
331    fn reusable_eq(&self, other: &Self) -> bool {
332        match (self, other) {
333            (Self::Node(_) | Self::Nodes(_), _) | (_, Self::Node(_) | Self::Nodes(_)) => false,
334            (Self::Array(left), Self::Array(right)) => {
335                left.len() == right.len()
336                    && left
337                        .iter()
338                        .zip(right)
339                        .all(|(left, right)| left.reusable_eq(right))
340            }
341            (Self::Map(left), Self::Map(right)) => {
342                left.len() == right.len()
343                    && left.iter().all(|(name, value)| {
344                        right
345                            .get(name)
346                            .is_some_and(|other| value.reusable_eq(other))
347                    })
348            }
349            _ => self == other,
350        }
351    }
352}
353
354fn convert_component_prop(
355    schema: &ValueSchema,
356    value: Dynamic,
357    generation: ScriptGeneration,
358) -> Result<ComponentPropValue, ComponentPropConversionError> {
359    match schema {
360        ValueSchema::Optional { value: inner } if value.is_unit() => {
361            Ok(ComponentPropValue::Data(UiValue::Null))
362        }
363        ValueSchema::Optional { value: inner } => convert_component_prop(inner, value, generation),
364        ValueSchema::OneOf { variants } => {
365            let branch = variants
366                .iter()
367                .find(|variant| variant.validate(&value).is_ok())
368                .expect("validated component one_of prop matches one branch");
369            convert_component_prop(branch, value, generation)
370        }
371        ValueSchema::Node => Ok(ComponentPropValue::Node(Box::new(value.cast::<UiNode>()))),
372        ValueSchema::Callback if value.is::<rhai::FnPtr>() => {
373            Ok(ComponentPropValue::Callback(UiEventHandler::Script(
374                ScriptCallback::try_from_fn_ptr(value.cast::<rhai::FnPtr>(), generation)?,
375            )))
376        }
377        ValueSchema::Callback => Ok(ComponentPropValue::Callback(UiEventHandler::Native(
378            value.cast::<crate::NativeHandlerRef>(),
379        ))),
380        ValueSchema::Array { items, .. } if matches!(items.as_ref(), ValueSchema::Node) => {
381            Ok(ComponentPropValue::Nodes(
382                value
383                    .cast::<rhai::Array>()
384                    .into_iter()
385                    .map(Dynamic::cast::<UiNode>)
386                    .collect(),
387            ))
388        }
389        ValueSchema::Array { items, .. } => Ok(ComponentPropValue::Array(
390            value
391                .cast::<rhai::Array>()
392                .into_iter()
393                .map(|value| convert_component_prop(items, value, generation))
394                .collect::<Result<_, _>>()?,
395        )),
396        ValueSchema::Map { values } if matches!(values.as_ref(), ValueSchema::Style) => {
397            Ok(ComponentPropValue::Styles(
398                value
399                    .cast::<Map>()
400                    .into_iter()
401                    .map(|(name, value)| (name.to_string(), value.cast::<Style>()))
402                    .collect(),
403            ))
404        }
405        ValueSchema::Map { values } => Ok(ComponentPropValue::Map(
406            value
407                .cast::<Map>()
408                .into_iter()
409                .map(|(name, value)| {
410                    convert_component_prop(values, value, generation)
411                        .map(|value| (name.to_string(), value))
412                })
413                .collect::<Result<_, _>>()?,
414        )),
415        ValueSchema::Object {
416            fields,
417            allow_unknown,
418        } => Ok(ComponentPropValue::Map(
419            value
420                .cast::<Map>()
421                .into_iter()
422                .map(|(name, value)| {
423                    let name = name.to_string();
424                    let converted = if let Some(field) = fields.get(&name) {
425                        convert_component_prop(&field.schema, value, generation)
426                    } else {
427                        debug_assert!(*allow_unknown);
428                        UiValue::from_dynamic(value)
429                            .map(ComponentPropValue::Data)
430                            .map_err(Into::into)
431                    };
432                    converted.map(|value| (name, value))
433                })
434                .collect::<Result<_, _>>()?,
435        )),
436        ValueSchema::Style => Ok(ComponentPropValue::Style(Box::new(value.cast::<Style>()))),
437        ValueSchema::Length => Ok(ComponentPropValue::Length(value.cast::<Length>())),
438        ValueSchema::Asset => Ok(ComponentPropValue::Asset(value.cast::<AssetId>())),
439        ValueSchema::Signal => Ok(ComponentPropValue::Signal(
440            value.cast::<crate::NativeSignal>(),
441        )),
442        ValueSchema::Collection => Ok(ComponentPropValue::Collection(
443            value.cast::<crate::NativeCollection>(),
444        )),
445        ValueSchema::Document => Ok(ComponentPropValue::Document(
446            value.cast::<crate::NativeTextDocument>(),
447        )),
448        ValueSchema::Ref => Ok(ComponentPropValue::Ref(value.cast::<crate::ElementRef>())),
449        _ => UiValue::from_dynamic(value)
450            .map(ComponentPropValue::Data)
451            .map_err(Into::into),
452    }
453}
454
455#[derive(Debug, Error)]
456pub enum ComponentPropConversionError {
457    #[error(transparent)]
458    Value(#[from] UiValueError),
459    #[error(transparent)]
460    Callback(#[from] crate::ScriptCallbackDefinitionError),
461}
462
463#[derive(Clone, Debug, Default)]
464pub struct ComponentRegistry {
465    components: BTreeMap<ModuleId, ComponentDefinition>,
466}
467
468/// Collects definitions registered by `define_component` during module setup.
469#[derive(Clone, Debug, Default)]
470pub struct ComponentExportCollector {
471    registry: Arc<Mutex<ComponentRegistry>>,
472}
473
474impl ComponentExportCollector {
475    #[must_use]
476    pub fn new() -> Self {
477        Self::default()
478    }
479
480    pub(crate) fn register_definition(
481        &self,
482        definition: ComponentDefinition,
483    ) -> Result<(), ComponentRegistryError> {
484        self.registry
485            .lock()
486            .map_err(|_| ComponentRegistryError::Poisoned)?
487            .register(definition, RUNTIME_API_VERSION)
488    }
489
490    /// Clone the current exported-component registry.
491    ///
492    /// # Errors
493    ///
494    /// Returns [`ComponentExportError::Poisoned`] if a prior panic poisoned the
495    /// collector lock.
496    pub fn snapshot(&self) -> Result<ComponentRegistry, ComponentExportError> {
497        self.registry
498            .lock()
499            .map(|registry| registry.clone())
500            .map_err(|_| ComponentExportError::Poisoned)
501    }
502
503    /// Clear all collected component definitions.
504    ///
505    /// # Errors
506    ///
507    /// Returns [`ComponentExportError::Poisoned`] if a prior panic poisoned the
508    /// collector lock.
509    pub fn clear(&self) -> Result<(), ComponentExportError> {
510        *self
511            .registry
512            .lock()
513            .map_err(|_| ComponentExportError::Poisoned)? = ComponentRegistry::new();
514        Ok(())
515    }
516
517    /// Replace collected exports, used to roll back a failed reload candidate.
518    ///
519    /// # Errors
520    ///
521    /// Returns [`ComponentExportError::Poisoned`] if the collector lock is poisoned.
522    pub fn replace(&self, registry: ComponentRegistry) -> Result<(), ComponentExportError> {
523        *self
524            .registry
525            .lock()
526            .map_err(|_| ComponentExportError::Poisoned)? = registry;
527        Ok(())
528    }
529}
530
531impl ComponentRegistry {
532    #[must_use]
533    pub fn new() -> Self {
534        Self::default()
535    }
536
537    /// Register a component compatible with the current runtime API.
538    ///
539    /// # Errors
540    ///
541    /// Returns [`ComponentRegistryError`] for duplicate component IDs or an
542    /// incompatible runtime API range.
543    pub fn register(
544        &mut self,
545        definition: ComponentDefinition,
546        runtime_api: u32,
547    ) -> Result<(), ComponentRegistryError> {
548        let id = definition.metadata.id.clone();
549        if let Some(existing) = self.components.get(&id) {
550            return if existing == &definition {
551                Ok(())
552            } else {
553                Err(ComponentRegistryError::Duplicate(id))
554            };
555        }
556        if !definition.metadata.runtime_api.contains(runtime_api) {
557            return Err(ComponentRegistryError::IncompatibleRuntime {
558                component: id,
559                required: definition.metadata.runtime_api,
560                actual: runtime_api,
561            });
562        }
563        self.components.insert(id, definition);
564        Ok(())
565    }
566
567    #[must_use]
568    pub fn get(&self, id: &ModuleId) -> Option<&ComponentDefinition> {
569        self.components.get(id)
570    }
571
572    #[must_use]
573    pub fn len(&self) -> usize {
574        self.components.len()
575    }
576
577    #[must_use]
578    pub fn is_empty(&self) -> bool {
579        self.components.is_empty()
580    }
581
582    pub fn iter(&self) -> impl Iterator<Item = (&ModuleId, &ComponentDefinition)> {
583        self.components.iter()
584    }
585
586    /// Resolve dependencies in installation order, dependencies first.
587    ///
588    /// # Errors
589    ///
590    /// Returns [`ComponentRegistryError::Missing`] or
591    /// [`ComponentRegistryError::DependencyCycle`].
592    pub fn resolve(
593        &self,
594        requested: impl IntoIterator<Item = ModuleId>,
595    ) -> Result<Vec<ModuleId>, ComponentRegistryError> {
596        let mut ordered = Vec::new();
597        let mut complete = BTreeSet::new();
598        let mut stack = Vec::new();
599        for id in requested {
600            self.visit(&id, &mut stack, &mut complete, &mut ordered)?;
601        }
602        Ok(ordered)
603    }
604
605    fn visit(
606        &self,
607        id: &ModuleId,
608        stack: &mut Vec<ModuleId>,
609        complete: &mut BTreeSet<ModuleId>,
610        ordered: &mut Vec<ModuleId>,
611    ) -> Result<(), ComponentRegistryError> {
612        if complete.contains(id) {
613            return Ok(());
614        }
615        if let Some(start) = stack.iter().position(|active| active == id) {
616            let mut cycle = stack[start..].to_vec();
617            cycle.push(id.clone());
618            return Err(ComponentRegistryError::DependencyCycle(cycle));
619        }
620        let component = self
621            .components
622            .get(id)
623            .ok_or_else(|| ComponentRegistryError::Missing(id.clone()))?;
624        stack.push(id.clone());
625        for dependency in &component.metadata.dependencies {
626            self.visit(dependency, stack, complete, ordered)?;
627        }
628        stack.pop();
629        complete.insert(id.clone());
630        ordered.push(id.clone());
631        Ok(())
632    }
633}
634
635/// Parse the required JSON metadata block at the start of a component script.
636///
637/// # Errors
638///
639/// Returns [`ComponentHeaderError`] when the sentinel is absent, the block is
640/// unterminated, or the JSON metadata is invalid.
641pub fn parse_component_header(source: &str) -> Result<ComponentMetadata, ComponentHeaderError> {
642    let source = source.strip_prefix('\u{feff}').unwrap_or(source);
643    let body = source
644        .strip_prefix(HEADER_START)
645        .ok_or(ComponentHeaderError::Missing)?;
646    let end = body
647        .find(HEADER_END)
648        .ok_or(ComponentHeaderError::Unterminated)?;
649    serde_json::from_str(&body[..end]).map_err(ComponentHeaderError::InvalidJson)
650}
651
652fn validate_metadata(metadata: &ComponentMetadata) -> Result<(), ComponentError> {
653    if !is_pascal_case_identifier(&metadata.export) {
654        return Err(ComponentError::InvalidExport(metadata.export.clone()));
655    }
656    if !metadata.runtime_api.is_valid() {
657        return Err(ComponentError::InvalidRuntimeRange(metadata.runtime_api));
658    }
659    if metadata.dependencies.contains(&metadata.id) {
660        return Err(ComponentError::SelfDependency(metadata.id.clone()));
661    }
662    for name in metadata.capabilities.keys() {
663        if !is_namespaced_identifier(name) {
664            return Err(ComponentError::InvalidCapability(name.clone()));
665        }
666    }
667    for asset in &metadata.assets {
668        if !is_component_asset_path(asset) {
669            return Err(ComponentError::InvalidAsset(asset.clone()));
670        }
671    }
672    Ok(())
673}
674
675fn validate_schema(schema: &ComponentSchema) -> Result<(), ComponentError> {
676    for (name, field) in &schema.props {
677        if !is_snake_case_identifier(name) {
678            return Err(ComponentError::InvalidSchemaName(name.clone()));
679        }
680        if field.required && field.default.is_some() {
681            return Err(ComponentError::RequiredPropHasDefault(name.clone()));
682        }
683        field.schema.validate_definition().map_err(|source| {
684            ComponentError::InvalidSchemaDefinition {
685                location: format!("prop `{name}`"),
686                source,
687            }
688        })?;
689        if let Some(default) = &field.default {
690            field.schema.validate_ui_value(default).map_err(|source| {
691                ComponentError::InvalidPropDefault {
692                    prop: name.clone(),
693                    source,
694                }
695            })?;
696        }
697    }
698    for (name, event) in &schema.events {
699        if !is_snake_case_identifier(name) {
700            return Err(ComponentError::InvalidSchemaName(name.clone()));
701        }
702        let callback_name = format!("on_{name}");
703        if !schema
704            .props
705            .get(&callback_name)
706            .is_some_and(|field| schema_accepts_callback(&field.schema))
707        {
708            return Err(ComponentError::MissingEventCallback {
709                event: name.clone(),
710                prop: callback_name,
711            });
712        }
713        event.payload.validate_definition().map_err(|source| {
714            ComponentError::InvalidSchemaDefinition {
715                location: format!("event `{name}`"),
716                source,
717            }
718        })?;
719    }
720    for (name, slot) in &schema.slots {
721        if !is_snake_case_identifier(name) {
722            return Err(ComponentError::InvalidSchemaName(name.clone()));
723        }
724        let valid = schema.props.get(name).is_some_and(|field| {
725            if slot.multiple {
726                matches!(
727                    &field.schema,
728                    ValueSchema::Array { items, .. } if matches!(items.as_ref(), ValueSchema::Node)
729                )
730            } else {
731                schema_accepts_node(&field.schema)
732            }
733        });
734        if !valid {
735            return Err(ComponentError::MissingSlotProp(name.clone()));
736        }
737    }
738    for part in &schema.parts {
739        if !is_snake_case_identifier(part) {
740            return Err(ComponentError::InvalidSchemaName(part.clone()));
741        }
742    }
743    for effect in &schema.effects {
744        if !is_snake_case_identifier(effect) {
745            return Err(ComponentError::InvalidSchemaName(effect.clone()));
746        }
747    }
748    for (name, field) in schema.state.fields() {
749        if !is_snake_case_identifier(name) {
750            return Err(ComponentError::InvalidSchemaName(name.clone()));
751        }
752        field.schema.validate_definition().map_err(|source| {
753            ComponentError::InvalidSchemaDefinition {
754                location: format!("state field `{name}`"),
755                source,
756            }
757        })?;
758        field
759            .schema
760            .validate_ui_value(&field.default)
761            .map_err(|source| ComponentError::InvalidStateDefault {
762                field: name.clone(),
763                source,
764            })?;
765    }
766    Ok(())
767}
768
769fn schema_accepts_callback(schema: &ValueSchema) -> bool {
770    matches!(schema, ValueSchema::Callback)
771        || matches!(schema, ValueSchema::Optional { value } if matches!(value.as_ref(), ValueSchema::Callback))
772        || matches!(schema, ValueSchema::OneOf { variants } if variants.iter().any(schema_accepts_callback))
773}
774
775fn schema_accepts_node(schema: &ValueSchema) -> bool {
776    matches!(schema, ValueSchema::Node)
777        || matches!(schema, ValueSchema::Optional { value } if matches!(value.as_ref(), ValueSchema::Node))
778        || matches!(schema, ValueSchema::OneOf { variants } if variants.iter().any(schema_accepts_node))
779}
780
781fn is_pascal_case_identifier(value: &str) -> bool {
782    value
783        .chars()
784        .next()
785        .is_some_and(|first| first.is_ascii_uppercase())
786        && value
787            .chars()
788            .all(|character| character.is_ascii_alphanumeric())
789}
790
791fn is_snake_case_identifier(value: &str) -> bool {
792    !value.is_empty()
793        && !value.starts_with('_')
794        && !value.ends_with('_')
795        && !value.contains("__")
796        && value.chars().all(|character| {
797            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
798        })
799}
800
801fn is_namespaced_identifier(value: &str) -> bool {
802    value.split_once('.').is_some_and(|(namespace, name)| {
803        is_snake_case_identifier(namespace) && is_snake_case_identifier(name)
804    })
805}
806
807fn is_component_asset_path(value: &str) -> bool {
808    !value.is_empty()
809        && !value.starts_with('/')
810        && !value.ends_with('/')
811        && !value.contains(':')
812        && !value.contains('\\')
813        && value.split('/').all(|segment| {
814            !segment.is_empty()
815                && !matches!(segment, "." | "..")
816                && segment.chars().all(|character| {
817                    character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')
818                })
819        })
820}
821
822#[derive(Debug, Error)]
823pub enum ComponentError {
824    #[error("component export `{0}` must be a PascalCase identifier")]
825    InvalidExport(String),
826    #[error("runtime API range {0:?} is empty")]
827    InvalidRuntimeRange(RuntimeApiRange),
828    #[error("component `{0}` cannot depend on itself")]
829    SelfDependency(ModuleId),
830    #[error("capability `{0}` must be a namespaced snake_case identifier")]
831    InvalidCapability(String),
832    #[error("component asset `{0}` must be a safe provider-relative path")]
833    InvalidAsset(String),
834    #[error("schema name `{0}` must be a snake_case identifier")]
835    InvalidSchemaName(String),
836    #[error("required prop `{0}` cannot also declare a default")]
837    RequiredPropHasDefault(String),
838    #[error("invalid schema definition for {location}: {source}")]
839    InvalidSchemaDefinition {
840        location: String,
841        #[source]
842        source: crate::SchemaDefinitionError,
843    },
844    #[error("default for state field `{field}` is invalid: {source}")]
845    InvalidStateDefault {
846        field: String,
847        #[source]
848        source: SchemaValidationError,
849    },
850    #[error("standard style prop `{0}` has an incompatible schema")]
851    InvalidStandardStyleProp(String),
852    #[error("standard component key prop must be a string")]
853    InvalidStandardKeyProp,
854    #[error("default for prop `{prop}` is invalid: {source}")]
855    InvalidPropDefault {
856        prop: String,
857        #[source]
858        source: SchemaValidationError,
859    },
860    #[error("event `{event}` requires callback prop `{prop}`")]
861    MissingEventCallback { event: String, prop: String },
862    #[error("slot `{0}` requires a compatible node prop with the same name")]
863    MissingSlotProp(String),
864    #[error("stateful component `{component}` requires a stable key")]
865    MissingKey { component: ModuleId },
866    #[error("props for component `{component}` are invalid: {source}")]
867    InvalidProps {
868        component: ModuleId,
869        #[source]
870        source: SchemaValidationError,
871    },
872    #[error("component prop `{prop}` cannot cross the retained boundary: {source}")]
873    PropConversion {
874        prop: String,
875        source: ComponentPropConversionError,
876    },
877    #[error("component `{component}` does not declare style part `{part}`")]
878    UnknownStylePart { component: ModuleId, part: String },
879    #[error("component `{component}` does not declare event `{event}`")]
880    UnknownEvent { component: ModuleId, event: String },
881    #[error("payload for `{component}` event `{event}` is invalid: {source}")]
882    InvalidEventPayload {
883        component: ModuleId,
884        event: String,
885        #[source]
886        source: SchemaValidationError,
887    },
888    #[error("component header metadata does not match exported metadata")]
889    HeaderMismatch {
890        header: Box<ComponentMetadata>,
891        exported: Box<ComponentMetadata>,
892    },
893}
894
895#[derive(Debug, Error)]
896pub enum ComponentRegistryError {
897    #[error("component export registry is poisoned")]
898    Poisoned,
899    #[error("component `{0}` is already registered")]
900    Duplicate(ModuleId),
901    #[error("component `{component}` requires runtime API {required:?}, current API is {actual}")]
902    IncompatibleRuntime {
903        component: ModuleId,
904        required: RuntimeApiRange,
905        actual: u32,
906    },
907    #[error("component `{0}` is not registered")]
908    Missing(ModuleId),
909    #[error("component dependency cycle: {0:?}")]
910    DependencyCycle(Vec<ModuleId>),
911}
912
913#[derive(Debug, Error)]
914pub enum ComponentHeaderError {
915    #[error("component source must start with `/* gpui-rhai` metadata")]
916    Missing,
917    #[error("component metadata block is missing its closing `*/`")]
918    Unterminated,
919    #[error("component metadata JSON is invalid: {0}")]
920    InvalidJson(#[source] serde_json::Error),
921}
922
923#[derive(Clone, Debug, Error, Eq, PartialEq)]
924pub enum ComponentExportError {
925    #[error("component export registry lock is poisoned")]
926    Poisoned,
927    #[error("component render registry is already borrowed")]
928    Borrowed,
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::{ObjectField, StateField, Style, UiValue};
935    use rhai::Engine;
936
937    fn metadata(id: &str, export: &str) -> ComponentMetadata {
938        ComponentMetadata {
939            id: ModuleId::parse(id).unwrap(),
940            export: export.to_owned(),
941            version: Version::new(0, 1, 0),
942            runtime_api: RuntimeApiRange::new(2, 3),
943            dependencies: BTreeSet::new(),
944            capabilities: BTreeMap::new(),
945            assets: BTreeSet::new(),
946        }
947    }
948
949    fn button() -> ComponentDefinition {
950        ComponentDefinition::new(
951            metadata("components/button", "Button"),
952            ComponentSchema {
953                props: BTreeMap::from([
954                    (
955                        "text".to_owned(),
956                        ObjectField::required(ValueSchema::string()),
957                    ),
958                    (
959                        "variant".to_owned(),
960                        ObjectField::optional(ValueSchema::enumeration(["primary", "secondary"]))
961                            .with_default(UiValue::String("primary".to_owned())),
962                    ),
963                    (
964                        "on_click".to_owned(),
965                        ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
966                    ),
967                ]),
968                events: BTreeMap::from([(
969                    "click".to_owned(),
970                    EventSchema {
971                        payload: ValueSchema::Null,
972                    },
973                )]),
974                parts: BTreeSet::from(["root".to_owned(), "label".to_owned()]),
975                ..ComponentSchema::default()
976            },
977        )
978        .unwrap()
979    }
980
981    #[test]
982    fn invocation_applies_defaults_and_rejects_unknown_props() {
983        let button = button();
984        let invocation = button
985            .invoke(
986                None,
987                Map::from_iter([("text".into(), Dynamic::from("Save"))]),
988            )
989            .unwrap();
990        assert_eq!(
991            invocation
992                .props
993                .get("variant")
994                .unwrap()
995                .clone_cast::<String>(),
996            "primary"
997        );
998
999        let error = button
1000            .invoke(
1001                None,
1002                Map::from_iter([
1003                    ("text".into(), Dynamic::from("Save")),
1004                    ("lable".into(), Dynamic::from("typo")),
1005                ]),
1006            )
1007            .unwrap_err();
1008        assert!(matches!(error, ComponentError::InvalidProps { .. }));
1009    }
1010
1011    #[test]
1012    fn invocation_normalizes_data_style_and_callback_props_for_retention() {
1013        let button = button();
1014        let invocation = button
1015            .invoke(
1016                None,
1017                Map::from_iter([
1018                    ("text".into(), Dynamic::from("Save")),
1019                    ("style".into(), Dynamic::from(Style::new().flex_row())),
1020                    (
1021                        "on_click".into(),
1022                        Dynamic::from(rhai::FnPtr::new("clicked").unwrap()),
1023                    ),
1024                ]),
1025            )
1026            .unwrap();
1027        assert!(matches!(
1028            invocation.retained_props.get("text"),
1029            Some(ComponentPropValue::Data(UiValue::String(value))) if value == "Save"
1030        ));
1031        assert!(matches!(
1032            invocation.retained_props.get("style"),
1033            Some(ComponentPropValue::Style(_))
1034        ));
1035        assert!(matches!(
1036            invocation.retained_props.get("on_click"),
1037            Some(ComponentPropValue::Callback(UiEventHandler::Script(_)))
1038        ));
1039    }
1040
1041    #[test]
1042    fn invocation_rejects_anonymous_retained_callback_props() {
1043        let button = button();
1044        let engine = Engine::new();
1045        let callback = engine.eval::<rhai::FnPtr>("|| ()").unwrap();
1046        assert!(matches!(
1047            button.invoke(
1048                None,
1049                Map::from_iter([
1050                    ("text".into(), Dynamic::from("Save")),
1051                    ("on_click".into(), Dynamic::from(callback)),
1052                ]),
1053            ),
1054            Err(ComponentError::PropConversion { .. })
1055        ));
1056    }
1057
1058    #[test]
1059    fn standard_style_props_are_typed_and_reject_unknown_parts() {
1060        let component = button();
1061        assert!(matches!(
1062            component.schema.props["style"].schema,
1063            ValueSchema::Style
1064        ));
1065        let props = Map::from_iter([
1066            ("text".into(), Dynamic::from("Save")),
1067            (
1068                "part_styles".into(),
1069                Dynamic::from_map(Map::from_iter([(
1070                    "missing".into(),
1071                    Dynamic::from(Style::new()),
1072                )])),
1073            ),
1074        ]);
1075        assert!(matches!(
1076            component.invoke(None, props),
1077            Err(ComponentError::UnknownStylePart { part, .. }) if part == "missing"
1078        ));
1079    }
1080
1081    #[test]
1082    fn stateful_components_require_keys() {
1083        let state = ComponentStateSchema::new(BTreeMap::from([(
1084            "open".to_owned(),
1085            StateField::new(ValueSchema::Bool, UiValue::Bool(false)),
1086        )]))
1087        .unwrap();
1088        let definition = ComponentDefinition::new(
1089            metadata("components/popover", "Popover"),
1090            ComponentSchema {
1091                state,
1092                ..ComponentSchema::default()
1093            },
1094        )
1095        .unwrap();
1096        assert!(matches!(
1097            definition.invoke(None, Map::new()),
1098            Err(ComponentError::MissingKey { .. })
1099        ));
1100        definition
1101            .invoke(Some("settings".to_owned()), Map::new())
1102            .unwrap();
1103    }
1104
1105    #[test]
1106    fn registry_resolves_dependencies_first() {
1107        let mut registry = ComponentRegistry::new();
1108        registry
1109            .register(button(), crate::RUNTIME_API_VERSION)
1110            .unwrap();
1111        let mut popover_metadata = metadata("components/popover", "Popover");
1112        popover_metadata
1113            .dependencies
1114            .insert(ModuleId::parse("components/button").unwrap());
1115        registry
1116            .register(
1117                ComponentDefinition::new(popover_metadata, ComponentSchema::default()).unwrap(),
1118                crate::RUNTIME_API_VERSION,
1119            )
1120            .unwrap();
1121
1122        assert_eq!(
1123            registry
1124                .resolve([ModuleId::parse("components/popover").unwrap()])
1125                .unwrap(),
1126            vec![
1127                ModuleId::parse("components/button").unwrap(),
1128                ModuleId::parse("components/popover").unwrap()
1129            ]
1130        );
1131    }
1132
1133    #[test]
1134    fn component_header_is_machine_readable() {
1135        let header = r#"/* gpui-rhai
1136{
1137  "id": "components/button",
1138  "export": "Button",
1139  "version": "0.1.0",
1140  "runtime_api": { "min_inclusive": 2, "max_exclusive": 3 },
1141  "dependencies": [],
1142  "capabilities": {},
1143  "assets": []
1144}
1145*/
1146// Human-facing Button documentation follows.
1147fn render_button(props) { text(props.text) }
1148"#;
1149        assert_eq!(
1150            parse_component_header(header).unwrap(),
1151            metadata("components/button", "Button")
1152        );
1153    }
1154
1155    #[test]
1156    fn component_collector_is_idempotent_and_rejects_conflicts() {
1157        let collector = ComponentExportCollector::new();
1158        let definition = button();
1159        collector.register_definition(definition.clone()).unwrap();
1160        assert_eq!(collector.snapshot().unwrap().len(), 1);
1161        collector.register_definition(definition.clone()).unwrap();
1162        assert_eq!(collector.snapshot().unwrap().len(), 1);
1163
1164        let mut conflicting = definition;
1165        conflicting.metadata.export = "OtherButton".to_owned();
1166        assert!(collector.register_definition(conflicting).is_err());
1167        assert_eq!(
1168            collector
1169                .snapshot()
1170                .unwrap()
1171                .get(&ModuleId::parse("components/button").unwrap())
1172                .unwrap()
1173                .metadata
1174                .export,
1175            "Button"
1176        );
1177    }
1178
1179    #[test]
1180    fn header_and_exported_metadata_must_match() {
1181        let definition = button();
1182        let mut header = definition.metadata.clone();
1183        header.version = Version::new(0, 2, 0);
1184        assert!(matches!(
1185            definition.validate_header(&header),
1186            Err(ComponentError::HeaderMismatch { .. })
1187        ));
1188    }
1189
1190    #[test]
1191    fn component_schema_round_trips_for_tooling() {
1192        let definition = button();
1193        let json = serde_json::to_string_pretty(&definition).unwrap();
1194        let decoded: ComponentDefinition = serde_json::from_str(&json).unwrap();
1195        assert_eq!(decoded, definition);
1196    }
1197
1198    #[test]
1199    fn component_assets_are_safe_and_part_of_header_identity() {
1200        let mut valid = metadata("components/icon_button", "IconButton");
1201        valid.assets.insert("icons/arrow-next.svg".to_owned());
1202        let definition =
1203            ComponentDefinition::new(valid.clone(), ComponentSchema::default()).unwrap();
1204        definition.validate_header(&valid).unwrap();
1205
1206        let mut mismatch = valid.clone();
1207        mismatch.assets.clear();
1208        assert!(matches!(
1209            definition.validate_header(&mismatch),
1210            Err(ComponentError::HeaderMismatch { .. })
1211        ));
1212
1213        let mut invalid = metadata("components/icon_button", "IconButton");
1214        invalid.assets.insert("../secret.svg".to_owned());
1215        assert!(matches!(
1216            ComponentDefinition::new(invalid, ComponentSchema::default()),
1217            Err(ComponentError::InvalidAsset(_))
1218        ));
1219    }
1220
1221    #[test]
1222    fn invalid_nested_schema_definition_is_rejected_at_export() {
1223        let schema = ComponentSchema {
1224            props: BTreeMap::from([(
1225                "page".to_owned(),
1226                ObjectField::required(ValueSchema::bounded_integer(Some(3), Some(1))),
1227            )]),
1228            ..ComponentSchema::default()
1229        };
1230        assert!(matches!(
1231            ComponentDefinition::new(metadata("components/pager", "Pager"), schema),
1232            Err(ComponentError::InvalidSchemaDefinition { .. })
1233        ));
1234    }
1235
1236    #[test]
1237    fn component_reuse_equality_is_conservative_for_nested_nodes() {
1238        let data = ComponentProps(BTreeMap::from([(
1239            "label".to_owned(),
1240            ComponentPropValue::Data(UiValue::String("same".to_owned())),
1241        )]));
1242        assert!(data.reusable_eq(&data.clone()));
1243
1244        let slot = ComponentProps(BTreeMap::from([(
1245            "content".to_owned(),
1246            ComponentPropValue::Node(Box::new(UiNode::text("same"))),
1247        )]));
1248        assert!(!slot.reusable_eq(&slot.clone()));
1249
1250        let nested_slot = ComponentProps(BTreeMap::from([(
1251            "payload".to_owned(),
1252            ComponentPropValue::Array(vec![ComponentPropValue::Map(BTreeMap::from([(
1253                "content".to_owned(),
1254                ComponentPropValue::Node(Box::new(UiNode::text("same"))),
1255            )]))]),
1256        )]));
1257        assert!(!nested_slot.reusable_eq(&nested_slot.clone()));
1258    }
1259}