Skip to main content

fallow_extract/
sfc.rs

1//! Vue/Svelte Single File Component (SFC) script and style extraction.
2//!
3//! Extracts `<script>` block content from `.vue` and `.svelte` files using regex,
4//! handling `lang`, `src` metadata, and `generic` attributes, and filtering
5//! HTML comments. Vue external script references are emitted as graph edges;
6//! Svelte markup-level script `src` references are treated as runtime HTML.
7//! Also extracts `<style>` block sources (`@import` / `@use` / `@forward` /
8//! `@plugin` and `<style src="...">`) so referenced CSS / SCSS files become
9//! reachable from the component, preventing false `unused-files` reports on
10//! co-located styles.
11
12use std::path::Path;
13use std::sync::LazyLock;
14
15use oxc_allocator::Allocator;
16use oxc_ast_visit::Visit;
17use oxc_parser::Parser;
18use oxc_span::SourceType;
19use rustc_hash::{FxHashMap, FxHashSet};
20
21use crate::asset_url::normalize_asset_url;
22use crate::parse::{
23    compute_auto_import_candidates, compute_import_binding_usage, compute_semantic_usage,
24};
25use crate::sfc_template::{SfcKind, collect_template_usage_with_bound_targets};
26use crate::source_map::ExtractionResult;
27use crate::visitor::ModuleInfoExtractor;
28use crate::{ImportInfo, ImportedName, ModuleInfo};
29use fallow_types::discover::FileId;
30use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
31use oxc_span::Span;
32
33/// Regex to extract `<script>` block content from Vue/Svelte SFCs.
34/// The attrs pattern handles `>` inside quoted attribute values (e.g., `generic="T extends Foo<Bar>"`).
35static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
36    crate::static_regex(
37        r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
38    )
39});
40
41/// Regex to extract the `lang` attribute value from a script tag.
42static LANG_ATTR_RE: LazyLock<regex::Regex> =
43    LazyLock::new(|| crate::static_regex(r#"lang\s*=\s*["'](\w+)["']"#));
44
45/// Regex to extract the `src` attribute value from a script tag.
46/// Requires whitespace (or start of string) before `src` to avoid matching `data-src` etc.
47static SRC_ATTR_RE: LazyLock<regex::Regex> =
48    LazyLock::new(|| crate::static_regex(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#));
49
50/// Regex to detect Vue's bare `setup` attribute.
51static SETUP_ATTR_RE: LazyLock<regex::Regex> =
52    LazyLock::new(|| crate::static_regex(r"(?:^|\s)setup(?:\s|$)"));
53
54/// Regex to detect Svelte's `context="module"` attribute.
55static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
56    LazyLock::new(|| crate::static_regex(r#"context\s*=\s*["']module["']"#));
57
58/// Regex to extract Vue's `generic="..."` attribute value (script-setup
59/// generics). Matches the contents between the quotes and stops at the
60/// closing quote, mirroring `LANG_ATTR_RE`.
61static VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
62    crate::static_regex(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
63});
64
65/// Regex to extract Svelte's `generics="..."` attribute value (Svelte 4
66/// generic script attribute, repurposed by some Svelte 5 code).
67static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
68    crate::static_regex(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
69});
70
71/// Regex to match HTML comments for filtering script blocks inside comments.
72static HTML_COMMENT_RE: LazyLock<regex::Regex> =
73    LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
74
75/// Regex to extract `<style>` block content from Vue/Svelte SFCs.
76/// Mirrors `SCRIPT_BLOCK_RE`: handles `>` inside quoted attribute values and
77/// captures the body so `@import` / `@use` / `@forward` directives can be parsed.
78static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
79    crate::static_regex(
80        r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
81    )
82});
83
84/// An extracted `<script>` block from a Vue or Svelte SFC.
85pub struct SfcScript {
86    /// The script body text.
87    pub body: String,
88    /// Whether the script uses TypeScript (`lang="ts"` or `lang="tsx"`).
89    pub is_typescript: bool,
90    /// Whether the script uses JSX syntax (`lang="tsx"` or `lang="jsx"`).
91    pub is_jsx: bool,
92    /// Byte offset of the script body within the full SFC source.
93    pub byte_offset: usize,
94    /// External script source path from `src` attribute.
95    pub src: Option<String>,
96    /// Span of the `src` attribute value in the full SFC source.
97    pub src_span: Option<Span>,
98    /// Whether this script is a Vue `<script setup>` block.
99    pub is_setup: bool,
100    /// Whether this script is a Svelte module-context block.
101    pub is_context_module: bool,
102    /// Type-parameter list from a `generic="..."` (Vue) or `generics="..."`
103    /// (Svelte) attribute on the script tag. Holds the bare constraint, no
104    /// surrounding angle brackets, e.g. `T extends Test<boolean>`.
105    pub generic_attr: Option<String>,
106}
107
108/// Extract all `<script>` blocks from a Vue/Svelte SFC source string.
109pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
110    let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
111        .find_iter(source)
112        .map(|m| (m.start(), m.end()))
113        .collect();
114
115    SCRIPT_BLOCK_RE
116        .captures_iter(source)
117        .filter(|cap| {
118            let start = cap.get(0).map_or(0, |m| m.start());
119            !comment_ranges
120                .iter()
121                .any(|&(cs, ce)| start >= cs && start < ce)
122        })
123        .map(|cap| {
124            let attrs = cap.name("attrs").map_or("", |m| m.as_str());
125            let body_match = cap.name("body");
126            let byte_offset = body_match.map_or(0, |m| m.start());
127            let body = body_match.map_or("", |m| m.as_str()).to_string();
128            let lang = LANG_ATTR_RE
129                .captures(attrs)
130                .and_then(|c| c.get(1))
131                .map(|m| m.as_str());
132            let is_typescript = matches!(lang, Some("ts" | "tsx"));
133            let is_jsx = matches!(lang, Some("tsx" | "jsx"));
134            let src = SRC_ATTR_RE
135                .captures(attrs)
136                .and_then(|c| c.get(1))
137                .map(|m| m.as_str().to_string());
138            let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
139            let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
140                Span::new(
141                    (attrs_start + m.start()) as u32,
142                    (attrs_start + m.end()) as u32,
143                )
144            });
145            let is_setup = SETUP_ATTR_RE.is_match(attrs);
146            let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
147            let generic_attr = VUE_GENERIC_ATTR_RE
148                .captures(attrs)
149                .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
150                .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
151                .map(|m| m.as_str().to_string())
152                .filter(|value| !value.trim().is_empty());
153            SfcScript {
154                body,
155                is_typescript,
156                is_jsx,
157                byte_offset,
158                src,
159                src_span,
160                is_setup,
161                is_context_module,
162                generic_attr,
163            }
164        })
165        .collect()
166}
167
168/// An extracted `<style>` block from a Vue or Svelte SFC.
169pub struct SfcStyle {
170    /// The style body text (CSS / SCSS / Sass / Less / Stylus / PostCSS source).
171    pub body: String,
172    /// The `lang` attribute value (`scss`, `sass`, `less`, `stylus`, `postcss`, ...).
173    /// `None` for plain `<style>` (CSS).
174    pub lang: Option<String>,
175    /// External style source path from the `src` attribute (`<style src="./theme.scss">`).
176    pub src: Option<String>,
177    /// Span of the `src` attribute value in the full SFC source.
178    pub src_span: Option<Span>,
179    /// Byte offset of the style body within the full SFC source.
180    pub byte_offset: usize,
181}
182
183/// Extract all `<style>` blocks from a Vue/Svelte SFC source string.
184///
185/// Mirrors [`extract_sfc_scripts`]: filters blocks inside HTML comments and
186/// captures the `lang` and `src` attributes so the caller can route the body to
187/// the right preprocessor's import scanner (currently only CSS / SCSS / Sass) or
188/// seed the `src` reference as a side-effect import.
189pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
190    let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
191        .find_iter(source)
192        .map(|m| (m.start(), m.end()))
193        .collect();
194
195    STYLE_BLOCK_RE
196        .captures_iter(source)
197        .filter(|cap| {
198            let start = cap.get(0).map_or(0, |m| m.start());
199            !comment_ranges
200                .iter()
201                .any(|&(cs, ce)| start >= cs && start < ce)
202        })
203        .map(|cap| {
204            let attrs = cap.name("attrs").map_or("", |m| m.as_str());
205            let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
206            let byte_offset = cap.name("body").map_or(0, |m| m.start());
207            let lang = LANG_ATTR_RE
208                .captures(attrs)
209                .and_then(|c| c.get(1))
210                .map(|m| m.as_str().to_string());
211            let src = SRC_ATTR_RE
212                .captures(attrs)
213                .and_then(|c| c.get(1))
214                .map(|m| m.as_str().to_string());
215            let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
216            let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
217                Span::new(
218                    (attrs_start + m.start()) as u32,
219                    (attrs_start + m.end()) as u32,
220                )
221            });
222            SfcStyle {
223                body,
224                lang,
225                src,
226                src_span,
227                byte_offset,
228            }
229        })
230        .collect()
231}
232
233/// Check if a file path is a Vue or Svelte SFC (`.vue` or `.svelte`).
234#[must_use]
235pub fn is_sfc_file(path: &Path) -> bool {
236    path.extension()
237        .and_then(|e| e.to_str())
238        .is_some_and(|ext| ext == "vue" || ext == "svelte")
239}
240
241/// Parse an SFC file by extracting and combining all `<script>` and `<style>` blocks.
242pub(crate) fn parse_sfc_to_module(
243    file_id: FileId,
244    path: &Path,
245    source: &str,
246    content_hash: u64,
247    need_complexity: bool,
248) -> ModuleInfo {
249    let scripts = extract_sfc_scripts(source);
250    let styles = extract_sfc_styles(source);
251    let kind = sfc_kind(path);
252    let mut combined = empty_sfc_module(file_id, source, content_hash);
253    let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
254    let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
255
256    for script in &scripts {
257        merge_script_into_module(
258            kind,
259            script,
260            &mut combined,
261            &mut template_visible_imports,
262            &mut template_visible_bound_targets,
263            need_complexity,
264        );
265    }
266
267    for style in &styles {
268        merge_style_into_module(style, &mut combined);
269    }
270
271    apply_template_usage(
272        kind,
273        source,
274        &template_visible_imports,
275        &template_visible_bound_targets,
276        &mut combined,
277    );
278    combined.unused_import_bindings.sort_unstable();
279    combined.unused_import_bindings.dedup();
280    combined.type_referenced_import_bindings.sort_unstable();
281    combined.type_referenced_import_bindings.dedup();
282    combined.value_referenced_import_bindings.sort_unstable();
283    combined.value_referenced_import_bindings.dedup();
284    combined.auto_import_candidates.sort_unstable();
285    combined.auto_import_candidates.dedup();
286
287    combined
288}
289
290fn sfc_kind(path: &Path) -> SfcKind {
291    if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
292        SfcKind::Vue
293    } else {
294        SfcKind::Svelte
295    }
296}
297
298fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
299    let parsed = crate::suppress::parse_suppressions_from_source(source);
300
301    ModuleInfo {
302        file_id,
303        exports: Vec::new(),
304        imports: Vec::new(),
305        re_exports: Vec::new(),
306        dynamic_imports: Vec::new(),
307        dynamic_import_patterns: Vec::new(),
308        require_calls: Vec::new(),
309        package_path_references: Vec::new(),
310        member_accesses: Vec::new(),
311        whole_object_uses: Vec::new(),
312        has_cjs_exports: false,
313        has_angular_component_template_url: false,
314        content_hash,
315        suppressions: parsed.suppressions,
316        unknown_suppression_kinds: parsed.unknown_kinds,
317        unused_import_bindings: Vec::new(),
318        type_referenced_import_bindings: Vec::new(),
319        value_referenced_import_bindings: Vec::new(),
320        line_offsets: compute_line_offsets(source),
321        complexity: Vec::new(),
322        flag_uses: Vec::new(),
323        class_heritage: vec![],
324        injection_tokens: vec![],
325        local_type_declarations: Vec::new(),
326        public_signature_type_references: Vec::new(),
327        namespace_object_aliases: Vec::new(),
328        iconify_prefixes: Vec::new(),
329        iconify_icon_names: Vec::new(),
330        auto_import_candidates: Vec::new(),
331        directives: Vec::new(),
332        security_sinks: Vec::new(),
333        security_sinks_skipped: 0,
334        security_unresolved_callee_sites: Vec::new(),
335        tainted_bindings: Vec::new(),
336        sanitized_sink_args: Vec::new(),
337        security_control_sites: Vec::new(),
338        callee_uses: Vec::new(),
339    }
340}
341
342fn merge_script_into_module(
343    kind: SfcKind,
344    script: &SfcScript,
345    combined: &mut ModuleInfo,
346    template_visible_imports: &mut FxHashSet<String>,
347    template_visible_bound_targets: &mut FxHashMap<String, String>,
348    need_complexity: bool,
349) {
350    if kind == SfcKind::Vue
351        && let Some(src) = &script.src
352    {
353        add_script_src_import(combined, src, script.src_span);
354    }
355
356    let allocator = Allocator::default();
357    let parser_return =
358        Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
359    let mut extractor = ModuleInfoExtractor::new();
360    extractor.visit_program(&parser_return.program);
361    let extraction = ExtractionResult::contiguous(&script.body, script.byte_offset);
362    extractor.remap_spans_with(|span| extraction.remap_span(span));
363    extractor.resolve_typed_destructure_bindings();
364
365    let augmented_body = build_generic_attr_probe_source(script);
366    let empty_template_used = rustc_hash::FxHashSet::default();
367    let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
368    {
369        let augmented_return =
370            Parser::new(&allocator, augmented, source_type_for_script(script)).parse();
371        (
372            compute_import_binding_usage(
373                &augmented_return.program,
374                &extractor.imports,
375                &empty_template_used,
376            ),
377            compute_auto_import_candidates(&parser_return.program),
378        )
379    } else {
380        let semantic_usage = compute_semantic_usage(
381            &parser_return.program,
382            &extractor.imports,
383            &empty_template_used,
384        );
385        (
386            semantic_usage.import_binding_usage,
387            semantic_usage.auto_import_candidates,
388        )
389    };
390    combined
391        .unused_import_bindings
392        .extend(binding_usage.unused.iter().cloned());
393    combined
394        .type_referenced_import_bindings
395        .extend(binding_usage.type_referenced.iter().cloned());
396    combined
397        .value_referenced_import_bindings
398        .extend(binding_usage.value_referenced.iter().cloned());
399    combined
400        .auto_import_candidates
401        .extend(auto_import_candidates);
402    if need_complexity {
403        combined.complexity.extend(translate_script_complexity(
404            script,
405            &parser_return.program,
406            &combined.line_offsets,
407        ));
408    }
409
410    if is_template_visible_script(kind, script) {
411        template_visible_imports.extend(
412            extractor
413                .imports
414                .iter()
415                .filter(|import| !import.local_name.is_empty())
416                .map(|import| import.local_name.clone()),
417        );
418        template_visible_bound_targets.extend(
419            extractor
420                .binding_target_names()
421                .iter()
422                .filter(|(local, _)| !local.starts_with("this."))
423                .map(|(local, target)| (local.clone(), target.clone())),
424        );
425    }
426
427    extractor.merge_into(combined);
428}
429
430fn translate_script_complexity(
431    script: &SfcScript,
432    program: &oxc_ast::ast::Program<'_>,
433    sfc_line_offsets: &[u32],
434) -> Vec<FunctionComplexity> {
435    let script_line_offsets = compute_line_offsets(&script.body);
436    let mut complexity =
437        crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
438    let (body_start_line, body_start_col) =
439        byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
440
441    for function in &mut complexity {
442        function.line = body_start_line + function.line.saturating_sub(1);
443        if function.line == body_start_line {
444            function.col += body_start_col;
445        }
446    }
447
448    complexity
449}
450
451fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
452    let span = source_span.unwrap_or_default();
453    module.imports.push(ImportInfo {
454        source: normalize_asset_url(source),
455        imported_name: ImportedName::SideEffect,
456        local_name: String::new(),
457        is_type_only: false,
458        from_style: false,
459        span,
460        source_span: span,
461    });
462}
463
464/// `lang` attribute values whose body we know how to scan for `@import` /
465/// `@use` / `@forward` / `@plugin` directives. Plain `<style>` (no `lang`) is treated as
466/// CSS. `less`, `stylus`, and `postcss` bodies are NOT scanned because their
467/// import syntax differs (`@import (reference)` modifiers, etc.); their
468/// `<style src="...">` references are still seeded.
469fn style_lang_is_scss(lang: Option<&str>) -> bool {
470    matches!(lang, Some("scss" | "sass"))
471}
472
473fn style_lang_is_css_like(lang: Option<&str>) -> bool {
474    lang.is_none() || matches!(lang, Some("css"))
475}
476
477fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
478    if let Some(src) = &style.src {
479        let span = style.src_span.unwrap_or_default();
480        combined.imports.push(ImportInfo {
481            source: normalize_asset_url(src),
482            imported_name: ImportedName::SideEffect,
483            local_name: String::new(),
484            is_type_only: false,
485            from_style: true,
486            span,
487            source_span: span,
488        });
489    }
490
491    let lang = style.lang.as_deref();
492    let is_scss = style_lang_is_scss(lang);
493    let is_css_like = style_lang_is_css_like(lang);
494    if !is_scss && !is_css_like {
495        return;
496    }
497
498    for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
499        let source_span = Span::new(
500            style.byte_offset as u32 + source.span.start,
501            style.byte_offset as u32 + source.span.end,
502        );
503        combined.imports.push(ImportInfo {
504            source: source.normalized,
505            imported_name: if source.is_plugin {
506                ImportedName::Default
507            } else {
508                ImportedName::SideEffect
509            },
510            local_name: String::new(),
511            is_type_only: false,
512            from_style: true,
513            span: source_span,
514            source_span,
515        });
516    }
517}
518
519fn source_type_for_script(script: &SfcScript) -> SourceType {
520    match (script.is_typescript, script.is_jsx) {
521        (true, true) => SourceType::tsx(),
522        (true, false) => SourceType::ts(),
523        (false, true) => SourceType::jsx(),
524        (false, false) => SourceType::mjs(),
525    }
526}
527
528/// Build an augmented script body that pins the `generic="..."` constraint as
529/// a synthetic local type alias. The alias is unexported and uses a sentinel
530/// name so it can't collide with user code. Returns `None` when there is no
531/// generic attribute to pin (the common case), so callers fall back to the
532/// raw body without paying for a second parse.
533fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
534    let constraint = script.generic_attr.as_deref()?.trim();
535    if constraint.is_empty() {
536        return None;
537    }
538    Some(format!(
539        "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
540        script.body, constraint,
541    ))
542}
543
544fn apply_template_usage(
545    kind: SfcKind,
546    source: &str,
547    template_visible_imports: &FxHashSet<String>,
548    template_visible_bound_targets: &FxHashMap<String, String>,
549    combined: &mut ModuleInfo,
550) {
551    let template_usage = collect_template_usage_with_bound_targets(
552        kind,
553        source,
554        template_visible_imports,
555        template_visible_bound_targets,
556    );
557    combined
558        .unused_import_bindings
559        .retain(|binding| !template_usage.used_bindings.contains(binding));
560    combined
561        .member_accesses
562        .extend(template_usage.member_accesses);
563    combined
564        .whole_object_uses
565        .extend(template_usage.whole_object_uses);
566    combined
567        .security_sinks
568        .extend(template_usage.security_sinks);
569    if !template_usage.unresolved_tag_names.is_empty() {
570        let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
571        names.sort_unstable();
572        combined.auto_import_candidates.extend(names);
573        combined.auto_import_candidates.dedup();
574    }
575}
576
577fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
578    match kind {
579        SfcKind::Vue => script.is_setup,
580        SfcKind::Svelte => !script.is_context_module,
581    }
582}
583
584#[cfg(all(test, not(miri)))]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn is_sfc_file_vue() {
590        assert!(is_sfc_file(Path::new("App.vue")));
591    }
592
593    #[test]
594    fn is_sfc_file_svelte() {
595        assert!(is_sfc_file(Path::new("Counter.svelte")));
596    }
597
598    #[test]
599    fn is_sfc_file_rejects_ts() {
600        assert!(!is_sfc_file(Path::new("utils.ts")));
601    }
602
603    #[test]
604    fn is_sfc_file_rejects_jsx() {
605        assert!(!is_sfc_file(Path::new("App.jsx")));
606    }
607
608    #[test]
609    fn is_sfc_file_rejects_astro() {
610        assert!(!is_sfc_file(Path::new("Layout.astro")));
611    }
612
613    #[test]
614    fn single_plain_script() {
615        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
616        assert_eq!(scripts.len(), 1);
617        assert_eq!(scripts[0].body, "const x = 1;");
618        assert!(!scripts[0].is_typescript);
619        assert!(!scripts[0].is_jsx);
620        assert!(scripts[0].src.is_none());
621    }
622
623    #[test]
624    fn single_ts_script() {
625        let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
626        assert_eq!(scripts.len(), 1);
627        assert!(scripts[0].is_typescript);
628        assert!(!scripts[0].is_jsx);
629    }
630
631    #[test]
632    fn single_tsx_script() {
633        let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
634        assert_eq!(scripts.len(), 1);
635        assert!(scripts[0].is_typescript);
636        assert!(scripts[0].is_jsx);
637    }
638
639    #[test]
640    fn single_jsx_script() {
641        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
642        assert_eq!(scripts.len(), 1);
643        assert!(!scripts[0].is_typescript);
644        assert!(scripts[0].is_jsx);
645    }
646
647    #[test]
648    fn two_script_blocks() {
649        let source = r#"
650<script lang="ts">
651export default {};
652</script>
653<script setup lang="ts">
654const count = 0;
655</script>
656"#;
657        let scripts = extract_sfc_scripts(source);
658        assert_eq!(scripts.len(), 2);
659        assert!(scripts[0].body.contains("export default"));
660        assert!(scripts[1].body.contains("count"));
661    }
662
663    #[test]
664    fn script_setup_extracted() {
665        let scripts =
666            extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
667        assert_eq!(scripts.len(), 1);
668        assert!(scripts[0].body.contains("import"));
669        assert!(scripts[0].is_typescript);
670    }
671
672    #[test]
673    fn script_src_detected() {
674        let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
675        assert_eq!(scripts.len(), 1);
676        assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
677    }
678
679    #[test]
680    fn data_src_not_treated_as_src() {
681        let scripts =
682            extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
683        assert_eq!(scripts.len(), 1);
684        assert!(scripts[0].src.is_none());
685    }
686
687    #[test]
688    fn script_inside_html_comment_filtered() {
689        let source = r#"
690<!-- <script lang="ts">import { bad } from 'bad';</script> -->
691<script lang="ts">import { good } from 'good';</script>
692"#;
693        let scripts = extract_sfc_scripts(source);
694        assert_eq!(scripts.len(), 1);
695        assert!(scripts[0].body.contains("good"));
696    }
697
698    #[test]
699    fn spanning_comment_filters_script() {
700        let source = r#"
701<!-- disabled:
702<script lang="ts">import { bad } from 'bad';</script>
703-->
704<script lang="ts">const ok = true;</script>
705"#;
706        let scripts = extract_sfc_scripts(source);
707        assert_eq!(scripts.len(), 1);
708        assert!(scripts[0].body.contains("ok"));
709    }
710
711    #[test]
712    fn string_containing_comment_markers_not_corrupted() {
713        let source = r#"
714<script setup lang="ts">
715const marker = "<!-- not a comment -->";
716import { ref } from 'vue';
717</script>
718"#;
719        let scripts = extract_sfc_scripts(source);
720        assert_eq!(scripts.len(), 1);
721        assert!(scripts[0].body.contains("import"));
722    }
723
724    #[test]
725    fn generic_attr_with_angle_bracket() {
726        let source =
727            r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
728        let scripts = extract_sfc_scripts(source);
729        assert_eq!(scripts.len(), 1);
730        assert_eq!(scripts[0].body, "const x = 1;");
731    }
732
733    #[test]
734    fn nested_generic_attr() {
735        let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
736        let scripts = extract_sfc_scripts(source);
737        assert_eq!(scripts.len(), 1);
738        assert_eq!(scripts[0].body, "const x = 1;");
739    }
740
741    #[test]
742    fn lang_single_quoted() {
743        let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
744        assert_eq!(scripts.len(), 1);
745        assert!(scripts[0].is_typescript);
746    }
747
748    #[test]
749    fn uppercase_script_tag() {
750        let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
751        assert_eq!(scripts.len(), 1);
752        assert!(scripts[0].is_typescript);
753    }
754
755    #[test]
756    fn no_script_block() {
757        let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
758        assert!(scripts.is_empty());
759    }
760
761    #[test]
762    fn empty_script_body() {
763        let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
764        assert_eq!(scripts.len(), 1);
765        assert!(scripts[0].body.is_empty());
766    }
767
768    #[test]
769    fn whitespace_only_script() {
770        let scripts = extract_sfc_scripts("<script lang=\"ts\">\n  \n</script>");
771        assert_eq!(scripts.len(), 1);
772        assert!(scripts[0].body.trim().is_empty());
773    }
774
775    #[test]
776    fn byte_offset_is_set() {
777        let source = r#"<template><div/></template><script lang="ts">code</script>"#;
778        let scripts = extract_sfc_scripts(source);
779        assert_eq!(scripts.len(), 1);
780        let offset = scripts[0].byte_offset;
781        assert_eq!(&source[offset..offset + 4], "code");
782    }
783
784    #[test]
785    fn script_with_extra_attributes() {
786        let scripts = extract_sfc_scripts(
787            r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
788        );
789        assert_eq!(scripts.len(), 1);
790        assert!(scripts[0].is_typescript);
791        assert!(scripts[0].src.is_none());
792    }
793
794    #[test]
795    fn multiple_script_blocks_exports_combined() {
796        let source = r#"
797<script lang="ts">
798export const version = '1.0';
799</script>
800<script setup lang="ts">
801import { ref } from 'vue';
802const count = ref(0);
803</script>
804"#;
805        let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
806        assert!(
807            info.exports
808                .iter()
809                .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
810            "export from <script> block should be extracted"
811        );
812        assert!(
813            info.imports.iter().any(|i| i.source == "vue"),
814            "import from <script setup> block should be extracted"
815        );
816    }
817
818    #[test]
819    fn lang_tsx_detected_as_typescript_jsx() {
820        let scripts =
821            extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
822        assert_eq!(scripts.len(), 1);
823        assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
824        assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
825    }
826
827    #[test]
828    fn multiline_html_comment_filters_all_script_blocks_inside() {
829        let source = r#"
830<!--
831  This whole section is disabled:
832  <script lang="ts">import { bad1 } from 'bad1';</script>
833  <script lang="ts">import { bad2 } from 'bad2';</script>
834-->
835<script lang="ts">import { good } from 'good';</script>
836"#;
837        let scripts = extract_sfc_scripts(source);
838        assert_eq!(scripts.len(), 1);
839        assert!(scripts[0].body.contains("good"));
840    }
841
842    #[test]
843    fn script_src_generates_side_effect_import() {
844        let info = parse_sfc_to_module(
845            FileId(0),
846            Path::new("External.vue"),
847            r#"<script src="./external-logic.ts" lang="ts"></script>"#,
848            0,
849            false,
850        );
851        assert!(
852            info.imports
853                .iter()
854                .any(|i| i.source == "./external-logic.ts"
855                    && matches!(i.imported_name, ImportedName::SideEffect)),
856            "script src should generate a side-effect import"
857        );
858    }
859
860    #[test]
861    fn parse_sfc_no_script_returns_empty_module() {
862        let info = parse_sfc_to_module(
863            FileId(0),
864            Path::new("Empty.vue"),
865            "<template><div>Hello</div></template>",
866            42,
867            false,
868        );
869        assert!(info.imports.is_empty());
870        assert!(info.exports.is_empty());
871        assert_eq!(info.content_hash, 42);
872        assert_eq!(info.file_id, FileId(0));
873    }
874
875    #[test]
876    fn parse_sfc_has_line_offsets() {
877        let info = parse_sfc_to_module(
878            FileId(0),
879            Path::new("LineOffsets.vue"),
880            r#"<script lang="ts">const x = 1;</script>"#,
881            0,
882            false,
883        );
884        assert!(!info.line_offsets.is_empty());
885    }
886
887    #[test]
888    fn parse_sfc_has_suppressions() {
889        let info = parse_sfc_to_module(
890            FileId(0),
891            Path::new("Suppressions.vue"),
892            r#"<script lang="ts">
893// fallow-ignore-file
894export const foo = 1;
895</script>"#,
896            0,
897            false,
898        );
899        assert!(!info.suppressions.is_empty());
900    }
901
902    #[test]
903    fn source_type_jsx_detection() {
904        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
905        assert_eq!(scripts.len(), 1);
906        assert!(!scripts[0].is_typescript);
907        assert!(scripts[0].is_jsx);
908    }
909
910    #[test]
911    fn source_type_plain_js_detection() {
912        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
913        assert_eq!(scripts.len(), 1);
914        assert!(!scripts[0].is_typescript);
915        assert!(!scripts[0].is_jsx);
916    }
917
918    #[test]
919    fn is_sfc_file_rejects_no_extension() {
920        assert!(!is_sfc_file(Path::new("Makefile")));
921    }
922
923    #[test]
924    fn is_sfc_file_rejects_mdx() {
925        assert!(!is_sfc_file(Path::new("post.mdx")));
926    }
927
928    #[test]
929    fn is_sfc_file_rejects_css() {
930        assert!(!is_sfc_file(Path::new("styles.css")));
931    }
932
933    #[test]
934    fn multiple_script_blocks_both_have_offsets() {
935        let source = r#"<script lang="ts">const a = 1;</script>
936<script setup lang="ts">const b = 2;</script>"#;
937        let scripts = extract_sfc_scripts(source);
938        assert_eq!(scripts.len(), 2);
939        let offset0 = scripts[0].byte_offset;
940        let offset1 = scripts[1].byte_offset;
941        assert_eq!(
942            &source[offset0..offset0 + "const a = 1;".len()],
943            "const a = 1;"
944        );
945        assert_eq!(
946            &source[offset1..offset1 + "const b = 2;".len()],
947            "const b = 2;"
948        );
949    }
950
951    #[test]
952    fn script_with_src_and_lang() {
953        let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
954        assert_eq!(scripts.len(), 1);
955        assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
956        assert!(scripts[0].is_typescript);
957        assert!(scripts[0].is_jsx);
958    }
959
960    #[test]
961    fn extract_style_block_lang_scss() {
962        let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
963        let styles = extract_sfc_styles(source);
964        assert_eq!(styles.len(), 1);
965        assert_eq!(styles[0].lang.as_deref(), Some("scss"));
966        assert!(styles[0].body.contains("@import"));
967        assert!(styles[0].src.is_none());
968    }
969
970    #[test]
971    fn extract_style_block_with_src() {
972        let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
973        let styles = extract_sfc_styles(source);
974        assert_eq!(styles.len(), 1);
975        assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
976        assert_eq!(styles[0].lang.as_deref(), Some("scss"));
977    }
978
979    #[test]
980    fn extract_style_block_plain_no_lang() {
981        let source = r"<style>.foo { color: red; }</style>";
982        let styles = extract_sfc_styles(source);
983        assert_eq!(styles.len(), 1);
984        assert!(styles[0].lang.is_none());
985    }
986
987    #[test]
988    fn extract_multiple_style_blocks() {
989        let source = r#"<style lang="scss">@import 'a';</style>
990<style scoped lang="scss">@import 'b';</style>"#;
991        let styles = extract_sfc_styles(source);
992        assert_eq!(styles.len(), 2);
993    }
994
995    #[test]
996    fn style_block_inside_html_comment_filtered() {
997        let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
998<style lang="scss">@import 'good';</style>"#;
999        let styles = extract_sfc_styles(source);
1000        assert_eq!(styles.len(), 1);
1001        assert!(styles[0].body.contains("good"));
1002    }
1003
1004    #[test]
1005    fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1006        let info = parse_sfc_to_module(
1007            FileId(0),
1008            Path::new("Foo.vue"),
1009            r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1010            0,
1011            false,
1012        );
1013        let style_import = info
1014            .imports
1015            .iter()
1016            .find(|i| i.source == "./Foo")
1017            .expect("scss @import 'Foo' should be normalized to ./Foo");
1018        assert!(
1019            style_import.from_style,
1020            "imports from <style> blocks must carry from_style=true so the resolver \
1021             enables SCSS partial fallback for the SFC importer"
1022        );
1023        assert!(matches!(
1024            style_import.imported_name,
1025            ImportedName::SideEffect
1026        ));
1027    }
1028
1029    #[test]
1030    fn parse_sfc_extracts_style_plugin_as_default_import() {
1031        let info = parse_sfc_to_module(
1032            FileId(0),
1033            Path::new("Foo.vue"),
1034            r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1035            0,
1036            false,
1037        );
1038        let plugin_import = info
1039            .imports
1040            .iter()
1041            .find(|i| i.source == "./tailwind-plugin.js")
1042            .expect("style @plugin should create an import");
1043        assert!(plugin_import.from_style);
1044        assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1045    }
1046
1047    #[test]
1048    fn parse_sfc_extracts_style_src_with_from_style_flag() {
1049        let info = parse_sfc_to_module(
1050            FileId(0),
1051            Path::new("Bar.vue"),
1052            r#"<style src="./Bar.scss" lang="scss"></style>"#,
1053            0,
1054            false,
1055        );
1056        let style_src = info
1057            .imports
1058            .iter()
1059            .find(|i| i.source == "./Bar.scss")
1060            .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1061        assert!(style_src.from_style);
1062    }
1063
1064    #[test]
1065    fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1066        let info = parse_sfc_to_module(
1067            FileId(0),
1068            Path::new("Baz.vue"),
1069            r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1070            0,
1071            false,
1072        );
1073        assert!(
1074            info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1075            "src reference should still be seeded for unsupported lang"
1076        );
1077        assert!(
1078            !info.imports.iter().any(|i| i.source.contains("skipped")),
1079            "postcss body should not be scanned for @import directives"
1080        );
1081    }
1082}