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