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