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