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 template_visible_iterable_types: FxHashMap<String, String> = FxHashMap::default();
407 let mut props_return_binding: Option<String> = None;
408 let mut emit_return_binding: Option<String> = None;
409
410 for script in &scripts {
411 merge_script_into_module(&mut SfcScriptMergeInput {
412 kind,
413 script,
414 combined: &mut combined,
415 template_visible_imports: &mut template_visible_imports,
416 template_visible_bound_targets: &mut template_visible_bound_targets,
417 template_visible_iterable_types: &mut template_visible_iterable_types,
418 props_return_binding: &mut props_return_binding,
419 emit_return_binding: &mut emit_return_binding,
420 need_complexity,
421 });
422 }
423
424 for style in &styles {
425 merge_style_into_module(style, &mut combined);
426 }
427
428 if kind == SfcKind::Vue
432 && !combined.component_props.is_empty()
433 && PROPS_ATTRS_SPREAD_RE.is_match(source)
434 {
435 combined.has_props_attrs_fallthrough = true;
436 }
437
438 apply_template_usage(TemplateUsageInput {
439 kind,
440 source,
441 template_visible_imports: &template_visible_imports,
442 template_visible_bound_targets: &template_visible_bound_targets,
443 template_visible_iterable_types: &template_visible_iterable_types,
444 props_return_binding: props_return_binding.as_deref(),
445 credit_load_data: kind == SfcKind::Svelte && is_sveltekit_route_data_component(path),
446 combined: &mut combined,
447 });
448
449 if need_complexity {
450 append_template_complexity(kind, source, &mut combined);
451 }
452
453 if kind == SfcKind::Vue && !combined.component_emits.is_empty() {
457 apply_template_emit_usage(source, emit_return_binding.as_deref(), &mut combined);
458 }
459
460 if kind == SfcKind::Svelte {
464 combined.svelte_listened_events =
465 crate::sfc_template::collect_svelte_listened_events(source);
466 }
467
468 append_template_asset_imports(source, &mut combined);
469 dedup_import_binding_lists(&mut combined);
470
471 combined
472}
473
474fn append_template_complexity(kind: SfcKind, source: &str, combined: &mut ModuleInfo) {
482 let template_complexity = match kind {
483 SfcKind::Vue => crate::template_complexity::compute_vue_template_complexity(source),
484 SfcKind::Svelte => crate::template_complexity::compute_svelte_template_complexity(source),
485 };
486 combined.complexity.extend(template_complexity);
487}
488
489fn append_template_asset_imports(source: &str, combined: &mut ModuleInfo) {
493 for (specifier, span) in collect_template_asset_refs(source) {
494 combined.imports.push(ImportInfo {
495 source: specifier,
496 imported_name: ImportedName::SideEffect,
497 local_name: String::new(),
498 is_type_only: false,
499 from_style: false,
500 span,
501 source_span: span,
502 });
503 }
504}
505
506fn dedup_import_binding_lists(combined: &mut ModuleInfo) {
509 combined.unused_import_bindings.sort_unstable();
510 combined.unused_import_bindings.dedup();
511 combined.type_referenced_import_bindings.sort_unstable();
512 combined.type_referenced_import_bindings.dedup();
513 combined.value_referenced_import_bindings.sort_unstable();
514 combined.value_referenced_import_bindings.dedup();
515 combined.auto_import_candidates.sort_unstable();
516 combined.auto_import_candidates.dedup();
517}
518
519fn sfc_kind(path: &Path) -> SfcKind {
520 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
521 SfcKind::Vue
522 } else {
523 SfcKind::Svelte
524 }
525}
526
527fn is_sveltekit_route_data_component(path: &Path) -> bool {
538 let Some(stem) = path
539 .file_name()
540 .and_then(|name| name.to_str())
541 .and_then(|name| name.strip_suffix(".svelte"))
542 else {
543 return false;
544 };
545 ["+page", "+layout"].iter().any(|prefix| {
546 stem.strip_prefix(prefix)
547 .is_some_and(|rest| rest.is_empty() || rest.starts_with('@'))
548 })
549}
550
551fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
552 let parsed = crate::suppress::parse_suppressions_from_source(source);
553
554 crate::module_info::non_js_module_info(
555 file_id,
556 content_hash,
557 source,
558 parsed,
559 Vec::new(),
560 Vec::new(),
561 )
562}
563
564struct SfcScriptMergeInput<'a> {
565 kind: SfcKind,
566 script: &'a SfcScript,
567 combined: &'a mut ModuleInfo,
568 template_visible_imports: &'a mut FxHashSet<String>,
569 template_visible_bound_targets: &'a mut FxHashMap<String, String>,
570 template_visible_iterable_types: &'a mut FxHashMap<String, String>,
571 props_return_binding: &'a mut Option<String>,
572 emit_return_binding: &'a mut Option<String>,
573 need_complexity: bool,
574}
575
576fn merge_script_into_module(input: &mut SfcScriptMergeInput<'_>) {
577 if input.kind == SfcKind::Vue
578 && let Some(src) = &input.script.src
579 {
580 add_script_src_import(input.combined, src, input.script.src_span);
581 }
582
583 let allocator = Allocator::default();
584 let parser_return = Parser::new(
585 &allocator,
586 &input.script.body,
587 source_type_for_script(input.script),
588 )
589 .parse();
590 let mut extractor = ModuleInfoExtractor::new();
591 extractor.visit_program(&parser_return.program);
592 let extraction = ExtractionResult::contiguous(&input.script.body, input.script.byte_offset);
593 extractor.remap_spans_with(|span| extraction.remap_span(span));
594 extractor.resolve_typed_destructure_bindings();
595
596 merge_script_binding_usage(input, &allocator, &parser_return, &extractor.imports);
597 if input.need_complexity {
598 input
599 .combined
600 .complexity
601 .extend(translate_script_complexity(
602 input.script,
603 &parser_return.program,
604 &input.combined.line_offsets,
605 ));
606 }
607
608 if input.kind == SfcKind::Vue {
612 merge_vue_props_emits_into(input, &parser_return.program, &mut extractor);
613 }
614
615 if input.kind == SfcKind::Svelte && is_template_visible_script(input.kind, input.script) {
620 merge_svelte_props_into(
621 input.combined,
622 &parser_return.program,
623 input.script.byte_offset,
624 );
625 }
626
627 if is_template_visible_script(input.kind, input.script) {
628 harvest_template_visible_bindings(input, &extractor);
629 }
630
631 let dispatch_base = input.combined.svelte_dispatched_events.len();
636 extractor.merge_into(input.combined);
637 for event in &mut input.combined.svelte_dispatched_events[dispatch_base..] {
638 event.span_start += input.script.byte_offset as u32;
639 }
640}
641
642fn merge_script_binding_usage(
647 input: &mut SfcScriptMergeInput<'_>,
648 allocator: &Allocator,
649 parser_return: &oxc_parser::ParserReturn<'_>,
650 imports: &[ImportInfo],
651) {
652 let augmented_body = build_generic_attr_probe_source(input.script);
653 let empty_template_used = FxHashSet::default();
654 let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
655 {
656 let augmented_return =
657 Parser::new(allocator, augmented, source_type_for_script(input.script)).parse();
658 (
659 compute_import_binding_usage(&augmented_return.program, imports, &empty_template_used),
660 compute_auto_import_candidates(&parser_return.program),
661 )
662 } else {
663 let semantic_usage =
664 compute_semantic_usage(&parser_return.program, imports, &empty_template_used);
665 (
666 semantic_usage.import_binding_usage,
667 semantic_usage.auto_import_candidates,
668 )
669 };
670 input
671 .combined
672 .unused_import_bindings
673 .extend(binding_usage.unused.iter().cloned());
674 input
675 .combined
676 .type_referenced_import_bindings
677 .extend(binding_usage.type_referenced.iter().cloned());
678 input
679 .combined
680 .value_referenced_import_bindings
681 .extend(binding_usage.value_referenced.iter().cloned());
682 input
683 .combined
684 .auto_import_candidates
685 .extend(auto_import_candidates);
686}
687
688fn harvest_template_visible_bindings(
692 input: &mut SfcScriptMergeInput<'_>,
693 extractor: &ModuleInfoExtractor,
694) {
695 input.template_visible_imports.extend(
696 extractor
697 .imports
698 .iter()
699 .filter(|import| !import.local_name.is_empty())
700 .map(|import| import.local_name.clone()),
701 );
702 input.template_visible_bound_targets.extend(
703 extractor
704 .binding_target_names()
705 .iter()
706 .filter(|(local, _)| !local.starts_with("this."))
707 .filter_map(|(local, target)| {
708 target
709 .class_name()
710 .map(|class_name| (local.clone(), class_name.to_string()))
711 }),
712 );
713 input.template_visible_iterable_types.extend(
717 extractor
718 .array_binding_element_types()
719 .iter()
720 .filter(|(local, _)| !local.starts_with("this."))
721 .map(|(local, element)| (local.clone(), element.clone())),
722 );
723}
724
725fn merge_svelte_props_into(
729 combined: &mut ModuleInfo,
730 program: &oxc_ast::ast::Program<'_>,
731 byte_offset: usize,
732) {
733 let harvest = crate::sfc_props::harvest_svelte_props(program);
734 if harvest.has_unharvestable_props {
735 combined.has_unharvestable_props = true;
736 }
737 if harvest.has_props_attrs_fallthrough {
738 combined.has_props_attrs_fallthrough = true;
739 }
740 for mut prop in harvest.props {
741 prop.span_start += byte_offset as u32;
742 combined.component_props.push(prop);
743 }
744}
745
746fn merge_vue_props_emits_into(
753 input: &mut SfcScriptMergeInput<'_>,
754 program: &oxc_ast::ast::Program<'_>,
755 extractor: &mut ModuleInfoExtractor,
756) {
757 let byte_offset = input.script.byte_offset as u32;
758 if input.script.is_setup {
759 apply_props_harvest(
760 input,
761 crate::sfc_props::harvest_define_props(program),
762 byte_offset,
763 extractor,
764 );
765 apply_emits_harvest(
766 input,
767 crate::sfc_props::harvest_define_emits(program),
768 byte_offset,
769 );
770 } else {
771 apply_props_harvest(
772 input,
773 crate::sfc_props::harvest_options_api_props(program),
774 byte_offset,
775 extractor,
776 );
777 apply_emits_harvest(
778 input,
779 crate::sfc_props::harvest_options_api_emits(program),
780 byte_offset,
781 );
782 }
783}
784
785fn apply_props_harvest(
791 input: &mut SfcScriptMergeInput<'_>,
792 harvest: crate::sfc_props::DefinePropsHarvest,
793 byte_offset: u32,
794 extractor: &mut ModuleInfoExtractor,
795) {
796 if harvest.has_unharvestable_props {
797 input.combined.has_unharvestable_props = true;
798 }
799 if harvest.has_props_attrs_fallthrough {
800 input.combined.has_props_attrs_fallthrough = true;
801 }
802 if harvest.has_define_expose {
803 input.combined.has_define_expose = true;
804 }
805 if harvest.has_define_model {
806 input.combined.has_define_model = true;
807 }
808 if let Some(binding) = harvest.props_return_binding {
809 *input.props_return_binding = Some(binding);
810 }
811 for (field_name, element_type) in harvest.props_array_element_types {
819 extractor
820 .array_binding_element_types_mut()
821 .insert(format!("props.{field_name}"), element_type);
822 }
823 for mut prop in harvest.props {
824 prop.span_start += byte_offset;
825 input.combined.component_props.push(prop);
826 }
827}
828
829fn apply_emits_harvest(
835 input: &mut SfcScriptMergeInput<'_>,
836 harvest: crate::sfc_props::DefineEmitsHarvest,
837 byte_offset: u32,
838) {
839 if harvest.has_unharvestable_emits {
840 input.combined.has_unharvestable_emits = true;
841 }
842 if harvest.has_dynamic_emit {
843 input.combined.has_dynamic_emit = true;
844 }
845 if harvest.has_emit_whole_object_use {
846 input.combined.has_emit_whole_object_use = true;
847 }
848 if let Some(binding) = harvest.emit_binding {
849 *input.emit_return_binding = Some(binding);
850 }
851 for mut emit in harvest.emits {
852 emit.span_start += byte_offset;
853 input.combined.component_emits.push(emit);
854 }
855}
856
857fn translate_script_complexity(
858 script: &SfcScript,
859 program: &oxc_ast::ast::Program<'_>,
860 sfc_line_offsets: &[u32],
861) -> Vec<FunctionComplexity> {
862 let script_line_offsets = compute_line_offsets(&script.body);
863 let mut complexity =
864 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
865 let (body_start_line, body_start_col) =
866 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
867
868 for function in &mut complexity {
869 function.line = body_start_line + function.line.saturating_sub(1);
870 if function.line == body_start_line {
871 function.col += body_start_col;
872 }
873 }
874
875 complexity
876}
877
878fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
879 let span = source_span.unwrap_or_default();
880 module.imports.push(ImportInfo {
881 source: normalize_asset_url(source),
882 imported_name: ImportedName::SideEffect,
883 local_name: String::new(),
884 is_type_only: false,
885 from_style: false,
886 span,
887 source_span: span,
888 });
889}
890
891fn style_lang_is_scss(lang: Option<&str>) -> bool {
897 matches!(lang, Some("scss" | "sass"))
898}
899
900fn style_lang_is_css_like(lang: Option<&str>) -> bool {
901 lang.is_none() || matches!(lang, Some("css"))
902}
903
904fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
905 if let Some(src) = &style.src {
906 let span = style.src_span.unwrap_or_default();
907 combined.imports.push(ImportInfo {
908 source: normalize_asset_url(src),
909 imported_name: ImportedName::SideEffect,
910 local_name: String::new(),
911 is_type_only: false,
912 from_style: true,
913 span,
914 source_span: span,
915 });
916 }
917
918 let lang = style.lang.as_deref();
919 let is_scss = style_lang_is_scss(lang);
920 let is_css_like = style_lang_is_css_like(lang);
921 if !is_scss && !is_css_like {
922 return;
923 }
924
925 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
926 let source_span = Span::new(
927 style.byte_offset as u32 + source.span.start,
928 style.byte_offset as u32 + source.span.end,
929 );
930 combined.imports.push(ImportInfo {
931 source: source.normalized,
932 imported_name: if source.is_plugin {
933 ImportedName::Default
934 } else {
935 ImportedName::SideEffect
936 },
937 local_name: String::new(),
938 is_type_only: false,
939 from_style: true,
940 span: source_span,
941 source_span,
942 });
943 }
944}
945
946fn source_type_for_script(script: &SfcScript) -> SourceType {
947 match (script.is_typescript, script.is_jsx) {
948 (true, true) => SourceType::tsx(),
949 (true, false) => SourceType::ts(),
950 (false, true) => SourceType::jsx(),
951 (false, false) => SourceType::mjs(),
952 }
953}
954
955fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
961 let constraint = script.generic_attr.as_deref()?.trim();
962 if constraint.is_empty() {
963 return None;
964 }
965 Some(format!(
966 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
967 script.body, constraint,
968 ))
969}
970
971struct TemplateUsageInput<'a> {
972 kind: SfcKind,
973 source: &'a str,
974 template_visible_imports: &'a FxHashSet<String>,
975 template_visible_bound_targets: &'a FxHashMap<String, String>,
976 template_visible_iterable_types: &'a FxHashMap<String, String>,
977 props_return_binding: Option<&'a str>,
978 credit_load_data: bool,
979 combined: &'a mut ModuleInfo,
980}
981
982fn apply_template_usage(input: TemplateUsageInput<'_>) {
983 let TemplateUsageInput {
984 kind,
985 source,
986 template_visible_imports,
987 template_visible_bound_targets,
988 template_visible_iterable_types,
989 props_return_binding,
990 credit_load_data,
991 combined,
992 } = input;
993 let credited = build_template_credited_set(
994 template_visible_imports,
995 props_return_binding,
996 credit_load_data,
997 source,
998 combined,
999 );
1000 let template_usage = compute_template_usage(
1001 kind,
1002 source,
1003 &credited,
1004 template_visible_bound_targets,
1005 template_visible_iterable_types,
1006 credit_load_data,
1007 );
1008 apply_prop_template_credit(&template_usage, props_return_binding, combined);
1009 merge_template_usage_into_combined(template_usage, combined);
1010}
1011
1012fn build_template_credited_set(
1018 template_visible_imports: &FxHashSet<String>,
1019 props_return_binding: Option<&str>,
1020 credit_load_data: bool,
1021 source: &str,
1022 combined: &mut ModuleInfo,
1023) -> FxHashSet<String> {
1024 let mut credited: FxHashSet<String> = template_visible_imports.clone();
1025 if credit_load_data {
1030 credited.insert("data".to_string());
1031 if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
1034 combined.has_load_data_whole_use = true;
1035 }
1036 }
1037 if !combined.component_props.is_empty() {
1038 for prop in &combined.component_props {
1039 credited.insert(prop.name.clone());
1042 credited.insert(prop.local.clone());
1043 }
1044 credited.insert("$props".to_string());
1047 if let Some(binding) = props_return_binding {
1048 credited.insert(binding.to_string());
1049 }
1050 }
1051 credited
1052}
1053
1054fn compute_template_usage(
1059 kind: SfcKind,
1060 source: &str,
1061 credited: &FxHashSet<String>,
1062 template_visible_bound_targets: &FxHashMap<String, String>,
1063 template_visible_iterable_types: &FxHashMap<String, String>,
1064 credit_load_data: bool,
1065) -> crate::template_usage::TemplateUsage {
1066 if credit_load_data && template_visible_bound_targets.contains_key("data") {
1067 let mut filtered = template_visible_bound_targets.clone();
1068 filtered.remove("data");
1069 collect_template_usage_with_bound_targets(
1070 kind,
1071 source,
1072 credited,
1073 &filtered,
1074 template_visible_iterable_types,
1075 )
1076 } else {
1077 collect_template_usage_with_bound_targets(
1078 kind,
1079 source,
1080 credited,
1081 template_visible_bound_targets,
1082 template_visible_iterable_types,
1083 )
1084 }
1085}
1086
1087fn apply_prop_template_credit(
1092 template_usage: &crate::template_usage::TemplateUsage,
1093 props_return_binding: Option<&str>,
1094 combined: &mut ModuleInfo,
1095) {
1096 if !combined.component_props.is_empty() {
1097 let member_used: FxHashSet<&str> = template_usage
1098 .member_accesses
1099 .iter()
1100 .filter(|access| {
1101 access.object == "$props"
1102 || props_return_binding.is_some_and(|binding| access.object == binding)
1103 })
1104 .map(|access| access.member.as_str())
1105 .collect();
1106 for prop in &mut combined.component_props {
1107 if template_usage.used_bindings.contains(&prop.name)
1108 || template_usage.used_bindings.contains(&prop.local)
1109 || member_used.contains(prop.name.as_str())
1110 {
1111 prop.used_in_template = true;
1112 }
1113 }
1114 }
1115
1116 if let Some(binding) = props_return_binding
1117 && (template_usage.used_bindings.contains(binding)
1118 || template_usage
1119 .whole_object_uses
1120 .iter()
1121 .any(|used| used == binding))
1122 {
1123 combined.has_props_attrs_fallthrough = true;
1124 }
1125}
1126
1127fn merge_template_usage_into_combined(
1132 template_usage: crate::template_usage::TemplateUsage,
1133 combined: &mut ModuleInfo,
1134) {
1135 combined
1136 .unused_import_bindings
1137 .retain(|binding| !template_usage.used_bindings.contains(binding));
1138 combined
1139 .member_accesses
1140 .extend(template_usage.member_accesses);
1141 let mut whole_object_uses = std::mem::take(&mut combined.whole_object_uses).into_vec();
1142 whole_object_uses.extend(template_usage.whole_object_uses);
1143 combined.whole_object_uses = whole_object_uses.into_boxed_slice();
1144 combined
1145 .security_sinks
1146 .extend(template_usage.security_sinks);
1147 if !template_usage.unresolved_tag_names.is_empty() {
1148 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
1149 names.sort_unstable();
1150 combined.auto_import_candidates.extend(names);
1151 combined.auto_import_candidates.dedup();
1152 }
1153}
1154
1155fn apply_template_emit_usage(
1173 source: &str,
1174 emit_return_binding: Option<&str>,
1175 combined: &mut ModuleInfo,
1176) {
1177 let masked = mask_non_markup_regions(source);
1178 let mut used: FxHashSet<String> = FxHashSet::default();
1179 let mut dynamic = false;
1180
1181 for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
1182 let Some(callee) = caps.get(1) else {
1183 continue;
1184 };
1185 let callee = callee.as_str();
1186 let is_emit_call =
1187 callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
1188 if !is_emit_call {
1189 continue;
1190 }
1191 if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
1192 used.insert(event.as_str().to_string());
1195 } else if caps.get(4).is_some() {
1196 dynamic = true;
1199 }
1200 }
1201
1202 if dynamic {
1203 combined.has_dynamic_emit = true;
1204 }
1205 if !used.is_empty() {
1206 for emit in &mut combined.component_emits {
1207 if used.contains(&emit.name) {
1208 emit.used = true;
1209 }
1210 }
1211 }
1212}
1213
1214fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
1215 match kind {
1216 SfcKind::Vue => script.is_setup,
1217 SfcKind::Svelte => !script.is_context_module,
1218 }
1219}
1220
1221#[cfg(all(test, not(miri)))]
1222mod tests {
1223 use super::*;
1224
1225 #[test]
1226 fn is_sfc_file_vue() {
1227 assert!(is_sfc_file(Path::new("App.vue")));
1228 }
1229
1230 #[test]
1231 fn is_sfc_file_svelte() {
1232 assert!(is_sfc_file(Path::new("Counter.svelte")));
1233 }
1234
1235 #[test]
1236 fn is_sfc_file_rejects_ts() {
1237 assert!(!is_sfc_file(Path::new("utils.ts")));
1238 }
1239
1240 #[test]
1241 fn is_sfc_file_rejects_jsx() {
1242 assert!(!is_sfc_file(Path::new("App.jsx")));
1243 }
1244
1245 #[test]
1246 fn is_sfc_file_rejects_astro() {
1247 assert!(!is_sfc_file(Path::new("Layout.astro")));
1248 }
1249
1250 #[test]
1251 fn single_plain_script() {
1252 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1253 assert_eq!(scripts.len(), 1);
1254 assert_eq!(scripts[0].body, "const x = 1;");
1255 assert!(!scripts[0].is_typescript);
1256 assert!(!scripts[0].is_jsx);
1257 assert!(scripts[0].src.is_none());
1258 }
1259
1260 #[test]
1261 fn single_ts_script() {
1262 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1263 assert_eq!(scripts.len(), 1);
1264 assert!(scripts[0].is_typescript);
1265 assert!(!scripts[0].is_jsx);
1266 }
1267
1268 #[test]
1269 fn single_tsx_script() {
1270 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1271 assert_eq!(scripts.len(), 1);
1272 assert!(scripts[0].is_typescript);
1273 assert!(scripts[0].is_jsx);
1274 }
1275
1276 #[test]
1277 fn single_jsx_script() {
1278 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1279 assert_eq!(scripts.len(), 1);
1280 assert!(!scripts[0].is_typescript);
1281 assert!(scripts[0].is_jsx);
1282 }
1283
1284 #[test]
1285 fn two_script_blocks() {
1286 let source = r#"
1287<script lang="ts">
1288export default {};
1289</script>
1290<script setup lang="ts">
1291const count = 0;
1292</script>
1293"#;
1294 let scripts = extract_sfc_scripts(source);
1295 assert_eq!(scripts.len(), 2);
1296 assert!(scripts[0].body.contains("export default"));
1297 assert!(scripts[1].body.contains("count"));
1298 }
1299
1300 #[test]
1301 fn script_setup_extracted() {
1302 let scripts =
1303 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1304 assert_eq!(scripts.len(), 1);
1305 assert!(scripts[0].body.contains("import"));
1306 assert!(scripts[0].is_typescript);
1307 }
1308
1309 #[test]
1310 fn script_src_detected() {
1311 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1312 assert_eq!(scripts.len(), 1);
1313 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1314 }
1315
1316 #[test]
1319 fn svelte4_context_module_is_module_context() {
1320 let scripts =
1321 extract_sfc_scripts(r#"<script context="module">export const x = 1;</script>"#);
1322 assert_eq!(scripts.len(), 1);
1323 assert!(scripts[0].is_context_module);
1324 }
1325
1326 #[test]
1327 fn svelte5_bare_module_attr_is_module_context() {
1328 let scripts = extract_sfc_scripts(r"<script module>export const x = 1;</script>");
1329 assert_eq!(scripts.len(), 1);
1330 assert!(scripts[0].is_context_module);
1331 }
1332
1333 #[test]
1334 fn svelte5_module_with_lang_is_module_context() {
1335 let scripts =
1336 extract_sfc_scripts(r#"<script module lang="ts">export const x = 1;</script>"#);
1337 assert_eq!(scripts.len(), 1);
1338 assert!(scripts[0].is_context_module);
1339 assert!(scripts[0].is_typescript);
1340 }
1341
1342 #[test]
1343 fn plain_script_is_not_module_context() {
1344 let scripts = extract_sfc_scripts(r"<script>const x = 1;</script>");
1345 assert_eq!(scripts.len(), 1);
1346 assert!(!scripts[0].is_context_module);
1347 }
1348
1349 #[test]
1350 fn lang_ts_script_is_not_module_context() {
1351 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x = 1;</script>"#);
1352 assert_eq!(scripts.len(), 1);
1353 assert!(!scripts[0].is_context_module);
1354 }
1355
1356 #[test]
1357 fn data_module_attr_is_not_module_context() {
1358 let scripts =
1360 extract_sfc_scripts(r#"<script data-module="x" lang="ts">const x = 1;</script>"#);
1361 assert_eq!(scripts.len(), 1);
1362 assert!(!scripts[0].is_context_module);
1363 }
1364
1365 #[test]
1366 fn bare_module_script_is_not_template_visible() {
1367 let module_script = SfcScript {
1370 body: String::new(),
1371 is_typescript: false,
1372 is_jsx: false,
1373 byte_offset: 0,
1374 src: None,
1375 src_span: None,
1376 is_setup: false,
1377 is_context_module: true,
1378 generic_attr: None,
1379 };
1380 assert!(!is_template_visible_script(SfcKind::Svelte, &module_script));
1381 let instance_script = SfcScript {
1382 is_context_module: false,
1383 ..module_script
1384 };
1385 assert!(is_template_visible_script(
1386 SfcKind::Svelte,
1387 &instance_script
1388 ));
1389 }
1390
1391 #[test]
1392 fn data_src_not_treated_as_src() {
1393 let scripts =
1394 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1395 assert_eq!(scripts.len(), 1);
1396 assert!(scripts[0].src.is_none());
1397 }
1398
1399 #[test]
1400 fn script_inside_html_comment_filtered() {
1401 let source = r#"
1402<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1403<script lang="ts">import { good } from 'good';</script>
1404"#;
1405 let scripts = extract_sfc_scripts(source);
1406 assert_eq!(scripts.len(), 1);
1407 assert!(scripts[0].body.contains("good"));
1408 }
1409
1410 #[test]
1411 fn spanning_comment_filters_script() {
1412 let source = r#"
1413<!-- disabled:
1414<script lang="ts">import { bad } from 'bad';</script>
1415-->
1416<script lang="ts">const ok = true;</script>
1417"#;
1418 let scripts = extract_sfc_scripts(source);
1419 assert_eq!(scripts.len(), 1);
1420 assert!(scripts[0].body.contains("ok"));
1421 }
1422
1423 #[test]
1424 fn string_containing_comment_markers_not_corrupted() {
1425 let source = r#"
1426<script setup lang="ts">
1427const marker = "<!-- not a comment -->";
1428import { ref } from 'vue';
1429</script>
1430"#;
1431 let scripts = extract_sfc_scripts(source);
1432 assert_eq!(scripts.len(), 1);
1433 assert!(scripts[0].body.contains("import"));
1434 }
1435
1436 #[test]
1437 fn generic_attr_with_angle_bracket() {
1438 let source =
1439 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1440 let scripts = extract_sfc_scripts(source);
1441 assert_eq!(scripts.len(), 1);
1442 assert_eq!(scripts[0].body, "const x = 1;");
1443 }
1444
1445 #[test]
1446 fn nested_generic_attr() {
1447 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1448 let scripts = extract_sfc_scripts(source);
1449 assert_eq!(scripts.len(), 1);
1450 assert_eq!(scripts[0].body, "const x = 1;");
1451 }
1452
1453 #[test]
1454 fn lang_single_quoted() {
1455 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1456 assert_eq!(scripts.len(), 1);
1457 assert!(scripts[0].is_typescript);
1458 }
1459
1460 #[test]
1461 fn uppercase_script_tag() {
1462 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1463 assert_eq!(scripts.len(), 1);
1464 assert!(scripts[0].is_typescript);
1465 }
1466
1467 #[test]
1468 fn no_script_block() {
1469 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1470 assert!(scripts.is_empty());
1471 }
1472
1473 #[test]
1474 fn empty_script_body() {
1475 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1476 assert_eq!(scripts.len(), 1);
1477 assert!(scripts[0].body.is_empty());
1478 }
1479
1480 #[test]
1481 fn whitespace_only_script() {
1482 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
1483 assert_eq!(scripts.len(), 1);
1484 assert!(scripts[0].body.trim().is_empty());
1485 }
1486
1487 #[test]
1488 fn byte_offset_is_set() {
1489 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1490 let scripts = extract_sfc_scripts(source);
1491 assert_eq!(scripts.len(), 1);
1492 let offset = scripts[0].byte_offset;
1493 assert_eq!(&source[offset..offset + 4], "code");
1494 }
1495
1496 #[test]
1497 fn script_with_extra_attributes() {
1498 let scripts = extract_sfc_scripts(
1499 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1500 );
1501 assert_eq!(scripts.len(), 1);
1502 assert!(scripts[0].is_typescript);
1503 assert!(scripts[0].src.is_none());
1504 }
1505
1506 #[test]
1507 fn multiple_script_blocks_exports_combined() {
1508 let source = r#"
1509<script lang="ts">
1510export const version = '1.0';
1511</script>
1512<script setup lang="ts">
1513import { ref } from 'vue';
1514const count = ref(0);
1515</script>
1516"#;
1517 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1518 assert!(
1519 info.exports
1520 .iter()
1521 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1522 "export from <script> block should be extracted"
1523 );
1524 assert!(
1525 info.imports.iter().any(|i| i.source == "vue"),
1526 "import from <script setup> block should be extracted"
1527 );
1528 }
1529
1530 #[test]
1531 fn lang_tsx_detected_as_typescript_jsx() {
1532 let scripts =
1533 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1534 assert_eq!(scripts.len(), 1);
1535 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1536 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1537 }
1538
1539 #[test]
1540 fn multiline_html_comment_filters_all_script_blocks_inside() {
1541 let source = r#"
1542<!--
1543 This whole section is disabled:
1544 <script lang="ts">import { bad1 } from 'bad1';</script>
1545 <script lang="ts">import { bad2 } from 'bad2';</script>
1546-->
1547<script lang="ts">import { good } from 'good';</script>
1548"#;
1549 let scripts = extract_sfc_scripts(source);
1550 assert_eq!(scripts.len(), 1);
1551 assert!(scripts[0].body.contains("good"));
1552 }
1553
1554 #[test]
1555 fn script_src_generates_side_effect_import() {
1556 let info = parse_sfc_to_module(
1557 FileId(0),
1558 Path::new("External.vue"),
1559 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1560 0,
1561 false,
1562 );
1563 assert!(
1564 info.imports
1565 .iter()
1566 .any(|i| i.source == "./external-logic.ts"
1567 && matches!(i.imported_name, ImportedName::SideEffect)),
1568 "script src should generate a side-effect import"
1569 );
1570 }
1571
1572 #[test]
1573 fn parse_sfc_no_script_returns_empty_module() {
1574 let info = parse_sfc_to_module(
1575 FileId(0),
1576 Path::new("Empty.vue"),
1577 "<template><div>Hello</div></template>",
1578 42,
1579 false,
1580 );
1581 assert!(info.imports.is_empty());
1582 assert!(info.exports.is_empty());
1583 assert_eq!(info.content_hash, 42);
1584 assert_eq!(info.file_id, FileId(0));
1585 }
1586
1587 #[test]
1588 fn parse_sfc_has_line_offsets() {
1589 let info = parse_sfc_to_module(
1590 FileId(0),
1591 Path::new("LineOffsets.vue"),
1592 r#"<script lang="ts">const x = 1;</script>"#,
1593 0,
1594 false,
1595 );
1596 assert!(!info.line_offsets.is_empty());
1597 }
1598
1599 #[test]
1600 fn parse_sfc_has_suppressions() {
1601 let info = parse_sfc_to_module(
1602 FileId(0),
1603 Path::new("Suppressions.vue"),
1604 r#"<script lang="ts">
1605// fallow-ignore-file
1606export const foo = 1;
1607</script>"#,
1608 0,
1609 false,
1610 );
1611 assert!(!info.suppressions.is_empty());
1612 }
1613
1614 #[test]
1615 fn source_type_jsx_detection() {
1616 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1617 assert_eq!(scripts.len(), 1);
1618 assert!(!scripts[0].is_typescript);
1619 assert!(scripts[0].is_jsx);
1620 }
1621
1622 #[test]
1623 fn source_type_plain_js_detection() {
1624 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1625 assert_eq!(scripts.len(), 1);
1626 assert!(!scripts[0].is_typescript);
1627 assert!(!scripts[0].is_jsx);
1628 }
1629
1630 #[test]
1631 fn is_sfc_file_rejects_no_extension() {
1632 assert!(!is_sfc_file(Path::new("Makefile")));
1633 }
1634
1635 #[test]
1636 fn is_sfc_file_rejects_mdx() {
1637 assert!(!is_sfc_file(Path::new("post.mdx")));
1638 }
1639
1640 #[test]
1641 fn is_sfc_file_rejects_css() {
1642 assert!(!is_sfc_file(Path::new("styles.css")));
1643 }
1644
1645 #[test]
1646 fn multiple_script_blocks_both_have_offsets() {
1647 let source = r#"<script lang="ts">const a = 1;</script>
1648<script setup lang="ts">const b = 2;</script>"#;
1649 let scripts = extract_sfc_scripts(source);
1650 assert_eq!(scripts.len(), 2);
1651 let offset0 = scripts[0].byte_offset;
1652 let offset1 = scripts[1].byte_offset;
1653 assert_eq!(
1654 &source[offset0..offset0 + "const a = 1;".len()],
1655 "const a = 1;"
1656 );
1657 assert_eq!(
1658 &source[offset1..offset1 + "const b = 2;".len()],
1659 "const b = 2;"
1660 );
1661 }
1662
1663 #[test]
1664 fn script_with_src_and_lang() {
1665 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1666 assert_eq!(scripts.len(), 1);
1667 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1668 assert!(scripts[0].is_typescript);
1669 assert!(scripts[0].is_jsx);
1670 }
1671
1672 #[test]
1673 fn extract_style_block_lang_scss() {
1674 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1675 let styles = extract_sfc_styles(source);
1676 assert_eq!(styles.len(), 1);
1677 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1678 assert!(styles[0].body.contains("@import"));
1679 assert!(styles[0].src.is_none());
1680 }
1681
1682 #[test]
1683 fn extract_style_block_with_src() {
1684 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1685 let styles = extract_sfc_styles(source);
1686 assert_eq!(styles.len(), 1);
1687 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1688 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1689 }
1690
1691 #[test]
1692 fn extract_style_block_plain_no_lang() {
1693 let source = r"<style>.foo { color: red; }</style>";
1694 let styles = extract_sfc_styles(source);
1695 assert_eq!(styles.len(), 1);
1696 assert!(styles[0].lang.is_none());
1697 }
1698
1699 #[test]
1700 fn extract_multiple_style_blocks() {
1701 let source = r#"<style lang="scss">@import 'a';</style>
1702<style scoped lang="scss">@import 'b';</style>"#;
1703 let styles = extract_sfc_styles(source);
1704 assert_eq!(styles.len(), 2);
1705 }
1706
1707 #[test]
1708 fn style_block_inside_html_comment_filtered() {
1709 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1710<style lang="scss">@import 'good';</style>"#;
1711 let styles = extract_sfc_styles(source);
1712 assert_eq!(styles.len(), 1);
1713 assert!(styles[0].body.contains("good"));
1714 }
1715
1716 #[test]
1717 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1718 let info = parse_sfc_to_module(
1719 FileId(0),
1720 Path::new("Foo.vue"),
1721 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1722 0,
1723 false,
1724 );
1725 let style_import = info
1726 .imports
1727 .iter()
1728 .find(|i| i.source == "./Foo")
1729 .expect("scss @import 'Foo' should be normalized to ./Foo");
1730 assert!(
1731 style_import.from_style,
1732 "imports from <style> blocks must carry from_style=true so the resolver \
1733 enables SCSS partial fallback for the SFC importer"
1734 );
1735 assert!(matches!(
1736 style_import.imported_name,
1737 ImportedName::SideEffect
1738 ));
1739 }
1740
1741 #[test]
1742 fn parse_sfc_extracts_style_plugin_as_default_import() {
1743 let info = parse_sfc_to_module(
1744 FileId(0),
1745 Path::new("Foo.vue"),
1746 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1747 0,
1748 false,
1749 );
1750 let plugin_import = info
1751 .imports
1752 .iter()
1753 .find(|i| i.source == "./tailwind-plugin.js")
1754 .expect("style @plugin should create an import");
1755 assert!(plugin_import.from_style);
1756 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1757 }
1758
1759 #[test]
1760 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1761 let info = parse_sfc_to_module(
1762 FileId(0),
1763 Path::new("Bar.vue"),
1764 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1765 0,
1766 false,
1767 );
1768 let style_src = info
1769 .imports
1770 .iter()
1771 .find(|i| i.source == "./Bar.scss")
1772 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1773 assert!(style_src.from_style);
1774 }
1775
1776 #[test]
1777 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1778 let info = parse_sfc_to_module(
1779 FileId(0),
1780 Path::new("Baz.vue"),
1781 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1782 0,
1783 false,
1784 );
1785 assert!(
1786 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1787 "src reference should still be seeded for unsupported lang"
1788 );
1789 assert!(
1790 !info.imports.iter().any(|i| i.source.contains("skipped")),
1791 "postcss body should not be scanned for @import directives"
1792 );
1793 }
1794
1795 fn asset_refs(source: &str) -> Vec<String> {
1796 super::collect_template_asset_refs(source)
1797 .into_iter()
1798 .map(|(s, _)| s)
1799 .collect()
1800 }
1801
1802 #[test]
1803 fn captures_static_relative_template_asset_refs() {
1804 assert_eq!(
1805 asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1806 vec!["./logo.png".to_string()]
1807 );
1808 assert_eq!(
1809 asset_refs(r#"<source src="../media/clip.mp4">"#),
1810 vec!["../media/clip.mp4".to_string()]
1811 );
1812 assert_eq!(
1813 asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1814 vec!["./thumb.jpg".to_string()]
1815 );
1816 }
1817
1818 #[test]
1819 fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1820 assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1822 assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1823 assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1824 assert!(asset_refs(r"<img src={logo} />").is_empty());
1825 assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1826 assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1828 assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1829 assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1830 assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1832 assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1834 }
1835
1836 #[test]
1837 fn skips_custom_component_src_prop() {
1838 assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1840 assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1841 }
1842
1843 #[test]
1844 fn skips_asset_refs_inside_script_style_and_comments() {
1845 assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1847 assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1848 assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1849 }
1850
1851 #[test]
1852 fn parse_sfc_emits_template_asset_as_side_effect_import() {
1853 let info = parse_sfc_to_module(
1854 FileId(0),
1855 Path::new("Hero.vue"),
1856 r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1857 0,
1858 false,
1859 );
1860 assert!(
1861 info.imports.iter().any(|i| i.source == "./hero.png"
1862 && matches!(i.imported_name, ImportedName::SideEffect)
1863 && !i.from_style),
1864 "template <img src> should seed a SideEffect import: {:?}",
1865 info.imports
1866 );
1867 }
1868
1869 fn svelte_props(source: &str) -> Vec<crate::ModuleInfo> {
1872 vec![parse_sfc_to_module(
1873 FileId(0),
1874 Path::new("Component.svelte"),
1875 source,
1876 0,
1877 false,
1878 )]
1879 }
1880
1881 fn prop_names(info: &crate::ModuleInfo) -> Vec<String> {
1882 let mut names: Vec<String> = info
1883 .component_props
1884 .iter()
1885 .map(|p| p.name.clone())
1886 .collect();
1887 names.sort();
1888 names
1889 }
1890
1891 #[test]
1892 fn svelte_shorthand_props_harvested() {
1893 let info = &svelte_props(r"<script>let { a, b } = $props();</script>")[0];
1895 assert_eq!(prop_names(info), vec!["a", "b"]);
1896 for prop in &info.component_props {
1897 assert_eq!(prop.local, prop.name);
1898 }
1899 }
1900
1901 #[test]
1902 fn svelte_renamed_prop_tracks_local_and_script_use() {
1903 let info =
1906 &svelte_props(r"<script>let { a: alias } = $props(); console.log(alias);</script>")[0];
1907 assert_eq!(prop_names(info), vec!["a"]);
1908 let prop = &info.component_props[0];
1909 assert_eq!(prop.local, "alias");
1910 assert!(
1911 prop.used_in_script,
1912 "alias is referenced, so a is used in script"
1913 );
1914 }
1915
1916 #[test]
1917 fn svelte_unreferenced_prop_is_unused_in_script() {
1918 let info = &svelte_props(r"<script>let { a } = $props();</script>")[0];
1919 assert_eq!(prop_names(info), vec!["a"]);
1920 assert!(!info.component_props[0].used_in_script);
1921 }
1922
1923 #[test]
1924 fn svelte_default_prop_peeled() {
1925 let info = &svelte_props(r"<script>let { a = 1 } = $props();</script>")[0];
1927 assert_eq!(prop_names(info), vec!["a"]);
1928 }
1929
1930 #[test]
1931 fn svelte_bindable_default_peeled() {
1932 let info = &svelte_props(r"<script>let { a = $bindable() } = $props();</script>")[0];
1935 assert_eq!(prop_names(info), vec!["a"]);
1936 }
1937
1938 #[test]
1939 fn svelte_rest_element_sets_fallthrough_abstain() {
1940 let info = &svelte_props(r"<script>let { a, ...rest } = $props();</script>")[0];
1942 assert!(info.has_props_attrs_fallthrough);
1943 }
1944
1945 #[test]
1946 fn svelte_bare_identifier_binding_sets_unharvestable_abstain() {
1947 let info = &svelte_props(r"<script>let p = $props(); console.log(p.x);</script>")[0];
1949 assert!(info.has_unharvestable_props);
1950 assert!(info.component_props.is_empty());
1951 }
1952
1953 #[test]
1954 fn svelte_nested_destructure_sets_unharvestable_abstain() {
1955 let info = &svelte_props(r"<script>let { a: { x } } = $props();</script>")[0];
1957 assert!(info.has_unharvestable_props);
1958 }
1959
1960 #[test]
1961 fn svelte_prop_used_only_in_markup_credited_as_template_root() {
1962 let info = &svelte_props(r"<script>let { a } = $props();</script><p>{a}</p>")[0];
1965 assert_eq!(prop_names(info), vec!["a"]);
1966 assert!(
1967 info.component_props[0].used_in_template,
1968 "a is used in markup, so used_in_template should be true"
1969 );
1970 }
1971
1972 #[test]
1973 fn svelte_module_script_props_not_harvested() {
1974 let info = &svelte_props(
1976 r"<script module>let { a } = $props();</script><script>let { b } = $props();</script>",
1977 )[0];
1978 assert_eq!(prop_names(info), vec!["b"]);
1980 }
1981
1982 fn dispatched_names(info: &crate::ModuleInfo) -> Vec<String> {
1985 let mut names: Vec<String> = info
1986 .svelte_dispatched_events
1987 .iter()
1988 .map(|e| e.name.clone())
1989 .collect();
1990 names.sort();
1991 names
1992 }
1993
1994 #[test]
1995 fn svelte_dispatch_literal_event_is_harvested() {
1996 let info = &svelte_props(
1997 r"<script>import { createEventDispatcher } from 'svelte';
1998 const dispatch = createEventDispatcher();
1999 function save() { dispatch('save'); }</script>",
2000 )[0];
2001 assert_eq!(dispatched_names(info), vec!["save"]);
2002 assert!(!info.has_dynamic_dispatch);
2003 }
2004
2005 #[test]
2006 fn svelte_dispatch_without_svelte_import_is_ignored() {
2007 let info = &svelte_props(
2010 r"<script>function createEventDispatcher() { return () => {}; }
2011 const dispatch = createEventDispatcher();
2012 dispatch('save');</script>",
2013 )[0];
2014 assert!(info.svelte_dispatched_events.is_empty());
2015 }
2016
2017 #[test]
2018 fn svelte_dynamic_dispatch_sets_abstain() {
2019 let info = &svelte_props(
2020 r"<script>import { createEventDispatcher } from 'svelte';
2021 const dispatch = createEventDispatcher();
2022 function fire(name) { dispatch(name); }</script>",
2023 )[0];
2024 assert!(
2025 info.has_dynamic_dispatch,
2026 "a non-literal dispatch arg must set the abstain flag"
2027 );
2028 }
2029
2030 #[test]
2031 fn svelte_dispatch_whole_value_use_sets_abstain() {
2032 let info = &svelte_props(
2033 r"<script>import { createEventDispatcher } from 'svelte';
2034 const dispatch = createEventDispatcher();
2035 forward(dispatch);</script>",
2036 )[0];
2037 assert!(
2038 info.has_dynamic_dispatch,
2039 "passing the dispatch binding as a whole value must set the abstain flag"
2040 );
2041 }
2042
2043 #[test]
2044 fn svelte_listened_event_on_component_is_harvested() {
2045 let info =
2046 &svelte_props(r"<script>import Child from './Child.svelte';</script><Child on:save />")
2047 [0];
2048 assert!(info.svelte_listened_events.contains(&"save".to_string()));
2049 }
2050}