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