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    let Some(start_idx) = source.find(open) else {
310        return (None, false);
311    };
312    let after_open = &source[start_idx..];
313    let Some(tag_end) = after_open.find('>') else {
314        return (None, false);
315    };
316
317    // Check if the opening tag attributes contain "scoped"
318    let tag_attrs = &after_open[..tag_end];
319    let is_scoped = tag_attrs.contains("scoped");
320
321    let content_start = start_idx + tag_end + 1;
322    let remaining = &source[content_start..];
323    let Some(end_offset) = remaining.find(close) else {
324        return (None, false);
325    };
326    let end_idx = content_start + end_offset;
327
328    (Some(source[content_start..end_idx].trim().to_string()), is_scoped)
329}
330
331/// Generate a deterministic 8-hex-char scope ID from content (typically CSS).
332///
333/// Uses `DefaultHasher` with fixed seed (SipHash keys 0,0) so the same
334/// content always produces the same ID, even across process restarts.
335pub fn scope_id(content: &str) -> String {
336    let mut hasher = DefaultHasher::new();
337    content.hash(&mut hasher);
338    format!("{:08x}", hasher.finish() as u32)
339}
340
341/// Tags that should NOT receive a scope class.
342/// - `slot` / `template`: virtual tags replaced during resolution
343/// - structural/head tags: not rendered or not styleable targets
344const SKIP_SCOPE_TAGS: &[&str] = &[
345    "slot", "template",
346    "html", "head", "body", "meta", "link", "title",
347    "script", "style", "base", "noscript",
348];
349
350/// Add a scope class to every opening HTML tag in the fragment.
351///
352/// Skips closing tags, comments, and tags in [`SKIP_SCOPE_TAGS`].
353/// Handles: existing class, no class, self-closing tags.
354pub fn add_scope_class(html: &str, id: &str) -> String {
355    let mut result = String::with_capacity(html.len() + id.len() * 10);
356    let mut rest = html;
357
358    while let Some(lt_pos) = rest.find('<') {
359        result.push_str(&rest[..lt_pos]);
360        rest = &rest[lt_pos..];
361
362        // Skip closing tags and comments
363        if rest.starts_with("</") || rest.starts_with("<!--") || rest.starts_with("<!") {
364            if let Some(gt) = rest.find('>') {
365                result.push_str(&rest[..=gt]);
366                rest = &rest[gt + 1..];
367            } else {
368                result.push_str(rest);
369                return result;
370            }
371            continue;
372        }
373
374        // Opening tag — find '>'
375        let Some(gt) = rest.find('>') else {
376            result.push_str(rest);
377            return result;
378        };
379
380        // Extract tag name to check skip list
381        let tag_name_end = rest[1..]
382            .find(|c: char| !c.is_alphanumeric() && c != '-')
383            .map(|p| p + 1)
384            .unwrap_or(gt);
385        let tag_name = &rest[1..tag_name_end];
386
387        let should_skip = SKIP_SCOPE_TAGS.iter().any(|&t| t.eq_ignore_ascii_case(tag_name));
388
389        if should_skip {
390            result.push_str(&rest[..=gt]);
391            rest = &rest[gt + 1..];
392            continue;
393        }
394
395        let tag = &rest[..gt];
396        let is_self_closing = tag.trim_end().ends_with('/');
397
398        if let Some(class_idx) = tag.find("class=\"") {
399            let after_quote = class_idx + 7;
400            if let Some(end_quote) = tag[after_quote..].find('"') {
401                let insert = after_quote + end_quote;
402                result.push_str(&rest[..insert]);
403                result.push(' ');
404                result.push_str(id);
405                result.push_str(&rest[insert..=gt]);
406            } else {
407                result.push_str(&rest[..=gt]);
408            }
409        } else if is_self_closing {
410            let slash = tag.rfind('/').unwrap();
411            result.push_str(&rest[..slash]);
412            result.push_str("class=\"");
413            result.push_str(id);
414            result.push_str("\" ");
415            result.push_str(&rest[slash..=gt]);
416        } else {
417            result.push_str(&rest[..gt]);
418            result.push_str(" class=\"");
419            result.push_str(id);
420            result.push_str("\">");
421        }
422
423        rest = &rest[gt + 1..];
424    }
425
426    result.push_str(rest);
427    result
428}
429
430/// Scope CSS by inserting `.{id}` before any pseudo-class/pseudo-element
431/// on the last simple selector of each rule.
432///
433/// Input: `.card { border: 1px solid; }  a:hover { color: navy; }`
434/// Output: `.card.a1b2c3d4 { border: 1px solid; }  a.a1b2c3d4:hover { color: navy; }`
435pub fn scope_css(css: &str, id: &str) -> String {
436    let suffix = format!(".{id}");
437    let rule_re = Regex::new(r"([^{}]+)\{([^{}]*)\}").unwrap();
438
439    rule_re.replace_all(css, |caps: &regex::Captures| {
440        let selectors = caps[1].trim();
441        let body = &caps[2];
442
443        let scoped: Vec<String> = selectors
444            .split(',')
445            .map(|s| insert_scope_suffix(s.trim(), &suffix))
446            .collect();
447
448        format!("{} {{{}}}", scoped.join(", "), body)
449    }).to_string()
450}
451
452/// Insert a scope class suffix before any pseudo-class/pseudo-element
453/// at the end of a selector.
454///
455/// `.demo-list a:hover` → `.demo-list a.{suffix}:hover`
456/// `.foo::before` → `.foo.{suffix}::before`
457/// `.card h3` → `.card h3.{suffix}`
458fn insert_scope_suffix(selector: &str, suffix: &str) -> String {
459    // Find the last simple selector (after space or combinator)
460    let last_start = selector
461        .rfind(|c: char| c == ' ' || c == '>' || c == '+' || c == '~')
462        .map(|p| p + 1)
463        .unwrap_or(0);
464
465    let last_part = &selector[last_start..];
466
467    // Find the first `:` in the last part (pseudo-class or pseudo-element)
468    if let Some(colon_pos) = last_part.find(':') {
469        let insert_at = last_start + colon_pos;
470        format!("{}{}{}", &selector[..insert_at], suffix, &selector[insert_at..])
471    } else {
472        format!("{}{}", selector, suffix)
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_parse_blocks_basic() {
482        let source = r#"
483<script setup lang="ts">
484import Hello from './hello.van'
485</script>
486
487<template>
488  <div>Hello {{ name }}</div>
489</template>
490
491<style scoped>
492.hello { color: red; }
493</style>
494"#;
495        let blocks = parse_blocks(source);
496        assert!(blocks.template.is_some());
497        assert!(blocks.template.unwrap().contains("Hello {{ name }}"));
498        assert!(blocks.script_setup.is_some());
499        assert!(blocks.script_setup.unwrap().contains("import Hello"));
500        assert!(blocks.style.is_some());
501        assert!(blocks.style.unwrap().contains("color: red"));
502        assert!(blocks.script_server.is_none());
503    }
504
505    #[test]
506    fn test_parse_blocks_with_java_script() {
507        let source = r#"
508<template>
509  <div></div>
510</template>
511
512<script setup lang="ts">
513// ts code
514</script>
515
516<script lang="java">
517// java code
518</script>
519"#;
520        let blocks = parse_blocks(source);
521        assert!(blocks.template.is_some());
522        assert!(blocks.script_setup.is_some());
523        assert!(blocks.script_server.is_some());
524        assert!(blocks.script_server.unwrap().contains("java code"));
525    }
526
527    #[test]
528    fn test_parse_blocks_empty() {
529        let blocks = parse_blocks("");
530        assert!(blocks.template.is_none());
531        assert!(blocks.script_setup.is_none());
532        assert!(blocks.script_server.is_none());
533        assert!(blocks.style.is_none());
534    }
535
536    #[test]
537    fn test_parse_blocks_nested_template_slots() {
538        let source = r#"
539<template>
540  <default-layout>
541    <template #title>{{ title }}</template>
542    <h1>Welcome</h1>
543  </default-layout>
544</template>
545
546<script setup lang="ts">
547import DefaultLayout from '../layouts/default.van'
548</script>
549"#;
550        let blocks = parse_blocks(source);
551        let template = blocks.template.unwrap();
552        assert!(template.contains("<default-layout>"), "Should contain opening tag");
553        assert!(template.contains("</default-layout>"), "Should contain closing tag");
554        assert!(template.contains("<template #title>"), "Should contain slot template");
555        assert!(template.contains("<h1>Welcome</h1>"), "Should contain h1");
556    }
557
558    #[test]
559    fn test_parse_imports() {
560        let script = r#"
561import DefaultLayout from '../layouts/default.van'
562import Hello from '../components/hello.van'
563
564defineProps({
565  title: String
566})
567"#;
568        let imports = parse_imports(script);
569        assert_eq!(imports.len(), 2);
570        assert_eq!(imports[0].name, "DefaultLayout");
571        assert_eq!(imports[0].tag_name, "default-layout");
572        assert_eq!(imports[0].path, "../layouts/default.van");
573        assert_eq!(imports[1].name, "Hello");
574        assert_eq!(imports[1].tag_name, "hello");
575        assert_eq!(imports[1].path, "../components/hello.van");
576    }
577
578    #[test]
579    fn test_parse_imports_double_quotes() {
580        let script = r#"import Foo from "../components/foo.van""#;
581        let imports = parse_imports(script);
582        assert_eq!(imports.len(), 1);
583        assert_eq!(imports[0].name, "Foo");
584        assert_eq!(imports[0].path, "../components/foo.van");
585    }
586
587    #[test]
588    fn test_parse_imports_no_van_files() {
589        let script = r#"import { ref } from 'vue'"#;
590        let imports = parse_imports(script);
591        assert!(imports.is_empty());
592    }
593
594    #[test]
595    fn test_pascal_to_kebab() {
596        assert_eq!(pascal_to_kebab("DefaultLayout"), "default-layout");
597        assert_eq!(pascal_to_kebab("Hello"), "hello");
598        assert_eq!(pascal_to_kebab("MyComponent"), "my-component");
599        assert_eq!(pascal_to_kebab("A"), "a");
600    }
601
602    // ─── Scoped style tests ──────────────────────────────────────────
603
604    #[test]
605    fn test_style_scoped_detection() {
606        let scoped_source = r#"
607<template><div>Hi</div></template>
608<style scoped>
609.card { color: red; }
610</style>
611"#;
612        let blocks = parse_blocks(scoped_source);
613        assert!(blocks.style_scoped);
614        assert!(blocks.style.unwrap().contains("color: red"));
615
616        let unscoped_source = r#"
617<template><div>Hi</div></template>
618<style>
619.card { color: blue; }
620</style>
621"#;
622        let blocks = parse_blocks(unscoped_source);
623        assert!(!blocks.style_scoped);
624        assert!(blocks.style.unwrap().contains("color: blue"));
625    }
626
627    #[test]
628    fn test_style_scoped_with_lang() {
629        let source = r#"
630<template><div>Hi</div></template>
631<style scoped lang="css">
632h1 { font-size: 2rem; }
633</style>
634"#;
635        let blocks = parse_blocks(source);
636        assert!(blocks.style_scoped);
637    }
638
639    #[test]
640    fn test_scope_id_deterministic() {
641        let id1 = scope_id(".card { color: red; }");
642        let id2 = scope_id(".card { color: red; }");
643        assert_eq!(id1, id2);
644        assert_eq!(id1.len(), 8);
645        // Different content → different ID
646        let id3 = scope_id("h1 { color: blue; }");
647        assert_ne!(id1, id3);
648    }
649
650    #[test]
651    fn test_add_scope_class_all_elements() {
652        let html = r#"<div class="card"><h1>Title</h1><p>Text</p></div>"#;
653        let result = add_scope_class(html, "a1b2c3d4");
654        assert_eq!(
655            result,
656            r#"<div class="card a1b2c3d4"><h1 class="a1b2c3d4">Title</h1><p class="a1b2c3d4">Text</p></div>"#
657        );
658    }
659
660    #[test]
661    fn test_add_scope_class_no_class() {
662        let html = r#"<div><h1>Title</h1></div>"#;
663        let result = add_scope_class(html, "a1b2c3d4");
664        assert_eq!(result, r#"<div class="a1b2c3d4"><h1 class="a1b2c3d4">Title</h1></div>"#);
665    }
666
667    #[test]
668    fn test_add_scope_class_self_closing() {
669        let html = r#"<div><img src="x.png" /><br /></div>"#;
670        let result = add_scope_class(html, "a1b2c3d4");
671        assert_eq!(
672            result,
673            r#"<div class="a1b2c3d4"><img src="x.png" class="a1b2c3d4" /><br class="a1b2c3d4" /></div>"#
674        );
675    }
676
677    #[test]
678    fn test_add_scope_class_skips_comments() {
679        let html = r#"<!-- comment --><div>Hi</div>"#;
680        let result = add_scope_class(html, "a1b2c3d4");
681        assert_eq!(result, r#"<!-- comment --><div class="a1b2c3d4">Hi</div>"#);
682    }
683
684    #[test]
685    fn test_add_scope_class_skips_slot() {
686        let html = r#"<div><slot /><slot name="x">fallback</slot></div>"#;
687        let result = add_scope_class(html, "a1b2c3d4");
688        assert_eq!(result, r#"<div class="a1b2c3d4"><slot /><slot name="x">fallback</slot></div>"#);
689    }
690
691    #[test]
692    fn test_add_scope_class_skips_structural() {
693        let html = r#"<html><head><meta charset="UTF-8" /></head><body><nav class="x">Hi</nav></body></html>"#;
694        let result = add_scope_class(html, "a1b2c3d4");
695        assert_eq!(
696            result,
697            r#"<html><head><meta charset="UTF-8" /></head><body><nav class="x a1b2c3d4">Hi</nav></body></html>"#
698        );
699    }
700
701    #[test]
702    fn test_scope_css_single_selector() {
703        let css = ".card { border: 1px solid; }";
704        let result = scope_css(css, "a1b2c3d4");
705        assert_eq!(result, ".card.a1b2c3d4 { border: 1px solid; }");
706    }
707
708    #[test]
709    fn test_scope_css_multiple_rules() {
710        let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
711        let result = scope_css(css, "a1b2c3d4");
712        assert!(result.contains(".card.a1b2c3d4 { border: 1px solid; }"));
713        assert!(result.contains("h1.a1b2c3d4 { color: navy; }"));
714    }
715
716    #[test]
717    fn test_scope_css_comma_selectors() {
718        let css = ".card, .box { border: 1px solid; }";
719        let result = scope_css(css, "a1b2c3d4");
720        assert_eq!(result, ".card.a1b2c3d4, .box.a1b2c3d4 { border: 1px solid; }");
721    }
722
723    #[test]
724    fn test_scope_css_descendant_selector() {
725        let css = ".card h1 { color: navy; }";
726        let result = scope_css(css, "a1b2c3d4");
727        assert_eq!(result, ".card h1.a1b2c3d4 { color: navy; }");
728    }
729
730    #[test]
731    fn test_scope_css_pseudo_class() {
732        let css = ".demo-list a:hover { text-decoration: underline; }";
733        let result = scope_css(css, "a1b2c3d4");
734        assert_eq!(result, ".demo-list a.a1b2c3d4:hover { text-decoration: underline; }");
735    }
736
737    #[test]
738    fn test_scope_css_pseudo_element() {
739        let css = ".item::before { content: '-'; }";
740        let result = scope_css(css, "a1b2c3d4");
741        assert_eq!(result, ".item.a1b2c3d4::before { content: '-'; }");
742    }
743
744    #[test]
745    fn test_scope_css_no_pseudo() {
746        let css = "h1 { font-size: 2rem; }";
747        let result = scope_css(css, "a1b2c3d4");
748        assert_eq!(result, "h1.a1b2c3d4 { font-size: 2rem; }");
749    }
750
751    // ─── defineProps tests ──────────────────────────────────────────
752
753    #[test]
754    fn test_parse_define_props_simple() {
755        let script = "defineProps({ title: String, count: Number })";
756        let props = parse_define_props(script);
757        assert_eq!(props.len(), 2);
758        assert_eq!(props[0].name, "title");
759        assert_eq!(props[0].prop_type, Some("String".to_string()));
760        assert!(!props[0].required);
761        assert_eq!(props[1].name, "count");
762        assert_eq!(props[1].prop_type, Some("Number".to_string()));
763        assert!(!props[1].required);
764    }
765
766    #[test]
767    fn test_parse_define_props_with_required() {
768        let script = "defineProps({ user: { type: Object, required: true } })";
769        let props = parse_define_props(script);
770        assert_eq!(props.len(), 1);
771        assert_eq!(props[0].name, "user");
772        assert_eq!(props[0].prop_type, Some("Object".to_string()));
773        assert!(props[0].required);
774    }
775
776    #[test]
777    fn test_parse_define_props_mixed() {
778        let script = r#"defineProps({
779  title: String,
780  user: { type: Object, required: true },
781  count: Number
782})"#;
783        let props = parse_define_props(script);
784        assert_eq!(props.len(), 3);
785        assert_eq!(props[0].name, "title");
786        assert_eq!(props[0].prop_type, Some("String".to_string()));
787        assert!(!props[0].required);
788        assert_eq!(props[1].name, "user");
789        assert_eq!(props[1].prop_type, Some("Object".to_string()));
790        assert!(props[1].required);
791        assert_eq!(props[2].name, "count");
792        assert_eq!(props[2].prop_type, Some("Number".to_string()));
793        assert!(!props[2].required);
794    }
795
796    #[test]
797    fn test_parse_define_props_missing() {
798        let script = "const count = ref(0)";
799        let props = parse_define_props(script);
800        assert!(props.is_empty());
801    }
802
803    #[test]
804    fn test_parse_define_props_empty() {
805        let script = "defineProps({})";
806        let props = parse_define_props(script);
807        assert!(props.is_empty());
808    }
809
810    #[test]
811    fn test_parse_blocks_includes_props() {
812        let source = r#"
813<script setup lang="ts">
814defineProps({ title: String, count: Number })
815</script>
816
817<template>
818  <h1>{{ title }}</h1>
819</template>
820"#;
821        let blocks = parse_blocks(source);
822        assert_eq!(blocks.props.len(), 2);
823        assert_eq!(blocks.props[0].name, "title");
824        assert_eq!(blocks.props[1].name, "count");
825    }
826
827    // ─── parse_script_imports tests ───────────────────────────────────
828
829    #[test]
830    fn test_parse_script_imports_ts() {
831        let script = r#"
832import { formatDate } from '../utils/format.ts'
833import DefaultLayout from '../layouts/default.van'
834const count = ref(0)
835"#;
836        let imports = parse_script_imports(script);
837        assert_eq!(imports.len(), 1);
838        assert_eq!(imports[0].path, "../utils/format.ts");
839        assert!(!imports[0].is_type_only);
840        assert!(imports[0].raw.contains("formatDate"));
841    }
842
843    #[test]
844    fn test_parse_script_imports_type_only() {
845        let script = r#"
846import type { User } from '../types/models.ts'
847import { formatDate } from '../utils/format.ts'
848"#;
849        let imports = parse_script_imports(script);
850        assert_eq!(imports.len(), 2);
851        assert!(imports[0].is_type_only);
852        assert_eq!(imports[0].path, "../types/models.ts");
853        assert!(!imports[1].is_type_only);
854        assert_eq!(imports[1].path, "../utils/format.ts");
855    }
856
857    #[test]
858    fn test_parse_script_imports_js() {
859        let script = r#"import foo from '../utils/helper.js'"#;
860        let imports = parse_script_imports(script);
861        assert_eq!(imports.len(), 1);
862        assert_eq!(imports[0].path, "../utils/helper.js");
863        assert!(!imports[0].is_type_only);
864    }
865
866    #[test]
867    fn test_parse_script_imports_ignores_van() {
868        let script = r#"
869import Hello from './hello.van'
870import Foo from '../foo.van'
871"#;
872        let imports = parse_script_imports(script);
873        assert!(imports.is_empty());
874    }
875
876    #[test]
877    fn test_parse_script_imports_ignores_bare() {
878        let script = r#"import { ref } from 'vue'"#;
879        let imports = parse_script_imports(script);
880        assert!(imports.is_empty());
881    }
882
883    #[test]
884    fn test_parse_script_imports_mixed_type() {
885        // `import { type User, formatDate } from ...` is NOT type-only
886        let script = r#"import { type User, formatDate } from '../utils.ts'"#;
887        let imports = parse_script_imports(script);
888        assert_eq!(imports.len(), 1);
889        assert!(!imports[0].is_type_only);
890    }
891
892    #[test]
893    fn test_parse_imports_scoped_package() {
894        let script = r#"
895import VanButton from '@van-ui/button/button.van'
896import DefaultLayout from '../layouts/default.van'
897"#;
898        let imports = parse_imports(script);
899        assert_eq!(imports.len(), 2);
900        assert_eq!(imports[0].name, "VanButton");
901        assert_eq!(imports[0].tag_name, "van-button");
902        assert_eq!(imports[0].path, "@van-ui/button/button.van");
903        assert_eq!(imports[1].name, "DefaultLayout");
904        assert_eq!(imports[1].path, "../layouts/default.van");
905    }
906
907    #[test]
908    fn test_parse_script_imports_scoped_package() {
909        let script = r#"
910import { formatDate } from '@van-ui/utils/format.ts'
911import { helper } from '../utils/helper.ts'
912"#;
913        let imports = parse_script_imports(script);
914        assert_eq!(imports.len(), 2);
915        assert_eq!(imports[0].path, "@van-ui/utils/format.ts");
916        assert_eq!(imports[1].path, "../utils/helper.ts");
917    }
918
919    #[test]
920    fn test_parse_script_imports_tsx_jsx() {
921        let script = r#"
922import { render } from '../lib/render.tsx'
923import { helper } from '../lib/helper.jsx'
924"#;
925        let imports = parse_script_imports(script);
926        assert_eq!(imports.len(), 2);
927        assert_eq!(imports[0].path, "../lib/render.tsx");
928        assert_eq!(imports[1].path, "../lib/helper.jsx");
929    }
930}