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