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