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