1use std::path::Path;
13use std::sync::LazyLock;
14
15use oxc_allocator::Allocator;
16use oxc_ast_visit::Visit;
17use oxc_parser::Parser;
18use oxc_span::SourceType;
19use rustc_hash::{FxHashMap, FxHashSet};
20
21use crate::asset_url::normalize_asset_url;
22use crate::parse::{
23 compute_auto_import_candidates, compute_import_binding_usage, compute_semantic_usage,
24};
25use crate::sfc_template::{SfcKind, collect_template_usage_with_bound_targets};
26use crate::source_map::ExtractionResult;
27use crate::visitor::ModuleInfoExtractor;
28use crate::{ImportInfo, ImportedName, ModuleInfo};
29use fallow_types::discover::FileId;
30use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
31use oxc_span::Span;
32
33static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
36 crate::static_regex(
37 r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
38 )
39});
40
41static LANG_ATTR_RE: LazyLock<regex::Regex> =
43 LazyLock::new(|| crate::static_regex(r#"lang\s*=\s*["'](\w+)["']"#));
44
45static SRC_ATTR_RE: LazyLock<regex::Regex> =
48 LazyLock::new(|| crate::static_regex(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#));
49
50static SETUP_ATTR_RE: LazyLock<regex::Regex> =
52 LazyLock::new(|| crate::static_regex(r"(?:^|\s)setup(?:\s|$)"));
53
54static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
56 LazyLock::new(|| crate::static_regex(r#"context\s*=\s*["']module["']"#));
57
58static SVELTE_MODULE_ATTR_RE: LazyLock<regex::Regex> =
63 LazyLock::new(|| crate::static_regex(r"(?:^|\s)module(?:\s|$|=)"));
64
65static VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
69 crate::static_regex(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
70});
71
72static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
75 crate::static_regex(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
76});
77
78static HTML_COMMENT_RE: LazyLock<regex::Regex> =
80 LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
81
82static PROPS_ATTRS_SPREAD_RE: LazyLock<regex::Regex> =
87 LazyLock::new(|| crate::static_regex(r#"v-bind\s*=\s*["'](?:\$attrs|\$props|props)["']"#));
88
89static SVELTE_TEMPLATE_DATA_WHOLE_USE_RE: LazyLock<regex::Regex> =
97 LazyLock::new(|| crate::static_regex(r"(?:=\s*\{\s*data\s*\}|\{\s*\.\.\.\s*data\s*\})"));
98
99static TEMPLATE_EMIT_CALL_RE: LazyLock<regex::Regex> =
109 LazyLock::new(|| crate::static_regex(r#"([\w$]+)\s*\(\s*(?:'([\w:-]*)'|"([\w:-]*)"|(\S))"#));
110
111static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
115 crate::static_regex(
116 r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
117 )
118});
119
120static TEMPLATE_ASSET_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
135 crate::static_regex(
136 r#"(?si)<(?:img|source|video|audio|track|embed)\b(?:[^>"']|"[^"]*"|'[^']*')*?\s(?:src|poster)\s*=\s*(?:"((?:\./|\.\./)[^"<>{}?#\s]*)"|'((?:\./|\.\./)[^'<>{}?#\s]*)')"#,
137 )
138});
139
140fn mask_non_markup_regions(source: &str) -> String {
144 let mut masked = source.to_string();
145 for re in [&*SCRIPT_BLOCK_RE, &*STYLE_BLOCK_RE, &*HTML_COMMENT_RE] {
146 masked = re
147 .replace_all(&masked, |caps: ®ex::Captures<'_>| {
148 " ".repeat(caps[0].len())
149 })
150 .into_owned();
151 }
152 masked
153}
154
155fn collect_template_asset_refs(source: &str) -> Vec<(String, Span)> {
158 let masked = mask_non_markup_regions(source);
159 let mut refs = Vec::new();
160 for caps in TEMPLATE_ASSET_RE.captures_iter(&masked) {
161 let Some(value) = caps.get(1).or_else(|| caps.get(2)) else {
162 continue;
163 };
164 let raw = value.as_str();
165 if raw.is_empty() {
166 continue;
167 }
168 refs.push((
169 normalize_asset_url(raw),
170 Span::new(value.start() as u32, value.end() as u32),
171 ));
172 }
173 refs
174}
175
176pub struct SfcScript {
178 pub body: String,
180 pub is_typescript: bool,
182 pub is_jsx: bool,
184 pub byte_offset: usize,
186 pub src: Option<String>,
188 pub src_span: Option<Span>,
190 pub is_setup: bool,
192 pub is_context_module: bool,
194 pub generic_attr: Option<String>,
198}
199
200pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
202 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
203 .find_iter(source)
204 .map(|m| (m.start(), m.end()))
205 .collect();
206
207 SCRIPT_BLOCK_RE
208 .captures_iter(source)
209 .filter(|cap| {
210 let start = cap.get(0).map_or(0, |m| m.start());
211 !comment_ranges
212 .iter()
213 .any(|&(cs, ce)| start >= cs && start < ce)
214 })
215 .map(|cap| {
216 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
217 let body_match = cap.name("body");
218 let byte_offset = body_match.map_or(0, |m| m.start());
219 let body = body_match.map_or("", |m| m.as_str()).to_string();
220 let lang = LANG_ATTR_RE
221 .captures(attrs)
222 .and_then(|c| c.get(1))
223 .map(|m| m.as_str());
224 let is_typescript = matches!(lang, Some("ts" | "tsx"));
225 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
226 let src = SRC_ATTR_RE
227 .captures(attrs)
228 .and_then(|c| c.get(1))
229 .map(|m| m.as_str().to_string());
230 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
231 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
232 Span::new(
233 (attrs_start + m.start()) as u32,
234 (attrs_start + m.end()) as u32,
235 )
236 });
237 let is_setup = SETUP_ATTR_RE.is_match(attrs);
238 let is_context_module =
243 CONTEXT_MODULE_ATTR_RE.is_match(attrs) || SVELTE_MODULE_ATTR_RE.is_match(attrs);
244 let generic_attr = VUE_GENERIC_ATTR_RE
245 .captures(attrs)
246 .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
247 .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
248 .map(|m| m.as_str().to_string())
249 .filter(|value| !value.trim().is_empty());
250 SfcScript {
251 body,
252 is_typescript,
253 is_jsx,
254 byte_offset,
255 src,
256 src_span,
257 is_setup,
258 is_context_module,
259 generic_attr,
260 }
261 })
262 .collect()
263}
264
265pub struct SfcStyle {
267 pub body: String,
269 pub lang: Option<String>,
272 pub src: Option<String>,
274 pub src_span: Option<Span>,
276 pub byte_offset: usize,
278}
279
280pub struct SourceRegion {
283 pub body: String,
285 pub byte_offset: usize,
287}
288
289#[must_use]
295pub fn extract_sfc_template_regions(source: &str) -> Vec<SourceRegion> {
296 let mut ranges: Vec<(usize, usize)> = SCRIPT_BLOCK_RE
297 .find_iter(source)
298 .chain(STYLE_BLOCK_RE.find_iter(source))
299 .chain(HTML_COMMENT_RE.find_iter(source))
300 .map(|m| (m.start(), m.end()))
301 .collect();
302 ranges.sort_unstable_by_key(|(start, _)| *start);
303 ranges_to_gaps(source, &ranges)
304}
305
306pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
313 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
314 .find_iter(source)
315 .map(|m| (m.start(), m.end()))
316 .collect();
317
318 STYLE_BLOCK_RE
319 .captures_iter(source)
320 .filter(|cap| {
321 let start = cap.get(0).map_or(0, |m| m.start());
322 !comment_ranges
323 .iter()
324 .any(|&(cs, ce)| start >= cs && start < ce)
325 })
326 .map(|cap| {
327 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
328 let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
329 let byte_offset = cap.name("body").map_or(0, |m| m.start());
330 let lang = LANG_ATTR_RE
331 .captures(attrs)
332 .and_then(|c| c.get(1))
333 .map(|m| m.as_str().to_string());
334 let src = SRC_ATTR_RE
335 .captures(attrs)
336 .and_then(|c| c.get(1))
337 .map(|m| m.as_str().to_string());
338 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
339 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
340 Span::new(
341 (attrs_start + m.start()) as u32,
342 (attrs_start + m.end()) as u32,
343 )
344 });
345 SfcStyle {
346 body,
347 lang,
348 src,
349 src_span,
350 byte_offset,
351 }
352 })
353 .collect()
354}
355
356fn ranges_to_gaps(source: &str, ranges: &[(usize, usize)]) -> Vec<SourceRegion> {
357 let mut regions = Vec::new();
358 let mut cursor = 0;
359 for &(start, end) in ranges {
360 if start > cursor {
361 push_region(source, cursor, start, &mut regions);
362 }
363 cursor = cursor.max(end);
364 }
365 if cursor < source.len() {
366 push_region(source, cursor, source.len(), &mut regions);
367 }
368 regions
369}
370
371fn push_region(source: &str, start: usize, end: usize, regions: &mut Vec<SourceRegion>) {
372 let Some(body) = source.get(start..end) else {
373 return;
374 };
375 if body.trim().is_empty() {
376 return;
377 }
378 regions.push(SourceRegion {
379 body: body.to_string(),
380 byte_offset: start,
381 });
382}
383
384#[must_use]
386pub fn is_sfc_file(path: &Path) -> bool {
387 path.extension()
388 .and_then(|e| e.to_str())
389 .is_some_and(|ext| ext == "vue" || ext == "svelte")
390}
391
392pub(crate) fn parse_sfc_to_module(
394 file_id: FileId,
395 path: &Path,
396 source: &str,
397 content_hash: u64,
398 need_complexity: bool,
399) -> ModuleInfo {
400 let scripts = extract_sfc_scripts(source);
401 let styles = extract_sfc_styles(source);
402 let kind = sfc_kind(path);
403 let mut combined = empty_sfc_module(file_id, source, content_hash);
404 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
405 let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
406 let mut props_return_binding: Option<String> = None;
407 let mut emit_return_binding: Option<String> = None;
408
409 for script in &scripts {
410 merge_script_into_module(&mut SfcScriptMergeInput {
411 kind,
412 script,
413 combined: &mut combined,
414 template_visible_imports: &mut template_visible_imports,
415 template_visible_bound_targets: &mut template_visible_bound_targets,
416 props_return_binding: &mut props_return_binding,
417 emit_return_binding: &mut emit_return_binding,
418 need_complexity,
419 });
420 }
421
422 for style in &styles {
423 merge_style_into_module(style, &mut combined);
424 }
425
426 if kind == SfcKind::Vue
430 && !combined.component_props.is_empty()
431 && PROPS_ATTRS_SPREAD_RE.is_match(source)
432 {
433 combined.has_props_attrs_fallthrough = true;
434 }
435
436 apply_template_usage(
437 kind,
438 source,
439 &template_visible_imports,
440 &template_visible_bound_targets,
441 props_return_binding.as_deref(),
442 kind == SfcKind::Svelte && is_sveltekit_route_data_component(path),
443 &mut combined,
444 );
445
446 if need_complexity {
454 let template_complexity = match kind {
455 SfcKind::Vue => crate::template_complexity::compute_vue_template_complexity(source),
456 SfcKind::Svelte => {
457 crate::template_complexity::compute_svelte_template_complexity(source)
458 }
459 };
460 combined.complexity.extend(template_complexity);
461 }
462
463 if kind == SfcKind::Vue && !combined.component_emits.is_empty() {
467 apply_template_emit_usage(source, emit_return_binding.as_deref(), &mut combined);
468 }
469
470 if kind == SfcKind::Svelte {
474 combined.svelte_listened_events =
475 crate::sfc_template::collect_svelte_listened_events(source);
476 }
477
478 for (specifier, span) in collect_template_asset_refs(source) {
482 combined.imports.push(ImportInfo {
483 source: specifier,
484 imported_name: ImportedName::SideEffect,
485 local_name: String::new(),
486 is_type_only: false,
487 from_style: false,
488 span,
489 source_span: span,
490 });
491 }
492
493 combined.unused_import_bindings.sort_unstable();
494 combined.unused_import_bindings.dedup();
495 combined.type_referenced_import_bindings.sort_unstable();
496 combined.type_referenced_import_bindings.dedup();
497 combined.value_referenced_import_bindings.sort_unstable();
498 combined.value_referenced_import_bindings.dedup();
499 combined.auto_import_candidates.sort_unstable();
500 combined.auto_import_candidates.dedup();
501
502 combined
503}
504
505fn sfc_kind(path: &Path) -> SfcKind {
506 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
507 SfcKind::Vue
508 } else {
509 SfcKind::Svelte
510 }
511}
512
513fn is_sveltekit_route_data_component(path: &Path) -> bool {
524 let Some(stem) = path
525 .file_name()
526 .and_then(|name| name.to_str())
527 .and_then(|name| name.strip_suffix(".svelte"))
528 else {
529 return false;
530 };
531 ["+page", "+layout"].iter().any(|prefix| {
532 stem.strip_prefix(prefix)
533 .is_some_and(|rest| rest.is_empty() || rest.starts_with('@'))
534 })
535}
536
537fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
538 let parsed = crate::suppress::parse_suppressions_from_source(source);
539
540 ModuleInfo {
541 file_id,
542 exports: Vec::new(),
543 imports: Vec::new(),
544 re_exports: Vec::new(),
545 dynamic_imports: Vec::new(),
546 dynamic_import_patterns: Vec::new(),
547 require_calls: Vec::new(),
548 package_path_references: Vec::new(),
549 member_accesses: Vec::new(),
550 whole_object_uses: Vec::new(),
551 has_cjs_exports: false,
552 has_angular_component_template_url: false,
553 content_hash,
554 suppressions: parsed.suppressions,
555 unknown_suppression_kinds: parsed.unknown_kinds,
556 unused_import_bindings: Vec::new(),
557 type_referenced_import_bindings: Vec::new(),
558 value_referenced_import_bindings: Vec::new(),
559 line_offsets: compute_line_offsets(source),
560 complexity: Vec::new(),
561 flag_uses: Vec::new(),
562 class_heritage: vec![],
563 injection_tokens: vec![],
564 local_type_declarations: Vec::new(),
565 public_signature_type_references: Vec::new(),
566 namespace_object_aliases: Vec::new(),
567 iconify_prefixes: Vec::new(),
568 iconify_icon_names: Vec::new(),
569 auto_import_candidates: Vec::new(),
570 directives: Vec::new(),
571 client_only_dynamic_import_spans: Vec::new(),
572 security_sinks: Vec::new(),
573 security_sinks_skipped: 0,
574 security_unresolved_callee_sites: Vec::new(),
575 tainted_bindings: Vec::new(),
576 sanitized_sink_args: Vec::new(),
577 security_control_sites: Vec::new(),
578 callee_uses: Vec::new(),
579 misplaced_directives: Vec::new(),
580 inline_server_action_exports: Vec::new(),
581 di_key_sites: Vec::new(),
582 has_dynamic_provide: false,
583 referenced_import_bindings: Vec::new(),
584 component_props: Vec::new(),
585 has_props_attrs_fallthrough: false,
586 has_define_expose: false,
587 has_define_model: false,
588 has_unharvestable_props: false,
589 component_emits: Vec::new(),
590 angular_inputs: Vec::new(),
591 angular_outputs: Vec::new(),
592 angular_component_selectors: Vec::new(),
593 angular_used_selectors: Vec::new(),
594 angular_entry_component_refs: Vec::new(),
595 has_dynamic_component_render: false,
596 has_unharvestable_emits: false,
597 has_dynamic_emit: false,
598 has_emit_whole_object_use: false,
599 load_return_keys: Vec::new(),
600 has_unharvestable_load: false,
601 has_load_data_whole_use: false,
602 has_page_data_store_whole_use: false,
603 component_functions: Vec::new(),
604 react_props: Vec::new(),
605 hook_uses: Vec::new(),
606 render_edges: Vec::new(),
607 svelte_dispatched_events: Vec::new(),
608 svelte_listened_events: Vec::new(),
609 has_dynamic_dispatch: false,
610 }
611}
612
613struct SfcScriptMergeInput<'a> {
614 kind: SfcKind,
615 script: &'a SfcScript,
616 combined: &'a mut ModuleInfo,
617 template_visible_imports: &'a mut FxHashSet<String>,
618 template_visible_bound_targets: &'a mut FxHashMap<String, String>,
619 props_return_binding: &'a mut Option<String>,
620 emit_return_binding: &'a mut Option<String>,
621 need_complexity: bool,
622}
623
624fn merge_script_into_module(input: &mut SfcScriptMergeInput<'_>) {
625 if input.kind == SfcKind::Vue
626 && let Some(src) = &input.script.src
627 {
628 add_script_src_import(input.combined, src, input.script.src_span);
629 }
630
631 let allocator = Allocator::default();
632 let parser_return = Parser::new(
633 &allocator,
634 &input.script.body,
635 source_type_for_script(input.script),
636 )
637 .parse();
638 let mut extractor = ModuleInfoExtractor::new();
639 extractor.visit_program(&parser_return.program);
640 let extraction = ExtractionResult::contiguous(&input.script.body, input.script.byte_offset);
641 extractor.remap_spans_with(|span| extraction.remap_span(span));
642 extractor.resolve_typed_destructure_bindings();
643
644 let augmented_body = build_generic_attr_probe_source(input.script);
645 let empty_template_used = rustc_hash::FxHashSet::default();
646 let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
647 {
648 let augmented_return =
649 Parser::new(&allocator, augmented, source_type_for_script(input.script)).parse();
650 (
651 compute_import_binding_usage(
652 &augmented_return.program,
653 &extractor.imports,
654 &empty_template_used,
655 ),
656 compute_auto_import_candidates(&parser_return.program),
657 )
658 } else {
659 let semantic_usage = compute_semantic_usage(
660 &parser_return.program,
661 &extractor.imports,
662 &empty_template_used,
663 );
664 (
665 semantic_usage.import_binding_usage,
666 semantic_usage.auto_import_candidates,
667 )
668 };
669 input
670 .combined
671 .unused_import_bindings
672 .extend(binding_usage.unused.iter().cloned());
673 input
674 .combined
675 .type_referenced_import_bindings
676 .extend(binding_usage.type_referenced.iter().cloned());
677 input
678 .combined
679 .value_referenced_import_bindings
680 .extend(binding_usage.value_referenced.iter().cloned());
681 input
682 .combined
683 .auto_import_candidates
684 .extend(auto_import_candidates);
685 if input.need_complexity {
686 input
687 .combined
688 .complexity
689 .extend(translate_script_complexity(
690 input.script,
691 &parser_return.program,
692 &input.combined.line_offsets,
693 ));
694 }
695
696 if input.kind == SfcKind::Vue {
700 merge_vue_props_emits_into(input, &parser_return.program);
701 }
702
703 if input.kind == SfcKind::Svelte && is_template_visible_script(input.kind, input.script) {
708 merge_svelte_props_into(
709 input.combined,
710 &parser_return.program,
711 input.script.byte_offset,
712 );
713 }
714
715 if is_template_visible_script(input.kind, input.script) {
716 input.template_visible_imports.extend(
717 extractor
718 .imports
719 .iter()
720 .filter(|import| !import.local_name.is_empty())
721 .map(|import| import.local_name.clone()),
722 );
723 input.template_visible_bound_targets.extend(
724 extractor
725 .binding_target_names()
726 .iter()
727 .filter(|(local, _)| !local.starts_with("this."))
728 .map(|(local, target)| (local.clone(), target.clone())),
729 );
730 }
731
732 let dispatch_base = input.combined.svelte_dispatched_events.len();
737 extractor.merge_into(input.combined);
738 for event in &mut input.combined.svelte_dispatched_events[dispatch_base..] {
739 event.span_start += input.script.byte_offset as u32;
740 }
741}
742
743fn merge_svelte_props_into(
747 combined: &mut ModuleInfo,
748 program: &oxc_ast::ast::Program<'_>,
749 byte_offset: usize,
750) {
751 let harvest = crate::sfc_props::harvest_svelte_props(program);
752 if harvest.has_unharvestable_props {
753 combined.has_unharvestable_props = true;
754 }
755 if harvest.has_props_attrs_fallthrough {
756 combined.has_props_attrs_fallthrough = true;
757 }
758 for mut prop in harvest.props {
759 prop.span_start += byte_offset as u32;
760 combined.component_props.push(prop);
761 }
762}
763
764fn merge_vue_props_emits_into(
771 input: &mut SfcScriptMergeInput<'_>,
772 program: &oxc_ast::ast::Program<'_>,
773) {
774 let byte_offset = input.script.byte_offset as u32;
775 if input.script.is_setup {
776 let harvest = crate::sfc_props::harvest_define_props(program);
777 if harvest.has_unharvestable_props {
778 input.combined.has_unharvestable_props = true;
779 }
780 if harvest.has_props_attrs_fallthrough {
781 input.combined.has_props_attrs_fallthrough = true;
782 }
783 if harvest.has_define_expose {
784 input.combined.has_define_expose = true;
785 }
786 if harvest.has_define_model {
787 input.combined.has_define_model = true;
788 }
789 if let Some(binding) = harvest.props_return_binding {
790 *input.props_return_binding = Some(binding);
791 }
792 for mut prop in harvest.props {
793 prop.span_start += byte_offset;
794 input.combined.component_props.push(prop);
795 }
796
797 let emit_harvest = crate::sfc_props::harvest_define_emits(program);
798 if emit_harvest.has_unharvestable_emits {
799 input.combined.has_unharvestable_emits = true;
800 }
801 if emit_harvest.has_dynamic_emit {
802 input.combined.has_dynamic_emit = true;
803 }
804 if emit_harvest.has_emit_whole_object_use {
805 input.combined.has_emit_whole_object_use = true;
806 }
807 if let Some(binding) = emit_harvest.emit_binding {
808 *input.emit_return_binding = Some(binding);
809 }
810 for mut emit in emit_harvest.emits {
811 emit.span_start += byte_offset;
812 input.combined.component_emits.push(emit);
813 }
814 } else {
815 let harvest = crate::sfc_props::harvest_options_api_props(program);
816 if harvest.has_unharvestable_props {
817 input.combined.has_unharvestable_props = true;
818 }
819 if harvest.has_props_attrs_fallthrough {
820 input.combined.has_props_attrs_fallthrough = true;
821 }
822 for mut prop in harvest.props {
823 prop.span_start += byte_offset;
824 input.combined.component_props.push(prop);
825 }
826
827 let emit_harvest = crate::sfc_props::harvest_options_api_emits(program);
828 if emit_harvest.has_unharvestable_emits {
829 input.combined.has_unharvestable_emits = true;
830 }
831 if emit_harvest.has_dynamic_emit {
832 input.combined.has_dynamic_emit = true;
833 }
834 for mut emit in emit_harvest.emits {
835 emit.span_start += byte_offset;
836 input.combined.component_emits.push(emit);
837 }
838 }
839}
840
841fn translate_script_complexity(
842 script: &SfcScript,
843 program: &oxc_ast::ast::Program<'_>,
844 sfc_line_offsets: &[u32],
845) -> Vec<FunctionComplexity> {
846 let script_line_offsets = compute_line_offsets(&script.body);
847 let mut complexity =
848 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
849 let (body_start_line, body_start_col) =
850 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
851
852 for function in &mut complexity {
853 function.line = body_start_line + function.line.saturating_sub(1);
854 if function.line == body_start_line {
855 function.col += body_start_col;
856 }
857 }
858
859 complexity
860}
861
862fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
863 let span = source_span.unwrap_or_default();
864 module.imports.push(ImportInfo {
865 source: normalize_asset_url(source),
866 imported_name: ImportedName::SideEffect,
867 local_name: String::new(),
868 is_type_only: false,
869 from_style: false,
870 span,
871 source_span: span,
872 });
873}
874
875fn style_lang_is_scss(lang: Option<&str>) -> bool {
881 matches!(lang, Some("scss" | "sass"))
882}
883
884fn style_lang_is_css_like(lang: Option<&str>) -> bool {
885 lang.is_none() || matches!(lang, Some("css"))
886}
887
888fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
889 if let Some(src) = &style.src {
890 let span = style.src_span.unwrap_or_default();
891 combined.imports.push(ImportInfo {
892 source: normalize_asset_url(src),
893 imported_name: ImportedName::SideEffect,
894 local_name: String::new(),
895 is_type_only: false,
896 from_style: true,
897 span,
898 source_span: span,
899 });
900 }
901
902 let lang = style.lang.as_deref();
903 let is_scss = style_lang_is_scss(lang);
904 let is_css_like = style_lang_is_css_like(lang);
905 if !is_scss && !is_css_like {
906 return;
907 }
908
909 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
910 let source_span = Span::new(
911 style.byte_offset as u32 + source.span.start,
912 style.byte_offset as u32 + source.span.end,
913 );
914 combined.imports.push(ImportInfo {
915 source: source.normalized,
916 imported_name: if source.is_plugin {
917 ImportedName::Default
918 } else {
919 ImportedName::SideEffect
920 },
921 local_name: String::new(),
922 is_type_only: false,
923 from_style: true,
924 span: source_span,
925 source_span,
926 });
927 }
928}
929
930fn source_type_for_script(script: &SfcScript) -> SourceType {
931 match (script.is_typescript, script.is_jsx) {
932 (true, true) => SourceType::tsx(),
933 (true, false) => SourceType::ts(),
934 (false, true) => SourceType::jsx(),
935 (false, false) => SourceType::mjs(),
936 }
937}
938
939fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
945 let constraint = script.generic_attr.as_deref()?.trim();
946 if constraint.is_empty() {
947 return None;
948 }
949 Some(format!(
950 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
951 script.body, constraint,
952 ))
953}
954
955fn apply_template_usage(
956 kind: SfcKind,
957 source: &str,
958 template_visible_imports: &FxHashSet<String>,
959 template_visible_bound_targets: &FxHashMap<String, String>,
960 props_return_binding: Option<&str>,
961 credit_load_data: bool,
962 combined: &mut ModuleInfo,
963) {
964 let mut credited: FxHashSet<String> = template_visible_imports.clone();
971 if credit_load_data {
984 credited.insert("data".to_string());
985 if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
990 combined.has_load_data_whole_use = true;
991 }
992 }
993 if !combined.component_props.is_empty() {
994 for prop in &combined.component_props {
995 credited.insert(prop.name.clone());
998 credited.insert(prop.local.clone());
999 }
1000 credited.insert("$props".to_string());
1003 if let Some(binding) = props_return_binding {
1004 credited.insert(binding.to_string());
1005 }
1006 }
1007
1008 let template_usage = if credit_load_data && template_visible_bound_targets.contains_key("data")
1019 {
1020 let mut filtered = template_visible_bound_targets.clone();
1021 filtered.remove("data");
1022 collect_template_usage_with_bound_targets(kind, source, &credited, &filtered)
1023 } else {
1024 collect_template_usage_with_bound_targets(
1025 kind,
1026 source,
1027 &credited,
1028 template_visible_bound_targets,
1029 )
1030 };
1031
1032 if !combined.component_props.is_empty() {
1037 let member_used: FxHashSet<&str> = template_usage
1038 .member_accesses
1039 .iter()
1040 .filter(|access| {
1041 access.object == "$props"
1042 || props_return_binding.is_some_and(|binding| access.object == binding)
1043 })
1044 .map(|access| access.member.as_str())
1045 .collect();
1046 for prop in &mut combined.component_props {
1047 if template_usage.used_bindings.contains(&prop.name)
1048 || template_usage.used_bindings.contains(&prop.local)
1049 || member_used.contains(prop.name.as_str())
1050 {
1051 prop.used_in_template = true;
1052 }
1053 }
1054 }
1055
1056 if let Some(binding) = props_return_binding
1063 && (template_usage.used_bindings.contains(binding)
1064 || template_usage
1065 .whole_object_uses
1066 .iter()
1067 .any(|used| used == binding))
1068 {
1069 combined.has_props_attrs_fallthrough = true;
1070 }
1071
1072 combined
1073 .unused_import_bindings
1074 .retain(|binding| !template_usage.used_bindings.contains(binding));
1075 combined
1076 .member_accesses
1077 .extend(template_usage.member_accesses);
1078 combined
1079 .whole_object_uses
1080 .extend(template_usage.whole_object_uses);
1081 combined
1082 .security_sinks
1083 .extend(template_usage.security_sinks);
1084 if !template_usage.unresolved_tag_names.is_empty() {
1085 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
1086 names.sort_unstable();
1087 combined.auto_import_candidates.extend(names);
1088 combined.auto_import_candidates.dedup();
1089 }
1090}
1091
1092fn apply_template_emit_usage(
1110 source: &str,
1111 emit_return_binding: Option<&str>,
1112 combined: &mut ModuleInfo,
1113) {
1114 let masked = mask_non_markup_regions(source);
1115 let mut used: FxHashSet<String> = FxHashSet::default();
1116 let mut dynamic = false;
1117
1118 for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
1119 let Some(callee) = caps.get(1) else {
1120 continue;
1121 };
1122 let callee = callee.as_str();
1123 let is_emit_call =
1124 callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
1125 if !is_emit_call {
1126 continue;
1127 }
1128 if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
1129 used.insert(event.as_str().to_string());
1132 } else if caps.get(4).is_some() {
1133 dynamic = true;
1136 }
1137 }
1138
1139 if dynamic {
1140 combined.has_dynamic_emit = true;
1141 }
1142 if !used.is_empty() {
1143 for emit in &mut combined.component_emits {
1144 if used.contains(&emit.name) {
1145 emit.used = true;
1146 }
1147 }
1148 }
1149}
1150
1151fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
1152 match kind {
1153 SfcKind::Vue => script.is_setup,
1154 SfcKind::Svelte => !script.is_context_module,
1155 }
1156}
1157
1158#[cfg(all(test, not(miri)))]
1159mod tests {
1160 use super::*;
1161
1162 #[test]
1163 fn is_sfc_file_vue() {
1164 assert!(is_sfc_file(Path::new("App.vue")));
1165 }
1166
1167 #[test]
1168 fn is_sfc_file_svelte() {
1169 assert!(is_sfc_file(Path::new("Counter.svelte")));
1170 }
1171
1172 #[test]
1173 fn is_sfc_file_rejects_ts() {
1174 assert!(!is_sfc_file(Path::new("utils.ts")));
1175 }
1176
1177 #[test]
1178 fn is_sfc_file_rejects_jsx() {
1179 assert!(!is_sfc_file(Path::new("App.jsx")));
1180 }
1181
1182 #[test]
1183 fn is_sfc_file_rejects_astro() {
1184 assert!(!is_sfc_file(Path::new("Layout.astro")));
1185 }
1186
1187 #[test]
1188 fn single_plain_script() {
1189 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1190 assert_eq!(scripts.len(), 1);
1191 assert_eq!(scripts[0].body, "const x = 1;");
1192 assert!(!scripts[0].is_typescript);
1193 assert!(!scripts[0].is_jsx);
1194 assert!(scripts[0].src.is_none());
1195 }
1196
1197 #[test]
1198 fn single_ts_script() {
1199 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1200 assert_eq!(scripts.len(), 1);
1201 assert!(scripts[0].is_typescript);
1202 assert!(!scripts[0].is_jsx);
1203 }
1204
1205 #[test]
1206 fn single_tsx_script() {
1207 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1208 assert_eq!(scripts.len(), 1);
1209 assert!(scripts[0].is_typescript);
1210 assert!(scripts[0].is_jsx);
1211 }
1212
1213 #[test]
1214 fn single_jsx_script() {
1215 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1216 assert_eq!(scripts.len(), 1);
1217 assert!(!scripts[0].is_typescript);
1218 assert!(scripts[0].is_jsx);
1219 }
1220
1221 #[test]
1222 fn two_script_blocks() {
1223 let source = r#"
1224<script lang="ts">
1225export default {};
1226</script>
1227<script setup lang="ts">
1228const count = 0;
1229</script>
1230"#;
1231 let scripts = extract_sfc_scripts(source);
1232 assert_eq!(scripts.len(), 2);
1233 assert!(scripts[0].body.contains("export default"));
1234 assert!(scripts[1].body.contains("count"));
1235 }
1236
1237 #[test]
1238 fn script_setup_extracted() {
1239 let scripts =
1240 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1241 assert_eq!(scripts.len(), 1);
1242 assert!(scripts[0].body.contains("import"));
1243 assert!(scripts[0].is_typescript);
1244 }
1245
1246 #[test]
1247 fn script_src_detected() {
1248 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1249 assert_eq!(scripts.len(), 1);
1250 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1251 }
1252
1253 #[test]
1256 fn svelte4_context_module_is_module_context() {
1257 let scripts =
1258 extract_sfc_scripts(r#"<script context="module">export const x = 1;</script>"#);
1259 assert_eq!(scripts.len(), 1);
1260 assert!(scripts[0].is_context_module);
1261 }
1262
1263 #[test]
1264 fn svelte5_bare_module_attr_is_module_context() {
1265 let scripts = extract_sfc_scripts(r"<script module>export const x = 1;</script>");
1266 assert_eq!(scripts.len(), 1);
1267 assert!(scripts[0].is_context_module);
1268 }
1269
1270 #[test]
1271 fn svelte5_module_with_lang_is_module_context() {
1272 let scripts =
1273 extract_sfc_scripts(r#"<script module lang="ts">export const x = 1;</script>"#);
1274 assert_eq!(scripts.len(), 1);
1275 assert!(scripts[0].is_context_module);
1276 assert!(scripts[0].is_typescript);
1277 }
1278
1279 #[test]
1280 fn plain_script_is_not_module_context() {
1281 let scripts = extract_sfc_scripts(r"<script>const x = 1;</script>");
1282 assert_eq!(scripts.len(), 1);
1283 assert!(!scripts[0].is_context_module);
1284 }
1285
1286 #[test]
1287 fn lang_ts_script_is_not_module_context() {
1288 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x = 1;</script>"#);
1289 assert_eq!(scripts.len(), 1);
1290 assert!(!scripts[0].is_context_module);
1291 }
1292
1293 #[test]
1294 fn data_module_attr_is_not_module_context() {
1295 let scripts =
1297 extract_sfc_scripts(r#"<script data-module="x" lang="ts">const x = 1;</script>"#);
1298 assert_eq!(scripts.len(), 1);
1299 assert!(!scripts[0].is_context_module);
1300 }
1301
1302 #[test]
1303 fn bare_module_script_is_not_template_visible() {
1304 let module_script = SfcScript {
1307 body: String::new(),
1308 is_typescript: false,
1309 is_jsx: false,
1310 byte_offset: 0,
1311 src: None,
1312 src_span: None,
1313 is_setup: false,
1314 is_context_module: true,
1315 generic_attr: None,
1316 };
1317 assert!(!is_template_visible_script(SfcKind::Svelte, &module_script));
1318 let instance_script = SfcScript {
1319 is_context_module: false,
1320 ..module_script
1321 };
1322 assert!(is_template_visible_script(
1323 SfcKind::Svelte,
1324 &instance_script
1325 ));
1326 }
1327
1328 #[test]
1329 fn data_src_not_treated_as_src() {
1330 let scripts =
1331 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1332 assert_eq!(scripts.len(), 1);
1333 assert!(scripts[0].src.is_none());
1334 }
1335
1336 #[test]
1337 fn script_inside_html_comment_filtered() {
1338 let source = r#"
1339<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1340<script lang="ts">import { good } from 'good';</script>
1341"#;
1342 let scripts = extract_sfc_scripts(source);
1343 assert_eq!(scripts.len(), 1);
1344 assert!(scripts[0].body.contains("good"));
1345 }
1346
1347 #[test]
1348 fn spanning_comment_filters_script() {
1349 let source = r#"
1350<!-- disabled:
1351<script lang="ts">import { bad } from 'bad';</script>
1352-->
1353<script lang="ts">const ok = true;</script>
1354"#;
1355 let scripts = extract_sfc_scripts(source);
1356 assert_eq!(scripts.len(), 1);
1357 assert!(scripts[0].body.contains("ok"));
1358 }
1359
1360 #[test]
1361 fn string_containing_comment_markers_not_corrupted() {
1362 let source = r#"
1363<script setup lang="ts">
1364const marker = "<!-- not a comment -->";
1365import { ref } from 'vue';
1366</script>
1367"#;
1368 let scripts = extract_sfc_scripts(source);
1369 assert_eq!(scripts.len(), 1);
1370 assert!(scripts[0].body.contains("import"));
1371 }
1372
1373 #[test]
1374 fn generic_attr_with_angle_bracket() {
1375 let source =
1376 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1377 let scripts = extract_sfc_scripts(source);
1378 assert_eq!(scripts.len(), 1);
1379 assert_eq!(scripts[0].body, "const x = 1;");
1380 }
1381
1382 #[test]
1383 fn nested_generic_attr() {
1384 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1385 let scripts = extract_sfc_scripts(source);
1386 assert_eq!(scripts.len(), 1);
1387 assert_eq!(scripts[0].body, "const x = 1;");
1388 }
1389
1390 #[test]
1391 fn lang_single_quoted() {
1392 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1393 assert_eq!(scripts.len(), 1);
1394 assert!(scripts[0].is_typescript);
1395 }
1396
1397 #[test]
1398 fn uppercase_script_tag() {
1399 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1400 assert_eq!(scripts.len(), 1);
1401 assert!(scripts[0].is_typescript);
1402 }
1403
1404 #[test]
1405 fn no_script_block() {
1406 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1407 assert!(scripts.is_empty());
1408 }
1409
1410 #[test]
1411 fn empty_script_body() {
1412 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1413 assert_eq!(scripts.len(), 1);
1414 assert!(scripts[0].body.is_empty());
1415 }
1416
1417 #[test]
1418 fn whitespace_only_script() {
1419 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
1420 assert_eq!(scripts.len(), 1);
1421 assert!(scripts[0].body.trim().is_empty());
1422 }
1423
1424 #[test]
1425 fn byte_offset_is_set() {
1426 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1427 let scripts = extract_sfc_scripts(source);
1428 assert_eq!(scripts.len(), 1);
1429 let offset = scripts[0].byte_offset;
1430 assert_eq!(&source[offset..offset + 4], "code");
1431 }
1432
1433 #[test]
1434 fn script_with_extra_attributes() {
1435 let scripts = extract_sfc_scripts(
1436 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1437 );
1438 assert_eq!(scripts.len(), 1);
1439 assert!(scripts[0].is_typescript);
1440 assert!(scripts[0].src.is_none());
1441 }
1442
1443 #[test]
1444 fn multiple_script_blocks_exports_combined() {
1445 let source = r#"
1446<script lang="ts">
1447export const version = '1.0';
1448</script>
1449<script setup lang="ts">
1450import { ref } from 'vue';
1451const count = ref(0);
1452</script>
1453"#;
1454 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1455 assert!(
1456 info.exports
1457 .iter()
1458 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1459 "export from <script> block should be extracted"
1460 );
1461 assert!(
1462 info.imports.iter().any(|i| i.source == "vue"),
1463 "import from <script setup> block should be extracted"
1464 );
1465 }
1466
1467 #[test]
1468 fn lang_tsx_detected_as_typescript_jsx() {
1469 let scripts =
1470 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1471 assert_eq!(scripts.len(), 1);
1472 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1473 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1474 }
1475
1476 #[test]
1477 fn multiline_html_comment_filters_all_script_blocks_inside() {
1478 let source = r#"
1479<!--
1480 This whole section is disabled:
1481 <script lang="ts">import { bad1 } from 'bad1';</script>
1482 <script lang="ts">import { bad2 } from 'bad2';</script>
1483-->
1484<script lang="ts">import { good } from 'good';</script>
1485"#;
1486 let scripts = extract_sfc_scripts(source);
1487 assert_eq!(scripts.len(), 1);
1488 assert!(scripts[0].body.contains("good"));
1489 }
1490
1491 #[test]
1492 fn script_src_generates_side_effect_import() {
1493 let info = parse_sfc_to_module(
1494 FileId(0),
1495 Path::new("External.vue"),
1496 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1497 0,
1498 false,
1499 );
1500 assert!(
1501 info.imports
1502 .iter()
1503 .any(|i| i.source == "./external-logic.ts"
1504 && matches!(i.imported_name, ImportedName::SideEffect)),
1505 "script src should generate a side-effect import"
1506 );
1507 }
1508
1509 #[test]
1510 fn parse_sfc_no_script_returns_empty_module() {
1511 let info = parse_sfc_to_module(
1512 FileId(0),
1513 Path::new("Empty.vue"),
1514 "<template><div>Hello</div></template>",
1515 42,
1516 false,
1517 );
1518 assert!(info.imports.is_empty());
1519 assert!(info.exports.is_empty());
1520 assert_eq!(info.content_hash, 42);
1521 assert_eq!(info.file_id, FileId(0));
1522 }
1523
1524 #[test]
1525 fn parse_sfc_has_line_offsets() {
1526 let info = parse_sfc_to_module(
1527 FileId(0),
1528 Path::new("LineOffsets.vue"),
1529 r#"<script lang="ts">const x = 1;</script>"#,
1530 0,
1531 false,
1532 );
1533 assert!(!info.line_offsets.is_empty());
1534 }
1535
1536 #[test]
1537 fn parse_sfc_has_suppressions() {
1538 let info = parse_sfc_to_module(
1539 FileId(0),
1540 Path::new("Suppressions.vue"),
1541 r#"<script lang="ts">
1542// fallow-ignore-file
1543export const foo = 1;
1544</script>"#,
1545 0,
1546 false,
1547 );
1548 assert!(!info.suppressions.is_empty());
1549 }
1550
1551 #[test]
1552 fn source_type_jsx_detection() {
1553 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1554 assert_eq!(scripts.len(), 1);
1555 assert!(!scripts[0].is_typescript);
1556 assert!(scripts[0].is_jsx);
1557 }
1558
1559 #[test]
1560 fn source_type_plain_js_detection() {
1561 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1562 assert_eq!(scripts.len(), 1);
1563 assert!(!scripts[0].is_typescript);
1564 assert!(!scripts[0].is_jsx);
1565 }
1566
1567 #[test]
1568 fn is_sfc_file_rejects_no_extension() {
1569 assert!(!is_sfc_file(Path::new("Makefile")));
1570 }
1571
1572 #[test]
1573 fn is_sfc_file_rejects_mdx() {
1574 assert!(!is_sfc_file(Path::new("post.mdx")));
1575 }
1576
1577 #[test]
1578 fn is_sfc_file_rejects_css() {
1579 assert!(!is_sfc_file(Path::new("styles.css")));
1580 }
1581
1582 #[test]
1583 fn multiple_script_blocks_both_have_offsets() {
1584 let source = r#"<script lang="ts">const a = 1;</script>
1585<script setup lang="ts">const b = 2;</script>"#;
1586 let scripts = extract_sfc_scripts(source);
1587 assert_eq!(scripts.len(), 2);
1588 let offset0 = scripts[0].byte_offset;
1589 let offset1 = scripts[1].byte_offset;
1590 assert_eq!(
1591 &source[offset0..offset0 + "const a = 1;".len()],
1592 "const a = 1;"
1593 );
1594 assert_eq!(
1595 &source[offset1..offset1 + "const b = 2;".len()],
1596 "const b = 2;"
1597 );
1598 }
1599
1600 #[test]
1601 fn script_with_src_and_lang() {
1602 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1603 assert_eq!(scripts.len(), 1);
1604 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1605 assert!(scripts[0].is_typescript);
1606 assert!(scripts[0].is_jsx);
1607 }
1608
1609 #[test]
1610 fn extract_style_block_lang_scss() {
1611 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1612 let styles = extract_sfc_styles(source);
1613 assert_eq!(styles.len(), 1);
1614 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1615 assert!(styles[0].body.contains("@import"));
1616 assert!(styles[0].src.is_none());
1617 }
1618
1619 #[test]
1620 fn extract_style_block_with_src() {
1621 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1622 let styles = extract_sfc_styles(source);
1623 assert_eq!(styles.len(), 1);
1624 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1625 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1626 }
1627
1628 #[test]
1629 fn extract_style_block_plain_no_lang() {
1630 let source = r"<style>.foo { color: red; }</style>";
1631 let styles = extract_sfc_styles(source);
1632 assert_eq!(styles.len(), 1);
1633 assert!(styles[0].lang.is_none());
1634 }
1635
1636 #[test]
1637 fn extract_multiple_style_blocks() {
1638 let source = r#"<style lang="scss">@import 'a';</style>
1639<style scoped lang="scss">@import 'b';</style>"#;
1640 let styles = extract_sfc_styles(source);
1641 assert_eq!(styles.len(), 2);
1642 }
1643
1644 #[test]
1645 fn style_block_inside_html_comment_filtered() {
1646 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1647<style lang="scss">@import 'good';</style>"#;
1648 let styles = extract_sfc_styles(source);
1649 assert_eq!(styles.len(), 1);
1650 assert!(styles[0].body.contains("good"));
1651 }
1652
1653 #[test]
1654 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1655 let info = parse_sfc_to_module(
1656 FileId(0),
1657 Path::new("Foo.vue"),
1658 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1659 0,
1660 false,
1661 );
1662 let style_import = info
1663 .imports
1664 .iter()
1665 .find(|i| i.source == "./Foo")
1666 .expect("scss @import 'Foo' should be normalized to ./Foo");
1667 assert!(
1668 style_import.from_style,
1669 "imports from <style> blocks must carry from_style=true so the resolver \
1670 enables SCSS partial fallback for the SFC importer"
1671 );
1672 assert!(matches!(
1673 style_import.imported_name,
1674 ImportedName::SideEffect
1675 ));
1676 }
1677
1678 #[test]
1679 fn parse_sfc_extracts_style_plugin_as_default_import() {
1680 let info = parse_sfc_to_module(
1681 FileId(0),
1682 Path::new("Foo.vue"),
1683 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1684 0,
1685 false,
1686 );
1687 let plugin_import = info
1688 .imports
1689 .iter()
1690 .find(|i| i.source == "./tailwind-plugin.js")
1691 .expect("style @plugin should create an import");
1692 assert!(plugin_import.from_style);
1693 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1694 }
1695
1696 #[test]
1697 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1698 let info = parse_sfc_to_module(
1699 FileId(0),
1700 Path::new("Bar.vue"),
1701 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1702 0,
1703 false,
1704 );
1705 let style_src = info
1706 .imports
1707 .iter()
1708 .find(|i| i.source == "./Bar.scss")
1709 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1710 assert!(style_src.from_style);
1711 }
1712
1713 #[test]
1714 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1715 let info = parse_sfc_to_module(
1716 FileId(0),
1717 Path::new("Baz.vue"),
1718 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1719 0,
1720 false,
1721 );
1722 assert!(
1723 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1724 "src reference should still be seeded for unsupported lang"
1725 );
1726 assert!(
1727 !info.imports.iter().any(|i| i.source.contains("skipped")),
1728 "postcss body should not be scanned for @import directives"
1729 );
1730 }
1731
1732 fn asset_refs(source: &str) -> Vec<String> {
1733 super::collect_template_asset_refs(source)
1734 .into_iter()
1735 .map(|(s, _)| s)
1736 .collect()
1737 }
1738
1739 #[test]
1740 fn captures_static_relative_template_asset_refs() {
1741 assert_eq!(
1742 asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1743 vec!["./logo.png".to_string()]
1744 );
1745 assert_eq!(
1746 asset_refs(r#"<source src="../media/clip.mp4">"#),
1747 vec!["../media/clip.mp4".to_string()]
1748 );
1749 assert_eq!(
1750 asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1751 vec!["./thumb.jpg".to_string()]
1752 );
1753 }
1754
1755 #[test]
1756 fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1757 assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1759 assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1760 assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1761 assert!(asset_refs(r"<img src={logo} />").is_empty());
1762 assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1763 assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1765 assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1766 assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1767 assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1769 assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1771 }
1772
1773 #[test]
1774 fn skips_custom_component_src_prop() {
1775 assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1777 assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1778 }
1779
1780 #[test]
1781 fn skips_asset_refs_inside_script_style_and_comments() {
1782 assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1784 assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1785 assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1786 }
1787
1788 #[test]
1789 fn parse_sfc_emits_template_asset_as_side_effect_import() {
1790 let info = parse_sfc_to_module(
1791 FileId(0),
1792 Path::new("Hero.vue"),
1793 r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1794 0,
1795 false,
1796 );
1797 assert!(
1798 info.imports.iter().any(|i| i.source == "./hero.png"
1799 && matches!(i.imported_name, ImportedName::SideEffect)
1800 && !i.from_style),
1801 "template <img src> should seed a SideEffect import: {:?}",
1802 info.imports
1803 );
1804 }
1805
1806 fn svelte_props(source: &str) -> Vec<crate::ModuleInfo> {
1809 vec![parse_sfc_to_module(
1810 FileId(0),
1811 Path::new("Component.svelte"),
1812 source,
1813 0,
1814 false,
1815 )]
1816 }
1817
1818 fn prop_names(info: &crate::ModuleInfo) -> Vec<String> {
1819 let mut names: Vec<String> = info
1820 .component_props
1821 .iter()
1822 .map(|p| p.name.clone())
1823 .collect();
1824 names.sort();
1825 names
1826 }
1827
1828 #[test]
1829 fn svelte_shorthand_props_harvested() {
1830 let info = &svelte_props(r"<script>let { a, b } = $props();</script>")[0];
1832 assert_eq!(prop_names(info), vec!["a", "b"]);
1833 for prop in &info.component_props {
1834 assert_eq!(prop.local, prop.name);
1835 }
1836 }
1837
1838 #[test]
1839 fn svelte_renamed_prop_tracks_local_and_script_use() {
1840 let info =
1843 &svelte_props(r"<script>let { a: alias } = $props(); console.log(alias);</script>")[0];
1844 assert_eq!(prop_names(info), vec!["a"]);
1845 let prop = &info.component_props[0];
1846 assert_eq!(prop.local, "alias");
1847 assert!(
1848 prop.used_in_script,
1849 "alias is referenced, so a is used in script"
1850 );
1851 }
1852
1853 #[test]
1854 fn svelte_unreferenced_prop_is_unused_in_script() {
1855 let info = &svelte_props(r"<script>let { a } = $props();</script>")[0];
1856 assert_eq!(prop_names(info), vec!["a"]);
1857 assert!(!info.component_props[0].used_in_script);
1858 }
1859
1860 #[test]
1861 fn svelte_default_prop_peeled() {
1862 let info = &svelte_props(r"<script>let { a = 1 } = $props();</script>")[0];
1864 assert_eq!(prop_names(info), vec!["a"]);
1865 }
1866
1867 #[test]
1868 fn svelte_bindable_default_peeled() {
1869 let info = &svelte_props(r"<script>let { a = $bindable() } = $props();</script>")[0];
1872 assert_eq!(prop_names(info), vec!["a"]);
1873 }
1874
1875 #[test]
1876 fn svelte_rest_element_sets_fallthrough_abstain() {
1877 let info = &svelte_props(r"<script>let { a, ...rest } = $props();</script>")[0];
1879 assert!(info.has_props_attrs_fallthrough);
1880 }
1881
1882 #[test]
1883 fn svelte_bare_identifier_binding_sets_unharvestable_abstain() {
1884 let info = &svelte_props(r"<script>let p = $props(); console.log(p.x);</script>")[0];
1886 assert!(info.has_unharvestable_props);
1887 assert!(info.component_props.is_empty());
1888 }
1889
1890 #[test]
1891 fn svelte_nested_destructure_sets_unharvestable_abstain() {
1892 let info = &svelte_props(r"<script>let { a: { x } } = $props();</script>")[0];
1894 assert!(info.has_unharvestable_props);
1895 }
1896
1897 #[test]
1898 fn svelte_prop_used_only_in_markup_credited_as_template_root() {
1899 let info = &svelte_props(r"<script>let { a } = $props();</script><p>{a}</p>")[0];
1902 assert_eq!(prop_names(info), vec!["a"]);
1903 assert!(
1904 info.component_props[0].used_in_template,
1905 "a is used in markup, so used_in_template should be true"
1906 );
1907 }
1908
1909 #[test]
1910 fn svelte_module_script_props_not_harvested() {
1911 let info = &svelte_props(
1913 r"<script module>let { a } = $props();</script><script>let { b } = $props();</script>",
1914 )[0];
1915 assert_eq!(prop_names(info), vec!["b"]);
1917 }
1918
1919 fn dispatched_names(info: &crate::ModuleInfo) -> Vec<String> {
1922 let mut names: Vec<String> = info
1923 .svelte_dispatched_events
1924 .iter()
1925 .map(|e| e.name.clone())
1926 .collect();
1927 names.sort();
1928 names
1929 }
1930
1931 #[test]
1932 fn svelte_dispatch_literal_event_is_harvested() {
1933 let info = &svelte_props(
1934 r"<script>import { createEventDispatcher } from 'svelte';
1935 const dispatch = createEventDispatcher();
1936 function save() { dispatch('save'); }</script>",
1937 )[0];
1938 assert_eq!(dispatched_names(info), vec!["save"]);
1939 assert!(!info.has_dynamic_dispatch);
1940 }
1941
1942 #[test]
1943 fn svelte_dispatch_without_svelte_import_is_ignored() {
1944 let info = &svelte_props(
1947 r"<script>function createEventDispatcher() { return () => {}; }
1948 const dispatch = createEventDispatcher();
1949 dispatch('save');</script>",
1950 )[0];
1951 assert!(info.svelte_dispatched_events.is_empty());
1952 }
1953
1954 #[test]
1955 fn svelte_dynamic_dispatch_sets_abstain() {
1956 let info = &svelte_props(
1957 r"<script>import { createEventDispatcher } from 'svelte';
1958 const dispatch = createEventDispatcher();
1959 function fire(name) { dispatch(name); }</script>",
1960 )[0];
1961 assert!(
1962 info.has_dynamic_dispatch,
1963 "a non-literal dispatch arg must set the abstain flag"
1964 );
1965 }
1966
1967 #[test]
1968 fn svelte_dispatch_whole_value_use_sets_abstain() {
1969 let info = &svelte_props(
1970 r"<script>import { createEventDispatcher } from 'svelte';
1971 const dispatch = createEventDispatcher();
1972 forward(dispatch);</script>",
1973 )[0];
1974 assert!(
1975 info.has_dynamic_dispatch,
1976 "passing the dispatch binding as a whole value must set the abstain flag"
1977 );
1978 }
1979
1980 #[test]
1981 fn svelte_listened_event_on_component_is_harvested() {
1982 let info =
1983 &svelte_props(r"<script>import Child from './Child.svelte';</script><Child on:save />")
1984 [0];
1985 assert!(info.svelte_listened_events.contains(&"save".to_string()));
1986 }
1987}