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