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