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