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