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