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 fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
287 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
288 .find_iter(source)
289 .map(|m| (m.start(), m.end()))
290 .collect();
291
292 STYLE_BLOCK_RE
293 .captures_iter(source)
294 .filter(|cap| {
295 let start = cap.get(0).map_or(0, |m| m.start());
296 !comment_ranges
297 .iter()
298 .any(|&(cs, ce)| start >= cs && start < ce)
299 })
300 .map(|cap| {
301 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
302 let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
303 let byte_offset = cap.name("body").map_or(0, |m| m.start());
304 let lang = LANG_ATTR_RE
305 .captures(attrs)
306 .and_then(|c| c.get(1))
307 .map(|m| m.as_str().to_string());
308 let src = SRC_ATTR_RE
309 .captures(attrs)
310 .and_then(|c| c.get(1))
311 .map(|m| m.as_str().to_string());
312 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
313 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
314 Span::new(
315 (attrs_start + m.start()) as u32,
316 (attrs_start + m.end()) as u32,
317 )
318 });
319 SfcStyle {
320 body,
321 lang,
322 src,
323 src_span,
324 byte_offset,
325 }
326 })
327 .collect()
328}
329
330#[must_use]
332pub fn is_sfc_file(path: &Path) -> bool {
333 path.extension()
334 .and_then(|e| e.to_str())
335 .is_some_and(|ext| ext == "vue" || ext == "svelte")
336}
337
338pub(crate) fn parse_sfc_to_module(
340 file_id: FileId,
341 path: &Path,
342 source: &str,
343 content_hash: u64,
344 need_complexity: bool,
345) -> ModuleInfo {
346 let scripts = extract_sfc_scripts(source);
347 let styles = extract_sfc_styles(source);
348 let kind = sfc_kind(path);
349 let mut combined = empty_sfc_module(file_id, source, content_hash);
350 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
351 let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
352 let mut props_return_binding: Option<String> = None;
353 let mut emit_return_binding: Option<String> = None;
354
355 for script in &scripts {
356 merge_script_into_module(&mut SfcScriptMergeInput {
357 kind,
358 script,
359 combined: &mut combined,
360 template_visible_imports: &mut template_visible_imports,
361 template_visible_bound_targets: &mut template_visible_bound_targets,
362 props_return_binding: &mut props_return_binding,
363 emit_return_binding: &mut emit_return_binding,
364 need_complexity,
365 });
366 }
367
368 for style in &styles {
369 merge_style_into_module(style, &mut combined);
370 }
371
372 if kind == SfcKind::Vue
376 && !combined.component_props.is_empty()
377 && PROPS_ATTRS_SPREAD_RE.is_match(source)
378 {
379 combined.has_props_attrs_fallthrough = true;
380 }
381
382 apply_template_usage(
383 kind,
384 source,
385 &template_visible_imports,
386 &template_visible_bound_targets,
387 props_return_binding.as_deref(),
388 kind == SfcKind::Svelte && is_sveltekit_route_data_component(path),
389 &mut combined,
390 );
391
392 if need_complexity {
400 let template_complexity = match kind {
401 SfcKind::Vue => crate::template_complexity::compute_vue_template_complexity(source),
402 SfcKind::Svelte => {
403 crate::template_complexity::compute_svelte_template_complexity(source)
404 }
405 };
406 combined.complexity.extend(template_complexity);
407 }
408
409 if kind == SfcKind::Vue && !combined.component_emits.is_empty() {
413 apply_template_emit_usage(source, emit_return_binding.as_deref(), &mut combined);
414 }
415
416 if kind == SfcKind::Svelte {
420 combined.svelte_listened_events =
421 crate::sfc_template::collect_svelte_listened_events(source);
422 }
423
424 for (specifier, span) in collect_template_asset_refs(source) {
428 combined.imports.push(ImportInfo {
429 source: specifier,
430 imported_name: ImportedName::SideEffect,
431 local_name: String::new(),
432 is_type_only: false,
433 from_style: false,
434 span,
435 source_span: span,
436 });
437 }
438
439 combined.unused_import_bindings.sort_unstable();
440 combined.unused_import_bindings.dedup();
441 combined.type_referenced_import_bindings.sort_unstable();
442 combined.type_referenced_import_bindings.dedup();
443 combined.value_referenced_import_bindings.sort_unstable();
444 combined.value_referenced_import_bindings.dedup();
445 combined.auto_import_candidates.sort_unstable();
446 combined.auto_import_candidates.dedup();
447
448 combined
449}
450
451fn sfc_kind(path: &Path) -> SfcKind {
452 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
453 SfcKind::Vue
454 } else {
455 SfcKind::Svelte
456 }
457}
458
459fn is_sveltekit_route_data_component(path: &Path) -> bool {
470 let Some(stem) = path
471 .file_name()
472 .and_then(|name| name.to_str())
473 .and_then(|name| name.strip_suffix(".svelte"))
474 else {
475 return false;
476 };
477 ["+page", "+layout"].iter().any(|prefix| {
478 stem.strip_prefix(prefix)
479 .is_some_and(|rest| rest.is_empty() || rest.starts_with('@'))
480 })
481}
482
483fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
484 let parsed = crate::suppress::parse_suppressions_from_source(source);
485
486 ModuleInfo {
487 file_id,
488 exports: Vec::new(),
489 imports: Vec::new(),
490 re_exports: Vec::new(),
491 dynamic_imports: Vec::new(),
492 dynamic_import_patterns: Vec::new(),
493 require_calls: Vec::new(),
494 package_path_references: Vec::new(),
495 member_accesses: Vec::new(),
496 whole_object_uses: Vec::new(),
497 has_cjs_exports: false,
498 has_angular_component_template_url: false,
499 content_hash,
500 suppressions: parsed.suppressions,
501 unknown_suppression_kinds: parsed.unknown_kinds,
502 unused_import_bindings: Vec::new(),
503 type_referenced_import_bindings: Vec::new(),
504 value_referenced_import_bindings: Vec::new(),
505 line_offsets: compute_line_offsets(source),
506 complexity: Vec::new(),
507 flag_uses: Vec::new(),
508 class_heritage: vec![],
509 injection_tokens: vec![],
510 local_type_declarations: Vec::new(),
511 public_signature_type_references: Vec::new(),
512 namespace_object_aliases: Vec::new(),
513 iconify_prefixes: Vec::new(),
514 iconify_icon_names: Vec::new(),
515 auto_import_candidates: Vec::new(),
516 directives: Vec::new(),
517 client_only_dynamic_import_spans: Vec::new(),
518 security_sinks: Vec::new(),
519 security_sinks_skipped: 0,
520 security_unresolved_callee_sites: Vec::new(),
521 tainted_bindings: Vec::new(),
522 sanitized_sink_args: Vec::new(),
523 security_control_sites: Vec::new(),
524 callee_uses: Vec::new(),
525 misplaced_directives: Vec::new(),
526 inline_server_action_exports: Vec::new(),
527 di_key_sites: Vec::new(),
528 has_dynamic_provide: false,
529 referenced_import_bindings: Vec::new(),
530 component_props: Vec::new(),
531 has_props_attrs_fallthrough: false,
532 has_define_expose: false,
533 has_define_model: false,
534 has_unharvestable_props: false,
535 component_emits: Vec::new(),
536 angular_inputs: Vec::new(),
537 angular_outputs: Vec::new(),
538 angular_component_selectors: Vec::new(),
539 angular_used_selectors: Vec::new(),
540 angular_entry_component_refs: Vec::new(),
541 has_dynamic_component_render: false,
542 has_unharvestable_emits: false,
543 has_dynamic_emit: false,
544 has_emit_whole_object_use: false,
545 load_return_keys: Vec::new(),
546 has_unharvestable_load: false,
547 has_load_data_whole_use: false,
548 has_page_data_store_whole_use: false,
549 component_functions: Vec::new(),
550 react_props: Vec::new(),
551 hook_uses: Vec::new(),
552 render_edges: Vec::new(),
553 svelte_dispatched_events: Vec::new(),
554 svelte_listened_events: Vec::new(),
555 has_dynamic_dispatch: false,
556 }
557}
558
559struct SfcScriptMergeInput<'a> {
560 kind: SfcKind,
561 script: &'a SfcScript,
562 combined: &'a mut ModuleInfo,
563 template_visible_imports: &'a mut FxHashSet<String>,
564 template_visible_bound_targets: &'a mut FxHashMap<String, String>,
565 props_return_binding: &'a mut Option<String>,
566 emit_return_binding: &'a mut Option<String>,
567 need_complexity: bool,
568}
569
570fn merge_script_into_module(input: &mut SfcScriptMergeInput<'_>) {
571 if input.kind == SfcKind::Vue
572 && let Some(src) = &input.script.src
573 {
574 add_script_src_import(input.combined, src, input.script.src_span);
575 }
576
577 let allocator = Allocator::default();
578 let parser_return = Parser::new(
579 &allocator,
580 &input.script.body,
581 source_type_for_script(input.script),
582 )
583 .parse();
584 let mut extractor = ModuleInfoExtractor::new();
585 extractor.visit_program(&parser_return.program);
586 let extraction = ExtractionResult::contiguous(&input.script.body, input.script.byte_offset);
587 extractor.remap_spans_with(|span| extraction.remap_span(span));
588 extractor.resolve_typed_destructure_bindings();
589
590 let augmented_body = build_generic_attr_probe_source(input.script);
591 let empty_template_used = rustc_hash::FxHashSet::default();
592 let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
593 {
594 let augmented_return =
595 Parser::new(&allocator, augmented, source_type_for_script(input.script)).parse();
596 (
597 compute_import_binding_usage(
598 &augmented_return.program,
599 &extractor.imports,
600 &empty_template_used,
601 ),
602 compute_auto_import_candidates(&parser_return.program),
603 )
604 } else {
605 let semantic_usage = compute_semantic_usage(
606 &parser_return.program,
607 &extractor.imports,
608 &empty_template_used,
609 );
610 (
611 semantic_usage.import_binding_usage,
612 semantic_usage.auto_import_candidates,
613 )
614 };
615 input
616 .combined
617 .unused_import_bindings
618 .extend(binding_usage.unused.iter().cloned());
619 input
620 .combined
621 .type_referenced_import_bindings
622 .extend(binding_usage.type_referenced.iter().cloned());
623 input
624 .combined
625 .value_referenced_import_bindings
626 .extend(binding_usage.value_referenced.iter().cloned());
627 input
628 .combined
629 .auto_import_candidates
630 .extend(auto_import_candidates);
631 if input.need_complexity {
632 input
633 .combined
634 .complexity
635 .extend(translate_script_complexity(
636 input.script,
637 &parser_return.program,
638 &input.combined.line_offsets,
639 ));
640 }
641
642 if input.kind == SfcKind::Vue {
646 merge_vue_props_emits_into(input, &parser_return.program);
647 }
648
649 if input.kind == SfcKind::Svelte && is_template_visible_script(input.kind, input.script) {
655 merge_svelte_props_into(
656 input.combined,
657 &parser_return.program,
658 input.script.byte_offset,
659 );
660 }
661
662 if is_template_visible_script(input.kind, input.script) {
663 input.template_visible_imports.extend(
664 extractor
665 .imports
666 .iter()
667 .filter(|import| !import.local_name.is_empty())
668 .map(|import| import.local_name.clone()),
669 );
670 input.template_visible_bound_targets.extend(
671 extractor
672 .binding_target_names()
673 .iter()
674 .filter(|(local, _)| !local.starts_with("this."))
675 .map(|(local, target)| (local.clone(), target.clone())),
676 );
677 }
678
679 let dispatch_base = input.combined.svelte_dispatched_events.len();
684 extractor.merge_into(input.combined);
685 for event in &mut input.combined.svelte_dispatched_events[dispatch_base..] {
686 event.span_start += input.script.byte_offset as u32;
687 }
688}
689
690fn merge_svelte_props_into(
694 combined: &mut ModuleInfo,
695 program: &oxc_ast::ast::Program<'_>,
696 byte_offset: usize,
697) {
698 let harvest = crate::sfc_props::harvest_svelte_props(program);
699 if harvest.has_unharvestable_props {
700 combined.has_unharvestable_props = true;
701 }
702 if harvest.has_props_attrs_fallthrough {
703 combined.has_props_attrs_fallthrough = true;
704 }
705 for mut prop in harvest.props {
706 prop.span_start += byte_offset as u32;
707 combined.component_props.push(prop);
708 }
709}
710
711fn merge_vue_props_emits_into(
718 input: &mut SfcScriptMergeInput<'_>,
719 program: &oxc_ast::ast::Program<'_>,
720) {
721 let byte_offset = input.script.byte_offset as u32;
722 if input.script.is_setup {
723 let harvest = crate::sfc_props::harvest_define_props(program);
724 if harvest.has_unharvestable_props {
725 input.combined.has_unharvestable_props = true;
726 }
727 if harvest.has_props_attrs_fallthrough {
728 input.combined.has_props_attrs_fallthrough = true;
729 }
730 if harvest.has_define_expose {
731 input.combined.has_define_expose = true;
732 }
733 if harvest.has_define_model {
734 input.combined.has_define_model = true;
735 }
736 if let Some(binding) = harvest.props_return_binding {
737 *input.props_return_binding = Some(binding);
738 }
739 for mut prop in harvest.props {
740 prop.span_start += byte_offset;
741 input.combined.component_props.push(prop);
742 }
743
744 let emit_harvest = crate::sfc_props::harvest_define_emits(program);
745 if emit_harvest.has_unharvestable_emits {
746 input.combined.has_unharvestable_emits = true;
747 }
748 if emit_harvest.has_dynamic_emit {
749 input.combined.has_dynamic_emit = true;
750 }
751 if emit_harvest.has_emit_whole_object_use {
752 input.combined.has_emit_whole_object_use = true;
753 }
754 if let Some(binding) = emit_harvest.emit_binding {
755 *input.emit_return_binding = Some(binding);
756 }
757 for mut emit in emit_harvest.emits {
758 emit.span_start += byte_offset;
759 input.combined.component_emits.push(emit);
760 }
761 } else {
762 let harvest = crate::sfc_props::harvest_options_api_props(program);
763 if harvest.has_unharvestable_props {
764 input.combined.has_unharvestable_props = true;
765 }
766 if harvest.has_props_attrs_fallthrough {
767 input.combined.has_props_attrs_fallthrough = true;
768 }
769 for mut prop in harvest.props {
770 prop.span_start += byte_offset;
771 input.combined.component_props.push(prop);
772 }
773
774 let emit_harvest = crate::sfc_props::harvest_options_api_emits(program);
775 if emit_harvest.has_unharvestable_emits {
776 input.combined.has_unharvestable_emits = true;
777 }
778 if emit_harvest.has_dynamic_emit {
779 input.combined.has_dynamic_emit = true;
780 }
781 for mut emit in emit_harvest.emits {
782 emit.span_start += byte_offset;
783 input.combined.component_emits.push(emit);
784 }
785 }
786}
787
788fn translate_script_complexity(
789 script: &SfcScript,
790 program: &oxc_ast::ast::Program<'_>,
791 sfc_line_offsets: &[u32],
792) -> Vec<FunctionComplexity> {
793 let script_line_offsets = compute_line_offsets(&script.body);
794 let mut complexity =
795 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
796 let (body_start_line, body_start_col) =
797 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
798
799 for function in &mut complexity {
800 function.line = body_start_line + function.line.saturating_sub(1);
801 if function.line == body_start_line {
802 function.col += body_start_col;
803 }
804 }
805
806 complexity
807}
808
809fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
810 let span = source_span.unwrap_or_default();
811 module.imports.push(ImportInfo {
812 source: normalize_asset_url(source),
813 imported_name: ImportedName::SideEffect,
814 local_name: String::new(),
815 is_type_only: false,
816 from_style: false,
817 span,
818 source_span: span,
819 });
820}
821
822fn style_lang_is_scss(lang: Option<&str>) -> bool {
828 matches!(lang, Some("scss" | "sass"))
829}
830
831fn style_lang_is_css_like(lang: Option<&str>) -> bool {
832 lang.is_none() || matches!(lang, Some("css"))
833}
834
835fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
836 if let Some(src) = &style.src {
837 let span = style.src_span.unwrap_or_default();
838 combined.imports.push(ImportInfo {
839 source: normalize_asset_url(src),
840 imported_name: ImportedName::SideEffect,
841 local_name: String::new(),
842 is_type_only: false,
843 from_style: true,
844 span,
845 source_span: span,
846 });
847 }
848
849 let lang = style.lang.as_deref();
850 let is_scss = style_lang_is_scss(lang);
851 let is_css_like = style_lang_is_css_like(lang);
852 if !is_scss && !is_css_like {
853 return;
854 }
855
856 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
857 let source_span = Span::new(
858 style.byte_offset as u32 + source.span.start,
859 style.byte_offset as u32 + source.span.end,
860 );
861 combined.imports.push(ImportInfo {
862 source: source.normalized,
863 imported_name: if source.is_plugin {
864 ImportedName::Default
865 } else {
866 ImportedName::SideEffect
867 },
868 local_name: String::new(),
869 is_type_only: false,
870 from_style: true,
871 span: source_span,
872 source_span,
873 });
874 }
875}
876
877fn source_type_for_script(script: &SfcScript) -> SourceType {
878 match (script.is_typescript, script.is_jsx) {
879 (true, true) => SourceType::tsx(),
880 (true, false) => SourceType::ts(),
881 (false, true) => SourceType::jsx(),
882 (false, false) => SourceType::mjs(),
883 }
884}
885
886fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
892 let constraint = script.generic_attr.as_deref()?.trim();
893 if constraint.is_empty() {
894 return None;
895 }
896 Some(format!(
897 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
898 script.body, constraint,
899 ))
900}
901
902fn apply_template_usage(
903 kind: SfcKind,
904 source: &str,
905 template_visible_imports: &FxHashSet<String>,
906 template_visible_bound_targets: &FxHashMap<String, String>,
907 props_return_binding: Option<&str>,
908 credit_load_data: bool,
909 combined: &mut ModuleInfo,
910) {
911 let mut credited: FxHashSet<String> = template_visible_imports.clone();
918 if credit_load_data {
931 credited.insert("data".to_string());
932 if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
937 combined.has_load_data_whole_use = true;
938 }
939 }
940 if !combined.component_props.is_empty() {
941 for prop in &combined.component_props {
942 credited.insert(prop.name.clone());
945 credited.insert(prop.local.clone());
946 }
947 credited.insert("$props".to_string());
950 if let Some(binding) = props_return_binding {
951 credited.insert(binding.to_string());
952 }
953 }
954
955 let template_usage = if credit_load_data && template_visible_bound_targets.contains_key("data")
966 {
967 let mut filtered = template_visible_bound_targets.clone();
968 filtered.remove("data");
969 collect_template_usage_with_bound_targets(kind, source, &credited, &filtered)
970 } else {
971 collect_template_usage_with_bound_targets(
972 kind,
973 source,
974 &credited,
975 template_visible_bound_targets,
976 )
977 };
978
979 if !combined.component_props.is_empty() {
984 let member_used: FxHashSet<&str> = template_usage
985 .member_accesses
986 .iter()
987 .filter(|access| {
988 access.object == "$props"
989 || props_return_binding.is_some_and(|binding| access.object == binding)
990 })
991 .map(|access| access.member.as_str())
992 .collect();
993 for prop in &mut combined.component_props {
994 if template_usage.used_bindings.contains(&prop.name)
995 || template_usage.used_bindings.contains(&prop.local)
996 || member_used.contains(prop.name.as_str())
997 {
998 prop.used_in_template = true;
999 }
1000 }
1001 }
1002
1003 if let Some(binding) = props_return_binding
1010 && (template_usage.used_bindings.contains(binding)
1011 || template_usage
1012 .whole_object_uses
1013 .iter()
1014 .any(|used| used == binding))
1015 {
1016 combined.has_props_attrs_fallthrough = true;
1017 }
1018
1019 combined
1020 .unused_import_bindings
1021 .retain(|binding| !template_usage.used_bindings.contains(binding));
1022 combined
1023 .member_accesses
1024 .extend(template_usage.member_accesses);
1025 combined
1026 .whole_object_uses
1027 .extend(template_usage.whole_object_uses);
1028 combined
1029 .security_sinks
1030 .extend(template_usage.security_sinks);
1031 if !template_usage.unresolved_tag_names.is_empty() {
1032 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
1033 names.sort_unstable();
1034 combined.auto_import_candidates.extend(names);
1035 combined.auto_import_candidates.dedup();
1036 }
1037}
1038
1039fn apply_template_emit_usage(
1057 source: &str,
1058 emit_return_binding: Option<&str>,
1059 combined: &mut ModuleInfo,
1060) {
1061 let masked = mask_non_markup_regions(source);
1062 let mut used: FxHashSet<String> = FxHashSet::default();
1063 let mut dynamic = false;
1064
1065 for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
1066 let Some(callee) = caps.get(1) else {
1067 continue;
1068 };
1069 let callee = callee.as_str();
1070 let is_emit_call =
1071 callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
1072 if !is_emit_call {
1073 continue;
1074 }
1075 if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
1076 used.insert(event.as_str().to_string());
1079 } else if caps.get(4).is_some() {
1080 dynamic = true;
1083 }
1084 }
1085
1086 if dynamic {
1087 combined.has_dynamic_emit = true;
1088 }
1089 if !used.is_empty() {
1090 for emit in &mut combined.component_emits {
1091 if used.contains(&emit.name) {
1092 emit.used = true;
1093 }
1094 }
1095 }
1096}
1097
1098fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
1099 match kind {
1100 SfcKind::Vue => script.is_setup,
1101 SfcKind::Svelte => !script.is_context_module,
1102 }
1103}
1104
1105#[cfg(all(test, not(miri)))]
1106mod tests {
1107 use super::*;
1108
1109 #[test]
1110 fn is_sfc_file_vue() {
1111 assert!(is_sfc_file(Path::new("App.vue")));
1112 }
1113
1114 #[test]
1115 fn is_sfc_file_svelte() {
1116 assert!(is_sfc_file(Path::new("Counter.svelte")));
1117 }
1118
1119 #[test]
1120 fn is_sfc_file_rejects_ts() {
1121 assert!(!is_sfc_file(Path::new("utils.ts")));
1122 }
1123
1124 #[test]
1125 fn is_sfc_file_rejects_jsx() {
1126 assert!(!is_sfc_file(Path::new("App.jsx")));
1127 }
1128
1129 #[test]
1130 fn is_sfc_file_rejects_astro() {
1131 assert!(!is_sfc_file(Path::new("Layout.astro")));
1132 }
1133
1134 #[test]
1135 fn single_plain_script() {
1136 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1137 assert_eq!(scripts.len(), 1);
1138 assert_eq!(scripts[0].body, "const x = 1;");
1139 assert!(!scripts[0].is_typescript);
1140 assert!(!scripts[0].is_jsx);
1141 assert!(scripts[0].src.is_none());
1142 }
1143
1144 #[test]
1145 fn single_ts_script() {
1146 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1147 assert_eq!(scripts.len(), 1);
1148 assert!(scripts[0].is_typescript);
1149 assert!(!scripts[0].is_jsx);
1150 }
1151
1152 #[test]
1153 fn single_tsx_script() {
1154 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1155 assert_eq!(scripts.len(), 1);
1156 assert!(scripts[0].is_typescript);
1157 assert!(scripts[0].is_jsx);
1158 }
1159
1160 #[test]
1161 fn single_jsx_script() {
1162 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1163 assert_eq!(scripts.len(), 1);
1164 assert!(!scripts[0].is_typescript);
1165 assert!(scripts[0].is_jsx);
1166 }
1167
1168 #[test]
1169 fn two_script_blocks() {
1170 let source = r#"
1171<script lang="ts">
1172export default {};
1173</script>
1174<script setup lang="ts">
1175const count = 0;
1176</script>
1177"#;
1178 let scripts = extract_sfc_scripts(source);
1179 assert_eq!(scripts.len(), 2);
1180 assert!(scripts[0].body.contains("export default"));
1181 assert!(scripts[1].body.contains("count"));
1182 }
1183
1184 #[test]
1185 fn script_setup_extracted() {
1186 let scripts =
1187 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1188 assert_eq!(scripts.len(), 1);
1189 assert!(scripts[0].body.contains("import"));
1190 assert!(scripts[0].is_typescript);
1191 }
1192
1193 #[test]
1194 fn script_src_detected() {
1195 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1196 assert_eq!(scripts.len(), 1);
1197 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1198 }
1199
1200 #[test]
1203 fn svelte4_context_module_is_module_context() {
1204 let scripts =
1205 extract_sfc_scripts(r#"<script context="module">export const x = 1;</script>"#);
1206 assert_eq!(scripts.len(), 1);
1207 assert!(scripts[0].is_context_module);
1208 }
1209
1210 #[test]
1211 fn svelte5_bare_module_attr_is_module_context() {
1212 let scripts = extract_sfc_scripts(r"<script module>export const x = 1;</script>");
1213 assert_eq!(scripts.len(), 1);
1214 assert!(scripts[0].is_context_module);
1215 }
1216
1217 #[test]
1218 fn svelte5_module_with_lang_is_module_context() {
1219 let scripts =
1220 extract_sfc_scripts(r#"<script module lang="ts">export const x = 1;</script>"#);
1221 assert_eq!(scripts.len(), 1);
1222 assert!(scripts[0].is_context_module);
1223 assert!(scripts[0].is_typescript);
1224 }
1225
1226 #[test]
1227 fn plain_script_is_not_module_context() {
1228 let scripts = extract_sfc_scripts(r"<script>const x = 1;</script>");
1229 assert_eq!(scripts.len(), 1);
1230 assert!(!scripts[0].is_context_module);
1231 }
1232
1233 #[test]
1234 fn lang_ts_script_is_not_module_context() {
1235 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x = 1;</script>"#);
1236 assert_eq!(scripts.len(), 1);
1237 assert!(!scripts[0].is_context_module);
1238 }
1239
1240 #[test]
1241 fn data_module_attr_is_not_module_context() {
1242 let scripts =
1244 extract_sfc_scripts(r#"<script data-module="x" lang="ts">const x = 1;</script>"#);
1245 assert_eq!(scripts.len(), 1);
1246 assert!(!scripts[0].is_context_module);
1247 }
1248
1249 #[test]
1250 fn bare_module_script_is_not_template_visible() {
1251 let module_script = SfcScript {
1254 body: String::new(),
1255 is_typescript: false,
1256 is_jsx: false,
1257 byte_offset: 0,
1258 src: None,
1259 src_span: None,
1260 is_setup: false,
1261 is_context_module: true,
1262 generic_attr: None,
1263 };
1264 assert!(!is_template_visible_script(SfcKind::Svelte, &module_script));
1265 let instance_script = SfcScript {
1266 is_context_module: false,
1267 ..module_script
1268 };
1269 assert!(is_template_visible_script(
1270 SfcKind::Svelte,
1271 &instance_script
1272 ));
1273 }
1274
1275 #[test]
1276 fn data_src_not_treated_as_src() {
1277 let scripts =
1278 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1279 assert_eq!(scripts.len(), 1);
1280 assert!(scripts[0].src.is_none());
1281 }
1282
1283 #[test]
1284 fn script_inside_html_comment_filtered() {
1285 let source = r#"
1286<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1287<script lang="ts">import { good } from 'good';</script>
1288"#;
1289 let scripts = extract_sfc_scripts(source);
1290 assert_eq!(scripts.len(), 1);
1291 assert!(scripts[0].body.contains("good"));
1292 }
1293
1294 #[test]
1295 fn spanning_comment_filters_script() {
1296 let source = r#"
1297<!-- disabled:
1298<script lang="ts">import { bad } from 'bad';</script>
1299-->
1300<script lang="ts">const ok = true;</script>
1301"#;
1302 let scripts = extract_sfc_scripts(source);
1303 assert_eq!(scripts.len(), 1);
1304 assert!(scripts[0].body.contains("ok"));
1305 }
1306
1307 #[test]
1308 fn string_containing_comment_markers_not_corrupted() {
1309 let source = r#"
1310<script setup lang="ts">
1311const marker = "<!-- not a comment -->";
1312import { ref } from 'vue';
1313</script>
1314"#;
1315 let scripts = extract_sfc_scripts(source);
1316 assert_eq!(scripts.len(), 1);
1317 assert!(scripts[0].body.contains("import"));
1318 }
1319
1320 #[test]
1321 fn generic_attr_with_angle_bracket() {
1322 let source =
1323 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1324 let scripts = extract_sfc_scripts(source);
1325 assert_eq!(scripts.len(), 1);
1326 assert_eq!(scripts[0].body, "const x = 1;");
1327 }
1328
1329 #[test]
1330 fn nested_generic_attr() {
1331 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1332 let scripts = extract_sfc_scripts(source);
1333 assert_eq!(scripts.len(), 1);
1334 assert_eq!(scripts[0].body, "const x = 1;");
1335 }
1336
1337 #[test]
1338 fn lang_single_quoted() {
1339 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1340 assert_eq!(scripts.len(), 1);
1341 assert!(scripts[0].is_typescript);
1342 }
1343
1344 #[test]
1345 fn uppercase_script_tag() {
1346 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1347 assert_eq!(scripts.len(), 1);
1348 assert!(scripts[0].is_typescript);
1349 }
1350
1351 #[test]
1352 fn no_script_block() {
1353 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1354 assert!(scripts.is_empty());
1355 }
1356
1357 #[test]
1358 fn empty_script_body() {
1359 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1360 assert_eq!(scripts.len(), 1);
1361 assert!(scripts[0].body.is_empty());
1362 }
1363
1364 #[test]
1365 fn whitespace_only_script() {
1366 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
1367 assert_eq!(scripts.len(), 1);
1368 assert!(scripts[0].body.trim().is_empty());
1369 }
1370
1371 #[test]
1372 fn byte_offset_is_set() {
1373 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1374 let scripts = extract_sfc_scripts(source);
1375 assert_eq!(scripts.len(), 1);
1376 let offset = scripts[0].byte_offset;
1377 assert_eq!(&source[offset..offset + 4], "code");
1378 }
1379
1380 #[test]
1381 fn script_with_extra_attributes() {
1382 let scripts = extract_sfc_scripts(
1383 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1384 );
1385 assert_eq!(scripts.len(), 1);
1386 assert!(scripts[0].is_typescript);
1387 assert!(scripts[0].src.is_none());
1388 }
1389
1390 #[test]
1391 fn multiple_script_blocks_exports_combined() {
1392 let source = r#"
1393<script lang="ts">
1394export const version = '1.0';
1395</script>
1396<script setup lang="ts">
1397import { ref } from 'vue';
1398const count = ref(0);
1399</script>
1400"#;
1401 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1402 assert!(
1403 info.exports
1404 .iter()
1405 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1406 "export from <script> block should be extracted"
1407 );
1408 assert!(
1409 info.imports.iter().any(|i| i.source == "vue"),
1410 "import from <script setup> block should be extracted"
1411 );
1412 }
1413
1414 #[test]
1415 fn lang_tsx_detected_as_typescript_jsx() {
1416 let scripts =
1417 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1418 assert_eq!(scripts.len(), 1);
1419 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1420 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1421 }
1422
1423 #[test]
1424 fn multiline_html_comment_filters_all_script_blocks_inside() {
1425 let source = r#"
1426<!--
1427 This whole section is disabled:
1428 <script lang="ts">import { bad1 } from 'bad1';</script>
1429 <script lang="ts">import { bad2 } from 'bad2';</script>
1430-->
1431<script lang="ts">import { good } from 'good';</script>
1432"#;
1433 let scripts = extract_sfc_scripts(source);
1434 assert_eq!(scripts.len(), 1);
1435 assert!(scripts[0].body.contains("good"));
1436 }
1437
1438 #[test]
1439 fn script_src_generates_side_effect_import() {
1440 let info = parse_sfc_to_module(
1441 FileId(0),
1442 Path::new("External.vue"),
1443 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1444 0,
1445 false,
1446 );
1447 assert!(
1448 info.imports
1449 .iter()
1450 .any(|i| i.source == "./external-logic.ts"
1451 && matches!(i.imported_name, ImportedName::SideEffect)),
1452 "script src should generate a side-effect import"
1453 );
1454 }
1455
1456 #[test]
1457 fn parse_sfc_no_script_returns_empty_module() {
1458 let info = parse_sfc_to_module(
1459 FileId(0),
1460 Path::new("Empty.vue"),
1461 "<template><div>Hello</div></template>",
1462 42,
1463 false,
1464 );
1465 assert!(info.imports.is_empty());
1466 assert!(info.exports.is_empty());
1467 assert_eq!(info.content_hash, 42);
1468 assert_eq!(info.file_id, FileId(0));
1469 }
1470
1471 #[test]
1472 fn parse_sfc_has_line_offsets() {
1473 let info = parse_sfc_to_module(
1474 FileId(0),
1475 Path::new("LineOffsets.vue"),
1476 r#"<script lang="ts">const x = 1;</script>"#,
1477 0,
1478 false,
1479 );
1480 assert!(!info.line_offsets.is_empty());
1481 }
1482
1483 #[test]
1484 fn parse_sfc_has_suppressions() {
1485 let info = parse_sfc_to_module(
1486 FileId(0),
1487 Path::new("Suppressions.vue"),
1488 r#"<script lang="ts">
1489// fallow-ignore-file
1490export const foo = 1;
1491</script>"#,
1492 0,
1493 false,
1494 );
1495 assert!(!info.suppressions.is_empty());
1496 }
1497
1498 #[test]
1499 fn source_type_jsx_detection() {
1500 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1501 assert_eq!(scripts.len(), 1);
1502 assert!(!scripts[0].is_typescript);
1503 assert!(scripts[0].is_jsx);
1504 }
1505
1506 #[test]
1507 fn source_type_plain_js_detection() {
1508 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1509 assert_eq!(scripts.len(), 1);
1510 assert!(!scripts[0].is_typescript);
1511 assert!(!scripts[0].is_jsx);
1512 }
1513
1514 #[test]
1515 fn is_sfc_file_rejects_no_extension() {
1516 assert!(!is_sfc_file(Path::new("Makefile")));
1517 }
1518
1519 #[test]
1520 fn is_sfc_file_rejects_mdx() {
1521 assert!(!is_sfc_file(Path::new("post.mdx")));
1522 }
1523
1524 #[test]
1525 fn is_sfc_file_rejects_css() {
1526 assert!(!is_sfc_file(Path::new("styles.css")));
1527 }
1528
1529 #[test]
1530 fn multiple_script_blocks_both_have_offsets() {
1531 let source = r#"<script lang="ts">const a = 1;</script>
1532<script setup lang="ts">const b = 2;</script>"#;
1533 let scripts = extract_sfc_scripts(source);
1534 assert_eq!(scripts.len(), 2);
1535 let offset0 = scripts[0].byte_offset;
1536 let offset1 = scripts[1].byte_offset;
1537 assert_eq!(
1538 &source[offset0..offset0 + "const a = 1;".len()],
1539 "const a = 1;"
1540 );
1541 assert_eq!(
1542 &source[offset1..offset1 + "const b = 2;".len()],
1543 "const b = 2;"
1544 );
1545 }
1546
1547 #[test]
1548 fn script_with_src_and_lang() {
1549 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1550 assert_eq!(scripts.len(), 1);
1551 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1552 assert!(scripts[0].is_typescript);
1553 assert!(scripts[0].is_jsx);
1554 }
1555
1556 #[test]
1557 fn extract_style_block_lang_scss() {
1558 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1559 let styles = extract_sfc_styles(source);
1560 assert_eq!(styles.len(), 1);
1561 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1562 assert!(styles[0].body.contains("@import"));
1563 assert!(styles[0].src.is_none());
1564 }
1565
1566 #[test]
1567 fn extract_style_block_with_src() {
1568 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1569 let styles = extract_sfc_styles(source);
1570 assert_eq!(styles.len(), 1);
1571 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1572 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1573 }
1574
1575 #[test]
1576 fn extract_style_block_plain_no_lang() {
1577 let source = r"<style>.foo { color: red; }</style>";
1578 let styles = extract_sfc_styles(source);
1579 assert_eq!(styles.len(), 1);
1580 assert!(styles[0].lang.is_none());
1581 }
1582
1583 #[test]
1584 fn extract_multiple_style_blocks() {
1585 let source = r#"<style lang="scss">@import 'a';</style>
1586<style scoped lang="scss">@import 'b';</style>"#;
1587 let styles = extract_sfc_styles(source);
1588 assert_eq!(styles.len(), 2);
1589 }
1590
1591 #[test]
1592 fn style_block_inside_html_comment_filtered() {
1593 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1594<style lang="scss">@import 'good';</style>"#;
1595 let styles = extract_sfc_styles(source);
1596 assert_eq!(styles.len(), 1);
1597 assert!(styles[0].body.contains("good"));
1598 }
1599
1600 #[test]
1601 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1602 let info = parse_sfc_to_module(
1603 FileId(0),
1604 Path::new("Foo.vue"),
1605 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1606 0,
1607 false,
1608 );
1609 let style_import = info
1610 .imports
1611 .iter()
1612 .find(|i| i.source == "./Foo")
1613 .expect("scss @import 'Foo' should be normalized to ./Foo");
1614 assert!(
1615 style_import.from_style,
1616 "imports from <style> blocks must carry from_style=true so the resolver \
1617 enables SCSS partial fallback for the SFC importer"
1618 );
1619 assert!(matches!(
1620 style_import.imported_name,
1621 ImportedName::SideEffect
1622 ));
1623 }
1624
1625 #[test]
1626 fn parse_sfc_extracts_style_plugin_as_default_import() {
1627 let info = parse_sfc_to_module(
1628 FileId(0),
1629 Path::new("Foo.vue"),
1630 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1631 0,
1632 false,
1633 );
1634 let plugin_import = info
1635 .imports
1636 .iter()
1637 .find(|i| i.source == "./tailwind-plugin.js")
1638 .expect("style @plugin should create an import");
1639 assert!(plugin_import.from_style);
1640 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1641 }
1642
1643 #[test]
1644 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1645 let info = parse_sfc_to_module(
1646 FileId(0),
1647 Path::new("Bar.vue"),
1648 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1649 0,
1650 false,
1651 );
1652 let style_src = info
1653 .imports
1654 .iter()
1655 .find(|i| i.source == "./Bar.scss")
1656 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1657 assert!(style_src.from_style);
1658 }
1659
1660 #[test]
1661 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1662 let info = parse_sfc_to_module(
1663 FileId(0),
1664 Path::new("Baz.vue"),
1665 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1666 0,
1667 false,
1668 );
1669 assert!(
1670 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1671 "src reference should still be seeded for unsupported lang"
1672 );
1673 assert!(
1674 !info.imports.iter().any(|i| i.source.contains("skipped")),
1675 "postcss body should not be scanned for @import directives"
1676 );
1677 }
1678
1679 fn asset_refs(source: &str) -> Vec<String> {
1680 super::collect_template_asset_refs(source)
1681 .into_iter()
1682 .map(|(s, _)| s)
1683 .collect()
1684 }
1685
1686 #[test]
1687 fn captures_static_relative_template_asset_refs() {
1688 assert_eq!(
1689 asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1690 vec!["./logo.png".to_string()]
1691 );
1692 assert_eq!(
1693 asset_refs(r#"<source src="../media/clip.mp4">"#),
1694 vec!["../media/clip.mp4".to_string()]
1695 );
1696 assert_eq!(
1697 asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1698 vec!["./thumb.jpg".to_string()]
1699 );
1700 }
1701
1702 #[test]
1703 fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1704 assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1706 assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1707 assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1708 assert!(asset_refs(r"<img src={logo} />").is_empty());
1709 assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1710 assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1712 assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1713 assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1714 assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1716 assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1718 }
1719
1720 #[test]
1721 fn skips_custom_component_src_prop() {
1722 assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1724 assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1725 }
1726
1727 #[test]
1728 fn skips_asset_refs_inside_script_style_and_comments() {
1729 assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1731 assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1732 assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1733 }
1734
1735 #[test]
1736 fn parse_sfc_emits_template_asset_as_side_effect_import() {
1737 let info = parse_sfc_to_module(
1738 FileId(0),
1739 Path::new("Hero.vue"),
1740 r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1741 0,
1742 false,
1743 );
1744 assert!(
1745 info.imports.iter().any(|i| i.source == "./hero.png"
1746 && matches!(i.imported_name, ImportedName::SideEffect)
1747 && !i.from_style),
1748 "template <img src> should seed a SideEffect import: {:?}",
1749 info.imports
1750 );
1751 }
1752
1753 fn svelte_props(source: &str) -> Vec<crate::ModuleInfo> {
1756 vec![parse_sfc_to_module(
1757 FileId(0),
1758 Path::new("Component.svelte"),
1759 source,
1760 0,
1761 false,
1762 )]
1763 }
1764
1765 fn prop_names(info: &crate::ModuleInfo) -> Vec<String> {
1766 let mut names: Vec<String> = info
1767 .component_props
1768 .iter()
1769 .map(|p| p.name.clone())
1770 .collect();
1771 names.sort();
1772 names
1773 }
1774
1775 #[test]
1776 fn svelte_shorthand_props_harvested() {
1777 let info = &svelte_props(r"<script>let { a, b } = $props();</script>")[0];
1779 assert_eq!(prop_names(info), vec!["a", "b"]);
1780 for prop in &info.component_props {
1781 assert_eq!(prop.local, prop.name);
1782 }
1783 }
1784
1785 #[test]
1786 fn svelte_renamed_prop_tracks_local_and_script_use() {
1787 let info =
1790 &svelte_props(r"<script>let { a: alias } = $props(); console.log(alias);</script>")[0];
1791 assert_eq!(prop_names(info), vec!["a"]);
1792 let prop = &info.component_props[0];
1793 assert_eq!(prop.local, "alias");
1794 assert!(
1795 prop.used_in_script,
1796 "alias is referenced, so a is used in script"
1797 );
1798 }
1799
1800 #[test]
1801 fn svelte_unreferenced_prop_is_unused_in_script() {
1802 let info = &svelte_props(r"<script>let { a } = $props();</script>")[0];
1803 assert_eq!(prop_names(info), vec!["a"]);
1804 assert!(!info.component_props[0].used_in_script);
1805 }
1806
1807 #[test]
1808 fn svelte_default_prop_peeled() {
1809 let info = &svelte_props(r"<script>let { a = 1 } = $props();</script>")[0];
1811 assert_eq!(prop_names(info), vec!["a"]);
1812 }
1813
1814 #[test]
1815 fn svelte_bindable_default_peeled() {
1816 let info = &svelte_props(r"<script>let { a = $bindable() } = $props();</script>")[0];
1819 assert_eq!(prop_names(info), vec!["a"]);
1820 }
1821
1822 #[test]
1823 fn svelte_rest_element_sets_fallthrough_abstain() {
1824 let info = &svelte_props(r"<script>let { a, ...rest } = $props();</script>")[0];
1826 assert!(info.has_props_attrs_fallthrough);
1827 }
1828
1829 #[test]
1830 fn svelte_bare_identifier_binding_sets_unharvestable_abstain() {
1831 let info = &svelte_props(r"<script>let p = $props(); console.log(p.x);</script>")[0];
1833 assert!(info.has_unharvestable_props);
1834 assert!(info.component_props.is_empty());
1835 }
1836
1837 #[test]
1838 fn svelte_nested_destructure_sets_unharvestable_abstain() {
1839 let info = &svelte_props(r"<script>let { a: { x } } = $props();</script>")[0];
1841 assert!(info.has_unharvestable_props);
1842 }
1843
1844 #[test]
1845 fn svelte_prop_used_only_in_markup_credited_as_template_root() {
1846 let info = &svelte_props(r"<script>let { a } = $props();</script><p>{a}</p>")[0];
1849 assert_eq!(prop_names(info), vec!["a"]);
1850 assert!(
1851 info.component_props[0].used_in_template,
1852 "a is used in markup, so used_in_template should be true"
1853 );
1854 }
1855
1856 #[test]
1857 fn svelte_module_script_props_not_harvested() {
1858 let info = &svelte_props(
1860 r"<script module>let { a } = $props();</script><script>let { b } = $props();</script>",
1861 )[0];
1862 assert_eq!(prop_names(info), vec!["b"]);
1864 }
1865
1866 fn dispatched_names(info: &crate::ModuleInfo) -> Vec<String> {
1869 let mut names: Vec<String> = info
1870 .svelte_dispatched_events
1871 .iter()
1872 .map(|e| e.name.clone())
1873 .collect();
1874 names.sort();
1875 names
1876 }
1877
1878 #[test]
1879 fn svelte_dispatch_literal_event_is_harvested() {
1880 let info = &svelte_props(
1881 r"<script>import { createEventDispatcher } from 'svelte';
1882 const dispatch = createEventDispatcher();
1883 function save() { dispatch('save'); }</script>",
1884 )[0];
1885 assert_eq!(dispatched_names(info), vec!["save"]);
1886 assert!(!info.has_dynamic_dispatch);
1887 }
1888
1889 #[test]
1890 fn svelte_dispatch_without_svelte_import_is_ignored() {
1891 let info = &svelte_props(
1894 r"<script>function createEventDispatcher() { return () => {}; }
1895 const dispatch = createEventDispatcher();
1896 dispatch('save');</script>",
1897 )[0];
1898 assert!(info.svelte_dispatched_events.is_empty());
1899 }
1900
1901 #[test]
1902 fn svelte_dynamic_dispatch_sets_abstain() {
1903 let info = &svelte_props(
1904 r"<script>import { createEventDispatcher } from 'svelte';
1905 const dispatch = createEventDispatcher();
1906 function fire(name) { dispatch(name); }</script>",
1907 )[0];
1908 assert!(
1909 info.has_dynamic_dispatch,
1910 "a non-literal dispatch arg must set the abstain flag"
1911 );
1912 }
1913
1914 #[test]
1915 fn svelte_dispatch_whole_value_use_sets_abstain() {
1916 let info = &svelte_props(
1917 r"<script>import { createEventDispatcher } from 'svelte';
1918 const dispatch = createEventDispatcher();
1919 forward(dispatch);</script>",
1920 )[0];
1921 assert!(
1922 info.has_dynamic_dispatch,
1923 "passing the dispatch binding as a whole value must set the abstain flag"
1924 );
1925 }
1926
1927 #[test]
1928 fn svelte_listened_event_on_component_is_harvested() {
1929 let info =
1930 &svelte_props(r"<script>import Child from './Child.svelte';</script><Child on:save />")
1931 [0];
1932 assert!(info.svelte_listened_events.contains(&"save".to_string()));
1933 }
1934}