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