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` metadata, and `generic` attributes, and filtering
5//! HTML comments. Vue external script references are emitted as graph edges;
6//! Svelte markup-level script `src` references are treated as runtime HTML.
7//! Also extracts `<style>` block sources (`@import` / `@use` / `@forward` /
8//! `@plugin` and `<style src="...">`) so referenced CSS / SCSS files become
9//! reachable from the component, preventing false `unused-files` reports on
10//! co-located styles.
11
12use std::path::Path;
13use std::sync::LazyLock;
14
15use oxc_allocator::Allocator;
16use oxc_ast_visit::Visit;
17use oxc_parser::Parser;
18use oxc_span::SourceType;
19use rustc_hash::{FxHashMap, FxHashSet};
20
21use crate::asset_url::normalize_asset_url;
22use crate::parse::{
23    compute_auto_import_candidates, compute_import_binding_usage, compute_semantic_usage,
24};
25use crate::sfc_template::{SfcKind, collect_template_usage_with_bound_targets};
26use crate::source_map::ExtractionResult;
27use crate::visitor::ModuleInfoExtractor;
28use crate::{ImportInfo, ImportedName, ModuleInfo};
29use fallow_types::discover::FileId;
30use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
31use oxc_span::Span;
32
33/// Regex to extract `<script>` block content from Vue/Svelte SFCs.
34/// The attrs pattern handles `>` inside quoted attribute values (e.g., `generic="T extends Foo<Bar>"`).
35static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
36    crate::static_regex(
37        r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
38    )
39});
40
41/// Regex to extract the `lang` attribute value from a script tag.
42static LANG_ATTR_RE: LazyLock<regex::Regex> =
43    LazyLock::new(|| crate::static_regex(r#"lang\s*=\s*["'](\w+)["']"#));
44
45/// Regex to extract the `src` attribute value from a script tag.
46/// Requires whitespace (or start of string) before `src` to avoid matching `data-src` etc.
47static SRC_ATTR_RE: LazyLock<regex::Regex> =
48    LazyLock::new(|| crate::static_regex(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#));
49
50/// Regex to detect Vue's bare `setup` attribute.
51static SETUP_ATTR_RE: LazyLock<regex::Regex> =
52    LazyLock::new(|| crate::static_regex(r"(?:^|\s)setup(?:\s|$)"));
53
54/// Regex to detect Svelte's `context="module"` attribute.
55static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
56    LazyLock::new(|| crate::static_regex(r#"context\s*=\s*["']module["']"#));
57
58/// Regex to extract Vue's `generic="..."` attribute value (script-setup
59/// generics). Matches the contents between the quotes and stops at the
60/// closing quote, mirroring `LANG_ATTR_RE`.
61static VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
62    crate::static_regex(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
63});
64
65/// Regex to extract Svelte's `generics="..."` attribute value (Svelte 4
66/// generic script attribute, repurposed by some Svelte 5 code).
67static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
68    crate::static_regex(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
69});
70
71/// Regex to match HTML comments for filtering script blocks inside comments.
72static HTML_COMMENT_RE: LazyLock<regex::Regex> =
73    LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
74
75/// Regex to detect a whole-object prop/attr spread in a Vue template:
76/// `v-bind="$attrs"`, `v-bind="$props"`, or `v-bind="props"` (with single or
77/// double quotes). A bound prop may be consumed indirectly, so the
78/// `unused-component-prop` detector abstains on the whole file when this matches.
79static PROPS_ATTRS_SPREAD_RE: LazyLock<regex::Regex> =
80    LazyLock::new(|| crate::static_regex(r#"v-bind\s*=\s*["'](?:\$attrs|\$props|props)["']"#));
81
82/// FP-1 (unused-load-data-key): a SvelteKit route component passing the whole
83/// `data` prop opaquely in MARKUP, where a child reads arbitrary keys the
84/// detector cannot see. Matches `data={data}` (whole-prop pass to a child) and
85/// `{...data}` (Svelte template spread). The script-side `const x = {...data}` /
86/// `fn(data)` / `const X = data` forms are captured by the JS visitor instead.
87/// Only a whole-`data` pass forces the abstain; `data.x` member access stays a
88/// credited consumer.
89static SVELTE_TEMPLATE_DATA_WHOLE_USE_RE: LazyLock<regex::Regex> =
90    LazyLock::new(|| crate::static_regex(r"(?:=\s*\{\s*data\s*\}|\{\s*\.\.\.\s*data\s*\})"));
91
92/// Matches an emit-style call in template markup: a callee identifier (or
93/// `$emit`) followed by `(` and its first argument. Group 1 is the callee name
94/// (filtered against the harvested emit binding / `$emit` by the caller), groups
95/// 2 and 3 are a string-literal first arg (single- or double-quoted: the event
96/// name, credited as used), and group 4 is the first non-space character of a
97/// NON-literal first arg (a dynamic emit, whose event name is unknowable, forcing
98/// a whole-file abstain). Event names allow kebab and namespaced forms
99/// (`update:modelValue`, `my-event`). The Rust `regex` crate has no
100/// backreferences, so the two quote styles are separate alternatives.
101static TEMPLATE_EMIT_CALL_RE: LazyLock<regex::Regex> =
102    LazyLock::new(|| crate::static_regex(r#"([\w$]+)\s*\(\s*(?:'([\w:-]*)'|"([\w:-]*)"|(\S))"#));
103
104/// Regex to extract `<style>` block content from Vue/Svelte SFCs.
105/// Mirrors `SCRIPT_BLOCK_RE`: handles `>` inside quoted attribute values and
106/// captures the body so `@import` / `@use` / `@forward` directives can be parsed.
107static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
108    crate::static_regex(
109        r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
110    )
111});
112
113/// Static asset references in SFC markup: `<img src="./logo.png">`,
114/// `<source src="...">`, `<video poster="...">`, etc.
115///
116/// Scoped to genuine asset elements (`img` / `source` / `video` / `audio` /
117/// `track` / `embed`) so a custom component's `src` PROP (`<MyImage src="./x">`)
118/// is never misread as an asset edge. ONLY plain relative literals (`./` or
119/// `../`) are captured: dynamic bindings (`:src`, `v-bind:src`, `bind:src`,
120/// `src={...}`, `data-src`), alias-prefixed (`@/`), root-relative (`/foo`),
121/// remote, interpolated (`{{ }}` / `{ }`), and query/hash-suffixed values are
122/// all skipped (the value class excludes `{`, `?`, `#`, whitespace, and angle
123/// brackets, and the alternation anchors on a leading `./` or `../`). A
124/// captured ref becomes a `SideEffect` import; an existing asset resolves to
125/// `ExternalFile` (no finding) and a genuinely-missing one surfaces as
126/// `unresolved-import` on the trusted resolver path.
127static TEMPLATE_ASSET_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
128    crate::static_regex(
129        r#"(?si)<(?:img|source|video|audio|track|embed)\b(?:[^>"']|"[^"]*"|'[^']*')*?\s(?:src|poster)\s*=\s*(?:"((?:\./|\.\./)[^"<>{}?#\s]*)"|'((?:\./|\.\./)[^'<>{}?#\s]*)')"#,
130    )
131});
132
133/// Mask `<script>` / `<style>` blocks and HTML comments to equal-length spaces
134/// so a markup-region scan (asset refs) sees only the template, while byte
135/// offsets still map 1:1 into the original source for line/col reporting.
136fn mask_non_markup_regions(source: &str) -> String {
137    let mut masked = source.to_string();
138    for re in [&*SCRIPT_BLOCK_RE, &*STYLE_BLOCK_RE, &*HTML_COMMENT_RE] {
139        masked = re
140            .replace_all(&masked, |caps: &regex::Captures<'_>| {
141                " ".repeat(caps[0].len())
142            })
143            .into_owned();
144    }
145    masked
146}
147
148/// Collect static relative asset references from SFC markup as
149/// `(normalized_specifier, value_span)` pairs. See [`TEMPLATE_ASSET_RE`].
150fn collect_template_asset_refs(source: &str) -> Vec<(String, Span)> {
151    let masked = mask_non_markup_regions(source);
152    let mut refs = Vec::new();
153    for caps in TEMPLATE_ASSET_RE.captures_iter(&masked) {
154        let Some(value) = caps.get(1).or_else(|| caps.get(2)) else {
155            continue;
156        };
157        let raw = value.as_str();
158        if raw.is_empty() {
159            continue;
160        }
161        refs.push((
162            normalize_asset_url(raw),
163            Span::new(value.start() as u32, value.end() as u32),
164        ));
165    }
166    refs
167}
168
169/// An extracted `<script>` block from a Vue or Svelte SFC.
170pub struct SfcScript {
171    /// The script body text.
172    pub body: String,
173    /// Whether the script uses TypeScript (`lang="ts"` or `lang="tsx"`).
174    pub is_typescript: bool,
175    /// Whether the script uses JSX syntax (`lang="tsx"` or `lang="jsx"`).
176    pub is_jsx: bool,
177    /// Byte offset of the script body within the full SFC source.
178    pub byte_offset: usize,
179    /// External script source path from `src` attribute.
180    pub src: Option<String>,
181    /// Span of the `src` attribute value in the full SFC source.
182    pub src_span: Option<Span>,
183    /// Whether this script is a Vue `<script setup>` block.
184    pub is_setup: bool,
185    /// Whether this script is a Svelte module-context block.
186    pub is_context_module: bool,
187    /// Type-parameter list from a `generic="..."` (Vue) or `generics="..."`
188    /// (Svelte) attribute on the script tag. Holds the bare constraint, no
189    /// surrounding angle brackets, e.g. `T extends Test<boolean>`.
190    pub generic_attr: Option<String>,
191}
192
193/// Extract all `<script>` blocks from a Vue/Svelte SFC source string.
194pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
195    let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
196        .find_iter(source)
197        .map(|m| (m.start(), m.end()))
198        .collect();
199
200    SCRIPT_BLOCK_RE
201        .captures_iter(source)
202        .filter(|cap| {
203            let start = cap.get(0).map_or(0, |m| m.start());
204            !comment_ranges
205                .iter()
206                .any(|&(cs, ce)| start >= cs && start < ce)
207        })
208        .map(|cap| {
209            let attrs = cap.name("attrs").map_or("", |m| m.as_str());
210            let body_match = cap.name("body");
211            let byte_offset = body_match.map_or(0, |m| m.start());
212            let body = body_match.map_or("", |m| m.as_str()).to_string();
213            let lang = LANG_ATTR_RE
214                .captures(attrs)
215                .and_then(|c| c.get(1))
216                .map(|m| m.as_str());
217            let is_typescript = matches!(lang, Some("ts" | "tsx"));
218            let is_jsx = matches!(lang, Some("tsx" | "jsx"));
219            let src = SRC_ATTR_RE
220                .captures(attrs)
221                .and_then(|c| c.get(1))
222                .map(|m| m.as_str().to_string());
223            let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
224            let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
225                Span::new(
226                    (attrs_start + m.start()) as u32,
227                    (attrs_start + m.end()) as u32,
228                )
229            });
230            let is_setup = SETUP_ATTR_RE.is_match(attrs);
231            let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
232            let generic_attr = VUE_GENERIC_ATTR_RE
233                .captures(attrs)
234                .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
235                .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
236                .map(|m| m.as_str().to_string())
237                .filter(|value| !value.trim().is_empty());
238            SfcScript {
239                body,
240                is_typescript,
241                is_jsx,
242                byte_offset,
243                src,
244                src_span,
245                is_setup,
246                is_context_module,
247                generic_attr,
248            }
249        })
250        .collect()
251}
252
253/// An extracted `<style>` block from a Vue or Svelte SFC.
254pub struct SfcStyle {
255    /// The style body text (CSS / SCSS / Sass / Less / Stylus / PostCSS source).
256    pub body: String,
257    /// The `lang` attribute value (`scss`, `sass`, `less`, `stylus`, `postcss`, ...).
258    /// `None` for plain `<style>` (CSS).
259    pub lang: Option<String>,
260    /// External style source path from the `src` attribute (`<style src="./theme.scss">`).
261    pub src: Option<String>,
262    /// Span of the `src` attribute value in the full SFC source.
263    pub src_span: Option<Span>,
264    /// Byte offset of the style body within the full SFC source.
265    pub byte_offset: usize,
266}
267
268/// Extract all `<style>` blocks from a Vue/Svelte SFC source string.
269///
270/// Mirrors [`extract_sfc_scripts`]: filters blocks inside HTML comments and
271/// captures the `lang` and `src` attributes so the caller can route the body to
272/// the right preprocessor's import scanner (currently only CSS / SCSS / Sass) or
273/// seed the `src` reference as a side-effect import.
274pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
275    let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
276        .find_iter(source)
277        .map(|m| (m.start(), m.end()))
278        .collect();
279
280    STYLE_BLOCK_RE
281        .captures_iter(source)
282        .filter(|cap| {
283            let start = cap.get(0).map_or(0, |m| m.start());
284            !comment_ranges
285                .iter()
286                .any(|&(cs, ce)| start >= cs && start < ce)
287        })
288        .map(|cap| {
289            let attrs = cap.name("attrs").map_or("", |m| m.as_str());
290            let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
291            let byte_offset = cap.name("body").map_or(0, |m| m.start());
292            let lang = LANG_ATTR_RE
293                .captures(attrs)
294                .and_then(|c| c.get(1))
295                .map(|m| m.as_str().to_string());
296            let src = SRC_ATTR_RE
297                .captures(attrs)
298                .and_then(|c| c.get(1))
299                .map(|m| m.as_str().to_string());
300            let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
301            let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
302                Span::new(
303                    (attrs_start + m.start()) as u32,
304                    (attrs_start + m.end()) as u32,
305                )
306            });
307            SfcStyle {
308                body,
309                lang,
310                src,
311                src_span,
312                byte_offset,
313            }
314        })
315        .collect()
316}
317
318/// Check if a file path is a Vue or Svelte SFC (`.vue` or `.svelte`).
319#[must_use]
320pub fn is_sfc_file(path: &Path) -> bool {
321    path.extension()
322        .and_then(|e| e.to_str())
323        .is_some_and(|ext| ext == "vue" || ext == "svelte")
324}
325
326/// Parse an SFC file by extracting and combining all `<script>` and `<style>` blocks.
327pub(crate) fn parse_sfc_to_module(
328    file_id: FileId,
329    path: &Path,
330    source: &str,
331    content_hash: u64,
332    need_complexity: bool,
333) -> ModuleInfo {
334    let scripts = extract_sfc_scripts(source);
335    let styles = extract_sfc_styles(source);
336    let kind = sfc_kind(path);
337    let mut combined = empty_sfc_module(file_id, source, content_hash);
338    let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
339    let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
340    let mut props_return_binding: Option<String> = None;
341    let mut emit_return_binding: Option<String> = None;
342
343    for script in &scripts {
344        merge_script_into_module(&mut SfcScriptMergeInput {
345            kind,
346            script,
347            combined: &mut combined,
348            template_visible_imports: &mut template_visible_imports,
349            template_visible_bound_targets: &mut template_visible_bound_targets,
350            props_return_binding: &mut props_return_binding,
351            emit_return_binding: &mut emit_return_binding,
352            need_complexity,
353        });
354    }
355
356    for style in &styles {
357        merge_style_into_module(style, &mut combined);
358    }
359
360    // Whole-object prop/attr spread in the template (`v-bind="$attrs"`,
361    // `v-bind="$props"`, `v-bind="props"`) can consume a prop indirectly, so the
362    // `unused-component-prop` detector must abstain on the whole file.
363    if kind == SfcKind::Vue
364        && !combined.component_props.is_empty()
365        && PROPS_ATTRS_SPREAD_RE.is_match(source)
366    {
367        combined.has_props_attrs_fallthrough = true;
368    }
369
370    apply_template_usage(
371        kind,
372        source,
373        &template_visible_imports,
374        &template_visible_bound_targets,
375        props_return_binding.as_deref(),
376        kind == SfcKind::Svelte && is_sveltekit_route_data_component(path),
377        &mut combined,
378    );
379
380    // Credit `<emit_binding>('event')` / `$emit('event')` calls in the template
381    // (`@click="emit('close')"`), which the script-only emit usage walk cannot
382    // see. A dynamic template emit (`$emit(someVar)`) abstains the whole file.
383    if kind == SfcKind::Vue && !combined.component_emits.is_empty() {
384        apply_template_emit_usage(source, emit_return_binding.as_deref(), &mut combined);
385    }
386
387    // Static relative asset references in markup (`<img src="./logo.png">`)
388    // become SideEffect imports so a genuinely-missing asset surfaces as
389    // `unresolved-import` (existing assets resolve to `ExternalFile`, no finding).
390    for (specifier, span) in collect_template_asset_refs(source) {
391        combined.imports.push(ImportInfo {
392            source: specifier,
393            imported_name: ImportedName::SideEffect,
394            local_name: String::new(),
395            is_type_only: false,
396            from_style: false,
397            span,
398            source_span: span,
399        });
400    }
401
402    combined.unused_import_bindings.sort_unstable();
403    combined.unused_import_bindings.dedup();
404    combined.type_referenced_import_bindings.sort_unstable();
405    combined.type_referenced_import_bindings.dedup();
406    combined.value_referenced_import_bindings.sort_unstable();
407    combined.value_referenced_import_bindings.dedup();
408    combined.auto_import_candidates.sort_unstable();
409    combined.auto_import_candidates.dedup();
410
411    combined
412}
413
414fn sfc_kind(path: &Path) -> SfcKind {
415    if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
416        SfcKind::Vue
417    } else {
418        SfcKind::Svelte
419    }
420}
421
422/// SvelteKit route components receive a `data` prop populated by the route's
423/// `load()` return object. This predicate gates the `data`-as-template-root
424/// credit (unused-load-data-key Primitive B) to exactly those files. It matches
425/// `+page.svelte` / `+layout.svelte` AND their layout-reset variants
426/// (`+page@.svelte`, `+page@named.svelte`, `+page@(group).svelte`, and the
427/// `+layout@...` forms), all of which still receive the `data` prop. `+error.svelte`
428/// is excluded (it receives `$page.error`, not the `load()` `data` prop), and a
429/// non-route file like `+pageHelper.svelte` is excluded by the grammar (the part
430/// after `+page` must be empty or start with `@`). The leading `+` is a
431/// SvelteKit-only filename convention, so no ordinary `.svelte` component matches.
432fn is_sveltekit_route_data_component(path: &Path) -> bool {
433    let Some(stem) = path
434        .file_name()
435        .and_then(|name| name.to_str())
436        .and_then(|name| name.strip_suffix(".svelte"))
437    else {
438        return false;
439    };
440    ["+page", "+layout"].iter().any(|prefix| {
441        stem.strip_prefix(prefix)
442            .is_some_and(|rest| rest.is_empty() || rest.starts_with('@'))
443    })
444}
445
446fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
447    let parsed = crate::suppress::parse_suppressions_from_source(source);
448
449    ModuleInfo {
450        file_id,
451        exports: Vec::new(),
452        imports: Vec::new(),
453        re_exports: Vec::new(),
454        dynamic_imports: Vec::new(),
455        dynamic_import_patterns: Vec::new(),
456        require_calls: Vec::new(),
457        package_path_references: Vec::new(),
458        member_accesses: Vec::new(),
459        whole_object_uses: Vec::new(),
460        has_cjs_exports: false,
461        has_angular_component_template_url: false,
462        content_hash,
463        suppressions: parsed.suppressions,
464        unknown_suppression_kinds: parsed.unknown_kinds,
465        unused_import_bindings: Vec::new(),
466        type_referenced_import_bindings: Vec::new(),
467        value_referenced_import_bindings: Vec::new(),
468        line_offsets: compute_line_offsets(source),
469        complexity: Vec::new(),
470        flag_uses: Vec::new(),
471        class_heritage: vec![],
472        injection_tokens: vec![],
473        local_type_declarations: Vec::new(),
474        public_signature_type_references: Vec::new(),
475        namespace_object_aliases: Vec::new(),
476        iconify_prefixes: Vec::new(),
477        iconify_icon_names: Vec::new(),
478        auto_import_candidates: Vec::new(),
479        directives: Vec::new(),
480        client_only_dynamic_import_spans: Vec::new(),
481        security_sinks: Vec::new(),
482        security_sinks_skipped: 0,
483        security_unresolved_callee_sites: Vec::new(),
484        tainted_bindings: Vec::new(),
485        sanitized_sink_args: Vec::new(),
486        security_control_sites: Vec::new(),
487        callee_uses: Vec::new(),
488        misplaced_directives: Vec::new(),
489        di_key_sites: Vec::new(),
490        has_dynamic_provide: false,
491        referenced_import_bindings: Vec::new(),
492        component_props: Vec::new(),
493        has_props_attrs_fallthrough: false,
494        has_define_expose: false,
495        has_define_model: false,
496        has_unharvestable_props: false,
497        component_emits: Vec::new(),
498        has_unharvestable_emits: false,
499        has_dynamic_emit: false,
500        has_emit_whole_object_use: false,
501        load_return_keys: Vec::new(),
502        has_unharvestable_load: false,
503        has_load_data_whole_use: false,
504        has_page_data_store_whole_use: false,
505        component_functions: Vec::new(),
506        react_props: Vec::new(),
507        hook_uses: Vec::new(),
508        render_edges: Vec::new(),
509    }
510}
511
512struct SfcScriptMergeInput<'a> {
513    kind: SfcKind,
514    script: &'a SfcScript,
515    combined: &'a mut ModuleInfo,
516    template_visible_imports: &'a mut FxHashSet<String>,
517    template_visible_bound_targets: &'a mut FxHashMap<String, String>,
518    props_return_binding: &'a mut Option<String>,
519    emit_return_binding: &'a mut Option<String>,
520    need_complexity: bool,
521}
522
523fn merge_script_into_module(input: &mut SfcScriptMergeInput<'_>) {
524    if input.kind == SfcKind::Vue
525        && let Some(src) = &input.script.src
526    {
527        add_script_src_import(input.combined, src, input.script.src_span);
528    }
529
530    let allocator = Allocator::default();
531    let parser_return = Parser::new(
532        &allocator,
533        &input.script.body,
534        source_type_for_script(input.script),
535    )
536    .parse();
537    let mut extractor = ModuleInfoExtractor::new();
538    extractor.visit_program(&parser_return.program);
539    let extraction = ExtractionResult::contiguous(&input.script.body, input.script.byte_offset);
540    extractor.remap_spans_with(|span| extraction.remap_span(span));
541    extractor.resolve_typed_destructure_bindings();
542
543    let augmented_body = build_generic_attr_probe_source(input.script);
544    let empty_template_used = rustc_hash::FxHashSet::default();
545    let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
546    {
547        let augmented_return =
548            Parser::new(&allocator, augmented, source_type_for_script(input.script)).parse();
549        (
550            compute_import_binding_usage(
551                &augmented_return.program,
552                &extractor.imports,
553                &empty_template_used,
554            ),
555            compute_auto_import_candidates(&parser_return.program),
556        )
557    } else {
558        let semantic_usage = compute_semantic_usage(
559            &parser_return.program,
560            &extractor.imports,
561            &empty_template_used,
562        );
563        (
564            semantic_usage.import_binding_usage,
565            semantic_usage.auto_import_candidates,
566        )
567    };
568    input
569        .combined
570        .unused_import_bindings
571        .extend(binding_usage.unused.iter().cloned());
572    input
573        .combined
574        .type_referenced_import_bindings
575        .extend(binding_usage.type_referenced.iter().cloned());
576    input
577        .combined
578        .value_referenced_import_bindings
579        .extend(binding_usage.value_referenced.iter().cloned());
580    input
581        .combined
582        .auto_import_candidates
583        .extend(auto_import_candidates);
584    if input.need_complexity {
585        input
586            .combined
587            .complexity
588            .extend(translate_script_complexity(
589                input.script,
590                &parser_return.program,
591                &input.combined.line_offsets,
592            ));
593    }
594
595    // Vue `<script setup>` `defineProps` harvesting for `unused-component-prop`.
596    // Spans returned by the harvest are relative to the script body; remap onto
597    // the SFC source via the script byte offset.
598    if input.kind == SfcKind::Vue && input.script.is_setup {
599        let harvest = crate::sfc_props::harvest_define_props(&parser_return.program);
600        if harvest.has_unharvestable_props {
601            input.combined.has_unharvestable_props = true;
602        }
603        if harvest.has_props_attrs_fallthrough {
604            input.combined.has_props_attrs_fallthrough = true;
605        }
606        if harvest.has_define_expose {
607            input.combined.has_define_expose = true;
608        }
609        if harvest.has_define_model {
610            input.combined.has_define_model = true;
611        }
612        if let Some(binding) = harvest.props_return_binding {
613            *input.props_return_binding = Some(binding);
614        }
615        for mut prop in harvest.props {
616            prop.span_start += input.script.byte_offset as u32;
617            input.combined.component_props.push(prop);
618        }
619
620        // `defineEmits` harvesting for `unused-component-emit`. Same span remap.
621        // `defineModel` creates implicit `update:x` emits, so a file with
622        // `defineModel` must abstain emits too (reuse the props-side flag).
623        let emit_harvest = crate::sfc_props::harvest_define_emits(&parser_return.program);
624        if emit_harvest.has_unharvestable_emits {
625            input.combined.has_unharvestable_emits = true;
626        }
627        if emit_harvest.has_dynamic_emit {
628            input.combined.has_dynamic_emit = true;
629        }
630        if emit_harvest.has_emit_whole_object_use {
631            input.combined.has_emit_whole_object_use = true;
632        }
633        if let Some(binding) = emit_harvest.emit_binding {
634            *input.emit_return_binding = Some(binding);
635        }
636        for mut emit in emit_harvest.emits {
637            emit.span_start += input.script.byte_offset as u32;
638            input.combined.component_emits.push(emit);
639        }
640    }
641
642    if is_template_visible_script(input.kind, input.script) {
643        input.template_visible_imports.extend(
644            extractor
645                .imports
646                .iter()
647                .filter(|import| !import.local_name.is_empty())
648                .map(|import| import.local_name.clone()),
649        );
650        input.template_visible_bound_targets.extend(
651            extractor
652                .binding_target_names()
653                .iter()
654                .filter(|(local, _)| !local.starts_with("this."))
655                .map(|(local, target)| (local.clone(), target.clone())),
656        );
657    }
658
659    extractor.merge_into(input.combined);
660}
661
662fn translate_script_complexity(
663    script: &SfcScript,
664    program: &oxc_ast::ast::Program<'_>,
665    sfc_line_offsets: &[u32],
666) -> Vec<FunctionComplexity> {
667    let script_line_offsets = compute_line_offsets(&script.body);
668    let mut complexity =
669        crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
670    let (body_start_line, body_start_col) =
671        byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
672
673    for function in &mut complexity {
674        function.line = body_start_line + function.line.saturating_sub(1);
675        if function.line == body_start_line {
676            function.col += body_start_col;
677        }
678    }
679
680    complexity
681}
682
683fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
684    let span = source_span.unwrap_or_default();
685    module.imports.push(ImportInfo {
686        source: normalize_asset_url(source),
687        imported_name: ImportedName::SideEffect,
688        local_name: String::new(),
689        is_type_only: false,
690        from_style: false,
691        span,
692        source_span: span,
693    });
694}
695
696/// `lang` attribute values whose body we know how to scan for `@import` /
697/// `@use` / `@forward` / `@plugin` directives. Plain `<style>` (no `lang`) is treated as
698/// CSS. `less`, `stylus`, and `postcss` bodies are NOT scanned because their
699/// import syntax differs (`@import (reference)` modifiers, etc.); their
700/// `<style src="...">` references are still seeded.
701fn style_lang_is_scss(lang: Option<&str>) -> bool {
702    matches!(lang, Some("scss" | "sass"))
703}
704
705fn style_lang_is_css_like(lang: Option<&str>) -> bool {
706    lang.is_none() || matches!(lang, Some("css"))
707}
708
709fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
710    if let Some(src) = &style.src {
711        let span = style.src_span.unwrap_or_default();
712        combined.imports.push(ImportInfo {
713            source: normalize_asset_url(src),
714            imported_name: ImportedName::SideEffect,
715            local_name: String::new(),
716            is_type_only: false,
717            from_style: true,
718            span,
719            source_span: span,
720        });
721    }
722
723    let lang = style.lang.as_deref();
724    let is_scss = style_lang_is_scss(lang);
725    let is_css_like = style_lang_is_css_like(lang);
726    if !is_scss && !is_css_like {
727        return;
728    }
729
730    for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
731        let source_span = Span::new(
732            style.byte_offset as u32 + source.span.start,
733            style.byte_offset as u32 + source.span.end,
734        );
735        combined.imports.push(ImportInfo {
736            source: source.normalized,
737            imported_name: if source.is_plugin {
738                ImportedName::Default
739            } else {
740                ImportedName::SideEffect
741            },
742            local_name: String::new(),
743            is_type_only: false,
744            from_style: true,
745            span: source_span,
746            source_span,
747        });
748    }
749}
750
751fn source_type_for_script(script: &SfcScript) -> SourceType {
752    match (script.is_typescript, script.is_jsx) {
753        (true, true) => SourceType::tsx(),
754        (true, false) => SourceType::ts(),
755        (false, true) => SourceType::jsx(),
756        (false, false) => SourceType::mjs(),
757    }
758}
759
760/// Build an augmented script body that pins the `generic="..."` constraint as
761/// a synthetic local type alias. The alias is unexported and uses a sentinel
762/// name so it can't collide with user code. Returns `None` when there is no
763/// generic attribute to pin (the common case), so callers fall back to the
764/// raw body without paying for a second parse.
765fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
766    let constraint = script.generic_attr.as_deref()?.trim();
767    if constraint.is_empty() {
768        return None;
769    }
770    Some(format!(
771        "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
772        script.body, constraint,
773    ))
774}
775
776fn apply_template_usage(
777    kind: SfcKind,
778    source: &str,
779    template_visible_imports: &FxHashSet<String>,
780    template_visible_bound_targets: &FxHashMap<String, String>,
781    props_return_binding: Option<&str>,
782    credit_load_data: bool,
783    combined: &mut ModuleInfo,
784) {
785    // Props are NOT imports, so the template scanner does not credit them by
786    // default. Thread the harvested prop names (and the `defineProps` return
787    // binding, so `props.<name>` template member accesses are emitted) in as an
788    // additional credited set alongside `template_visible_imports`. Crediting a
789    // prop name against an import is inert (no import binding shares the name),
790    // so the unused-import retain is unaffected.
791    let mut credited: FxHashSet<String> = template_visible_imports.clone();
792    // unused-load-data-key Primitive B: a SvelteKit route component
793    // (`+page.svelte` / `+layout.svelte`) receives a `data` prop populated by
794    // the route's `load()` return object. The prop is template-visible
795    // (`{data.x}`, `{#each data.items as i}`) but is neither an import nor a
796    // tracked binding, so the member-access scanner gates it out by default.
797    // Credit `data` as a recognized root so its template member accesses
798    // (`data.<key>`) are emitted for the cross-file load-data-key join. Gated to
799    // route components only (`credit_load_data`): a non-route component's
800    // `let { data } = $props()` is a different, parent-passed `data`, so
801    // crediting it as load data would be semantically wrong. Inert for every
802    // other detector unless a tracked export/instance is named `data` (mirrors
803    // Primitive A); the load-data-key join is the only consumer of `data.<key>`.
804    if credit_load_data {
805        credited.insert("data".to_string());
806        // FP-1: a route component spreading / passing the whole `data` prop in
807        // markup consumes arbitrary keys opaquely; force the detector to abstain
808        // on this route. A false abstain only suppresses findings, never creates
809        // one, so matching the full source (script + template) is safe.
810        if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
811            combined.has_load_data_whole_use = true;
812        }
813    }
814    if !combined.component_props.is_empty() {
815        for prop in &combined.component_props {
816            // Credit both the declared name (Vue exposes props by name in the
817            // template) and the destructure local (a renamed prop is used via it).
818            credited.insert(prop.name.clone());
819            credited.insert(prop.local.clone());
820        }
821        // Vue's implicit `$props` whole-props object is always available in a
822        // template; credit `$props.<name>` member accesses too.
823        credited.insert("$props".to_string());
824        if let Some(binding) = props_return_binding {
825            credited.insert(binding.to_string());
826        }
827    }
828
829    // unused-load-data-key Primitive B: a route `data` prop is usually typed as
830    // SvelteKit's generated `PageData` / `LayoutData` (`export let data:
831    // PageData`). That typed binding (`data -> PageData`) would otherwise make the
832    // template scanner remap `{data.x}` / `<Child foo={data.x} />` member accesses
833    // onto the generated type (`PageData.x`), dropping the `data`-keyed access the
834    // cross-file load-data join needs. Drop `data` from the bound-targets for the
835    // template scan so its template member accesses stay keyed on `data`. The
836    // generated `$types` aliases carry no real members for any member detector, so
837    // losing the type-keyed template credit is inert; script-side `data` reads are
838    // unaffected (their resolution already ran during `merge_into`).
839    let template_usage = if credit_load_data && template_visible_bound_targets.contains_key("data")
840    {
841        let mut filtered = template_visible_bound_targets.clone();
842        filtered.remove("data");
843        collect_template_usage_with_bound_targets(kind, source, &credited, &filtered)
844    } else {
845        collect_template_usage_with_bound_targets(
846            kind,
847            source,
848            &credited,
849            template_visible_bound_targets,
850        )
851    };
852
853    // A template reference credits `used_in_template`: either a bare prop name in
854    // `used_bindings` (destructured prop form, or template uses the bare name) OR
855    // a `<props>.<name>` / `$props.<name>` member access (the
856    // `const props = defineProps()` form and Vue's implicit `$props`).
857    if !combined.component_props.is_empty() {
858        let member_used: FxHashSet<&str> = template_usage
859            .member_accesses
860            .iter()
861            .filter(|access| {
862                access.object == "$props"
863                    || props_return_binding.is_some_and(|binding| access.object == binding)
864            })
865            .map(|access| access.member.as_str())
866            .collect();
867        for prop in &mut combined.component_props {
868            if template_usage.used_bindings.contains(&prop.name)
869                || template_usage.used_bindings.contains(&prop.local)
870                || member_used.contains(prop.name.as_str())
871            {
872                prop.used_in_template = true;
873            }
874        }
875    }
876
877    // A custom-named `defineProps` return spread as a whole object in the template
878    // (`const myProps = defineProps(); <Child v-bind="myProps" />`) consumes every
879    // prop opaquely; the literal `props`/`$props`/`$attrs` regex misses a custom
880    // name. The scanner records a bare `v-bind="myProps"` value as a used binding
881    // (not a whole-object use), so a bare reference to the return binding in either
882    // set means abstain on the whole file.
883    if let Some(binding) = props_return_binding
884        && (template_usage.used_bindings.contains(binding)
885            || template_usage
886                .whole_object_uses
887                .iter()
888                .any(|used| used == binding))
889    {
890        combined.has_props_attrs_fallthrough = true;
891    }
892
893    combined
894        .unused_import_bindings
895        .retain(|binding| !template_usage.used_bindings.contains(binding));
896    combined
897        .member_accesses
898        .extend(template_usage.member_accesses);
899    combined
900        .whole_object_uses
901        .extend(template_usage.whole_object_uses);
902    combined
903        .security_sinks
904        .extend(template_usage.security_sinks);
905    if !template_usage.unresolved_tag_names.is_empty() {
906        let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
907        names.sort_unstable();
908        combined.auto_import_candidates.extend(names);
909        combined.auto_import_candidates.dedup();
910    }
911}
912
913/// Credit emit events fired from the `<template>` (`@click="emit('close')"`,
914/// `@click="$emit('remove')"`, `:close="{ onClick: () => emit('close') }"`),
915/// which the script-only emit usage walk in `harvest_define_emits` cannot see.
916///
917/// Scans the template-only region (scripts/styles/comments masked) for
918/// [`TEMPLATE_EMIT_CALL_RE`]: a call whose callee is the harvested emit binding
919/// (`emit` / `emits` / whatever it was bound to) or the implicit `$emit` (always
920/// available in a Vue template regardless of `<script setup>` binding). A
921/// string-literal first arg credits the matching `ComponentEmit` as used; a
922/// non-literal first arg (a variable / template-literal) is a dynamic template
923/// emit whose event is unknowable, so the whole file abstains (`has_dynamic_emit`)
924/// to preserve the zero-FP doctrine.
925///
926/// Over-crediting is the safe direction (it only suppresses a finding), so a
927/// liberal raw-source scan is intentional here. The scan is byte-safe: the regex
928/// runs over the `&str` template and only reads captured-group text, never
929/// slicing at arbitrary byte offsets.
930fn apply_template_emit_usage(
931    source: &str,
932    emit_return_binding: Option<&str>,
933    combined: &mut ModuleInfo,
934) {
935    let masked = mask_non_markup_regions(source);
936    let mut used: FxHashSet<String> = FxHashSet::default();
937    let mut dynamic = false;
938
939    for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
940        let Some(callee) = caps.get(1) else {
941            continue;
942        };
943        let callee = callee.as_str();
944        let is_emit_call =
945            callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
946        if !is_emit_call {
947            continue;
948        }
949        if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
950            // String-literal first arg (single- or double-quoted): the event
951            // name. Credit it as used.
952            used.insert(event.as_str().to_string());
953        } else if caps.get(4).is_some() {
954            // Non-literal first arg (`$emit(someVar)`, `emit(\`x\`)`): the event
955            // cannot be known. Abstain on the whole file.
956            dynamic = true;
957        }
958    }
959
960    if dynamic {
961        combined.has_dynamic_emit = true;
962    }
963    if !used.is_empty() {
964        for emit in &mut combined.component_emits {
965            if used.contains(&emit.name) {
966                emit.used = true;
967            }
968        }
969    }
970}
971
972fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
973    match kind {
974        SfcKind::Vue => script.is_setup,
975        SfcKind::Svelte => !script.is_context_module,
976    }
977}
978
979#[cfg(all(test, not(miri)))]
980mod tests {
981    use super::*;
982
983    #[test]
984    fn is_sfc_file_vue() {
985        assert!(is_sfc_file(Path::new("App.vue")));
986    }
987
988    #[test]
989    fn is_sfc_file_svelte() {
990        assert!(is_sfc_file(Path::new("Counter.svelte")));
991    }
992
993    #[test]
994    fn is_sfc_file_rejects_ts() {
995        assert!(!is_sfc_file(Path::new("utils.ts")));
996    }
997
998    #[test]
999    fn is_sfc_file_rejects_jsx() {
1000        assert!(!is_sfc_file(Path::new("App.jsx")));
1001    }
1002
1003    #[test]
1004    fn is_sfc_file_rejects_astro() {
1005        assert!(!is_sfc_file(Path::new("Layout.astro")));
1006    }
1007
1008    #[test]
1009    fn single_plain_script() {
1010        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1011        assert_eq!(scripts.len(), 1);
1012        assert_eq!(scripts[0].body, "const x = 1;");
1013        assert!(!scripts[0].is_typescript);
1014        assert!(!scripts[0].is_jsx);
1015        assert!(scripts[0].src.is_none());
1016    }
1017
1018    #[test]
1019    fn single_ts_script() {
1020        let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1021        assert_eq!(scripts.len(), 1);
1022        assert!(scripts[0].is_typescript);
1023        assert!(!scripts[0].is_jsx);
1024    }
1025
1026    #[test]
1027    fn single_tsx_script() {
1028        let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1029        assert_eq!(scripts.len(), 1);
1030        assert!(scripts[0].is_typescript);
1031        assert!(scripts[0].is_jsx);
1032    }
1033
1034    #[test]
1035    fn single_jsx_script() {
1036        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1037        assert_eq!(scripts.len(), 1);
1038        assert!(!scripts[0].is_typescript);
1039        assert!(scripts[0].is_jsx);
1040    }
1041
1042    #[test]
1043    fn two_script_blocks() {
1044        let source = r#"
1045<script lang="ts">
1046export default {};
1047</script>
1048<script setup lang="ts">
1049const count = 0;
1050</script>
1051"#;
1052        let scripts = extract_sfc_scripts(source);
1053        assert_eq!(scripts.len(), 2);
1054        assert!(scripts[0].body.contains("export default"));
1055        assert!(scripts[1].body.contains("count"));
1056    }
1057
1058    #[test]
1059    fn script_setup_extracted() {
1060        let scripts =
1061            extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1062        assert_eq!(scripts.len(), 1);
1063        assert!(scripts[0].body.contains("import"));
1064        assert!(scripts[0].is_typescript);
1065    }
1066
1067    #[test]
1068    fn script_src_detected() {
1069        let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1070        assert_eq!(scripts.len(), 1);
1071        assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1072    }
1073
1074    #[test]
1075    fn data_src_not_treated_as_src() {
1076        let scripts =
1077            extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1078        assert_eq!(scripts.len(), 1);
1079        assert!(scripts[0].src.is_none());
1080    }
1081
1082    #[test]
1083    fn script_inside_html_comment_filtered() {
1084        let source = r#"
1085<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1086<script lang="ts">import { good } from 'good';</script>
1087"#;
1088        let scripts = extract_sfc_scripts(source);
1089        assert_eq!(scripts.len(), 1);
1090        assert!(scripts[0].body.contains("good"));
1091    }
1092
1093    #[test]
1094    fn spanning_comment_filters_script() {
1095        let source = r#"
1096<!-- disabled:
1097<script lang="ts">import { bad } from 'bad';</script>
1098-->
1099<script lang="ts">const ok = true;</script>
1100"#;
1101        let scripts = extract_sfc_scripts(source);
1102        assert_eq!(scripts.len(), 1);
1103        assert!(scripts[0].body.contains("ok"));
1104    }
1105
1106    #[test]
1107    fn string_containing_comment_markers_not_corrupted() {
1108        let source = r#"
1109<script setup lang="ts">
1110const marker = "<!-- not a comment -->";
1111import { ref } from 'vue';
1112</script>
1113"#;
1114        let scripts = extract_sfc_scripts(source);
1115        assert_eq!(scripts.len(), 1);
1116        assert!(scripts[0].body.contains("import"));
1117    }
1118
1119    #[test]
1120    fn generic_attr_with_angle_bracket() {
1121        let source =
1122            r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1123        let scripts = extract_sfc_scripts(source);
1124        assert_eq!(scripts.len(), 1);
1125        assert_eq!(scripts[0].body, "const x = 1;");
1126    }
1127
1128    #[test]
1129    fn nested_generic_attr() {
1130        let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1131        let scripts = extract_sfc_scripts(source);
1132        assert_eq!(scripts.len(), 1);
1133        assert_eq!(scripts[0].body, "const x = 1;");
1134    }
1135
1136    #[test]
1137    fn lang_single_quoted() {
1138        let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1139        assert_eq!(scripts.len(), 1);
1140        assert!(scripts[0].is_typescript);
1141    }
1142
1143    #[test]
1144    fn uppercase_script_tag() {
1145        let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1146        assert_eq!(scripts.len(), 1);
1147        assert!(scripts[0].is_typescript);
1148    }
1149
1150    #[test]
1151    fn no_script_block() {
1152        let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1153        assert!(scripts.is_empty());
1154    }
1155
1156    #[test]
1157    fn empty_script_body() {
1158        let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1159        assert_eq!(scripts.len(), 1);
1160        assert!(scripts[0].body.is_empty());
1161    }
1162
1163    #[test]
1164    fn whitespace_only_script() {
1165        let scripts = extract_sfc_scripts("<script lang=\"ts\">\n  \n</script>");
1166        assert_eq!(scripts.len(), 1);
1167        assert!(scripts[0].body.trim().is_empty());
1168    }
1169
1170    #[test]
1171    fn byte_offset_is_set() {
1172        let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1173        let scripts = extract_sfc_scripts(source);
1174        assert_eq!(scripts.len(), 1);
1175        let offset = scripts[0].byte_offset;
1176        assert_eq!(&source[offset..offset + 4], "code");
1177    }
1178
1179    #[test]
1180    fn script_with_extra_attributes() {
1181        let scripts = extract_sfc_scripts(
1182            r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1183        );
1184        assert_eq!(scripts.len(), 1);
1185        assert!(scripts[0].is_typescript);
1186        assert!(scripts[0].src.is_none());
1187    }
1188
1189    #[test]
1190    fn multiple_script_blocks_exports_combined() {
1191        let source = r#"
1192<script lang="ts">
1193export const version = '1.0';
1194</script>
1195<script setup lang="ts">
1196import { ref } from 'vue';
1197const count = ref(0);
1198</script>
1199"#;
1200        let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1201        assert!(
1202            info.exports
1203                .iter()
1204                .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1205            "export from <script> block should be extracted"
1206        );
1207        assert!(
1208            info.imports.iter().any(|i| i.source == "vue"),
1209            "import from <script setup> block should be extracted"
1210        );
1211    }
1212
1213    #[test]
1214    fn lang_tsx_detected_as_typescript_jsx() {
1215        let scripts =
1216            extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1217        assert_eq!(scripts.len(), 1);
1218        assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1219        assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1220    }
1221
1222    #[test]
1223    fn multiline_html_comment_filters_all_script_blocks_inside() {
1224        let source = r#"
1225<!--
1226  This whole section is disabled:
1227  <script lang="ts">import { bad1 } from 'bad1';</script>
1228  <script lang="ts">import { bad2 } from 'bad2';</script>
1229-->
1230<script lang="ts">import { good } from 'good';</script>
1231"#;
1232        let scripts = extract_sfc_scripts(source);
1233        assert_eq!(scripts.len(), 1);
1234        assert!(scripts[0].body.contains("good"));
1235    }
1236
1237    #[test]
1238    fn script_src_generates_side_effect_import() {
1239        let info = parse_sfc_to_module(
1240            FileId(0),
1241            Path::new("External.vue"),
1242            r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1243            0,
1244            false,
1245        );
1246        assert!(
1247            info.imports
1248                .iter()
1249                .any(|i| i.source == "./external-logic.ts"
1250                    && matches!(i.imported_name, ImportedName::SideEffect)),
1251            "script src should generate a side-effect import"
1252        );
1253    }
1254
1255    #[test]
1256    fn parse_sfc_no_script_returns_empty_module() {
1257        let info = parse_sfc_to_module(
1258            FileId(0),
1259            Path::new("Empty.vue"),
1260            "<template><div>Hello</div></template>",
1261            42,
1262            false,
1263        );
1264        assert!(info.imports.is_empty());
1265        assert!(info.exports.is_empty());
1266        assert_eq!(info.content_hash, 42);
1267        assert_eq!(info.file_id, FileId(0));
1268    }
1269
1270    #[test]
1271    fn parse_sfc_has_line_offsets() {
1272        let info = parse_sfc_to_module(
1273            FileId(0),
1274            Path::new("LineOffsets.vue"),
1275            r#"<script lang="ts">const x = 1;</script>"#,
1276            0,
1277            false,
1278        );
1279        assert!(!info.line_offsets.is_empty());
1280    }
1281
1282    #[test]
1283    fn parse_sfc_has_suppressions() {
1284        let info = parse_sfc_to_module(
1285            FileId(0),
1286            Path::new("Suppressions.vue"),
1287            r#"<script lang="ts">
1288// fallow-ignore-file
1289export const foo = 1;
1290</script>"#,
1291            0,
1292            false,
1293        );
1294        assert!(!info.suppressions.is_empty());
1295    }
1296
1297    #[test]
1298    fn source_type_jsx_detection() {
1299        let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1300        assert_eq!(scripts.len(), 1);
1301        assert!(!scripts[0].is_typescript);
1302        assert!(scripts[0].is_jsx);
1303    }
1304
1305    #[test]
1306    fn source_type_plain_js_detection() {
1307        let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1308        assert_eq!(scripts.len(), 1);
1309        assert!(!scripts[0].is_typescript);
1310        assert!(!scripts[0].is_jsx);
1311    }
1312
1313    #[test]
1314    fn is_sfc_file_rejects_no_extension() {
1315        assert!(!is_sfc_file(Path::new("Makefile")));
1316    }
1317
1318    #[test]
1319    fn is_sfc_file_rejects_mdx() {
1320        assert!(!is_sfc_file(Path::new("post.mdx")));
1321    }
1322
1323    #[test]
1324    fn is_sfc_file_rejects_css() {
1325        assert!(!is_sfc_file(Path::new("styles.css")));
1326    }
1327
1328    #[test]
1329    fn multiple_script_blocks_both_have_offsets() {
1330        let source = r#"<script lang="ts">const a = 1;</script>
1331<script setup lang="ts">const b = 2;</script>"#;
1332        let scripts = extract_sfc_scripts(source);
1333        assert_eq!(scripts.len(), 2);
1334        let offset0 = scripts[0].byte_offset;
1335        let offset1 = scripts[1].byte_offset;
1336        assert_eq!(
1337            &source[offset0..offset0 + "const a = 1;".len()],
1338            "const a = 1;"
1339        );
1340        assert_eq!(
1341            &source[offset1..offset1 + "const b = 2;".len()],
1342            "const b = 2;"
1343        );
1344    }
1345
1346    #[test]
1347    fn script_with_src_and_lang() {
1348        let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1349        assert_eq!(scripts.len(), 1);
1350        assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1351        assert!(scripts[0].is_typescript);
1352        assert!(scripts[0].is_jsx);
1353    }
1354
1355    #[test]
1356    fn extract_style_block_lang_scss() {
1357        let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1358        let styles = extract_sfc_styles(source);
1359        assert_eq!(styles.len(), 1);
1360        assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1361        assert!(styles[0].body.contains("@import"));
1362        assert!(styles[0].src.is_none());
1363    }
1364
1365    #[test]
1366    fn extract_style_block_with_src() {
1367        let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1368        let styles = extract_sfc_styles(source);
1369        assert_eq!(styles.len(), 1);
1370        assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1371        assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1372    }
1373
1374    #[test]
1375    fn extract_style_block_plain_no_lang() {
1376        let source = r"<style>.foo { color: red; }</style>";
1377        let styles = extract_sfc_styles(source);
1378        assert_eq!(styles.len(), 1);
1379        assert!(styles[0].lang.is_none());
1380    }
1381
1382    #[test]
1383    fn extract_multiple_style_blocks() {
1384        let source = r#"<style lang="scss">@import 'a';</style>
1385<style scoped lang="scss">@import 'b';</style>"#;
1386        let styles = extract_sfc_styles(source);
1387        assert_eq!(styles.len(), 2);
1388    }
1389
1390    #[test]
1391    fn style_block_inside_html_comment_filtered() {
1392        let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1393<style lang="scss">@import 'good';</style>"#;
1394        let styles = extract_sfc_styles(source);
1395        assert_eq!(styles.len(), 1);
1396        assert!(styles[0].body.contains("good"));
1397    }
1398
1399    #[test]
1400    fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1401        let info = parse_sfc_to_module(
1402            FileId(0),
1403            Path::new("Foo.vue"),
1404            r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1405            0,
1406            false,
1407        );
1408        let style_import = info
1409            .imports
1410            .iter()
1411            .find(|i| i.source == "./Foo")
1412            .expect("scss @import 'Foo' should be normalized to ./Foo");
1413        assert!(
1414            style_import.from_style,
1415            "imports from <style> blocks must carry from_style=true so the resolver \
1416             enables SCSS partial fallback for the SFC importer"
1417        );
1418        assert!(matches!(
1419            style_import.imported_name,
1420            ImportedName::SideEffect
1421        ));
1422    }
1423
1424    #[test]
1425    fn parse_sfc_extracts_style_plugin_as_default_import() {
1426        let info = parse_sfc_to_module(
1427            FileId(0),
1428            Path::new("Foo.vue"),
1429            r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1430            0,
1431            false,
1432        );
1433        let plugin_import = info
1434            .imports
1435            .iter()
1436            .find(|i| i.source == "./tailwind-plugin.js")
1437            .expect("style @plugin should create an import");
1438        assert!(plugin_import.from_style);
1439        assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1440    }
1441
1442    #[test]
1443    fn parse_sfc_extracts_style_src_with_from_style_flag() {
1444        let info = parse_sfc_to_module(
1445            FileId(0),
1446            Path::new("Bar.vue"),
1447            r#"<style src="./Bar.scss" lang="scss"></style>"#,
1448            0,
1449            false,
1450        );
1451        let style_src = info
1452            .imports
1453            .iter()
1454            .find(|i| i.source == "./Bar.scss")
1455            .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1456        assert!(style_src.from_style);
1457    }
1458
1459    #[test]
1460    fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1461        let info = parse_sfc_to_module(
1462            FileId(0),
1463            Path::new("Baz.vue"),
1464            r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1465            0,
1466            false,
1467        );
1468        assert!(
1469            info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1470            "src reference should still be seeded for unsupported lang"
1471        );
1472        assert!(
1473            !info.imports.iter().any(|i| i.source.contains("skipped")),
1474            "postcss body should not be scanned for @import directives"
1475        );
1476    }
1477
1478    fn asset_refs(source: &str) -> Vec<String> {
1479        super::collect_template_asset_refs(source)
1480            .into_iter()
1481            .map(|(s, _)| s)
1482            .collect()
1483    }
1484
1485    #[test]
1486    fn captures_static_relative_template_asset_refs() {
1487        assert_eq!(
1488            asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1489            vec!["./logo.png".to_string()]
1490        );
1491        assert_eq!(
1492            asset_refs(r#"<source src="../media/clip.mp4">"#),
1493            vec!["../media/clip.mp4".to_string()]
1494        );
1495        assert_eq!(
1496            asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1497            vec!["./thumb.jpg".to_string()]
1498        );
1499    }
1500
1501    #[test]
1502    fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1503        // Dynamic bindings (Vue `:src`, `v-bind:src`, Svelte `bind:src` / `src={}`).
1504        assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1505        assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1506        assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1507        assert!(asset_refs(r"<img src={logo} />").is_empty());
1508        assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1509        // Alias-prefixed, root-relative, remote, bare: not plain relative literals.
1510        assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1511        assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1512        assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1513        // Query / hash suffix abstains (the resolver cannot verify them).
1514        assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1515        // Interpolated value abstains.
1516        assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1517    }
1518
1519    #[test]
1520    fn skips_custom_component_src_prop() {
1521        // A custom component's `src` PROP must never be read as an asset edge.
1522        assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1523        assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1524    }
1525
1526    #[test]
1527    fn skips_asset_refs_inside_script_style_and_comments() {
1528        // Masked regions must not contribute asset refs.
1529        assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1530        assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1531        assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1532    }
1533
1534    #[test]
1535    fn parse_sfc_emits_template_asset_as_side_effect_import() {
1536        let info = parse_sfc_to_module(
1537            FileId(0),
1538            Path::new("Hero.vue"),
1539            r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1540            0,
1541            false,
1542        );
1543        assert!(
1544            info.imports.iter().any(|i| i.source == "./hero.png"
1545                && matches!(i.imported_name, ImportedName::SideEffect)
1546                && !i.from_style),
1547            "template <img src> should seed a SideEffect import: {:?}",
1548            info.imports
1549        );
1550    }
1551}