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