Skip to main content

hypen_engine/ir/
component.rs

1use super::Element;
2use indexmap::IndexMap;
3use std::sync::Arc;
4
5/// Result from component resolution
6/// Contains the source code and the resolved path for the component
7pub struct ResolvedComponent {
8    pub source: String,
9    pub path: String,
10    pub passthrough: bool,
11    pub lazy: bool,
12}
13
14/// Callback type for resolving component source code
15/// Takes component name and optional path context, returns resolved component
16/// Path context is the file path where this component is being referenced from
17/// Resolver should return (source_code, resolved_path) where resolved_path is the
18/// absolute path to the component file (used for resolving nested components)
19pub type ComponentResolver =
20    Arc<dyn Fn(&str, Option<&str>) -> Option<ResolvedComponent> + Send + Sync>;
21
22/// A component definition - a template that can be instantiated
23#[derive(Clone)]
24pub struct Component {
25    /// Component name
26    pub name: String,
27
28    /// Template function that produces element tree
29    /// Takes props and returns expanded element tree
30    pub template: Arc<dyn Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync>,
31
32    /// Default props
33    pub default_props: IndexMap<String, serde_json::Value>,
34
35    /// Source path where this component was loaded from (optional)
36    pub source_path: Option<String>,
37
38    /// If true, this component acts as a passthrough container
39    /// It preserves its children and props without template expansion
40    pub passthrough: bool,
41
42    /// If true, this component's children are NOT expanded during initial pass
43    /// Children remain as component references for lazy rendering
44    pub lazy: bool,
45}
46
47impl Component {
48    pub fn new(
49        name: impl Into<String>,
50        template: impl Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync + 'static,
51    ) -> Self {
52        Self {
53            name: name.into(),
54            template: Arc::new(template),
55            default_props: IndexMap::new(),
56            source_path: None,
57            passthrough: false,
58            lazy: false,
59        }
60    }
61
62    pub fn with_defaults(mut self, defaults: IndexMap<String, serde_json::Value>) -> Self {
63        self.default_props = defaults;
64        self
65    }
66
67    pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
68        self.source_path = Some(path.into());
69        self
70    }
71
72    pub fn with_passthrough(mut self, passthrough: bool) -> Self {
73        self.passthrough = passthrough;
74        self
75    }
76
77    pub fn with_lazy(mut self, lazy: bool) -> Self {
78        self.lazy = lazy;
79        self
80    }
81
82    /// Instantiate this component with given props
83    pub fn instantiate(&self, props: IndexMap<String, serde_json::Value>) -> Element {
84        let mut merged_props = self.default_props.clone();
85        merged_props.extend(props);
86        (self.template)(merged_props)
87    }
88}
89
90/// Registry of all available components
91pub struct ComponentRegistry {
92    /// Components indexed by fully qualified key (path:name or just name)
93    components: IndexMap<String, Component>,
94    /// Optional resolver for dynamically loading components
95    resolver: Option<ComponentResolver>,
96    /// Cache of resolved component paths to prevent re-resolving
97    /// Key format: "path:component_name" or "component_name" if no path
98    resolved_cache: IndexMap<String, bool>,
99}
100
101impl ComponentRegistry {
102    pub fn new() -> Self {
103        Self {
104            components: IndexMap::new(),
105            resolver: None,
106            resolved_cache: IndexMap::new(),
107        }
108    }
109
110    /// Register a primitive element name to skip component resolution
111    /// Called by the renderer to mark built-in DOM elements
112    pub fn register_primitive(&mut self, name: &str) {
113        self.resolved_cache.insert(name.to_string(), false);
114    }
115
116    /// Set the component resolver callback
117    pub fn set_resolver(&mut self, resolver: ComponentResolver) {
118        self.resolver = Some(resolver);
119    }
120
121    pub fn register(&mut self, component: Component) {
122        // Register with qualified key if source path is available
123        if let Some(ref path) = component.source_path {
124            let qualified_key = format!("{}:{}", path, component.name);
125            self.components.insert(qualified_key, component.clone());
126        }
127
128        // Always register with unqualified name as fallback
129        self.components.insert(component.name.clone(), component);
130    }
131
132    /// Get a component by name and optional context path
133    pub fn get(&self, name: &str, context_path: Option<&str>) -> Option<&Component> {
134        // Try with context path first
135        if let Some(path) = context_path {
136            let qualified_key = format!("{}:{}", path, name);
137            if let Some(component) = self.components.get(&qualified_key) {
138                return Some(component);
139            }
140        }
141
142        // Fall back to just name (for globally registered components)
143        self.components.get(name)
144    }
145
146    /// Try to resolve and register a component by name and context path
147    fn try_resolve(&mut self, name: &str, context_path: Option<&str>) -> bool {
148        // Build cache key
149        let cache_key = if let Some(path) = context_path {
150            format!("{}:{}", path, name)
151        } else {
152            name.to_string()
153        };
154
155        // Check cache first
156        if let Some(&cached) = self.resolved_cache.get(&cache_key) {
157            return cached;
158        }
159
160        // Try to resolve
161        if let Some(ref resolver) = self.resolver {
162            if let Some(resolved) = resolver(name, context_path) {
163                // For lazy components, don't parse the template
164                // Children won't be expanded until explicitly requested
165                if resolved.lazy {
166                    #[cfg(all(target_arch = "wasm32", feature = "js"))]
167                    web_sys::console::log_1(
168                        &format!("Registering lazy component: {}", name).into(),
169                    );
170
171                    // Create a dummy component - children won't be expanded
172                    let dummy_element = Element::new(name);
173                    let component = Component::new(name, move |_props| dummy_element.clone())
174                        .with_source_path(resolved.path.clone())
175                        .with_lazy(true);
176
177                    self.register(component);
178                    self.resolved_cache.insert(cache_key, true);
179                    return true;
180                }
181
182                // For passthrough components, don't parse the template
183                // They act as transparent containers
184                if resolved.passthrough {
185                    #[cfg(all(target_arch = "wasm32", feature = "js"))]
186                    web_sys::console::log_1(
187                        &format!("Registering passthrough component: {}", name).into(),
188                    );
189
190                    // Create a dummy component - the template won't be used for passthrough
191                    let dummy_element = Element::new(name);
192                    let component = Component::new(name, move |_props| dummy_element.clone())
193                        .with_source_path(resolved.path.clone())
194                        .with_passthrough(true);
195
196                    self.register(component);
197                    self.resolved_cache.insert(cache_key, true);
198                    return true;
199                }
200
201                // Parse the component source for non-passthrough components
202                match hypen_parser::parse_component(&resolved.source) {
203                    Ok(component_spec) => {
204                        // Convert to IR
205                        let element = super::expand::ast_to_ir(&component_spec);
206
207                        // Create a component that returns the parsed element
208                        let component = Component::new(name, move |_props| element.clone())
209                            .with_source_path(resolved.path.clone())
210                            .with_passthrough(false);
211
212                        self.register(component);
213                        self.resolved_cache.insert(cache_key, true);
214                        return true;
215                    }
216                    Err(e) => {
217                        #[cfg(all(target_arch = "wasm32", feature = "js"))]
218                        web_sys::console::error_1(
219                            &format!("Failed to parse component {}: {:?}", name, e).into(),
220                        );
221
222                        #[cfg(not(all(target_arch = "wasm32", feature = "js")))]
223                        eprintln!("Failed to parse component {}: {:?}", name, e);
224
225                        self.resolved_cache.insert(cache_key, false);
226                        return false;
227                    }
228                }
229            }
230        }
231
232        self.resolved_cache.insert(cache_key, false);
233        false
234    }
235
236    pub fn expand(&mut self, element: &Element) -> Element {
237        self.expand_with_context(element, None)
238    }
239
240    /// Force expand an element's children (used for lazy components)
241    /// This will expand children that were previously kept unexpanded
242    pub fn expand_children(
243        &mut self,
244        element: &Element,
245        context_path: Option<&str>,
246    ) -> Vec<Element> {
247        element
248            .children
249            .iter()
250            .map(|child| self.expand_with_context(child, context_path))
251            .collect()
252    }
253
254    /// Expand an element with a context path for component resolution
255    fn expand_with_context(&mut self, element: &Element, context_path: Option<&str>) -> Element {
256        // First check if component exists, if not try to resolve it
257        let component_exists = self.get(&element.element_type, context_path).is_some();
258
259        if !component_exists {
260            // Try to resolve the component dynamically
261            self.try_resolve(&element.element_type, context_path);
262        }
263
264        // If this element references a registered component, expand it
265        if let Some(component) = self.get(&element.element_type, context_path) {
266            // Check if this is a lazy component (children NOT expanded until explicitly requested)
267            if component.lazy {
268                // Lazy component: keep element and children, but DON'T expand children yet
269                let mut element = element.clone();
270
271                // Mark as lazy so reconciler knows to skip children
272                element.props.insert(
273                    "__lazy".to_string(),
274                    super::Value::Static(serde_json::json!(true)),
275                );
276
277                #[cfg(all(target_arch = "wasm32", feature = "js"))]
278                web_sys::console::log_1(
279                    &format!(
280                        "Lazy {} (props: {:?}): {} children kept unexpanded",
281                        element.element_type,
282                        element.props.keys().collect::<Vec<_>>(),
283                        element.children.len()
284                    )
285                    .into(),
286                );
287
288                return element;
289            }
290
291            // Check if this is a passthrough component
292            if component.passthrough {
293                // Passthrough component: keep the original element but expand its children
294                let mut element = element.clone();
295
296                #[cfg(all(target_arch = "wasm32", feature = "js"))]
297                {
298                    let props_str = element
299                        .props
300                        .iter()
301                        .map(|(k, v)| format!("{}={:?}", k, v))
302                        .collect::<Vec<_>>()
303                        .join(", ");
304                    web_sys::console::log_1(
305                        &format!(
306                            "Passthrough {} (props: [{}]): {} children before expansion",
307                            element.element_type,
308                            props_str,
309                            element.children.len()
310                        )
311                        .into(),
312                    );
313                }
314
315                // Get the source path for child context
316                let child_context = component.source_path.clone();
317                let child_context_ref = child_context.as_deref();
318
319                // Recursively expand children
320                element.children = element
321                    .children
322                    .into_iter()
323                    .map(|child| Arc::new(self.expand_with_context(&child, child_context_ref)))
324                    .collect();
325
326                #[cfg(all(target_arch = "wasm32", feature = "js"))]
327                web_sys::console::log_1(
328                    &format!(
329                        "Passthrough {}: {} children after expansion",
330                        element.element_type,
331                        element.children.len()
332                    )
333                    .into(),
334                );
335
336                element
337            } else {
338                // Regular component: instantiate template and replace
339                // Convert Value props to serde_json::Value (resolve only static values here)
340                let mut props = IndexMap::new();
341                for (k, v) in &element.props {
342                    if let super::Value::Static(val) = v {
343                        props.insert(k.clone(), val.clone());
344                    }
345                }
346
347                let mut expanded = component.instantiate(props);
348
349                // Preserve bindings and actions from the original element
350                for (k, v) in &element.props {
351                    match v {
352                        super::Value::Binding(_) | super::Value::Action(_) => {
353                            expanded.props.insert(k.clone(), v.clone());
354                        }
355                        _ => {}
356                    }
357                }
358
359                // Get the source path of this component for resolving its children
360                // Clone it to avoid holding a borrow
361                let child_context = component.source_path.clone();
362
363                // Replace Children() placeholders with actual children from the caller
364                expanded.children = self.replace_children_slots(
365                    &expanded.children,
366                    &element.children,
367                    context_path,
368                );
369
370                // Recursively expand children with the new context
371                let child_context_ref = child_context.as_deref();
372                expanded.children = expanded
373                    .children
374                    .into_iter()
375                    .map(|child| Arc::new(self.expand_with_context(&child, child_context_ref)))
376                    .collect();
377
378                expanded
379            }
380        } else {
381            // Not a component, just expand children
382            let mut element = element.clone();
383            element.children = element
384                .children
385                .into_iter()
386                .map(|child| Arc::new(self.expand_with_context(&child, context_path)))
387                .collect();
388            element
389        }
390    }
391
392    /// Expand an IRNode recursively, expanding any Element nodes via the component registry
393    pub fn expand_ir_node(&mut self, node: &super::IRNode) -> super::IRNode {
394        self.expand_ir_node_with_context(node, None)
395    }
396
397    /// Expand an IRNode with context path for component resolution
398    fn expand_ir_node_with_context(
399        &mut self,
400        node: &super::IRNode,
401        context_path: Option<&str>,
402    ) -> super::IRNode {
403        match node {
404            super::IRNode::Element(element) => {
405                // Expand the element through the registry
406                let expanded = self.expand_with_context(element, context_path);
407                super::IRNode::Element(expanded)
408            }
409            super::IRNode::ForEach {
410                source,
411                item_name,
412                key_path,
413                template,
414                props,
415            } => {
416                // Recursively expand template children
417                let expanded_template: Vec<super::IRNode> = template
418                    .iter()
419                    .map(|child| self.expand_ir_node_with_context(child, context_path))
420                    .collect();
421
422                super::IRNode::ForEach {
423                    source: source.clone(),
424                    item_name: item_name.clone(),
425                    key_path: key_path.clone(),
426                    template: expanded_template,
427                    props: props.clone(),
428                }
429            }
430            super::IRNode::Conditional {
431                value,
432                branches,
433                fallback,
434            } => {
435                // Expand branch children
436                let expanded_branches: Vec<super::ConditionalBranch> = branches
437                    .iter()
438                    .map(|branch| super::ConditionalBranch {
439                        pattern: branch.pattern.clone(),
440                        children: branch
441                            .children
442                            .iter()
443                            .map(|child| self.expand_ir_node_with_context(child, context_path))
444                            .collect(),
445                    })
446                    .collect();
447
448                // Expand fallback children if present
449                let expanded_fallback = fallback.as_ref().map(|fb| {
450                    fb.iter()
451                        .map(|child| self.expand_ir_node_with_context(child, context_path))
452                        .collect()
453                });
454
455                super::IRNode::Conditional {
456                    value: value.clone(),
457                    branches: expanded_branches,
458                    fallback: expanded_fallback,
459                }
460            }
461        }
462    }
463
464    /// Replace Children() placeholders with actual children
465    /// Supports named slots via Children().slot("header")
466    fn replace_children_slots(
467        &self,
468        template_children: &im::Vector<Arc<Element>>,
469        actual_children: &im::Vector<Arc<Element>>,
470        _context_path: Option<&str>,
471    ) -> im::Vector<Arc<Element>> {
472        let mut result = im::Vector::new();
473
474        for child in template_children {
475            if child.element_type == "Children" {
476                // Check if this is a named slot via .slot() applicator
477                // Applicators are stored as props with format "applicatorName.argIndex"
478                let slot_name = self.get_slot_name(&child.props);
479
480                // If named slot, filter children by slot applicator
481                // Otherwise, include all children that don't have a slot applicator
482                if let Some(slot) = slot_name {
483                    for c in actual_children.iter() {
484                        if self.get_slot_name(&c.props) == Some(slot) {
485                            result.push_back(Arc::clone(c));
486                        }
487                    }
488                } else {
489                    // Default slot - children without slot applicator
490                    for c in actual_children.iter() {
491                        if self.get_slot_name(&c.props).is_none() {
492                            result.push_back(Arc::clone(c));
493                        }
494                    }
495                }
496            } else {
497                // Not a Children() placeholder - keep as is but recurse into its children
498                let mut new_child = (**child).clone();
499                new_child.children =
500                    self.replace_children_slots(&child.children, actual_children, _context_path);
501                result.push_back(Arc::new(new_child));
502            }
503        }
504
505        result
506    }
507
508    /// Extract slot name from applicators
509    /// Looks for .slot("name") which becomes prop "slot.0" = "name"
510    fn get_slot_name<'a>(&self, props: &'a super::Props) -> Option<&'a str> {
511        props.get("slot.0").and_then(|v| {
512            if let super::Value::Static(serde_json::Value::String(s)) = v {
513                Some(s.as_str())
514            } else {
515                None
516            }
517        })
518    }
519}
520
521impl Default for ComponentRegistry {
522    fn default() -> Self {
523        Self::new()
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::ir::Value;
531
532    #[test]
533    fn test_dynamic_component_resolution() {
534        let mut registry = ComponentRegistry::new();
535
536        // Set up a resolver that returns component source and path
537        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
538            if name == "Header" {
539                Some(ResolvedComponent {
540                    source: r#"Row { Text("Header") }"#.to_string(),
541                    path: "/components/Header.hypen".to_string(),
542                    passthrough: false,
543                    lazy: false,
544                })
545            } else {
546                None
547            }
548        }));
549
550        // Create an element that references the unregistered Header component
551        let element = Element::new("Column").with_child(Element::new("Header"));
552
553        // Expand should trigger resolution
554        let expanded = registry.expand(&element);
555
556        // Should have expanded Header into Row { Text }
557        assert_eq!(expanded.element_type, "Column");
558        assert_eq!(expanded.children.len(), 1);
559        assert_eq!(expanded.children[0].element_type, "Row");
560        assert_eq!(expanded.children[0].children[0].element_type, "Text");
561    }
562
563    #[test]
564    fn test_component_resolution_with_path_context() {
565        let mut registry = ComponentRegistry::new();
566
567        // Set up a resolver that resolves based on context path
568        registry.set_resolver(Arc::new(|name: &str, context: Option<&str>| {
569            match (name, context) {
570                ("Button", Some("/pages/Home.hypen")) => Some(ResolvedComponent {
571                    source: r#"Text("Home Button")"#.to_string(),
572                    path: "/components/buttons/HomeButton.hypen".to_string(),
573                    passthrough: false,
574                    lazy: false,
575                }),
576                ("Button", Some("/pages/About.hypen")) => Some(ResolvedComponent {
577                    source: r#"Text("About Button")"#.to_string(),
578                    path: "/components/buttons/AboutButton.hypen".to_string(),
579                    passthrough: false,
580                    lazy: false,
581                }),
582                _ => None,
583            }
584        }));
585
586        // Register a component with a source path
587        let home_element =
588            Element::new("Text").with_prop("0", Value::Static(serde_json::json!("Home")));
589        let home_component = Component::new("Home", move |_| home_element.clone())
590            .with_source_path("/pages/Home.hypen");
591        registry.register(home_component);
592
593        // Create an element that uses Button from Home context
594        let element = Element::new("Column")
595            .with_child(Element::new("Home").with_child(Element::new("Button")));
596
597        let expanded = registry.expand(&element);
598
599        // Button should resolve differently based on its context
600        assert_eq!(expanded.element_type, "Column");
601    }
602
603    #[test]
604    fn test_component_resolution_caching() {
605        let mut registry = ComponentRegistry::new();
606        let call_count = Arc::new(std::sync::Mutex::new(0));
607        let call_count_clone = call_count.clone();
608
609        // Resolver that tracks calls
610        registry.set_resolver(Arc::new(move |name: &str, _context: Option<&str>| {
611            if name == "Button" {
612                *call_count_clone.lock().unwrap() += 1;
613                Some(ResolvedComponent {
614                    source: r#"Text("Click")"#.to_string(),
615                    path: "/components/Button.hypen".to_string(),
616                    passthrough: false,
617                    lazy: false,
618                })
619            } else {
620                None
621            }
622        }));
623
624        // First expansion should call resolver
625        let element1 = Element::new("Button");
626        let _ = registry.expand(&element1);
627        assert_eq!(*call_count.lock().unwrap(), 1);
628
629        // Second expansion should use cache
630        let element2 = Element::new("Button");
631        let _ = registry.expand(&element2);
632        assert_eq!(*call_count.lock().unwrap(), 1); // Still 1, not 2
633    }
634
635    #[test]
636    fn test_failed_resolution_cached() {
637        let mut registry = ComponentRegistry::new();
638        let call_count = Arc::new(std::sync::Mutex::new(0));
639        let call_count_clone = call_count.clone();
640
641        // Resolver that returns None
642        registry.set_resolver(Arc::new(move |_name: &str, _context: Option<&str>| {
643            *call_count_clone.lock().unwrap() += 1;
644            None
645        }));
646
647        // First expansion should call resolver
648        let element1 = Element::new("Unknown");
649        let _ = registry.expand(&element1);
650        assert_eq!(*call_count.lock().unwrap(), 1);
651
652        // Second expansion should use cached failure
653        let element2 = Element::new("Unknown");
654        let _ = registry.expand(&element2);
655        assert_eq!(*call_count.lock().unwrap(), 1); // Cached
656    }
657
658    #[test]
659    fn test_passthrough_component_preserves_props() {
660        let mut registry = ComponentRegistry::new();
661
662        // Register Router and Route as passthrough components
663        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
664            if name == "Router" || name == "Route" {
665                Some(ResolvedComponent {
666                    source: String::new(), // Empty template for passthrough
667                    path: name.to_string(),
668                    passthrough: true,
669                    lazy: false,
670                })
671            } else if name == "HomePage" {
672                // Regular component with a simple template
673                Some(ResolvedComponent {
674                    source: "Text(\"Home\")".to_string(),
675                    path: name.to_string(),
676                    passthrough: false,
677                    lazy: false,
678                })
679            } else {
680                None
681            }
682        }));
683
684        // Build a tree structure like:
685        // Router {
686        //   Route("/") { HomePage }
687        //   Route("/about") { HomePage }
688        // }
689        let mut router = Element::new("Router");
690
691        let mut route1 = Element::new("Route");
692        route1
693            .props
694            .insert("0".to_string(), Value::Static(serde_json::json!("/")));
695        route1
696            .children
697            .push_back(std::sync::Arc::new(Element::new("HomePage")));
698
699        let mut route2 = Element::new("Route");
700        route2
701            .props
702            .insert("0".to_string(), Value::Static(serde_json::json!("/about")));
703        route2
704            .children
705            .push_back(std::sync::Arc::new(Element::new("HomePage")));
706
707        router.children.push_back(std::sync::Arc::new(route1));
708        router.children.push_back(std::sync::Arc::new(route2));
709
710        // Expand the tree
711        let expanded = registry.expand(&router);
712
713        // Verify Router is preserved
714        assert_eq!(expanded.element_type, "Router");
715        assert_eq!(expanded.children.len(), 2);
716
717        // Verify first Route preserves its path prop
718        let expanded_route1 = &expanded.children[0];
719        assert_eq!(expanded_route1.element_type, "Route");
720        if let Some(Value::Static(path)) = expanded_route1.props.get("0") {
721            assert_eq!(path.as_str().unwrap(), "/");
722        } else {
723            panic!("Route 1 missing path prop");
724        }
725
726        // Verify second Route preserves its path prop
727        let expanded_route2 = &expanded.children[1];
728        assert_eq!(expanded_route2.element_type, "Route");
729        if let Some(Value::Static(path)) = expanded_route2.props.get("0") {
730            assert_eq!(path.as_str().unwrap(), "/about");
731        } else {
732            panic!("Route 2 missing path prop");
733        }
734
735        // Verify children are expanded (HomePage should be replaced with Text)
736        assert_eq!(expanded_route1.children.len(), 1);
737        assert_eq!(expanded_route1.children[0].element_type, "Text");
738    }
739}