Skip to main content

van_compiler/
resolve.rs

1use regex::Regex;
2use serde_json::Value;
3use std::collections::HashMap;
4use van_parser::{add_scope_class, parse_blocks, parse_imports, parse_script_imports, scope_css, scope_id, VanImport};
5
6use crate::render::{escape_html, interpolate, resolve_path as resolve_json_path};
7
8const MAX_DEPTH: usize = 10;
9
10/// A resolved non-component module import (.ts/.js file).
11#[derive(Debug, Clone)]
12pub struct ResolvedModule {
13    /// Resolved virtual path.
14    pub path: String,
15    /// File content from the files map.
16    pub content: String,
17    /// Whether this is a type-only import (should be erased).
18    pub is_type_only: bool,
19}
20
21/// The result of resolving a `.van` file (with or without imports).
22#[derive(Debug)]
23pub struct ResolvedComponent {
24    /// The fully rendered HTML content.
25    pub html: String,
26    /// Collected CSS styles from this component and all descendants.
27    pub styles: Vec<String>,
28    /// The `<script setup>` content (for signal generation).
29    pub script_setup: Option<String>,
30    /// Resolved non-component module imports (.ts/.js files).
31    pub module_imports: Vec<ResolvedModule>,
32}
33
34// ─── Multi-file resolve (HashMap-based, no FS) ─────────────────────────
35
36/// Resolve a `.van` entry file with all its component imports from an in-memory file map.
37///
38/// This is the main API for multi-file compilation (playground, WASM, tests).
39/// Files are keyed by virtual path (e.g. `"index.van"`, `"hello.van"`).
40pub fn resolve_with_files(
41    entry_path: &str,
42    files: &HashMap<String, String>,
43    data: &Value,
44) -> Result<ResolvedComponent, String> {
45    resolve_with_files_inner(entry_path, files, data, false, &HashMap::new())
46}
47
48/// Like `resolve_with_files`, but with debug HTML comments showing component/slot boundaries.
49///
50/// `file_origins` maps each file path to its theme name (e.g. `"components/header.van" → "van1"`).
51/// When present, debug comments include the theme: `<!-- START: [van1] components/header.van -->`.
52pub fn resolve_with_files_debug(
53    entry_path: &str,
54    files: &HashMap<String, String>,
55    data: &Value,
56    file_origins: &HashMap<String, String>,
57) -> Result<ResolvedComponent, String> {
58    resolve_with_files_inner(entry_path, files, data, true, file_origins)
59}
60
61fn resolve_with_files_inner(
62    entry_path: &str,
63    files: &HashMap<String, String>,
64    data: &Value,
65    debug: bool,
66    file_origins: &HashMap<String, String>,
67) -> Result<ResolvedComponent, String> {
68    let source = files
69        .get(entry_path)
70        .ok_or_else(|| format!("Entry file not found: {entry_path}"))?;
71
72    // Collect reactive names from ALL .van files (entry + children),
73    // so that child-component reactive variables (e.g. `menuOpen`) are
74    // preserved during interpolation and not replaced by server data.
75    let mut reactive_names = Vec::new();
76    for (path, content) in files {
77        if path.ends_with(".van") {
78            let blk = parse_blocks(content);
79            if let Some(ref script) = blk.script_setup {
80                reactive_names.extend(extract_reactive_names(script));
81            }
82        }
83    }
84
85    resolve_recursive(source, data, entry_path, files, 0, &reactive_names, debug, file_origins)
86}
87
88/// Recursively resolve component tags in a `.van` source using in-memory files.
89fn resolve_recursive(
90    source: &str,
91    data: &Value,
92    current_path: &str,
93    files: &HashMap<String, String>,
94    depth: usize,
95    reactive_names: &[String],
96    debug: bool,
97    file_origins: &HashMap<String, String>,
98) -> Result<ResolvedComponent, String> {
99    if depth > MAX_DEPTH {
100        return Err(format!(
101            "Component nesting exceeded maximum depth of {MAX_DEPTH}"
102        ));
103    }
104
105    let blocks = parse_blocks(source);
106    let mut template = blocks
107        .template
108        .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
109
110    let mut styles: Vec<String> = Vec::new();
111    if let Some(css) = &blocks.style {
112        if blocks.style_scoped {
113            let id = scope_id(css);
114            template = add_scope_class(&template, &id);
115            styles.push(scope_css(css, &id));
116        } else {
117            styles.push(css.clone());
118        }
119    }
120
121    // Parse imports from script setup to build tag -> import mapping
122    let imports = if let Some(ref script) = blocks.script_setup {
123        parse_imports(script)
124    } else {
125        Vec::new()
126    };
127
128    let import_map: HashMap<String, &VanImport> = imports
129        .iter()
130        .map(|imp| (imp.tag_name.clone(), imp))
131        .collect();
132
133    // Expand v-for directives before component resolution
134    template = expand_v_for(&template, data);
135
136    // Collect child script_setup and module_imports for merging
137    let mut child_scripts: Vec<String> = Vec::new();
138    let mut child_module_imports: Vec<ResolvedModule> = Vec::new();
139
140    // Repeatedly find and replace component tags until none remain
141    loop {
142        let tag_match = find_component_tag(&template, &import_map);
143        let Some(tag_info) = tag_match else {
144            break;
145        };
146
147        let imp = &import_map[&tag_info.tag_name];
148
149        // Resolve the component .van file via virtual path
150        let resolved_key = resolve_virtual_path(current_path, &imp.path);
151        let component_source = files
152            .get(&resolved_key)
153            .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
154
155        // Parse props from the tag and build child data context
156        let child_data = parse_props(&tag_info.attrs, data);
157
158        // Parse slot content from children (using parent data + parent import_map)
159        let slot_result = parse_slot_content(
160            &tag_info.children,
161            data,
162            &imports,
163            current_path,
164            files,
165            depth,
166            reactive_names,
167            debug,
168            file_origins,
169        )?;
170
171        // Recursively resolve the child component
172        let child_resolved = resolve_recursive(
173            component_source,
174            &child_data,
175            &resolved_key,
176            files,
177            depth + 1,
178            reactive_names,
179            debug,
180            file_origins,
181        )?;
182
183        // Distribute slots into the child's rendered HTML
184        // Build per-slot theme map: check slot-specific origin first, then file-level origin
185        let file_theme = file_origins.get(current_path).cloned().unwrap_or_default();
186        let mut slot_themes: HashMap<String, String> = HashMap::new();
187        for slot_name in slot_result.slots.keys() {
188            let slot_key = format!("{}#{}", current_path, slot_name);
189            let theme = file_origins.get(&slot_key).unwrap_or(&file_theme);
190            slot_themes.insert(slot_name.clone(), theme.clone());
191        }
192        let with_slots = distribute_slots(&child_resolved.html, &slot_result.slots, debug, &slot_themes);
193
194        // Replace the component tag with the resolved content
195        let replacement = if debug {
196            let theme_prefix = file_origins.get(&resolved_key)
197                .map(|t| format!("[{t}] "))
198                .unwrap_or_default();
199            format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
200        } else {
201            with_slots
202        };
203
204        template = format!(
205            "{}{}{}",
206            &template[..tag_info.start],
207            replacement,
208            &template[tag_info.end..],
209        );
210
211        // Collect child script_setup and module_imports for merging
212        if let Some(ref cs) = child_resolved.script_setup {
213            child_scripts.push(cs.clone());
214        }
215        child_module_imports.extend(child_resolved.module_imports);
216
217        // Collect child styles and slot component styles
218        styles.extend(child_resolved.styles);
219        styles.extend(slot_result.styles);
220    }
221
222    // Reactive-aware interpolation: leave reactive {{ expr }} as-is for
223    // signal gen to find via tree walking; interpolate non-reactive ones.
224    let html = if !reactive_names.is_empty() {
225        interpolate_skip_reactive(&template, data, reactive_names)
226    } else {
227        interpolate(&template, data)
228    };
229
230    // Merge this component's script_setup with collected child scripts
231    let mut script_setup = blocks.script_setup.clone();
232    if !child_scripts.is_empty() {
233        let merged = child_scripts.join("\n");
234        script_setup = Some(match script_setup {
235            Some(s) => format!("{s}\n{merged}"),
236            None => merged,
237        });
238    }
239
240    // Resolve module imports from this component and merge child module imports
241    let mut module_imports: Vec<ResolvedModule> = if let Some(ref script) = blocks.script_setup {
242        let script_imports = parse_script_imports(script);
243        script_imports
244            .into_iter()
245            .filter_map(|imp| {
246                if imp.is_type_only {
247                    return None; // type-only imports are erased
248                }
249                let resolved_key = resolve_virtual_path(current_path, &imp.path);
250                let content = files.get(&resolved_key)?;
251                Some(ResolvedModule {
252                    path: resolved_key,
253                    content: content.clone(),
254                    is_type_only: false,
255                })
256            })
257            .collect()
258    } else {
259        Vec::new()
260    };
261    module_imports.extend(child_module_imports);
262
263    Ok(ResolvedComponent {
264        html,
265        styles,
266        script_setup,
267        module_imports,
268    })
269}
270
271// ─── Single-file resolve (no imports, no FS) ────────────────────────────
272
273/// Resolve a single `.van` source into HTML + styles (no import resolution).
274///
275/// This is the backward-compatible entry point for single-file usage.
276pub fn resolve_single(source: &str, data: &Value) -> Result<ResolvedComponent, String> {
277    resolve_single_with_path(source, data, "")
278}
279
280/// Like `resolve_single`, but kept for API compatibility.
281pub fn resolve_single_with_path(source: &str, data: &Value, _path: &str) -> Result<ResolvedComponent, String> {
282    let blocks = parse_blocks(source);
283
284    let mut template = blocks
285        .template
286        .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
287
288    let mut styles: Vec<String> = Vec::new();
289    if let Some(css) = &blocks.style {
290        if blocks.style_scoped {
291            let id = scope_id(css);
292            template = add_scope_class(&template, &id);
293            styles.push(scope_css(css, &id));
294        } else {
295            styles.push(css.clone());
296        }
297    }
298
299    // Extract reactive names from script setup
300    let reactive_names = if let Some(ref script) = blocks.script_setup {
301        extract_reactive_names(script)
302    } else {
303        Vec::new()
304    };
305
306    // Reactive-aware interpolation
307    let html = if !reactive_names.is_empty() {
308        interpolate_skip_reactive(&template, data, &reactive_names)
309    } else {
310        interpolate(&template, data)
311    };
312
313    Ok(ResolvedComponent {
314        html,
315        styles,
316        script_setup: blocks.script_setup.clone(),
317        module_imports: Vec::new(),
318    })
319}
320
321// ─── Virtual path resolution ────────────────────────────────────────────
322
323/// Resolve a relative import path against a current file's virtual path.
324///
325/// ```text
326/// current_file="index.van", import="./hello.van" → "hello.van"
327/// current_file="pages/index.van", import="../components/hello.van" → "components/hello.van"
328/// current_file="pages/index.van", import="./sub.van" → "pages/sub.van"
329/// import="@van-ui/button/button.van" → "@van-ui/button/button.van" (scoped package, returned as-is)
330/// ```
331fn resolve_virtual_path(current_file: &str, import_path: &str) -> String {
332    // @scope/pkg paths are absolute references into node_modules — return as-is
333    if import_path.starts_with('@') {
334        return import_path.to_string();
335    }
336
337    // Get the directory of the current file
338    let dir = if let Some(pos) = current_file.rfind('/') {
339        &current_file[..pos]
340    } else {
341        "" // root level
342    };
343
344    let combined = if dir.is_empty() {
345        import_path.to_string()
346    } else {
347        format!("{}/{}", dir, import_path)
348    };
349
350    normalize_virtual_path(&combined)
351}
352
353/// Normalize a virtual path by resolving `.` and `..` segments.
354fn normalize_virtual_path(path: &str) -> String {
355    let mut parts: Vec<&str> = Vec::new();
356    for part in path.split('/') {
357        match part {
358            "." | "" => {}
359            ".." => {
360                parts.pop();
361            }
362            other => parts.push(other),
363        }
364    }
365    parts.join("/")
366}
367
368// ─── Shared helpers ─────────────────────────────────────────────────────
369
370/// Extract reactive signal names from script setup (ref/computed declarations).
371pub fn extract_reactive_names(script: &str) -> Vec<String> {
372    let ref_re = Regex::new(r#"const\s+(\w+)\s*=\s*ref\("#).unwrap();
373    let computed_re = Regex::new(r#"const\s+(\w+)\s*=\s*computed\("#).unwrap();
374    let mut names = Vec::new();
375    for cap in ref_re.captures_iter(script) {
376        names.push(cap[1].to_string());
377    }
378    for cap in computed_re.captures_iter(script) {
379        names.push(cap[1].to_string());
380    }
381    names
382}
383
384/// Interpolate `{{ expr }}` / `{{{ expr }}}` but leave reactive expressions as-is.
385///
386/// - `{{ expr }}` — HTML-escaped output (default, safe)
387/// - `{{{ expr }}}` — raw output (no escaping, for trusted HTML content)
388fn interpolate_skip_reactive(template: &str, data: &Value, reactive_names: &[String]) -> String {
389    let mut result = String::with_capacity(template.len());
390    let mut rest = template;
391
392    // Closure: check if expression references any reactive signal
393    let check_reactive = |expr: &str| -> bool {
394        reactive_names.iter().any(|name| {
395            let bytes = expr.as_bytes();
396            let name_bytes = name.as_bytes();
397            let name_len = name.len();
398            let mut i = 0;
399            while i + name_len <= bytes.len() {
400                if &bytes[i..i + name_len] == name_bytes {
401                    let before_ok = i == 0 || !(bytes[i - 1] as char).is_alphanumeric();
402                    let after_ok = i + name_len == bytes.len()
403                        || !(bytes[i + name_len] as char).is_alphanumeric();
404                    if before_ok && after_ok {
405                        return true;
406                    }
407                }
408                i += 1;
409            }
410            false
411        })
412    };
413
414    while let Some(start) = rest.find("{{") {
415        result.push_str(&rest[..start]);
416
417        // Check for triple mustache {{{ }}} (raw, unescaped output)
418        if rest[start..].starts_with("{{{") {
419            let after_open = &rest[start + 3..];
420            if let Some(end) = after_open.find("}}}") {
421                let expr = after_open[..end].trim();
422                if check_reactive(expr) {
423                    // Keep reactive as double-mustache for signal runtime
424                    result.push_str(&format!("{{{{ {expr} }}}}"));
425                } else {
426                    let value = resolve_json_path(data, expr);
427                    result.push_str(&value);
428                }
429                rest = &after_open[end + 3..];
430            } else {
431                result.push_str("{{{");
432                rest = &rest[start + 3..];
433            }
434        } else {
435            let after_open = &rest[start + 2..];
436            if let Some(end) = after_open.find("}}") {
437                let expr = after_open[..end].trim();
438                if check_reactive(expr) {
439                    result.push_str(&format!("{{{{ {expr} }}}}"));
440                } else {
441                    let value = resolve_json_path(data, expr);
442                    result.push_str(&escape_html(&value));
443                }
444                rest = &after_open[end + 2..];
445            } else {
446                result.push_str("{{");
447                rest = after_open;
448            }
449        }
450    }
451    result.push_str(rest);
452    result
453}
454
455// ─── Component tag matching ─────────────────────────────────────────────
456
457/// Information about a matched component tag in the template.
458struct TagInfo {
459    tag_name: String,
460    attrs: String,
461    children: String,
462    start: usize,
463    end: usize,
464}
465
466/// Find the first component tag in the template that matches an import.
467fn find_component_tag(template: &str, import_map: &HashMap<String, &VanImport>) -> Option<TagInfo> {
468    for tag_name in import_map.keys() {
469        if let Some(info) = extract_component_tag(template, tag_name) {
470            return Some(info);
471        }
472    }
473    None
474}
475
476/// Extract a component tag (self-closing or paired) from the template.
477fn extract_component_tag(template: &str, tag_name: &str) -> Option<TagInfo> {
478    let open_pattern = format!("<{}", tag_name);
479
480    let start = template.find(&open_pattern)?;
481
482    // Verify it's a complete tag name (next char must be space, /, or >)
483    let after_tag = start + open_pattern.len();
484    if after_tag < template.len() {
485        let next_ch = template.as_bytes()[after_tag] as char;
486        if next_ch != ' '
487            && next_ch != '/'
488            && next_ch != '>'
489            && next_ch != '\n'
490            && next_ch != '\r'
491            && next_ch != '\t'
492        {
493            return None;
494        }
495    }
496
497    // Find the end of the opening tag '>'
498    let rest = &template[start..];
499    let gt_pos = rest.find('>')?;
500
501    // Check for self-closing: ends with />
502    let is_self_closing = rest[..gt_pos].ends_with('/');
503
504    if is_self_closing {
505        let attr_start = open_pattern.len();
506        let attr_end = gt_pos;
507        let attrs_str = &rest[attr_start..attr_end].trim_end_matches('/').trim();
508
509        return Some(TagInfo {
510            tag_name: tag_name.to_string(),
511            attrs: attrs_str.to_string(),
512            children: String::new(),
513            start,
514            end: start + gt_pos + 1,
515        });
516    }
517
518    // Paired tag: find matching closing tag </tag-name>
519    let content_start = start + gt_pos + 1;
520    let close_tag = format!("</{}>", tag_name);
521
522    let remaining = &template[content_start..];
523    let close_pos = remaining.find(&close_tag)?;
524
525    let attrs_raw = &rest[tag_name.len() + 1..gt_pos];
526    let children = remaining[..close_pos].to_string();
527
528    Some(TagInfo {
529        tag_name: tag_name.to_string(),
530        attrs: attrs_raw.trim().to_string(),
531        children,
532        start,
533        end: content_start + close_pos + close_tag.len(),
534    })
535}
536
537// ─── Props ──────────────────────────────────────────────────────────────
538
539/// Parse `:prop="expr"` attributes and resolve them against parent data.
540fn parse_props(attrs: &str, parent_data: &Value) -> Value {
541    let re = Regex::new(r#":(\w+)="([^"]*)""#).unwrap();
542    let mut map = serde_json::Map::new();
543
544    for cap in re.captures_iter(attrs) {
545        let key = &cap[1];
546        let expr = &cap[2];
547        let value_str = resolve_json_path(parent_data, expr);
548        map.insert(key.to_string(), Value::String(value_str));
549    }
550
551    Value::Object(map)
552}
553
554// ─── Slots ──────────────────────────────────────────────────────────────
555
556/// Parsed slot content keyed by slot name ("default" for unnamed).
557type SlotMap = HashMap<String, String>;
558
559/// Result of parsing slot content, including collected styles from resolved components.
560struct SlotResult {
561    slots: SlotMap,
562    styles: Vec<String>,
563}
564
565/// Parse `<template #name>...</template>` blocks and default content from children.
566fn parse_slot_content(
567    children: &str,
568    parent_data: &Value,
569    parent_imports: &[VanImport],
570    current_path: &str,
571    files: &HashMap<String, String>,
572    depth: usize,
573    reactive_names: &[String],
574    debug: bool,
575    file_origins: &HashMap<String, String>,
576) -> Result<SlotResult, String> {
577    let mut slots = SlotMap::new();
578    let mut styles: Vec<String> = Vec::new();
579    let mut default_parts: Vec<String> = Vec::new();
580    let mut rest = children;
581
582    let named_slot_re = Regex::new(r#"<template\s+#(\w+)\s*>"#).unwrap();
583
584    loop {
585        let Some(cap) = named_slot_re.captures(rest) else {
586            let trimmed = rest.trim();
587            if !trimmed.is_empty() {
588                default_parts.push(trimmed.to_string());
589            }
590            break;
591        };
592
593        let full_match = cap.get(0).unwrap();
594        let slot_name = cap[1].to_string();
595
596        // Content before this named slot is default content
597        let before = rest[..full_match.start()].trim();
598        if !before.is_empty() {
599            default_parts.push(before.to_string());
600        }
601
602        // Find closing </template>
603        let after_open = &rest[full_match.end()..];
604        let close_pos = after_open.find("</template>");
605        let slot_content = if let Some(pos) = close_pos {
606            let content = after_open[..pos].trim().to_string();
607            rest = &after_open[pos + "</template>".len()..];
608            content
609        } else {
610            let content = after_open.trim().to_string();
611            rest = "";
612            content
613        };
614
615        // Interpolate named slot content with parent data
616        let interpolated = if !reactive_names.is_empty() {
617            interpolate_skip_reactive(&slot_content, parent_data, reactive_names)
618        } else {
619            interpolate(&slot_content, parent_data)
620        };
621        slots.insert(slot_name, interpolated);
622    }
623
624    // Process default slot content: resolve any child components using parent's import context
625    if !default_parts.is_empty() {
626        let default_content = default_parts.join("\n");
627
628        let parent_import_map: HashMap<String, &VanImport> = parent_imports
629            .iter()
630            .map(|imp| (imp.tag_name.clone(), imp))
631            .collect();
632
633        let resolved = resolve_slot_components(
634            &default_content,
635            parent_data,
636            &parent_import_map,
637            current_path,
638            files,
639            depth,
640            reactive_names,
641            debug,
642            file_origins,
643        )?;
644
645        slots.insert("default".to_string(), resolved.html);
646        styles.extend(resolved.styles);
647    }
648
649    Ok(SlotResult { slots, styles })
650}
651
652/// Resolve component tags within slot content using the parent's import context.
653fn resolve_slot_components(
654    content: &str,
655    data: &Value,
656    import_map: &HashMap<String, &VanImport>,
657    current_path: &str,
658    files: &HashMap<String, String>,
659    depth: usize,
660    reactive_names: &[String],
661    debug: bool,
662    file_origins: &HashMap<String, String>,
663) -> Result<ResolvedComponent, String> {
664    let mut result = content.to_string();
665    let mut styles: Vec<String> = Vec::new();
666
667    loop {
668        let tag_match = find_component_tag(&result, import_map);
669        let Some(tag_info) = tag_match else {
670            break;
671        };
672
673        let imp = &import_map[&tag_info.tag_name];
674        let resolved_key = resolve_virtual_path(current_path, &imp.path);
675        let component_source = files
676            .get(&resolved_key)
677            .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
678
679        let child_data = parse_props(&tag_info.attrs, data);
680
681        let child_resolved = resolve_recursive(
682            component_source,
683            &child_data,
684            &resolved_key,
685            files,
686            depth + 1,
687            reactive_names,
688            debug,
689            file_origins,
690        )?;
691
692        let with_slots = distribute_slots(&child_resolved.html, &HashMap::new(), debug, &HashMap::new());
693        styles.extend(child_resolved.styles);
694
695        let replacement = if debug {
696            let theme_prefix = file_origins.get(&resolved_key)
697                .map(|t| format!("[{t}] "))
698                .unwrap_or_default();
699            format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
700        } else {
701            with_slots
702        };
703
704        result = format!(
705            "{}{}{}",
706            &result[..tag_info.start],
707            replacement,
708            &result[tag_info.end..],
709        );
710    }
711
712    // Interpolate remaining {{ }} with parent data (reactive-aware)
713    let html = if !reactive_names.is_empty() {
714        interpolate_skip_reactive(&result, data, reactive_names)
715    } else {
716        interpolate(&result, data)
717    };
718
719    Ok(ResolvedComponent {
720        html,
721        styles,
722        script_setup: None,
723        module_imports: Vec::new(),
724    })
725}
726
727/// Replace `<slot />` and `<slot name="x">fallback</slot>` with provided content.
728///
729/// `slot_themes` maps slot_name → theme_name for debug comments.
730/// Only shown for explicitly provided slots, not for fallback defaults.
731fn distribute_slots(html: &str, slots: &SlotMap, debug: bool, slot_themes: &HashMap<String, String>) -> String {
732    let mut result = html.to_string();
733
734    // Helper: build theme prefix for a given slot
735    let tp = |name: &str| -> String {
736        slot_themes.get(name)
737            .filter(|t| !t.is_empty())
738            .map(|t| format!("[{t}] "))
739            .unwrap_or_default()
740    };
741
742    // Handle named slots: <slot name="x">fallback</slot>
743    let named_re = Regex::new(r#"<slot\s+name="(\w+)">([\s\S]*?)</slot>"#).unwrap();
744    result = named_re
745        .replace_all(&result, |caps: &regex::Captures| {
746            let name = &caps[1];
747            let fallback = &caps[2];
748            let provided = slots.get(name);
749            let content = provided
750                .cloned()
751                .unwrap_or_else(|| fallback.trim().to_string());
752            if debug {
753                let p = if provided.is_some() { tp(name) } else { String::new() };
754                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
755            } else {
756                content
757            }
758        })
759        .to_string();
760
761    // Handle named self-closing slots: <slot name="x" />
762    let named_sc_re = Regex::new(r#"<slot\s+name="(\w+)"\s*/>"#).unwrap();
763    result = named_sc_re
764        .replace_all(&result, |caps: &regex::Captures| {
765            let name = &caps[1];
766            let provided = slots.get(name);
767            let content = provided.cloned().unwrap_or_default();
768            if debug {
769                let p = if provided.is_some() { tp(name) } else { String::new() };
770                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
771            } else {
772                content
773            }
774        })
775        .to_string();
776
777    // Handle default slot: <slot /> (self-closing)
778    let default_sc_re = Regex::new(r#"<slot\s*/>"#).unwrap();
779    result = default_sc_re
780        .replace_all(&result, |_: &regex::Captures| {
781            let provided = slots.get("default");
782            let content = provided.cloned().unwrap_or_default();
783            if debug {
784                let p = if provided.is_some() { tp("default") } else { String::new() };
785                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
786            } else {
787                content
788            }
789        })
790        .to_string();
791
792    // Handle default slot with fallback: <slot>fallback</slot>
793    let default_re = Regex::new(r#"<slot>([\s\S]*?)</slot>"#).unwrap();
794    result = default_re
795        .replace_all(&result, |caps: &regex::Captures| {
796            let fallback = &caps[1];
797            let provided = slots.get("default");
798            let content = provided
799                .cloned()
800                .unwrap_or_else(|| fallback.trim().to_string());
801            if debug {
802                let p = if provided.is_some() { tp("default") } else { String::new() };
803                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
804            } else {
805                content
806            }
807        })
808        .to_string();
809
810    result
811}
812
813/// Resolve a dot-separated path and return the raw JSON Value.
814fn resolve_path_value<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
815    let mut current = data;
816    for key in path.split('.') {
817        let key = key.trim();
818        match current.get(key) {
819            Some(v) => current = v,
820            None => return None,
821        }
822    }
823    Some(current)
824}
825
826/// Expand `v-for` directives by repeating elements for each array item.
827fn expand_v_for(template: &str, data: &Value) -> String {
828    let vfor_re = Regex::new(r#"<(\w[\w-]*)([^>]*)\sv-for="([^"]*)"([^>]*)>"#).unwrap();
829    let mut result = template.to_string();
830
831    for _ in 0..20 {
832        let Some(cap) = vfor_re.captures(&result) else {
833            break;
834        };
835
836        let full_match = cap.get(0).unwrap();
837        let tag_name = &cap[1];
838        let attrs_before = &cap[2];
839        let vfor_expr = &cap[3];
840        let attrs_after = &cap[4];
841
842        let (item_var, index_var, array_expr) = parse_vfor_expr(vfor_expr);
843        let open_tag_no_vfor = format!("<{}{}{}>", tag_name, attrs_before, attrs_after);
844        let match_start = full_match.start();
845        let after_open = full_match.end();
846        let is_self_closing = result[match_start..after_open].trim_end_matches('>').ends_with('/');
847
848        if is_self_closing {
849            let sc_tag = format!("<{}{}{} />", tag_name, attrs_before, attrs_after);
850            let array = resolve_path_value(data, &array_expr);
851            let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
852            let mut expanded = String::new();
853            for (idx, item) in items.iter().enumerate() {
854                let mut item_data = data.clone();
855                if let Value::Object(ref mut map) = item_data {
856                    map.insert(item_var.clone(), item.clone());
857                    if let Some(ref idx_var) = index_var {
858                        map.insert(idx_var.clone(), Value::Number(idx.into()));
859                    }
860                }
861                expanded.push_str(&interpolate(&sc_tag, &item_data));
862            }
863            result = format!("{}{}{}", &result[..match_start], expanded, &result[after_open..]);
864            continue;
865        }
866
867        let close_tag = format!("</{}>", tag_name);
868        let remaining = &result[after_open..];
869        let close_pos = find_matching_close_tag(remaining, tag_name);
870        let inner_content = remaining[..close_pos].to_string();
871        let element_end = after_open + close_pos + close_tag.len();
872
873        let array = resolve_path_value(data, &array_expr);
874        let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
875        let mut expanded = String::new();
876        for (idx, item) in items.iter().enumerate() {
877            let mut item_data = data.clone();
878            if let Value::Object(ref mut map) = item_data {
879                map.insert(item_var.clone(), item.clone());
880                if let Some(ref idx_var) = index_var {
881                    map.insert(idx_var.clone(), Value::Number(idx.into()));
882                }
883            }
884            let tag_interpolated = interpolate(&open_tag_no_vfor, &item_data);
885            let inner_interpolated = interpolate(&inner_content, &item_data);
886            expanded.push_str(&format!("{}{}</{}>", tag_interpolated, inner_interpolated, tag_name));
887        }
888
889        result = format!("{}{}{}", &result[..match_start], expanded, &result[element_end..]);
890    }
891
892    result
893}
894
895fn parse_vfor_expr(expr: &str) -> (String, Option<String>, String) {
896    let parts: Vec<&str> = expr.splitn(2, " in ").collect();
897    if parts.len() != 2 {
898        return (expr.to_string(), None, String::new());
899    }
900    let lhs = parts[0].trim();
901    let array_expr = parts[1].trim().to_string();
902    if lhs.starts_with('(') && lhs.ends_with(')') {
903        let inner = &lhs[1..lhs.len() - 1];
904        let vars: Vec<&str> = inner.split(',').collect();
905        let item_var = vars[0].trim().to_string();
906        let index_var = vars.get(1).map(|v| v.trim().to_string());
907        (item_var, index_var, array_expr)
908    } else {
909        (lhs.to_string(), None, array_expr)
910    }
911}
912
913fn find_matching_close_tag(html: &str, tag_name: &str) -> usize {
914    let open = format!("<{}", tag_name);
915    let close = format!("</{}>", tag_name);
916    let mut depth = 0;
917    let mut pos = 0;
918    while pos < html.len() {
919        if html[pos..].starts_with(&close) {
920            if depth == 0 {
921                return pos;
922            }
923            depth -= 1;
924            pos += close.len();
925        } else if html[pos..].starts_with(&open) {
926            let after = pos + open.len();
927            if after < html.len() {
928                let ch = html.as_bytes()[after] as char;
929                if ch == ' ' || ch == '>' || ch == '/' || ch == '\n' || ch == '\t' {
930                    depth += 1;
931                }
932            }
933            pos += open.len();
934        } else {
935            pos += 1;
936        }
937    }
938    html.len()
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944    use serde_json::json;
945
946    #[test]
947    fn test_extract_reactive_names() {
948        let script = r#"
949const count = ref(0)
950const doubled = computed(() => count * 2)
951"#;
952        let names = extract_reactive_names(script);
953        assert_eq!(names, vec!["count", "doubled"]);
954    }
955
956    #[test]
957    fn test_resolve_single_basic() {
958        let source = r#"
959<template>
960  <h1>{{ title }}</h1>
961</template>
962"#;
963        let data = json!({"title": "Hello"});
964        let resolved = resolve_single(source, &data).unwrap();
965        assert!(resolved.html.contains("<h1>Hello</h1>"));
966        assert!(resolved.styles.is_empty());
967        assert!(resolved.script_setup.is_none());
968    }
969
970    #[test]
971    fn test_resolve_single_with_style() {
972        let source = r#"
973<template>
974  <h1>Hello</h1>
975</template>
976
977<style scoped>
978h1 { color: red; }
979</style>
980"#;
981        let data = json!({});
982        let resolved = resolve_single(source, &data).unwrap();
983        assert_eq!(resolved.styles.len(), 1);
984        assert!(resolved.styles[0].contains("color: red"));
985    }
986
987    #[test]
988    fn test_resolve_single_reactive() {
989        let source = r#"
990<template>
991  <p>Count: {{ count }}</p>
992</template>
993
994<script setup>
995const count = ref(0)
996</script>
997"#;
998        let data = json!({});
999        let resolved = resolve_single(source, &data).unwrap();
1000        assert!(resolved.html.contains("{{ count }}"));
1001        assert!(resolved.script_setup.is_some());
1002    }
1003
1004    // ─── Virtual path tests ─────────────────────────────────────────
1005
1006    #[test]
1007    fn test_resolve_virtual_path_same_dir() {
1008        assert_eq!(
1009            resolve_virtual_path("index.van", "./hello.van"),
1010            "hello.van"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_resolve_virtual_path_parent_dir() {
1016        assert_eq!(
1017            resolve_virtual_path("pages/index.van", "../components/hello.van"),
1018            "components/hello.van"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_resolve_virtual_path_subdir() {
1024        assert_eq!(
1025            resolve_virtual_path("pages/index.van", "./sub.van"),
1026            "pages/sub.van"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_normalize_virtual_path() {
1032        assert_eq!(normalize_virtual_path("./hello.van"), "hello.van");
1033        assert_eq!(
1034            normalize_virtual_path("pages/../components/hello.van"),
1035            "components/hello.van"
1036        );
1037        assert_eq!(normalize_virtual_path("a/b/./c"), "a/b/c");
1038    }
1039
1040    #[test]
1041    fn test_resolve_virtual_path_scoped_package() {
1042        // @scope/pkg paths should be returned as-is regardless of current file
1043        assert_eq!(
1044            resolve_virtual_path("pages/index.van", "@van-ui/button/button.van"),
1045            "@van-ui/button/button.van"
1046        );
1047        assert_eq!(
1048            resolve_virtual_path("index.van", "@van-ui/utils/format.ts"),
1049            "@van-ui/utils/format.ts"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_resolve_with_files_scoped_import() {
1055        let mut files = HashMap::new();
1056        files.insert(
1057            "index.van".to_string(),
1058            r#"
1059<template>
1060  <van-button :label="title" />
1061</template>
1062
1063<script setup>
1064import VanButton from '@van-ui/button/button.van'
1065</script>
1066"#
1067            .to_string(),
1068        );
1069        // In-memory file map: key is "@van-ui/button/button.van"
1070        files.insert(
1071            "@van-ui/button/button.van".to_string(),
1072            r#"
1073<template>
1074  <button>{{ label }}</button>
1075</template>
1076"#
1077            .to_string(),
1078        );
1079
1080        let data = json!({"title": "Click me"});
1081        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1082        assert!(resolved.html.contains("<button>Click me</button>"));
1083    }
1084
1085    // ─── Multi-file resolve tests ───────────────────────────────────
1086
1087    #[test]
1088    fn test_resolve_with_files_basic_import() {
1089        let mut files = HashMap::new();
1090        files.insert(
1091            "index.van".to_string(),
1092            r#"
1093<template>
1094  <hello :name="title" />
1095</template>
1096
1097<script setup>
1098import Hello from './hello.van'
1099</script>
1100"#
1101            .to_string(),
1102        );
1103        files.insert(
1104            "hello.van".to_string(),
1105            r#"
1106<template>
1107  <h1>Hello, {{ name }}!</h1>
1108</template>
1109"#
1110            .to_string(),
1111        );
1112
1113        let data = json!({"title": "World"});
1114        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1115        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1116    }
1117
1118    #[test]
1119    fn test_resolve_with_files_missing_component() {
1120        let mut files = HashMap::new();
1121        files.insert(
1122            "index.van".to_string(),
1123            r#"
1124<template>
1125  <hello />
1126</template>
1127
1128<script setup>
1129import Hello from './hello.van'
1130</script>
1131"#
1132            .to_string(),
1133        );
1134
1135        let data = json!({});
1136        let result = resolve_with_files("index.van", &files, &data);
1137        assert!(result.is_err());
1138        assert!(result.unwrap_err().contains("Component not found"));
1139    }
1140
1141    #[test]
1142    fn test_resolve_with_files_slots() {
1143        let mut files = HashMap::new();
1144        files.insert(
1145            "index.van".to_string(),
1146            r#"
1147<template>
1148  <wrapper>
1149    <p>Default slot content</p>
1150  </wrapper>
1151</template>
1152
1153<script setup>
1154import Wrapper from './wrapper.van'
1155</script>
1156"#
1157            .to_string(),
1158        );
1159        files.insert(
1160            "wrapper.van".to_string(),
1161            r#"
1162<template>
1163  <div class="wrapper"><slot /></div>
1164</template>
1165"#
1166            .to_string(),
1167        );
1168
1169        let data = json!({});
1170        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1171        assert!(resolved.html.contains("<div class=\"wrapper\">"));
1172        assert!(resolved.html.contains("<p>Default slot content</p>"));
1173    }
1174
1175    #[test]
1176    fn test_resolve_with_files_styles_collected() {
1177        let mut files = HashMap::new();
1178        files.insert(
1179            "index.van".to_string(),
1180            r#"
1181<template>
1182  <hello />
1183</template>
1184
1185<script setup>
1186import Hello from './hello.van'
1187</script>
1188
1189<style>
1190.app { color: blue; }
1191</style>
1192"#
1193            .to_string(),
1194        );
1195        files.insert(
1196            "hello.van".to_string(),
1197            r#"
1198<template>
1199  <h1>Hello</h1>
1200</template>
1201
1202<style>
1203h1 { color: red; }
1204</style>
1205"#
1206            .to_string(),
1207        );
1208
1209        let data = json!({});
1210        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1211        assert_eq!(resolved.styles.len(), 2);
1212        assert!(resolved.styles[0].contains("color: blue"));
1213        assert!(resolved.styles[1].contains("color: red"));
1214    }
1215
1216    #[test]
1217    fn test_resolve_with_files_reactive_preserved() {
1218        let mut files = HashMap::new();
1219        files.insert(
1220            "index.van".to_string(),
1221            r#"
1222<template>
1223  <div>
1224    <p>Count: {{ count }}</p>
1225    <hello :name="title" />
1226  </div>
1227</template>
1228
1229<script setup>
1230import Hello from './hello.van'
1231const count = ref(0)
1232</script>
1233"#
1234            .to_string(),
1235        );
1236        files.insert(
1237            "hello.van".to_string(),
1238            r#"
1239<template>
1240  <h1>Hello, {{ name }}!</h1>
1241</template>
1242"#
1243            .to_string(),
1244        );
1245
1246        let data = json!({"title": "World"});
1247        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1248        // Reactive expression should be preserved
1249        assert!(resolved.html.contains("{{ count }}"));
1250        // Non-reactive prop should be interpolated
1251        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1252        assert!(resolved.script_setup.is_some());
1253    }
1254
1255    // ─── Component tag extraction tests ─────────────────────────────
1256
1257    #[test]
1258    fn test_extract_self_closing_tag() {
1259        let template = r#"<div><hello :name="title" /></div>"#;
1260        let info = extract_component_tag(template, "hello").unwrap();
1261        assert_eq!(info.tag_name, "hello");
1262        assert_eq!(info.attrs, r#":name="title""#);
1263        assert!(info.children.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_extract_paired_tag() {
1268        let template = r#"<default-layout><h1>Content</h1></default-layout>"#;
1269        let info = extract_component_tag(template, "default-layout").unwrap();
1270        assert_eq!(info.tag_name, "default-layout");
1271        assert_eq!(info.children, "<h1>Content</h1>");
1272    }
1273
1274    #[test]
1275    fn test_extract_no_match() {
1276        let template = r#"<div>no components here</div>"#;
1277        assert!(extract_component_tag(template, "hello").is_none());
1278    }
1279
1280    #[test]
1281    fn test_parse_props() {
1282        let data = json!({"title": "World", "count": 42});
1283        let attrs = r#":name="title" :num="count""#;
1284        let result = parse_props(attrs, &data);
1285        assert_eq!(result["name"], "World");
1286        assert_eq!(result["num"], "42");
1287    }
1288
1289    #[test]
1290    fn test_distribute_slots_default() {
1291        let html = r#"<div><slot /></div>"#;
1292        let mut slots = HashMap::new();
1293        slots.insert("default".to_string(), "Hello World".to_string());
1294        let result = distribute_slots(html, &slots, false, &HashMap::new());
1295        assert_eq!(result, "<div>Hello World</div>");
1296    }
1297
1298    #[test]
1299    fn test_distribute_slots_named() {
1300        let html =
1301            r#"<title><slot name="title">Fallback</slot></title><div><slot /></div>"#;
1302        let mut slots = HashMap::new();
1303        slots.insert("title".to_string(), "My Title".to_string());
1304        slots.insert("default".to_string(), "Body".to_string());
1305        let result = distribute_slots(html, &slots, false, &HashMap::new());
1306        assert_eq!(result, "<title>My Title</title><div>Body</div>");
1307    }
1308
1309    #[test]
1310    fn test_distribute_slots_fallback() {
1311        let html = r#"<title><slot name="title">Fallback Title</slot></title>"#;
1312        let slots = HashMap::new();
1313        let result = distribute_slots(html, &slots, false, &HashMap::new());
1314        assert_eq!(result, "<title>Fallback Title</title>");
1315    }
1316
1317    #[test]
1318    fn test_expand_v_for_basic() {
1319        let data = json!({"items": ["Alice", "Bob", "Charlie"]});
1320        let template = r#"<ul><li v-for="item in items">{{ item }}</li></ul>"#;
1321        let result = expand_v_for(template, &data);
1322        assert!(result.contains("<li>Alice</li>"));
1323        assert!(result.contains("<li>Bob</li>"));
1324        assert!(result.contains("<li>Charlie</li>"));
1325        assert!(!result.contains("v-for"));
1326    }
1327
1328    #[test]
1329    fn test_expand_v_for_with_index() {
1330        let data = json!({"items": ["A", "B"]});
1331        let template = r#"<ul><li v-for="(item, index) in items">{{ index }}: {{ item }}</li></ul>"#;
1332        let result = expand_v_for(template, &data);
1333        assert!(result.contains("0: A"));
1334        assert!(result.contains("1: B"));
1335    }
1336
1337    #[test]
1338    fn test_expand_v_for_nested_path() {
1339        let data = json!({"user": {"hobbies": ["coding", "reading"]}});
1340        let template = r#"<span v-for="h in user.hobbies">{{ h }}</span>"#;
1341        let result = expand_v_for(template, &data);
1342        assert!(result.contains("<span>coding</span>"));
1343        assert!(result.contains("<span>reading</span>"));
1344    }
1345
1346    // ─── Scoped style tests ──────────────────────────────────────────
1347
1348    #[test]
1349    fn test_resolve_scoped_style_single() {
1350        let source = r#"
1351<template>
1352  <div class="card"><h1>{{ title }}</h1></div>
1353</template>
1354
1355<style scoped>
1356.card { border: 1px solid; }
1357h1 { color: navy; }
1358</style>
1359"#;
1360        let data = json!({"title": "Hello"});
1361        let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
1362        let id = van_parser::scope_id(css);
1363        let resolved = resolve_single_with_path(source, &data, "components/card.van").unwrap();
1364        // All elements should have scope class
1365        assert!(resolved.html.contains(&format!("class=\"card {id}\"")), "Root should have scope class appended");
1366        assert!(resolved.html.contains(&format!("class=\"{id}\"")), "Child h1 should have scope class");
1367        // CSS selectors should have .{id} appended
1368        assert_eq!(resolved.styles.len(), 1);
1369        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1370        assert!(resolved.styles[0].contains(&format!("h1.{id}")));
1371    }
1372
1373    #[test]
1374    fn test_resolve_scoped_style_multi_file() {
1375        let mut files = HashMap::new();
1376        files.insert(
1377            "index.van".to_string(),
1378            r#"
1379<template>
1380  <card :title="title" />
1381</template>
1382
1383<script setup>
1384import Card from './card.van'
1385</script>
1386"#.to_string(),
1387        );
1388        files.insert(
1389            "card.van".to_string(),
1390            r#"
1391<template>
1392  <div class="card"><h1>{{ title }}</h1></div>
1393</template>
1394
1395<style scoped>
1396.card { border: 1px solid; }
1397</style>
1398"#.to_string(),
1399        );
1400
1401        let data = json!({"title": "Test"});
1402        let id = van_parser::scope_id(".card { border: 1px solid; }");
1403        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1404        // Child component HTML should have scope class on all elements
1405        assert!(resolved.html.contains(&format!("card {id}")), "Should contain scope class");
1406        // CSS selectors should have .{id} appended
1407        assert_eq!(resolved.styles.len(), 1);
1408        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1409    }
1410
1411    #[test]
1412    fn test_resolve_unscoped_style_unchanged() {
1413        let source = r#"
1414<template>
1415  <div class="app"><p>Hello</p></div>
1416</template>
1417
1418<style>
1419.app { margin: 0; }
1420</style>
1421"#;
1422        let data = json!({});
1423        let resolved = resolve_single(source, &data).unwrap();
1424        // HTML should be unchanged — no extra scope classes
1425        assert_eq!(resolved.html.matches("class=").count(), 1, "Only the original class attr");
1426        assert!(resolved.html.contains("class=\"app\""), "Original class preserved");
1427        assert_eq!(resolved.styles[0], ".app { margin: 0; }");
1428    }
1429}