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 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
61 pub tokens: BTreeSet<String>,
62 #[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 #[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 #[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 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 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 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 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 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 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#[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 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 pub fn clear(&self) -> Result<(), ComponentExportError> {
566 *self
567 .registry
568 .lock()
569 .map_err(|_| ComponentExportError::Poisoned)? = ComponentRegistry::new();
570 Ok(())
571 }
572
573 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 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 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
691pub 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}