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