Skip to main content

van_parser/
lib.rs

1use regex::Regex;
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4
5/// A non-component import from `<script setup>` (.ts/.js files).
6#[derive(Debug, Clone, PartialEq)]
7pub struct ScriptImport {
8    /// The full import statement as-is, e.g. `import { formatDate } from '../utils/format.ts'`
9    pub raw: String,
10    /// Whether this is a type-only import (`import type { ... }`)
11    pub is_type_only: bool,
12    /// The module path, e.g. `../utils/format.ts`
13    pub path: String,
14}
15
16/// Parse non-.van imports from a script setup block.
17/// Returns imports from .ts, .js, .tsx, .jsx files.
18/// Supports both relative paths and scoped packages (`@scope/pkg/file.ts`).
19/// Excludes: .van imports (handled by parse_imports), bare module imports like 'vue'.
20pub fn parse_script_imports(script_setup: &str) -> Vec<ScriptImport> {
21    let re = Regex::new(r#"(?m)^[ \t]*(import\s+(?:type\s+)?.*?\s+from\s+['"]([^'"]+\.(?:ts|js|tsx|jsx))['"].*)"#).unwrap();
22    let type_re = Regex::new(r#"^import\s+type\s"#).unwrap();
23    re.captures_iter(script_setup)
24        .map(|cap| {
25            let raw = cap[1].trim().to_string();
26            let path = cap[2].to_string();
27            let is_type_only = type_re.is_match(&raw);
28            ScriptImport {
29                raw,
30                is_type_only,
31                path,
32            }
33        })
34        .collect()
35}
36
37/// Represents an import from a `<script setup>` block.
38#[derive(Debug, Clone, PartialEq)]
39pub struct VanImport {
40    /// The imported identifier, e.g. `DefaultLayout`
41    pub name: String,
42    /// The kebab-case tag name, e.g. `default-layout`
43    pub tag_name: String,
44    /// The import path, e.g. `../layouts/default.van`
45    pub path: String,
46}
47
48/// Parse `import X from './path.van'` statements from a script setup block.
49/// Supports both relative paths (`./foo.van`, `../bar.van`) and scoped packages (`@scope/pkg/file.van`).
50pub fn parse_imports(script_setup: &str) -> Vec<VanImport> {
51    let re = Regex::new(r#"import\s+(\w+)\s+from\s+['"]([^'"]+\.van)['"]"#).unwrap();
52    re.captures_iter(script_setup)
53        .map(|cap| {
54            let name = cap[1].to_string();
55            let tag_name = pascal_to_kebab(&name);
56            let path = cap[2].to_string();
57            VanImport {
58                name,
59                tag_name,
60                path,
61            }
62        })
63        .collect()
64}
65
66/// Convert PascalCase to kebab-case: `DefaultLayout` → `default-layout`
67pub fn pascal_to_kebab(s: &str) -> String {
68    let mut result = String::with_capacity(s.len() + 4);
69    for (i, ch) in s.chars().enumerate() {
70        if ch.is_uppercase() {
71            if i > 0 {
72                result.push('-');
73            }
74            result.push(ch.to_lowercase().next().unwrap());
75        } else {
76            result.push(ch);
77        }
78    }
79    result
80}
81
82/// A single prop declaration from `defineProps({ ... })`.
83#[derive(Debug, Clone, PartialEq)]
84pub struct PropDef {
85    pub name: String,
86    /// The declared type: "String", "Number", "Boolean", "Array", "Object", or None.
87    pub prop_type: Option<String>,
88    pub required: bool,
89}
90
91/// Represents the extracted blocks from a `.van` file.
92#[derive(Debug, Default)]
93pub struct VanBlock {
94    pub template: Option<String>,
95    pub script_setup: Option<String>,
96    pub script_server: Option<String>,
97    pub style: Option<String>,
98    pub style_scoped: bool,
99    pub props: Vec<PropDef>,
100}
101
102/// Extract blocks from a `.van` source file using simple tag matching.
103///
104/// This is a minimal implementation that finds `<template>`, `<script setup>`,
105/// `<script lang="java">`, and `<style>` blocks by locating their opening and
106/// closing tags.
107pub fn parse_blocks(source: &str) -> VanBlock {
108    let (style, style_scoped) = extract_style(source);
109    let script_setup = extract_script_setup(source);
110    let props = if let Some(ref script) = script_setup {
111        parse_define_props(script)
112    } else {
113        Vec::new()
114    };
115    VanBlock {
116        template: extract_block(source, "template"),
117        script_setup,
118        script_server: extract_script_server(source),
119        style,
120        style_scoped,
121        props,
122    }
123}
124
125/// Parse `defineProps({ ... })` from a script setup block.
126///
127/// Supports two forms per entry:
128/// - Simple: `name: Type` → `PropDef { name, prop_type: Some("Type"), required: false }`
129/// - Object: `name: { type: Type, required: true }` → extracts type and required flag
130pub fn parse_define_props(script: &str) -> Vec<PropDef> {
131    // Find `defineProps({` ... `})`
132    let Some(start) = script.find("defineProps(") else {
133        return Vec::new();
134    };
135    let after_paren = start + "defineProps(".len();
136    let rest = &script[after_paren..];
137
138    // Extract the balanced `{ ... }` content
139    let Some(inner) = extract_balanced_braces(rest) else {
140        return Vec::new();
141    };
142
143    if inner.trim().is_empty() {
144        return Vec::new();
145    }
146
147    let mut props = Vec::new();
148
149    // Split entries by comma, respecting nested `{ ... }`
150    let entries = split_respecting_braces(inner);
151
152    for entry in entries {
153        let entry = entry.trim();
154        if entry.is_empty() {
155            continue;
156        }
157
158        // Each entry: `name: value`
159        let Some(colon_pos) = entry.find(':') else {
160            continue;
161        };
162        let name = entry[..colon_pos].trim().trim_matches('\'').trim_matches('"').to_string();
163        let value = entry[colon_pos + 1..].trim();
164
165        if value.starts_with('{') {
166            // Object form: `{ type: Type, required: true }`
167            let obj_inner = value
168                .strip_prefix('{')
169                .and_then(|s| s.strip_suffix('}'))
170                .unwrap_or(value)
171                .trim();
172
173            let mut prop_type = None;
174            let mut required = false;
175
176            for part in obj_inner.split(',') {
177                let part = part.trim();
178                if let Some(cp) = part.find(':') {
179                    let key = part[..cp].trim();
180                    let val = part[cp + 1..].trim();
181                    if key == "type" {
182                        prop_type = Some(val.to_string());
183                    } else if key == "required" {
184                        required = val == "true";
185                    }
186                }
187            }
188
189            props.push(PropDef {
190                name,
191                prop_type,
192                required,
193            });
194        } else {
195            // Simple form: `name: Type`
196            props.push(PropDef {
197                name,
198                prop_type: Some(value.to_string()),
199                required: false,
200            });
201        }
202    }
203
204    props
205}
206
207/// Extract the content between balanced `{` and `}` from the start of the string.
208fn extract_balanced_braces(s: &str) -> Option<&str> {
209    let s = s.trim();
210    if !s.starts_with('{') {
211        return None;
212    }
213    let mut depth = 0;
214    for (i, ch) in s.char_indices() {
215        match ch {
216            '{' => depth += 1,
217            '}' => {
218                depth -= 1;
219                if depth == 0 {
220                    return Some(&s[1..i]);
221                }
222            }
223            _ => {}
224        }
225    }
226    None
227}
228
229/// Split a string by commas, but respect nested `{ ... }` blocks.
230fn split_respecting_braces(s: &str) -> Vec<&str> {
231    let mut result = Vec::new();
232    let mut depth = 0;
233    let mut start = 0;
234    for (i, ch) in s.char_indices() {
235        match ch {
236            '{' => depth += 1,
237            '}' => depth -= 1,
238            ',' if depth == 0 => {
239                result.push(&s[start..i]);
240                start = i + 1;
241            }
242            _ => {}
243        }
244    }
245    let tail = &s[start..];
246    if !tail.trim().is_empty() {
247        result.push(tail);
248    }
249    result
250}
251
252fn extract_block(source: &str, tag: &str) -> Option<String> {
253    let open = format!("<{}", tag);
254    let close = format!("</{}>", tag);
255
256    let start_idx = source.find(&open)?;
257    let after_open = &source[start_idx..];
258    // Find the end of the opening tag (the '>')
259    let tag_end = after_open.find('>')?;
260    let content_start = start_idx + tag_end + 1;
261
262    // Use rfind for the closing tag to handle nested <template #slot> blocks
263    let end_idx = source.rfind(&close)?;
264    if end_idx <= content_start {
265        return None;
266    }
267
268    Some(source[content_start..end_idx].trim().to_string())
269}
270
271fn extract_script_setup(source: &str) -> Option<String> {
272    // Look for <script setup or <script setup lang="ts">
273    let marker = "<script setup";
274    let close = "</script>";
275
276    let start_idx = source.find(marker)?;
277    let after_open = &source[start_idx..];
278    let tag_end = after_open.find('>')?;
279    let content_start = start_idx + tag_end + 1;
280
281    // Find the closing </script> after this opening tag
282    let remaining = &source[content_start..];
283    let end_offset = remaining.find(close)?;
284    let end_idx = content_start + end_offset;
285
286    Some(source[content_start..end_idx].trim().to_string())
287}
288
289fn extract_script_server(source: &str) -> Option<String> {
290    // Look for <script lang="java">
291    let marker = "<script lang=\"java\">";
292    let close = "</script>";
293
294    let start_idx = source.find(marker)?;
295    let content_start = start_idx + marker.len();
296
297    // Find the closing </script> after this opening tag
298    let remaining = &source[content_start..];
299    let end_offset = remaining.find(close)?;
300    let end_idx = content_start + end_offset;
301
302    Some(source[content_start..end_idx].trim().to_string())
303}
304
305fn extract_style(source: &str) -> (Option<String>, bool) {
306    let open = "<style";
307    let close = "</style>";
308
309    // Only match top-level <style> blocks (after </template>), not <style> inside <template>
310    let search_start = source.rfind("</template>")
311        .map(|i| i + "</template>".len())
312        .unwrap_or(0);
313
314    let Some(rel_idx) = source[search_start..].find(open) else {
315        return (None, false);
316    };
317    let start_idx = search_start + rel_idx;
318    let after_open = &source[start_idx..];
319    let Some(tag_end) = after_open.find('>') else {
320        return (None, false);
321    };
322
323    // Check if the opening tag attributes contain "scoped"
324    let tag_attrs = &after_open[..tag_end];
325    let is_scoped = tag_attrs.contains("scoped");
326
327    let content_start = start_idx + tag_end + 1;
328    let remaining = &source[content_start..];
329    let Some(end_offset) = remaining.find(close) else {
330        return (None, false);
331    };
332    let end_idx = content_start + end_offset;
333
334    (Some(source[content_start..end_idx].trim().to_string()), is_scoped)
335}
336
337/// Generate a deterministic 8-hex-char scope ID from content (typically CSS).
338///
339/// Uses `DefaultHasher` with fixed seed (SipHash keys 0,0) so the same
340/// content always produces the same ID, even across process restarts.
341pub fn scope_id(content: &str) -> String {
342    let mut hasher = DefaultHasher::new();
343    content.hash(&mut hasher);
344    format!("{:08x}", hasher.finish() as u32)
345}
346
347/// Tags that should NOT receive a scope class.
348/// - `slot` / `template`: virtual tags replaced during resolution
349/// - structural/head tags: not rendered or not styleable targets
350const SKIP_SCOPE_TAGS: &[&str] = &[
351    "slot", "template",
352    "html", "head", "body", "meta", "link", "title",
353    "script", "style", "base", "noscript",
354];
355
356/// Add a scope class to every opening HTML tag in the fragment.
357///
358/// Skips closing tags, comments, and tags in [`SKIP_SCOPE_TAGS`].
359/// Handles: existing class, no class, self-closing tags.
360pub fn add_scope_class(html: &str, id: &str) -> String {
361    let mut result = String::with_capacity(html.len() + id.len() * 10);
362    let mut rest = html;
363
364    while let Some(lt_pos) = rest.find('<') {
365        result.push_str(&rest[..lt_pos]);
366        rest = &rest[lt_pos..];
367
368        // Skip closing tags and comments
369        if rest.starts_with("</") || rest.starts_with("<!--") || rest.starts_with("<!") {
370            if let Some(gt) = rest.find('>') {
371                result.push_str(&rest[..=gt]);
372                rest = &rest[gt + 1..];
373            } else {
374                result.push_str(rest);
375                return result;
376            }
377            continue;
378        }
379
380        // Opening tag — find '>'
381        let Some(gt) = rest.find('>') else {
382            result.push_str(rest);
383            return result;
384        };
385
386        // Extract tag name to check skip list
387        let tag_name_end = rest[1..]
388            .find(|c: char| !c.is_alphanumeric() && c != '-')
389            .map(|p| p + 1)
390            .unwrap_or(gt);
391        let tag_name = &rest[1..tag_name_end];
392
393        let should_skip = SKIP_SCOPE_TAGS.iter().any(|&t| t.eq_ignore_ascii_case(tag_name));
394
395        if should_skip {
396            result.push_str(&rest[..=gt]);
397            rest = &rest[gt + 1..];
398            continue;
399        }
400
401        let tag = &rest[..gt];
402        let is_self_closing = tag.trim_end().ends_with('/');
403
404        if let Some(class_idx) = tag.find("class=\"") {
405            let after_quote = class_idx + 7;
406            if let Some(end_quote) = tag[after_quote..].find('"') {
407                let insert = after_quote + end_quote;
408                result.push_str(&rest[..insert]);
409                result.push(' ');
410                result.push_str(id);
411                result.push_str(&rest[insert..=gt]);
412            } else {
413                result.push_str(&rest[..=gt]);
414            }
415        } else if is_self_closing {
416            let slash = tag.rfind('/').unwrap();
417            result.push_str(&rest[..slash]);
418            result.push_str("class=\"");
419            result.push_str(id);
420            result.push_str("\" ");
421            result.push_str(&rest[slash..=gt]);
422        } else {
423            result.push_str(&rest[..gt]);
424            result.push_str(" class=\"");
425            result.push_str(id);
426            result.push_str("\">");
427        }
428
429        rest = &rest[gt + 1..];
430    }
431
432    result.push_str(rest);
433    result
434}
435
436/// Scope CSS by inserting `.{id}` before any pseudo-class/pseudo-element
437/// on the last simple selector of each rule.
438///
439/// Input: `.card { border: 1px solid; }  a:hover { color: navy; }`
440/// Output: `.card.a1b2c3d4 { border: 1px solid; }  a.a1b2c3d4:hover { color: navy; }`
441pub fn scope_css(css: &str, id: &str) -> String {
442    let suffix = format!(".{id}");
443    let rule_re = Regex::new(r"([^{}]+)\{([^{}]*)\}").unwrap();
444
445    rule_re.replace_all(css, |caps: &regex::Captures| {
446        let selectors = caps[1].trim();
447        let body = &caps[2];
448
449        let scoped: Vec<String> = selectors
450            .split(',')
451            .map(|s| insert_scope_suffix(s.trim(), &suffix))
452            .collect();
453
454        format!("{} {{{}}}", scoped.join(", "), body)
455    }).to_string()
456}
457
458/// Insert a scope class suffix before any pseudo-class/pseudo-element
459/// at the end of a selector.
460///
461/// `.demo-list a:hover` → `.demo-list a.{suffix}:hover`
462/// `.foo::before` → `.foo.{suffix}::before`
463/// `.card h3` → `.card h3.{suffix}`
464fn insert_scope_suffix(selector: &str, suffix: &str) -> String {
465    // Find the last simple selector (after space or combinator)
466    let last_start = selector
467        .rfind(|c: char| c == ' ' || c == '>' || c == '+' || c == '~')
468        .map(|p| p + 1)
469        .unwrap_or(0);
470
471    let last_part = &selector[last_start..];
472
473    // Find the first `:` in the last part (pseudo-class or pseudo-element)
474    if let Some(colon_pos) = last_part.find(':') {
475        let insert_at = last_start + colon_pos;
476        format!("{}{}{}", &selector[..insert_at], suffix, &selector[insert_at..])
477    } else {
478        format!("{}{}", selector, suffix)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn test_parse_blocks_basic() {
488        let source = r#"
489<script setup lang="ts">
490import Hello from './hello.van'
491</script>
492
493<template>
494  <div>Hello {{ name }}</div>
495</template>
496
497<style scoped>
498.hello { color: red; }
499</style>
500"#;
501        let blocks = parse_blocks(source);
502        assert!(blocks.template.is_some());
503        assert!(blocks.template.unwrap().contains("Hello {{ name }}"));
504        assert!(blocks.script_setup.is_some());
505        assert!(blocks.script_setup.unwrap().contains("import Hello"));
506        assert!(blocks.style.is_some());
507        assert!(blocks.style.unwrap().contains("color: red"));
508        assert!(blocks.script_server.is_none());
509    }
510
511    #[test]
512    fn test_parse_blocks_with_java_script() {
513        let source = r#"
514<template>
515  <div></div>
516</template>
517
518<script setup lang="ts">
519// ts code
520</script>
521
522<script lang="java">
523// java code
524</script>
525"#;
526        let blocks = parse_blocks(source);
527        assert!(blocks.template.is_some());
528        assert!(blocks.script_setup.is_some());
529        assert!(blocks.script_server.is_some());
530        assert!(blocks.script_server.unwrap().contains("java code"));
531    }
532
533    #[test]
534    fn test_parse_blocks_empty() {
535        let blocks = parse_blocks("");
536        assert!(blocks.template.is_none());
537        assert!(blocks.script_setup.is_none());
538        assert!(blocks.script_server.is_none());
539        assert!(blocks.style.is_none());
540    }
541
542    #[test]
543    fn test_parse_blocks_nested_template_slots() {
544        let source = r#"
545<template>
546  <default-layout>
547    <template #title>{{ title }}</template>
548    <h1>Welcome</h1>
549  </default-layout>
550</template>
551
552<script setup lang="ts">
553import DefaultLayout from '../layouts/default.van'
554</script>
555"#;
556        let blocks = parse_blocks(source);
557        let template = blocks.template.unwrap();
558        assert!(template.contains("<default-layout>"), "Should contain opening tag");
559        assert!(template.contains("</default-layout>"), "Should contain closing tag");
560        assert!(template.contains("<template #title>"), "Should contain slot template");
561        assert!(template.contains("<h1>Welcome</h1>"), "Should contain h1");
562    }
563
564    #[test]
565    fn test_parse_imports() {
566        let script = r#"
567import DefaultLayout from '../layouts/default.van'
568import Hello from '../components/hello.van'
569
570defineProps({
571  title: String
572})
573"#;
574        let imports = parse_imports(script);
575        assert_eq!(imports.len(), 2);
576        assert_eq!(imports[0].name, "DefaultLayout");
577        assert_eq!(imports[0].tag_name, "default-layout");
578        assert_eq!(imports[0].path, "../layouts/default.van");
579        assert_eq!(imports[1].name, "Hello");
580        assert_eq!(imports[1].tag_name, "hello");
581        assert_eq!(imports[1].path, "../components/hello.van");
582    }
583
584    #[test]
585    fn test_parse_imports_double_quotes() {
586        let script = r#"import Foo from "../components/foo.van""#;
587        let imports = parse_imports(script);
588        assert_eq!(imports.len(), 1);
589        assert_eq!(imports[0].name, "Foo");
590        assert_eq!(imports[0].path, "../components/foo.van");
591    }
592
593    #[test]
594    fn test_parse_imports_no_van_files() {
595        let script = r#"import { ref } from 'vue'"#;
596        let imports = parse_imports(script);
597        assert!(imports.is_empty());
598    }
599
600    #[test]
601    fn test_pascal_to_kebab() {
602        assert_eq!(pascal_to_kebab("DefaultLayout"), "default-layout");
603        assert_eq!(pascal_to_kebab("Hello"), "hello");
604        assert_eq!(pascal_to_kebab("MyComponent"), "my-component");
605        assert_eq!(pascal_to_kebab("A"), "a");
606    }
607
608    // ─── Scoped style tests ──────────────────────────────────────────
609
610    #[test]
611    fn test_style_scoped_detection() {
612        let scoped_source = r#"
613<template><div>Hi</div></template>
614<style scoped>
615.card { color: red; }
616</style>
617"#;
618        let blocks = parse_blocks(scoped_source);
619        assert!(blocks.style_scoped);
620        assert!(blocks.style.unwrap().contains("color: red"));
621
622        let unscoped_source = r#"
623<template><div>Hi</div></template>
624<style>
625.card { color: blue; }
626</style>
627"#;
628        let blocks = parse_blocks(unscoped_source);
629        assert!(!blocks.style_scoped);
630        assert!(blocks.style.unwrap().contains("color: blue"));
631    }
632
633    #[test]
634    fn test_style_scoped_with_lang() {
635        let source = r#"
636<template><div>Hi</div></template>
637<style scoped lang="css">
638h1 { font-size: 2rem; }
639</style>
640"#;
641        let blocks = parse_blocks(source);
642        assert!(blocks.style_scoped);
643    }
644
645    #[test]
646    fn test_scope_id_deterministic() {
647        let id1 = scope_id(".card { color: red; }");
648        let id2 = scope_id(".card { color: red; }");
649        assert_eq!(id1, id2);
650        assert_eq!(id1.len(), 8);
651        // Different content → different ID
652        let id3 = scope_id("h1 { color: blue; }");
653        assert_ne!(id1, id3);
654    }
655
656    #[test]
657    fn test_add_scope_class_all_elements() {
658        let html = r#"<div class="card"><h1>Title</h1><p>Text</p></div>"#;
659        let result = add_scope_class(html, "a1b2c3d4");
660        assert_eq!(
661            result,
662            r#"<div class="card a1b2c3d4"><h1 class="a1b2c3d4">Title</h1><p class="a1b2c3d4">Text</p></div>"#
663        );
664    }
665
666    #[test]
667    fn test_add_scope_class_no_class() {
668        let html = r#"<div><h1>Title</h1></div>"#;
669        let result = add_scope_class(html, "a1b2c3d4");
670        assert_eq!(result, r#"<div class="a1b2c3d4"><h1 class="a1b2c3d4">Title</h1></div>"#);
671    }
672
673    #[test]
674    fn test_add_scope_class_self_closing() {
675        let html = r#"<div><img src="x.png" /><br /></div>"#;
676        let result = add_scope_class(html, "a1b2c3d4");
677        assert_eq!(
678            result,
679            r#"<div class="a1b2c3d4"><img src="x.png" class="a1b2c3d4" /><br class="a1b2c3d4" /></div>"#
680        );
681    }
682
683    #[test]
684    fn test_add_scope_class_skips_comments() {
685        let html = r#"<!-- comment --><div>Hi</div>"#;
686        let result = add_scope_class(html, "a1b2c3d4");
687        assert_eq!(result, r#"<!-- comment --><div class="a1b2c3d4">Hi</div>"#);
688    }
689
690    #[test]
691    fn test_add_scope_class_skips_slot() {
692        let html = r#"<div><slot /><slot name="x">fallback</slot></div>"#;
693        let result = add_scope_class(html, "a1b2c3d4");
694        assert_eq!(result, r#"<div class="a1b2c3d4"><slot /><slot name="x">fallback</slot></div>"#);
695    }
696
697    #[test]
698    fn test_add_scope_class_skips_structural() {
699        let html = r#"<html><head><meta charset="UTF-8" /></head><body><nav class="x">Hi</nav></body></html>"#;
700        let result = add_scope_class(html, "a1b2c3d4");
701        assert_eq!(
702            result,
703            r#"<html><head><meta charset="UTF-8" /></head><body><nav class="x a1b2c3d4">Hi</nav></body></html>"#
704        );
705    }
706
707    #[test]
708    fn test_scope_css_single_selector() {
709        let css = ".card { border: 1px solid; }";
710        let result = scope_css(css, "a1b2c3d4");
711        assert_eq!(result, ".card.a1b2c3d4 { border: 1px solid; }");
712    }
713
714    #[test]
715    fn test_scope_css_multiple_rules() {
716        let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
717        let result = scope_css(css, "a1b2c3d4");
718        assert!(result.contains(".card.a1b2c3d4 { border: 1px solid; }"));
719        assert!(result.contains("h1.a1b2c3d4 { color: navy; }"));
720    }
721
722    #[test]
723    fn test_scope_css_comma_selectors() {
724        let css = ".card, .box { border: 1px solid; }";
725        let result = scope_css(css, "a1b2c3d4");
726        assert_eq!(result, ".card.a1b2c3d4, .box.a1b2c3d4 { border: 1px solid; }");
727    }
728
729    #[test]
730    fn test_scope_css_descendant_selector() {
731        let css = ".card h1 { color: navy; }";
732        let result = scope_css(css, "a1b2c3d4");
733        assert_eq!(result, ".card h1.a1b2c3d4 { color: navy; }");
734    }
735
736    #[test]
737    fn test_scope_css_pseudo_class() {
738        let css = ".demo-list a:hover { text-decoration: underline; }";
739        let result = scope_css(css, "a1b2c3d4");
740        assert_eq!(result, ".demo-list a.a1b2c3d4:hover { text-decoration: underline; }");
741    }
742
743    #[test]
744    fn test_scope_css_pseudo_element() {
745        let css = ".item::before { content: '-'; }";
746        let result = scope_css(css, "a1b2c3d4");
747        assert_eq!(result, ".item.a1b2c3d4::before { content: '-'; }");
748    }
749
750    #[test]
751    fn test_scope_css_no_pseudo() {
752        let css = "h1 { font-size: 2rem; }";
753        let result = scope_css(css, "a1b2c3d4");
754        assert_eq!(result, "h1.a1b2c3d4 { font-size: 2rem; }");
755    }
756
757    // ─── defineProps tests ──────────────────────────────────────────
758
759    #[test]
760    fn test_parse_define_props_simple() {
761        let script = "defineProps({ title: String, count: Number })";
762        let props = parse_define_props(script);
763        assert_eq!(props.len(), 2);
764        assert_eq!(props[0].name, "title");
765        assert_eq!(props[0].prop_type, Some("String".to_string()));
766        assert!(!props[0].required);
767        assert_eq!(props[1].name, "count");
768        assert_eq!(props[1].prop_type, Some("Number".to_string()));
769        assert!(!props[1].required);
770    }
771
772    #[test]
773    fn test_parse_define_props_with_required() {
774        let script = "defineProps({ user: { type: Object, required: true } })";
775        let props = parse_define_props(script);
776        assert_eq!(props.len(), 1);
777        assert_eq!(props[0].name, "user");
778        assert_eq!(props[0].prop_type, Some("Object".to_string()));
779        assert!(props[0].required);
780    }
781
782    #[test]
783    fn test_parse_define_props_mixed() {
784        let script = r#"defineProps({
785  title: String,
786  user: { type: Object, required: true },
787  count: Number
788})"#;
789        let props = parse_define_props(script);
790        assert_eq!(props.len(), 3);
791        assert_eq!(props[0].name, "title");
792        assert_eq!(props[0].prop_type, Some("String".to_string()));
793        assert!(!props[0].required);
794        assert_eq!(props[1].name, "user");
795        assert_eq!(props[1].prop_type, Some("Object".to_string()));
796        assert!(props[1].required);
797        assert_eq!(props[2].name, "count");
798        assert_eq!(props[2].prop_type, Some("Number".to_string()));
799        assert!(!props[2].required);
800    }
801
802    #[test]
803    fn test_parse_define_props_missing() {
804        let script = "const count = ref(0)";
805        let props = parse_define_props(script);
806        assert!(props.is_empty());
807    }
808
809    #[test]
810    fn test_parse_define_props_empty() {
811        let script = "defineProps({})";
812        let props = parse_define_props(script);
813        assert!(props.is_empty());
814    }
815
816    #[test]
817    fn test_parse_blocks_includes_props() {
818        let source = r#"
819<script setup lang="ts">
820defineProps({ title: String, count: Number })
821</script>
822
823<template>
824  <h1>{{ title }}</h1>
825</template>
826"#;
827        let blocks = parse_blocks(source);
828        assert_eq!(blocks.props.len(), 2);
829        assert_eq!(blocks.props[0].name, "title");
830        assert_eq!(blocks.props[1].name, "count");
831    }
832
833    // ─── parse_script_imports tests ───────────────────────────────────
834
835    #[test]
836    fn test_parse_script_imports_ts() {
837        let script = r#"
838import { formatDate } from '../utils/format.ts'
839import DefaultLayout from '../layouts/default.van'
840const count = ref(0)
841"#;
842        let imports = parse_script_imports(script);
843        assert_eq!(imports.len(), 1);
844        assert_eq!(imports[0].path, "../utils/format.ts");
845        assert!(!imports[0].is_type_only);
846        assert!(imports[0].raw.contains("formatDate"));
847    }
848
849    #[test]
850    fn test_parse_script_imports_type_only() {
851        let script = r#"
852import type { User } from '../types/models.ts'
853import { formatDate } from '../utils/format.ts'
854"#;
855        let imports = parse_script_imports(script);
856        assert_eq!(imports.len(), 2);
857        assert!(imports[0].is_type_only);
858        assert_eq!(imports[0].path, "../types/models.ts");
859        assert!(!imports[1].is_type_only);
860        assert_eq!(imports[1].path, "../utils/format.ts");
861    }
862
863    #[test]
864    fn test_parse_script_imports_js() {
865        let script = r#"import foo from '../utils/helper.js'"#;
866        let imports = parse_script_imports(script);
867        assert_eq!(imports.len(), 1);
868        assert_eq!(imports[0].path, "../utils/helper.js");
869        assert!(!imports[0].is_type_only);
870    }
871
872    #[test]
873    fn test_parse_script_imports_ignores_van() {
874        let script = r#"
875import Hello from './hello.van'
876import Foo from '../foo.van'
877"#;
878        let imports = parse_script_imports(script);
879        assert!(imports.is_empty());
880    }
881
882    #[test]
883    fn test_parse_script_imports_ignores_bare() {
884        let script = r#"import { ref } from 'vue'"#;
885        let imports = parse_script_imports(script);
886        assert!(imports.is_empty());
887    }
888
889    #[test]
890    fn test_parse_script_imports_mixed_type() {
891        // `import { type User, formatDate } from ...` is NOT type-only
892        let script = r#"import { type User, formatDate } from '../utils.ts'"#;
893        let imports = parse_script_imports(script);
894        assert_eq!(imports.len(), 1);
895        assert!(!imports[0].is_type_only);
896    }
897
898    #[test]
899    fn test_parse_imports_scoped_package() {
900        let script = r#"
901import VanButton from '@van-ui/button/button.van'
902import DefaultLayout from '../layouts/default.van'
903"#;
904        let imports = parse_imports(script);
905        assert_eq!(imports.len(), 2);
906        assert_eq!(imports[0].name, "VanButton");
907        assert_eq!(imports[0].tag_name, "van-button");
908        assert_eq!(imports[0].path, "@van-ui/button/button.van");
909        assert_eq!(imports[1].name, "DefaultLayout");
910        assert_eq!(imports[1].path, "../layouts/default.van");
911    }
912
913    #[test]
914    fn test_parse_script_imports_scoped_package() {
915        let script = r#"
916import { formatDate } from '@van-ui/utils/format.ts'
917import { helper } from '../utils/helper.ts'
918"#;
919        let imports = parse_script_imports(script);
920        assert_eq!(imports.len(), 2);
921        assert_eq!(imports[0].path, "@van-ui/utils/format.ts");
922        assert_eq!(imports[1].path, "../utils/helper.ts");
923    }
924
925    #[test]
926    fn test_parse_script_imports_tsx_jsx() {
927        let script = r#"
928import { render } from '../lib/render.tsx'
929import { helper } from '../lib/helper.jsx'
930"#;
931        let imports = parse_script_imports(script);
932        assert_eq!(imports.len(), 2);
933        assert_eq!(imports[0].path, "../lib/render.tsx");
934        assert_eq!(imports[1].path, "../lib/helper.jsx");
935    }
936}