Skip to main content

atom_engine/
components.rs

1//! Component system for Atom Engine.
2//!
3//! This module provides the component infrastructure including:
4//! - Component registration and management
5//! - Props validation
6//! - Slot handling
7//! - Component caching
8//! - Scoped slots support
9
10use std::collections::HashMap;
11use std::hash::{Hash, Hasher};
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use serde_json::Value;
16
17use crate::error::Result;
18
19/// Computes a hash for component props.
20///
21/// Used for component caching to generate cache keys.
22pub fn compute_props_hash(props: &Value) -> u64 {
23    let mut hasher = std::collections::hash_map::DefaultHasher::new();
24    let json_str = serde_json::to_string(props).unwrap_or_default();
25    json_str.hash(&mut hasher);
26    hasher.finish()
27}
28
29/// Computes a cache key from component path and props hash.
30pub fn compute_cache_key(path: &str, props_hash: u64) -> u64 {
31    let mut hasher = std::collections::hash_map::DefaultHasher::new();
32    path.hash(&mut hasher);
33    props_hash.hash(&mut hasher);
34    hasher.finish()
35}
36
37/// The type of a component prop.
38///
39/// Used for props validation to ensure type safety.
40#[derive(Debug, Clone, PartialEq)]
41pub enum PropType {
42    String,
43    Number,
44    Boolean,
45    Array,
46    Object,
47    Any,
48}
49
50impl PropType {
51    /// Creates a PropType from a string representation.
52    ///
53    /// Supported types: "string", "number", "boolean", "array", "object", "any"
54    #[allow(clippy::should_implement_trait)]
55    pub fn from_str(s: &str) -> Self {
56        match s.to_lowercase().as_str() {
57            "string" => PropType::String,
58            "number" => PropType::Number,
59            "boolean" => PropType::Boolean,
60            "array" => PropType::Array,
61            "object" => PropType::Object,
62            _ => PropType::Any,
63        }
64    }
65
66    /// Checks if a value matches this prop type.
67    pub fn matches(&self, value: &Value) -> bool {
68        match self {
69            PropType::Any => true,
70            PropType::String => value.is_string(),
71            PropType::Number => value.is_number(),
72            PropType::Boolean => value.is_boolean(),
73            PropType::Array => value.is_array(),
74            PropType::Object => value.is_object(),
75        }
76    }
77}
78
79/// Definition of a component prop.
80#[derive(Debug, Clone)]
81pub struct PropDef {
82    pub name: String,
83    pub prop_type: PropType,
84    pub required: bool,
85    pub default: Option<Value>,
86}
87
88impl PropDef {
89    /// Validates a prop value against this definition.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the value is null but required, or if the type doesn't match.
94    pub fn validate(&self, value: &Value) -> std::result::Result<(), String> {
95        if value.is_null() && self.required {
96            return Err(format!("Required prop '{}' is null", self.name));
97        }
98        if !self.prop_type.matches(value) {
99            return Err(format!(
100                "Prop '{}' type mismatch: expected {:?}, got {}",
101                self.name,
102                self.prop_type,
103                match value {
104                    Value::Null => "null",
105                    Value::Bool(_) => "boolean",
106                    Value::Number(_) => "number",
107                    Value::String(_) => "string",
108                    Value::Array(_) => "array",
109                    Value::Object(_) => "object",
110                }
111            ));
112        }
113        Ok(())
114    }
115}
116
117/// A registered component with its template and prop definitions.
118#[derive(Debug, Clone)]
119pub struct Component {
120    pub path: String,
121    pub props: Vec<PropDef>,
122    pub template: String,
123    pub slots: Vec<String>,
124    pub optional_slots: Vec<String>,
125    pub scoped_slots: Vec<ScopedSlotDef>,
126}
127
128/// Definition of a scoped slot.
129#[derive(Debug, Clone)]
130pub struct ScopedSlotDef {
131    pub name: String,
132    pub props: Vec<String>,
133}
134
135/// Data for slot rendering.
136#[derive(Debug, Clone, Default)]
137pub struct SlotData {
138    pub fills: IndexMap<String, String>,
139    pub default: Option<String>,
140    pub scoped_data: HashMap<String, Value>,
141}
142
143/// Runtime component renderer that manages slot content and stack buffers.
144///
145/// Used during template rendering to accumulate and manage component content.
146#[derive(Clone, Default)]
147pub struct ComponentRenderer {
148    stack_buffers: HashMap<String, Vec<String>>,
149    slot_data: HashMap<String, SlotData>,
150    once_rendered: std::collections::HashSet<u64>,
151}
152
153impl ComponentRenderer {
154    /// Creates a new ComponentRenderer.
155    pub fn new() -> Self {
156        ComponentRenderer {
157            stack_buffers: HashMap::new(),
158            slot_data: HashMap::new(),
159            once_rendered: std::collections::HashSet::new(),
160        }
161    }
162
163    /// Pushes content onto a stack buffer.
164    pub fn push(&mut self, name: &str, content: String) {
165        self.stack_buffers
166            .entry(name.to_string())
167            .or_default()
168            .push(content);
169    }
170
171    /// Prepends content to a stack buffer.
172    pub fn prepend(&mut self, name: &str, content: String) {
173        self.stack_buffers
174            .entry(name.to_string())
175            .or_default()
176            .insert(0, content);
177    }
178
179    /// Drains and returns all content from a stack buffer, removing it.
180    pub fn drain(&mut self, name: &str) -> String {
181        self.stack_buffers
182            .remove(name)
183            .map(|v| v.join("\n"))
184            .unwrap_or_default()
185    }
186
187    /// Peeks at the content of a stack buffer without removing it.
188    pub fn peek(&self, name: &str) -> Option<String> {
189        self.stack_buffers.get(name).map(|v| v.join("\n"))
190    }
191
192    /// Sets the fill content for a slot.
193    pub fn set_slot_fill(&mut self, slot_name: &str, content: String) {
194        let slot_name = slot_name.trim_start_matches('$').to_string();
195        let slot = self.slot_data.entry(slot_name.clone()).or_default();
196        if slot_name == "default" || slot_name.is_empty() {
197            slot.default = Some(content);
198        } else {
199            slot.fills.insert(slot_name, content);
200        }
201    }
202
203    /// Gets the fill content for a slot.
204    pub fn get_slot(&self, name: &str) -> Option<String> {
205        let name = name.trim_start_matches('$').to_string();
206        if name == "default" || name.is_empty() {
207            self.slot_data
208                .get("default")
209                .and_then(|s| s.default.clone())
210        } else {
211            self.slot_data
212                .get(&name)
213                .and_then(|s| s.fills.get(&name).cloned())
214        }
215    }
216
217    /// Checks if a slot has fill content.
218    pub fn has_slot(&self, name: &str) -> bool {
219        let name = name.trim_start_matches('$').to_string();
220        if name == "default" || name.is_empty() {
221            self.slot_data
222                .get("default")
223                .map(|s| s.default.is_some())
224                .unwrap_or(false)
225        } else {
226            self.slot_data
227                .get(&name)
228                .map(|s| s.fills.contains_key(&name))
229                .unwrap_or(false)
230        }
231    }
232
233    /// Sets scoped data for a slot.
234    pub fn set_scoped_data(&mut self, slot_name: &str, key: &str, value: Value) {
235        let slot_name = slot_name.trim_start_matches('$').to_string();
236        let slot = self.slot_data.entry(slot_name).or_default();
237        slot.scoped_data.insert(key.to_string(), value);
238    }
239
240    /// Gets scoped data for a slot.
241    pub fn get_scoped_data(&self, slot_name: &str) -> Option<HashMap<String, Value>> {
242        let name = slot_name.trim_start_matches('$').to_string();
243        self.slot_data.get(&name).map(|s| s.scoped_data.clone())
244    }
245
246    /// Returns true if the key has not been rendered yet, then marks it as rendered.
247    ///
248    /// Used for the `once` directive to ensure content renders only once.
249    pub fn once(&mut self, key: u64) -> bool {
250        if self.once_rendered.contains(&key) {
251            return false;
252        }
253        self.once_rendered.insert(key);
254        true
255    }
256
257    /// Resets the renderer, clearing all buffers and slot data.
258    pub fn reset(&mut self) {
259        self.stack_buffers.clear();
260        self.slot_data.clear();
261    }
262}
263
264/// Registry for managing component templates.
265#[derive(Clone, Default)]
266pub struct ComponentRegistry {
267    components: HashMap<String, Component>,
268    cache: Arc<std::sync::RwLock<ComponentCache>>,
269    cache_enabled: bool,
270}
271
272/// Cache for rendered components.
273#[derive(Default)]
274pub struct ComponentCache {
275    entries: HashMap<u64, CachedRender>,
276}
277
278/// A cached component render.
279#[derive(Clone)]
280pub struct CachedRender {
281    pub html: String,
282    #[allow(dead_code)]
283    pub props_hash: u64,
284}
285
286impl ComponentCache {
287    /// Creates a new empty ComponentCache.
288    pub fn new() -> Self {
289        ComponentCache {
290            entries: HashMap::new(),
291        }
292    }
293
294    /// Gets a cached render by key.
295    pub fn get(&self, key: &u64) -> Option<String> {
296        self.entries.get(key).map(|c| c.html.clone())
297    }
298
299    /// Inserts a cached render.
300    pub fn insert(&mut self, key: u64, html: String, props_hash: u64) {
301        self.entries.insert(key, CachedRender { html, props_hash });
302    }
303
304    /// Clears all cached entries.
305    pub fn clear(&mut self) {
306        self.entries.clear();
307    }
308
309    /// Returns the number of cached entries.
310    pub fn len(&self) -> usize {
311        self.entries.len()
312    }
313
314    /// Returns true if the cache is empty.
315    pub fn is_empty(&self) -> bool {
316        self.entries.is_empty()
317    }
318}
319
320impl ComponentRegistry {
321    /// Creates a new ComponentRegistry.
322    pub fn new() -> Self {
323        ComponentRegistry {
324            components: HashMap::new(),
325            cache: Arc::new(std::sync::RwLock::new(ComponentCache::new())),
326            cache_enabled: false,
327        }
328    }
329
330    /// Registers a component with its template.
331    pub fn register(&mut self, path: &str, template: &str) -> Result<()> {
332        let props = Self::parse_props(template);
333        let (slots, optional_slots) = Self::parse_slots(template);
334        let scoped_slots = Self::parse_scoped_slots(template);
335
336        self.components.insert(
337            path.to_string(),
338            Component {
339                path: path.to_string(),
340                props,
341                template: template.to_string(),
342                slots,
343                optional_slots,
344                scoped_slots,
345            },
346        );
347
348        Ok(())
349    }
350
351    /// Gets a component by path.
352    pub fn get(&self, path: &str) -> Option<&Component> {
353        self.components.get(path)
354    }
355
356    /// Resolves a tag name to a component path.
357    ///
358    /// Tries multiple variations: exact match, with "components/" prefix,
359    /// with ".html" extension, and nested paths.
360    pub fn resolve_tag(&self, tag: &str) -> Option<String> {
361        if self.components.contains_key(tag) {
362            return Some(tag.to_string());
363        }
364
365        let with_prefix = format!("components/{}", tag);
366        if self.components.contains_key(&with_prefix) {
367            return Some(with_prefix);
368        }
369
370        let with_ext = format!("{}.html", tag);
371        if self.components.contains_key(&with_ext) {
372            return Some(with_ext);
373        }
374
375        let parts: Vec<&str> = tag.split('.').collect();
376        if parts.len() >= 2 {
377            let nested = format!("components/{}.html", parts.join("/"));
378            if self.components.contains_key(&nested) {
379                return Some(nested);
380            }
381        }
382
383        None
384    }
385
386    /// Lists all registered component paths.
387    pub fn list_components(&self) -> Vec<String> {
388        self.components.keys().cloned().collect()
389    }
390
391    /// Validates props against a component's prop definitions.
392    ///
393    /// # Errors
394    ///
395    /// Returns an error if a required prop is missing or a prop has an invalid type.
396    pub fn validate_props(
397        &self,
398        path: &str,
399        provided: &Value,
400    ) -> std::result::Result<HashMap<String, Value>, String> {
401        let component = self
402            .components
403            .get(path)
404            .ok_or_else(|| format!("Component '{}' not found", path))?;
405
406        let mut result = HashMap::new();
407        let provided_obj = provided.as_object().ok_or("Props must be an object")?;
408
409        for prop_def in &component.props {
410            if let Some(value) = provided_obj.get(&prop_def.name) {
411                prop_def.validate(value)?;
412                result.insert(prop_def.name.clone(), value.clone());
413            } else if let Some(default) = &prop_def.default {
414                result.insert(prop_def.name.clone(), default.clone());
415            } else if prop_def.required {
416                return Err(format!("Required prop '{}' not provided", prop_def.name));
417            }
418        }
419
420        Ok(result)
421    }
422
423    /// Enables or disables component caching.
424    pub fn enable_cache(&mut self, enabled: bool) {
425        self.cache_enabled = enabled;
426    }
427
428    /// Returns whether caching is enabled.
429    pub fn is_cache_enabled(&self) -> bool {
430        self.cache_enabled
431    }
432
433    /// Gets a cached render.
434    pub fn get_cached(&self, key: u64) -> Option<String> {
435        if !self.cache_enabled {
436            return None;
437        }
438        self.cache.read().ok()?.get(&key)
439    }
440
441    /// Stores a render in the cache.
442    pub fn set_cached(&self, key: u64, html: String, props_hash: u64) {
443        if !self.cache_enabled {
444            return;
445        }
446        if let Ok(mut cache) = self.cache.write() {
447            cache.insert(key, html, props_hash);
448        }
449    }
450
451    /// Clears the component cache.
452    pub fn clear_cache(&self) {
453        if let Ok(mut cache) = self.cache.write() {
454            cache.clear();
455        }
456    }
457
458    /// Returns the number of cached entries.
459    pub fn cache_len(&self) -> usize {
460        self.cache.read().map(|c| c.len()).unwrap_or(0)
461    }
462
463    fn parse_scoped_slots(template: &str) -> Vec<ScopedSlotDef> {
464        let mut scoped_slots = Vec::new();
465
466        if let Some(start) = template.find("{%-- atom:") {
467            if let Some(end) = template[start..].find("--%}") {
468                let atom_block = &template[start + 11..start + end];
469                if let Some(slots_start) = atom_block.find("@scoped_slots(") {
470                    let slots_end = atom_block[slots_start..]
471                        .find(')')
472                        .map(|i| slots_start + i + 1);
473                    if let Some(end_idx) = slots_end {
474                        let slots_str = &atom_block[slots_start + 14..end_idx];
475                        for part in slots_str.split(',') {
476                            let part = part.trim();
477                            if part.is_empty() {
478                                continue;
479                            }
480                            let parts: Vec<&str> = part.split(':').collect();
481                            let name = parts[0].trim().to_string();
482                            let props = if parts.len() > 1 {
483                                parts[1]
484                                    .trim()
485                                    .split(',')
486                                    .map(|s| s.trim().to_string())
487                                    .collect()
488                            } else {
489                                vec![]
490                            };
491                            scoped_slots.push(ScopedSlotDef { name, props });
492                        }
493                    }
494                }
495            }
496        }
497
498        scoped_slots
499    }
500
501    fn parse_props(template: &str) -> Vec<PropDef> {
502        let mut props = Vec::new();
503
504        if let Some(start) = template.find("{%-- atom:") {
505            if let Some(end) = template[start..].find("--%}") {
506                let atom_block = &template[start + 11..start + end];
507                if let Some(props_start) = atom_block.find("@props(") {
508                    let props_end = atom_block[props_start..]
509                        .find(')')
510                        .map(|i| props_start + i + 1);
511                    if let Some(end_idx) = props_end {
512                        let props_str = &atom_block[props_start + 7..end_idx];
513                        props = Self::parse_props_string(props_str);
514                    }
515                }
516            }
517        }
518
519        props
520    }
521
522    fn parse_props_string(s: &str) -> Vec<PropDef> {
523        let mut props = Vec::new();
524        let s = s.trim();
525        let s = s.trim_start_matches('{').trim_end_matches('}');
526
527        for part in s.split(',') {
528            let part = part.trim();
529            if part.is_empty() {
530                continue;
531            }
532
533            let parts: Vec<&str> = part.split(':').collect();
534            if parts.is_empty() {
535                continue;
536            }
537
538            let mut name = parts[0].trim().to_string();
539            let prop_type = if parts.len() > 1 {
540                PropType::from_str(parts[1].trim())
541            } else {
542                PropType::Any
543            };
544
545            let optional = name.ends_with('?');
546            if optional {
547                name = name.trim_end_matches('?').to_string();
548            }
549
550            let (required, default) = if let Some(eq_pos) = name.find('=') {
551                let default_str = &name[eq_pos + 1..];
552                let default = serde_json::Value::from(default_str.trim());
553                (false, Some(default))
554            } else {
555                (true, None)
556            };
557
558            name = name.split('=').next().unwrap_or(&name).trim().to_string();
559
560            let required = required && !optional;
561
562            props.push(PropDef {
563                name,
564                prop_type,
565                required,
566                default,
567            });
568        }
569
570        props
571    }
572
573    fn parse_slots(template: &str) -> (Vec<String>, Vec<String>) {
574        let mut slots = Vec::new();
575        let mut optional_slots = Vec::new();
576
577        let mut search = template;
578        while let Some(start) = search.find("slot_") {
579            search = &search[start + 5..];
580            if let Some(end) = search.find("()") {
581                let raw_name = &search[..end];
582                if raw_name.is_empty() {
583                    continue;
584                }
585                let is_optional = raw_name.ends_with('?');
586                let name = if is_optional {
587                    raw_name.trim_end_matches('?').to_string()
588                } else {
589                    raw_name.to_string()
590                };
591
592                if is_optional {
593                    if !optional_slots.contains(&name) {
594                        optional_slots.push(name);
595                    }
596                } else if !slots.contains(&name) {
597                    slots.push(name);
598                }
599            }
600        }
601
602        (slots, optional_slots)
603    }
604}