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