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 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 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 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 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 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#[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 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 pub fn clear(&self) -> Result<(), ComponentExportError> {
516 *self
517 .registry
518 .lock()
519 .map_err(|_| ComponentExportError::Poisoned)? = ComponentRegistry::new();
520 Ok(())
521 }
522
523 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 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 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
641pub 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}