Skip to main content

fallow_extract/
sfc.rs

1//! Vue/Svelte Single File Component (SFC) script 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
6use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_allocator::Allocator;
10use oxc_ast_visit::Visit;
11use oxc_parser::Parser;
12use oxc_span::SourceType;
13use rustc_hash::FxHashSet;
14
15use crate::asset_url::normalize_asset_url;
16use crate::parse::compute_import_binding_usage;
17use crate::sfc_template::{SfcKind, collect_template_usage};
18use crate::visitor::ModuleInfoExtractor;
19use crate::{ImportInfo, ImportedName, ModuleInfo};
20use fallow_types::discover::FileId;
21use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
22use oxc_span::Span;
23
24/// Regex to extract `<script>` block content from Vue/Svelte SFCs.
25/// The attrs pattern handles `>` inside quoted attribute values (e.g., `generic="T extends Foo<Bar>"`).
26static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
27    regex::Regex::new(
28        r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
29    )
30    .expect("valid regex")
31});
32
33/// Regex to extract the `lang` attribute value from a script tag.
34static LANG_ATTR_RE: LazyLock<regex::Regex> =
35    LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
36
37/// Regex to extract the `src` attribute value from a script tag.
38/// Requires whitespace (or start of string) before `src` to avoid matching `data-src` etc.
39static SRC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
40    regex::Regex::new(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#).expect("valid regex")
41});
42
43/// Regex to detect Vue's bare `setup` attribute.
44static SETUP_ATTR_RE: LazyLock<regex::Regex> =
45    LazyLock::new(|| regex::Regex::new(r"(?:^|\s)setup(?:\s|$)").expect("valid regex"));
46
47/// Regex to detect Svelte's `context="module"` attribute.
48static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
49    LazyLock::new(|| regex::Regex::new(r#"context\s*=\s*["']module["']"#).expect("valid regex"));
50
51/// Regex to match HTML comments for filtering script blocks inside comments.
52static HTML_COMMENT_RE: LazyLock<regex::Regex> =
53    LazyLock::new(|| regex::Regex::new(r"(?s)<!--.*?-->").expect("valid regex"));
54
55/// An extracted `<script>` block from a Vue or Svelte SFC.
56pub struct SfcScript {
57    /// The script body text.
58    pub body: String,
59    /// Whether the script uses TypeScript (`lang="ts"` or `lang="tsx"`).
60    pub is_typescript: bool,
61    /// Whether the script uses JSX syntax (`lang="tsx"` or `lang="jsx"`).
62    pub is_jsx: bool,
63    /// Byte offset of the script body within the full SFC source.
64    pub byte_offset: usize,
65    /// External script source path from `src` attribute.
66    pub src: Option<String>,
67    /// Whether this script is a Vue `<script setup>` block.
68    pub is_setup: bool,
69    /// Whether this script is a Svelte module-context block.
70    pub is_context_module: bool,
71}
72
73/// Extract all `<script>` blocks from a Vue/Svelte SFC source string.
74pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
75    // Build HTML comment ranges to filter out <script> blocks inside comments.
76    // Using ranges instead of source replacement avoids corrupting script body content
77    // (e.g., string literals containing "<!--" would be destroyed by replacement).
78    let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
79        .find_iter(source)
80        .map(|m| (m.start(), m.end()))
81        .collect();
82
83    SCRIPT_BLOCK_RE
84        .captures_iter(source)
85        .filter(|cap| {
86            let start = cap.get(0).map_or(0, |m| m.start());
87            !comment_ranges
88                .iter()
89                .any(|&(cs, ce)| start >= cs && start < ce)
90        })
91        .map(|cap| {
92            let attrs = cap.name("attrs").map_or("", |m| m.as_str());
93            let body_match = cap.name("body");
94            let byte_offset = body_match.map_or(0, |m| m.start());
95            let body = body_match.map_or("", |m| m.as_str()).to_string();
96            let lang = LANG_ATTR_RE
97                .captures(attrs)
98                .and_then(|c| c.get(1))
99                .map(|m| m.as_str());
100            let is_typescript = matches!(lang, Some("ts" | "tsx"));
101            let is_jsx = matches!(lang, Some("tsx" | "jsx"));
102            let src = SRC_ATTR_RE
103                .captures(attrs)
104                .and_then(|c| c.get(1))
105                .map(|m| m.as_str().to_string());
106            let is_setup = SETUP_ATTR_RE.is_match(attrs);
107            let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
108            SfcScript {
109                body,
110                is_typescript,
111                is_jsx,
112                byte_offset,
113                src,
114                is_setup,
115                is_context_module,
116            }
117        })
118        .collect()
119}
120
121/// Check if a file path is a Vue or Svelte SFC (`.vue` or `.svelte`).
122#[must_use]
123pub fn is_sfc_file(path: &Path) -> bool {
124    path.extension()
125        .and_then(|e| e.to_str())
126        .is_some_and(|ext| ext == "vue" || ext == "svelte")
127}
128
129/// Parse an SFC file by extracting and combining all `<script>` blocks.
130pub(crate) fn parse_sfc_to_module(
131    file_id: FileId,
132    path: &Path,
133    source: &str,
134    content_hash: u64,
135    need_complexity: bool,
136) -> ModuleInfo {
137    let scripts = extract_sfc_scripts(source);
138    let kind = sfc_kind(path);
139    let mut combined = empty_sfc_module(file_id, source, content_hash);
140    let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
141
142    for script in &scripts {
143        merge_script_into_module(
144            kind,
145            script,
146            &mut combined,
147            &mut template_visible_imports,
148            need_complexity,
149        );
150    }
151
152    apply_template_usage(kind, source, &template_visible_imports, &mut combined);
153    combined.unused_import_bindings.sort_unstable();
154    combined.unused_import_bindings.dedup();
155    combined.type_referenced_import_bindings.sort_unstable();
156    combined.type_referenced_import_bindings.dedup();
157    combined.value_referenced_import_bindings.sort_unstable();
158    combined.value_referenced_import_bindings.dedup();
159
160    combined
161}
162
163fn sfc_kind(path: &Path) -> SfcKind {
164    if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
165        SfcKind::Vue
166    } else {
167        SfcKind::Svelte
168    }
169}
170
171fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
172    // For SFC files, use string scanning for suppression comments since script block
173    // byte offsets don't correspond to the original file positions.
174    let suppressions = crate::suppress::parse_suppressions_from_source(source);
175
176    ModuleInfo {
177        file_id,
178        exports: Vec::new(),
179        imports: Vec::new(),
180        re_exports: Vec::new(),
181        dynamic_imports: Vec::new(),
182        dynamic_import_patterns: Vec::new(),
183        require_calls: Vec::new(),
184        member_accesses: Vec::new(),
185        whole_object_uses: Vec::new(),
186        has_cjs_exports: false,
187        content_hash,
188        suppressions,
189        unused_import_bindings: Vec::new(),
190        type_referenced_import_bindings: Vec::new(),
191        value_referenced_import_bindings: Vec::new(),
192        line_offsets: compute_line_offsets(source),
193        complexity: Vec::new(),
194        flag_uses: Vec::new(),
195        class_heritage: vec![],
196    }
197}
198
199fn merge_script_into_module(
200    kind: SfcKind,
201    script: &SfcScript,
202    combined: &mut ModuleInfo,
203    template_visible_imports: &mut FxHashSet<String>,
204    need_complexity: bool,
205) {
206    if let Some(src) = &script.src {
207        add_script_src_import(combined, src);
208    }
209
210    let allocator = Allocator::default();
211    let parser_return =
212        Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
213    let mut extractor = ModuleInfoExtractor::new();
214    extractor.visit_program(&parser_return.program);
215
216    let binding_usage = compute_import_binding_usage(&parser_return.program, &extractor.imports);
217    combined
218        .unused_import_bindings
219        .extend(binding_usage.unused.iter().cloned());
220    combined
221        .type_referenced_import_bindings
222        .extend(binding_usage.type_referenced.iter().cloned());
223    combined
224        .value_referenced_import_bindings
225        .extend(binding_usage.value_referenced.iter().cloned());
226    if need_complexity {
227        combined.complexity.extend(translate_script_complexity(
228            script,
229            &parser_return.program,
230            &combined.line_offsets,
231        ));
232    }
233
234    if is_template_visible_script(kind, script) {
235        template_visible_imports.extend(
236            extractor
237                .imports
238                .iter()
239                .filter(|import| !import.local_name.is_empty())
240                .map(|import| import.local_name.clone()),
241        );
242    }
243
244    extractor.merge_into(combined);
245}
246
247fn translate_script_complexity(
248    script: &SfcScript,
249    program: &oxc_ast::ast::Program<'_>,
250    sfc_line_offsets: &[u32],
251) -> Vec<FunctionComplexity> {
252    let script_line_offsets = compute_line_offsets(&script.body);
253    let mut complexity = crate::complexity::compute_complexity(program, &script_line_offsets);
254    let (body_start_line, body_start_col) =
255        byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
256
257    for function in &mut complexity {
258        function.line = body_start_line + function.line.saturating_sub(1);
259        if function.line == body_start_line {
260            function.col += body_start_col;
261        }
262    }
263
264    complexity
265}
266
267fn add_script_src_import(module: &mut ModuleInfo, source: &str) {
268    // Normalize bare filenames (e.g., `<script src="logic.ts">`) so the
269    // resolver treats them as file-relative references, not npm packages.
270    module.imports.push(ImportInfo {
271        source: normalize_asset_url(source),
272        imported_name: ImportedName::SideEffect,
273        local_name: String::new(),
274        is_type_only: false,
275        span: Span::default(),
276        source_span: Span::default(),
277    });
278}
279
280fn source_type_for_script(script: &SfcScript) -> SourceType {
281    match (script.is_typescript, script.is_jsx) {
282        (true, true) => SourceType::tsx(),
283        (true, false) => SourceType::ts(),
284        (false, true) => SourceType::jsx(),
285        (false, false) => SourceType::mjs(),
286    }
287}
288
289fn apply_template_usage(
290    kind: SfcKind,
291    source: &str,
292    template_visible_imports: &FxHashSet<String>,
293    combined: &mut ModuleInfo,
294) {
295    if template_visible_imports.is_empty() {
296        return;
297    }
298
299    let template_usage = collect_template_usage(kind, source, template_visible_imports);
300    combined
301        .unused_import_bindings
302        .retain(|binding| !template_usage.used_bindings.contains(binding));
303    combined
304        .member_accesses
305        .extend(template_usage.member_accesses);
306    combined
307        .whole_object_uses
308        .extend(template_usage.whole_object_uses);
309}
310
311fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
312    match kind {
313        SfcKind::Vue => script.is_setup,
314        SfcKind::Svelte => !script.is_context_module,
315    }
316}
317
318// SFC tests exercise regex-based HTML string extraction — no unsafe code,
319// no Miri-specific value. Oxc parser tests are additionally ~1000x slower.
320#[cfg(all(test, not(miri)))]
321mod tests {
322    use super::*;
323
324    // ── is_sfc_file ──────────────────────────────────────────────
325
326    #[test]
327    fn is_sfc_file_vue() {
328        assert!(is_sfc_file(Path::new("App.vue")));
329    }
330
331    #[test]
332    fn is_sfc_file_svelte() {
333        assert!(is_sfc_file(Path::new("Counter.svelte")));
334    }
335
336    #[test]
337    fn is_sfc_file_rejects_ts() {
338        assert!(!is_sfc_file(Path::new("utils.ts")));
339    }
340
341    #[test]
342    fn is_sfc_file_rejects_jsx() {
343        assert!(!is_sfc_file(Path::new("App.jsx")));
344    }
345
346    #[test]
347    fn is_sfc_file_rejects_astro() {
348        assert!(!is_sfc_file(Path::new("Layout.astro")));
349    }
350
351    // ── extract_sfc_scripts: single script block ─────────────────
352
353    #[test]
354    fn single_plain_script() {
355        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
356        assert_eq!(scripts.len(), 1);
357        assert_eq!(scripts[0].body, "const x = 1;");
358        assert!(!scripts[0].is_typescript);
359        assert!(!scripts[0].is_jsx);
360        assert!(scripts[0].src.is_none());
361    }
362
363    #[test]
364    fn single_ts_script() {
365        let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
366        assert_eq!(scripts.len(), 1);
367        assert!(scripts[0].is_typescript);
368        assert!(!scripts[0].is_jsx);
369    }
370
371    #[test]
372    fn single_tsx_script() {
373        let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
374        assert_eq!(scripts.len(), 1);
375        assert!(scripts[0].is_typescript);
376        assert!(scripts[0].is_jsx);
377    }
378
379    #[test]
380    fn single_jsx_script() {
381        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
382        assert_eq!(scripts.len(), 1);
383        assert!(!scripts[0].is_typescript);
384        assert!(scripts[0].is_jsx);
385    }
386
387    // ── Multiple script blocks ───────────────────────────────────
388
389    #[test]
390    fn two_script_blocks() {
391        let source = r#"
392<script lang="ts">
393export default {};
394</script>
395<script setup lang="ts">
396const count = 0;
397</script>
398"#;
399        let scripts = extract_sfc_scripts(source);
400        assert_eq!(scripts.len(), 2);
401        assert!(scripts[0].body.contains("export default"));
402        assert!(scripts[1].body.contains("count"));
403    }
404
405    // ── <script setup> ───────────────────────────────────────────
406
407    #[test]
408    fn script_setup_extracted() {
409        let scripts =
410            extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
411        assert_eq!(scripts.len(), 1);
412        assert!(scripts[0].body.contains("import"));
413        assert!(scripts[0].is_typescript);
414    }
415
416    // ── <script src="..."> external script ───────────────────────
417
418    #[test]
419    fn script_src_detected() {
420        let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
421        assert_eq!(scripts.len(), 1);
422        assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
423    }
424
425    #[test]
426    fn data_src_not_treated_as_src() {
427        let scripts =
428            extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
429        assert_eq!(scripts.len(), 1);
430        assert!(scripts[0].src.is_none());
431    }
432
433    // ── HTML comment filtering ───────────────────────────────────
434
435    #[test]
436    fn script_inside_html_comment_filtered() {
437        let source = r#"
438<!-- <script lang="ts">import { bad } from 'bad';</script> -->
439<script lang="ts">import { good } from 'good';</script>
440"#;
441        let scripts = extract_sfc_scripts(source);
442        assert_eq!(scripts.len(), 1);
443        assert!(scripts[0].body.contains("good"));
444    }
445
446    #[test]
447    fn spanning_comment_filters_script() {
448        let source = r#"
449<!-- disabled:
450<script lang="ts">import { bad } from 'bad';</script>
451-->
452<script lang="ts">const ok = true;</script>
453"#;
454        let scripts = extract_sfc_scripts(source);
455        assert_eq!(scripts.len(), 1);
456        assert!(scripts[0].body.contains("ok"));
457    }
458
459    #[test]
460    fn string_containing_comment_markers_not_corrupted() {
461        // A string in the script body containing <!-- should not cause filtering issues
462        let source = r#"
463<script setup lang="ts">
464const marker = "<!-- not a comment -->";
465import { ref } from 'vue';
466</script>
467"#;
468        let scripts = extract_sfc_scripts(source);
469        assert_eq!(scripts.len(), 1);
470        assert!(scripts[0].body.contains("import"));
471    }
472
473    // ── Generic attributes with > in quoted values ───────────────
474
475    #[test]
476    fn generic_attr_with_angle_bracket() {
477        let source =
478            r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
479        let scripts = extract_sfc_scripts(source);
480        assert_eq!(scripts.len(), 1);
481        assert_eq!(scripts[0].body, "const x = 1;");
482    }
483
484    #[test]
485    fn nested_generic_attr() {
486        let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
487        let scripts = extract_sfc_scripts(source);
488        assert_eq!(scripts.len(), 1);
489        assert_eq!(scripts[0].body, "const x = 1;");
490    }
491
492    // ── lang attribute with single quotes ────────────────────────
493
494    #[test]
495    fn lang_single_quoted() {
496        let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
497        assert_eq!(scripts.len(), 1);
498        assert!(scripts[0].is_typescript);
499    }
500
501    // ── Case-insensitive matching ────────────────────────────────
502
503    #[test]
504    fn uppercase_script_tag() {
505        let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
506        assert_eq!(scripts.len(), 1);
507        assert!(scripts[0].is_typescript);
508    }
509
510    // ── Edge cases ───────────────────────────────────────────────
511
512    #[test]
513    fn no_script_block() {
514        let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
515        assert!(scripts.is_empty());
516    }
517
518    #[test]
519    fn empty_script_body() {
520        let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
521        assert_eq!(scripts.len(), 1);
522        assert!(scripts[0].body.is_empty());
523    }
524
525    #[test]
526    fn whitespace_only_script() {
527        let scripts = extract_sfc_scripts("<script lang=\"ts\">\n  \n</script>");
528        assert_eq!(scripts.len(), 1);
529        assert!(scripts[0].body.trim().is_empty());
530    }
531
532    #[test]
533    fn byte_offset_is_set() {
534        let source = r#"<template><div/></template><script lang="ts">code</script>"#;
535        let scripts = extract_sfc_scripts(source);
536        assert_eq!(scripts.len(), 1);
537        // The byte_offset should point to where "code" starts in the source
538        let offset = scripts[0].byte_offset;
539        assert_eq!(&source[offset..offset + 4], "code");
540    }
541
542    #[test]
543    fn script_with_extra_attributes() {
544        let scripts = extract_sfc_scripts(
545            r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
546        );
547        assert_eq!(scripts.len(), 1);
548        assert!(scripts[0].is_typescript);
549        assert!(scripts[0].src.is_none());
550    }
551
552    // ── Full parse tests (Oxc parser ~1000x slower under Miri) ──
553
554    #[test]
555    fn multiple_script_blocks_exports_combined() {
556        let source = r#"
557<script lang="ts">
558export const version = '1.0';
559</script>
560<script setup lang="ts">
561import { ref } from 'vue';
562const count = ref(0);
563</script>
564"#;
565        let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
566        // The non-setup block exports `version`
567        assert!(
568            info.exports
569                .iter()
570                .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
571            "export from <script> block should be extracted"
572        );
573        // The setup block imports `ref` from 'vue'
574        assert!(
575            info.imports.iter().any(|i| i.source == "vue"),
576            "import from <script setup> block should be extracted"
577        );
578    }
579
580    // ── lang="tsx" detection ────────────────────────────────────
581
582    #[test]
583    fn lang_tsx_detected_as_typescript_jsx() {
584        let scripts =
585            extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
586        assert_eq!(scripts.len(), 1);
587        assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
588        assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
589    }
590
591    // ── HTML comment filtering of script blocks ─────────────────
592
593    #[test]
594    fn multiline_html_comment_filters_all_script_blocks_inside() {
595        let source = r#"
596<!--
597  This whole section is disabled:
598  <script lang="ts">import { bad1 } from 'bad1';</script>
599  <script lang="ts">import { bad2 } from 'bad2';</script>
600-->
601<script lang="ts">import { good } from 'good';</script>
602"#;
603        let scripts = extract_sfc_scripts(source);
604        assert_eq!(scripts.len(), 1);
605        assert!(scripts[0].body.contains("good"));
606    }
607
608    // ── <script src="..."> generates side-effect import ─────────
609
610    #[test]
611    fn script_src_generates_side_effect_import() {
612        let info = parse_sfc_to_module(
613            FileId(0),
614            Path::new("External.vue"),
615            r#"<script src="./external-logic.ts" lang="ts"></script>"#,
616            0,
617            false,
618        );
619        assert!(
620            info.imports
621                .iter()
622                .any(|i| i.source == "./external-logic.ts"
623                    && matches!(i.imported_name, ImportedName::SideEffect)),
624            "script src should generate a side-effect import"
625        );
626    }
627
628    // ── Additional coverage ─────────────────────────────────────
629
630    #[test]
631    fn parse_sfc_no_script_returns_empty_module() {
632        let info = parse_sfc_to_module(
633            FileId(0),
634            Path::new("Empty.vue"),
635            "<template><div>Hello</div></template>",
636            42,
637            false,
638        );
639        assert!(info.imports.is_empty());
640        assert!(info.exports.is_empty());
641        assert_eq!(info.content_hash, 42);
642        assert_eq!(info.file_id, FileId(0));
643    }
644
645    #[test]
646    fn parse_sfc_has_line_offsets() {
647        let info = parse_sfc_to_module(
648            FileId(0),
649            Path::new("LineOffsets.vue"),
650            r#"<script lang="ts">const x = 1;</script>"#,
651            0,
652            false,
653        );
654        assert!(!info.line_offsets.is_empty());
655    }
656
657    #[test]
658    fn parse_sfc_has_suppressions() {
659        let info = parse_sfc_to_module(
660            FileId(0),
661            Path::new("Suppressions.vue"),
662            r#"<script lang="ts">
663// fallow-ignore-file
664export const foo = 1;
665</script>"#,
666            0,
667            false,
668        );
669        assert!(!info.suppressions.is_empty());
670    }
671
672    #[test]
673    fn source_type_jsx_detection() {
674        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
675        assert_eq!(scripts.len(), 1);
676        assert!(!scripts[0].is_typescript);
677        assert!(scripts[0].is_jsx);
678    }
679
680    #[test]
681    fn source_type_plain_js_detection() {
682        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
683        assert_eq!(scripts.len(), 1);
684        assert!(!scripts[0].is_typescript);
685        assert!(!scripts[0].is_jsx);
686    }
687
688    #[test]
689    fn is_sfc_file_rejects_no_extension() {
690        assert!(!is_sfc_file(Path::new("Makefile")));
691    }
692
693    #[test]
694    fn is_sfc_file_rejects_mdx() {
695        assert!(!is_sfc_file(Path::new("post.mdx")));
696    }
697
698    #[test]
699    fn is_sfc_file_rejects_css() {
700        assert!(!is_sfc_file(Path::new("styles.css")));
701    }
702
703    #[test]
704    fn multiple_script_blocks_both_have_offsets() {
705        let source = r#"<script lang="ts">const a = 1;</script>
706<script setup lang="ts">const b = 2;</script>"#;
707        let scripts = extract_sfc_scripts(source);
708        assert_eq!(scripts.len(), 2);
709        // Both scripts should have valid byte offsets
710        let offset0 = scripts[0].byte_offset;
711        let offset1 = scripts[1].byte_offset;
712        assert_eq!(
713            &source[offset0..offset0 + "const a = 1;".len()],
714            "const a = 1;"
715        );
716        assert_eq!(
717            &source[offset1..offset1 + "const b = 2;".len()],
718            "const b = 2;"
719        );
720    }
721
722    #[test]
723    fn script_with_src_and_lang() {
724        // src + lang should both be detected
725        let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
726        assert_eq!(scripts.len(), 1);
727        assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
728        assert!(scripts[0].is_typescript);
729        assert!(scripts[0].is_jsx);
730    }
731}