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::compute_import_binding_usage;
23use crate::sfc_template::{SfcKind, collect_template_usage_with_bound_targets};
24use crate::source_map::ExtractionResult;
25use crate::visitor::ModuleInfoExtractor;
26use crate::{ImportInfo, ImportedName, ModuleInfo};
27use fallow_types::discover::FileId;
28use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
29use oxc_span::Span;
30
31static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
34 crate::static_regex(
35 r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
36 )
37});
38
39static LANG_ATTR_RE: LazyLock<regex::Regex> =
41 LazyLock::new(|| crate::static_regex(r#"lang\s*=\s*["'](\w+)["']"#));
42
43static SRC_ATTR_RE: LazyLock<regex::Regex> =
46 LazyLock::new(|| crate::static_regex(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#));
47
48static SETUP_ATTR_RE: LazyLock<regex::Regex> =
50 LazyLock::new(|| crate::static_regex(r"(?:^|\s)setup(?:\s|$)"));
51
52static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
54 LazyLock::new(|| crate::static_regex(r#"context\s*=\s*["']module["']"#));
55
56static SVELTE_MODULE_ATTR_RE: LazyLock<regex::Regex> =
61 LazyLock::new(|| crate::static_regex(r"(?:^|\s)module(?:\s|$|=)"));
62
63static VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
67 crate::static_regex(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
68});
69
70static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
73 crate::static_regex(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
74});
75
76static HTML_COMMENT_RE: LazyLock<regex::Regex> =
78 LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
79
80static PROPS_ATTRS_SPREAD_RE: LazyLock<regex::Regex> =
85 LazyLock::new(|| crate::static_regex(r#"v-bind\s*=\s*["'](?:\$attrs|\$props|props)["']"#));
86
87static SVELTE_TEMPLATE_DATA_WHOLE_USE_RE: LazyLock<regex::Regex> =
95 LazyLock::new(|| crate::static_regex(r"(?:=\s*\{\s*data\s*\}|\{\s*\.\.\.\s*data\s*\})"));
96
97static TEMPLATE_EMIT_CALL_RE: LazyLock<regex::Regex> =
107 LazyLock::new(|| crate::static_regex(r#"([\w$]+)\s*\(\s*(?:'([\w:-]*)'|"([\w:-]*)"|(\S))"#));
108
109static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
113 crate::static_regex(
114 r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
115 )
116});
117
118static TEMPLATE_ASSET_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
133 crate::static_regex(
134 r#"(?si)<(?:img|source|video|audio|track|embed)\b(?:[^>"']|"[^"]*"|'[^']*')*?\s(?:src|poster)\s*=\s*(?:"((?:\./|\.\./)[^"<>{}?#\s]*)"|'((?:\./|\.\./)[^'<>{}?#\s]*)')"#,
135 )
136});
137
138fn mask_non_markup_regions(source: &str) -> String {
142 let mut masked = source.to_string();
143 for re in [&*SCRIPT_BLOCK_RE, &*STYLE_BLOCK_RE, &*HTML_COMMENT_RE] {
144 masked = re
145 .replace_all(&masked, |caps: ®ex::Captures<'_>| {
146 " ".repeat(caps[0].len())
147 })
148 .into_owned();
149 }
150 masked
151}
152
153fn collect_template_asset_refs(source: &str) -> Vec<(String, Span)> {
156 let masked = mask_non_markup_regions(source);
157 let mut refs = Vec::new();
158 for caps in TEMPLATE_ASSET_RE.captures_iter(&masked) {
159 let Some(value) = caps.get(1).or_else(|| caps.get(2)) else {
160 continue;
161 };
162 let raw = value.as_str();
163 if raw.is_empty() {
164 continue;
165 }
166 refs.push((
167 normalize_asset_url(raw),
168 Span::new(value.start() as u32, value.end() as u32),
169 ));
170 }
171 refs
172}
173
174pub struct SfcScript {
176 pub body: String,
178 pub is_typescript: bool,
180 pub is_jsx: bool,
182 pub byte_offset: usize,
184 pub src: Option<String>,
186 pub src_span: Option<Span>,
188 pub is_setup: bool,
190 pub is_context_module: bool,
192 pub generic_attr: Option<String>,
196}
197
198pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
200 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
201 .find_iter(source)
202 .map(|m| (m.start(), m.end()))
203 .collect();
204
205 SCRIPT_BLOCK_RE
206 .captures_iter(source)
207 .filter(|cap| {
208 let start = cap.get(0).map_or(0, |m| m.start());
209 !comment_ranges
210 .iter()
211 .any(|&(cs, ce)| start >= cs && start < ce)
212 })
213 .map(|cap| {
214 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
215 let body_match = cap.name("body");
216 let byte_offset = body_match.map_or(0, |m| m.start());
217 let body = body_match.map_or("", |m| m.as_str()).to_string();
218 let lang = LANG_ATTR_RE
219 .captures(attrs)
220 .and_then(|c| c.get(1))
221 .map(|m| m.as_str());
222 let is_typescript = matches!(lang, Some("ts" | "tsx"));
223 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
224 let src = SRC_ATTR_RE
225 .captures(attrs)
226 .and_then(|c| c.get(1))
227 .map(|m| m.as_str().to_string());
228 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
229 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
230 Span::new(
231 (attrs_start + m.start()) as u32,
232 (attrs_start + m.end()) as u32,
233 )
234 });
235 let is_setup = SETUP_ATTR_RE.is_match(attrs);
236 let is_context_module =
241 CONTEXT_MODULE_ATTR_RE.is_match(attrs) || SVELTE_MODULE_ATTR_RE.is_match(attrs);
242 let generic_attr = VUE_GENERIC_ATTR_RE
243 .captures(attrs)
244 .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
245 .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
246 .map(|m| m.as_str().to_string())
247 .filter(|value| !value.trim().is_empty());
248 SfcScript {
249 body,
250 is_typescript,
251 is_jsx,
252 byte_offset,
253 src,
254 src_span,
255 is_setup,
256 is_context_module,
257 generic_attr,
258 }
259 })
260 .collect()
261}
262
263pub struct SfcStyle {
265 pub body: String,
267 pub lang: Option<String>,
270 pub src: Option<String>,
272 pub src_span: Option<Span>,
274 pub byte_offset: usize,
276}
277
278pub struct SourceRegion {
281 pub body: String,
283 pub byte_offset: usize,
285}
286
287#[must_use]
293pub fn extract_sfc_template_regions(source: &str) -> Vec<SourceRegion> {
294 let mut ranges: Vec<(usize, usize)> = SCRIPT_BLOCK_RE
295 .find_iter(source)
296 .chain(STYLE_BLOCK_RE.find_iter(source))
297 .chain(HTML_COMMENT_RE.find_iter(source))
298 .map(|m| (m.start(), m.end()))
299 .collect();
300 ranges.sort_unstable_by_key(|(start, _)| *start);
301 ranges_to_gaps(source, &ranges)
302}
303
304pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
311 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
312 .find_iter(source)
313 .map(|m| (m.start(), m.end()))
314 .collect();
315
316 STYLE_BLOCK_RE
317 .captures_iter(source)
318 .filter(|cap| {
319 let start = cap.get(0).map_or(0, |m| m.start());
320 !comment_ranges
321 .iter()
322 .any(|&(cs, ce)| start >= cs && start < ce)
323 })
324 .map(|cap| {
325 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
326 let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
327 let byte_offset = cap.name("body").map_or(0, |m| m.start());
328 let lang = LANG_ATTR_RE
329 .captures(attrs)
330 .and_then(|c| c.get(1))
331 .map(|m| m.as_str().to_string());
332 let src = SRC_ATTR_RE
333 .captures(attrs)
334 .and_then(|c| c.get(1))
335 .map(|m| m.as_str().to_string());
336 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
337 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
338 Span::new(
339 (attrs_start + m.start()) as u32,
340 (attrs_start + m.end()) as u32,
341 )
342 });
343 SfcStyle {
344 body,
345 lang,
346 src,
347 src_span,
348 byte_offset,
349 }
350 })
351 .collect()
352}
353
354fn ranges_to_gaps(source: &str, ranges: &[(usize, usize)]) -> Vec<SourceRegion> {
355 let mut regions = Vec::new();
356 let mut cursor = 0;
357 for &(start, end) in ranges {
358 if start > cursor {
359 push_region(source, cursor, start, &mut regions);
360 }
361 cursor = cursor.max(end);
362 }
363 if cursor < source.len() {
364 push_region(source, cursor, source.len(), &mut regions);
365 }
366 regions
367}
368
369fn push_region(source: &str, start: usize, end: usize, regions: &mut Vec<SourceRegion>) {
370 let Some(body) = source.get(start..end) else {
371 return;
372 };
373 if body.trim().is_empty() {
374 return;
375 }
376 regions.push(SourceRegion {
377 body: body.to_string(),
378 byte_offset: start,
379 });
380}
381
382#[must_use]
384pub fn is_sfc_file(path: &Path) -> bool {
385 path.extension()
386 .and_then(|e| e.to_str())
387 .is_some_and(|ext| ext == "vue" || ext == "svelte")
388}
389
390pub(crate) fn parse_sfc_to_module(
392 file_id: FileId,
393 path: &Path,
394 source: &str,
395 content_hash: u64,
396 need_complexity: bool,
397) -> ModuleInfo {
398 let scripts = extract_sfc_scripts(source);
399 let styles = extract_sfc_styles(source);
400 let kind = sfc_kind(path);
401 let mut combined = empty_sfc_module(file_id, source, content_hash);
402 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
403 let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
404 let mut template_visible_iterable_types: FxHashMap<String, String> = FxHashMap::default();
405 let mut props_return_binding: Option<String> = None;
406 let mut emit_return_binding: Option<String> = None;
407
408 for script in &scripts {
409 merge_script_into_module(&mut SfcScriptMergeInput {
410 kind,
411 script,
412 combined: &mut combined,
413 template_visible_imports: &mut template_visible_imports,
414 template_visible_bound_targets: &mut template_visible_bound_targets,
415 template_visible_iterable_types: &mut template_visible_iterable_types,
416 props_return_binding: &mut props_return_binding,
417 emit_return_binding: &mut emit_return_binding,
418 need_complexity,
419 });
420 }
421
422 for style in &styles {
423 merge_style_into_module(style, &mut combined);
424 }
425
426 if kind == SfcKind::Vue
430 && !combined.component_props.is_empty()
431 && PROPS_ATTRS_SPREAD_RE.is_match(source)
432 {
433 combined.has_props_attrs_fallthrough = true;
434 }
435
436 apply_template_usage(TemplateUsageInput {
437 kind,
438 source,
439 template_visible_imports: &template_visible_imports,
440 template_visible_bound_targets: &template_visible_bound_targets,
441 template_visible_iterable_types: &template_visible_iterable_types,
442 props_return_binding: props_return_binding.as_deref(),
443 credit_load_data: kind == SfcKind::Svelte && is_sveltekit_route_data_component(path),
444 combined: &mut combined,
445 });
446
447 if need_complexity {
448 append_template_complexity(kind, source, &mut combined);
449 }
450
451 if kind == SfcKind::Vue && !combined.component_emits.is_empty() {
455 apply_template_emit_usage(source, emit_return_binding.as_deref(), &mut combined);
456 }
457
458 if kind == SfcKind::Svelte {
462 combined.svelte_listened_events =
463 crate::sfc_template::collect_svelte_listened_events(source);
464 }
465
466 append_template_asset_imports(source, &mut combined);
467 dedup_import_binding_lists(&mut combined);
468
469 combined
470}
471
472fn append_template_complexity(kind: SfcKind, source: &str, combined: &mut ModuleInfo) {
480 match kind {
481 SfcKind::Vue => {
482 combined.complexity.extend(
483 crate::template_complexity::compute_vue_template_complexity(source),
484 );
485 }
486 SfcKind::Svelte => combined
489 .complexity
490 .extend(crate::template_complexity::compute_svelte_template_complexity(source)),
491 }
492}
493
494fn append_template_asset_imports(source: &str, combined: &mut ModuleInfo) {
498 for (specifier, span) in collect_template_asset_refs(source) {
499 combined.imports.push(ImportInfo {
500 source: specifier,
501 imported_name: ImportedName::SideEffect,
502 local_name: String::new(),
503 is_type_only: false,
504 is_type_only_star: false,
505 from_style: false,
506 span,
507 source_span: span,
508 });
509 }
510}
511
512fn dedup_import_binding_lists(combined: &mut ModuleInfo) {
515 combined.unused_import_bindings.sort_unstable();
516 combined.unused_import_bindings.dedup();
517 combined.type_referenced_import_bindings.sort_unstable();
518 combined.type_referenced_import_bindings.dedup();
519 combined.value_referenced_import_bindings.sort_unstable();
520 combined.value_referenced_import_bindings.dedup();
521 combined.auto_import_candidates.sort_unstable();
522 combined.auto_import_candidates.dedup();
523}
524
525fn sfc_kind(path: &Path) -> SfcKind {
526 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
527 SfcKind::Vue
528 } else {
529 SfcKind::Svelte
530 }
531}
532
533fn is_sveltekit_route_data_component(path: &Path) -> bool {
544 let Some(stem) = path
545 .file_name()
546 .and_then(|name| name.to_str())
547 .and_then(|name| name.strip_suffix(".svelte"))
548 else {
549 return false;
550 };
551 ["+page", "+layout"].iter().any(|prefix| {
552 stem.strip_prefix(prefix)
553 .is_some_and(|rest| rest.is_empty() || rest.starts_with('@'))
554 })
555}
556
557fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
558 let parsed = crate::suppress::parse_suppressions_from_source(source);
559
560 crate::module_info::non_js_module_info(crate::module_info::NonJsModuleInfoInput {
561 file_id,
562 content_hash,
563 source,
564 parsed_suppressions: parsed,
565 imports: Vec::new(),
566 exports: Vec::new(),
567 })
568}
569
570struct SfcScriptMergeInput<'a> {
571 kind: SfcKind,
572 script: &'a SfcScript,
573 combined: &'a mut ModuleInfo,
574 template_visible_imports: &'a mut FxHashSet<String>,
575 template_visible_bound_targets: &'a mut FxHashMap<String, String>,
576 template_visible_iterable_types: &'a mut FxHashMap<String, String>,
577 props_return_binding: &'a mut Option<String>,
578 emit_return_binding: &'a mut Option<String>,
579 need_complexity: bool,
580}
581
582fn merge_script_into_module(input: &mut SfcScriptMergeInput<'_>) {
583 if input.kind == SfcKind::Vue
584 && let Some(src) = &input.script.src
585 {
586 add_script_src_import(input.combined, src, input.script.src_span);
587 }
588
589 let allocator = Allocator::default();
590 let parser_return = Parser::new(
591 &allocator,
592 &input.script.body,
593 source_type_for_script(input.script),
594 )
595 .parse();
596 let mut extractor = ModuleInfoExtractor::new();
597 extractor.visit_program(&parser_return.program);
598 let empty_template_used = FxHashSet::default();
599 let semantic_usage = crate::parse::compute_semantic_usage_for_extractor(
600 &parser_return.program,
601 &mut extractor,
602 &empty_template_used,
603 );
604 let extraction = ExtractionResult::contiguous(&input.script.body, input.script.byte_offset);
605 extractor.remap_spans_with(|span| extraction.remap_span(span));
606 extractor.resolve_typed_destructure_bindings();
607
608 merge_script_binding_usage(
609 input,
610 &allocator,
611 &extractor.imports,
612 &extractor.import_equals_bindings,
613 semantic_usage,
614 );
615 if input.need_complexity {
616 input
617 .combined
618 .complexity
619 .extend(translate_script_complexity(
620 input.script,
621 &parser_return.program,
622 &input.combined.line_offsets,
623 ));
624 }
625
626 if input.kind == SfcKind::Vue {
630 merge_vue_props_emits_into(input, &parser_return.program, &mut extractor);
631 }
632
633 if input.kind == SfcKind::Svelte && is_template_visible_script(input.kind, input.script) {
638 merge_svelte_props_into(
639 input.combined,
640 &parser_return.program,
641 input.script.byte_offset,
642 );
643 }
644
645 if is_template_visible_script(input.kind, input.script) {
646 harvest_template_visible_bindings(input, &extractor);
647 }
648
649 let dispatch_base = input.combined.svelte_dispatched_events.len();
654 extractor.merge_into(input.combined);
655 for event in &mut input.combined.svelte_dispatched_events[dispatch_base..] {
656 event.span_start += input.script.byte_offset as u32;
657 }
658}
659
660fn merge_script_binding_usage(
671 input: &mut SfcScriptMergeInput<'_>,
672 allocator: &Allocator,
673 imports: &[ImportInfo],
674 import_equals_bindings: &[String],
675 semantic_usage: crate::parse::SemanticUsage,
676) {
677 let augmented_body = build_generic_attr_probe_source(input.script);
678 let empty_template_used = FxHashSet::default();
679 let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
680 {
681 let augmented_return =
682 Parser::new(allocator, augmented, source_type_for_script(input.script)).parse();
683 (
684 compute_import_binding_usage(
685 &augmented_return.program,
686 imports,
687 import_equals_bindings,
688 &empty_template_used,
689 ),
690 semantic_usage.auto_import_candidates,
691 )
692 } else {
693 (
694 semantic_usage.import_binding_usage,
695 semantic_usage.auto_import_candidates,
696 )
697 };
698 crate::parse::append_declaration_merge_facts(
699 &mut input.combined.semantic_facts,
700 semantic_usage.declaration_merges,
701 input.script.byte_offset as u32,
702 );
703 input
704 .combined
705 .unused_import_bindings
706 .extend(binding_usage.unused.iter().cloned());
707 input
708 .combined
709 .type_referenced_import_bindings
710 .extend(binding_usage.type_referenced.iter().cloned());
711 input
712 .combined
713 .value_referenced_import_bindings
714 .extend(binding_usage.value_referenced.iter().cloned());
715 input
716 .combined
717 .auto_import_candidates
718 .extend(auto_import_candidates);
719}
720
721fn harvest_template_visible_bindings(
725 input: &mut SfcScriptMergeInput<'_>,
726 extractor: &ModuleInfoExtractor,
727) {
728 input.template_visible_imports.extend(
729 extractor
730 .imports
731 .iter()
732 .filter(|import| !import.local_name.is_empty())
733 .map(|import| import.local_name.clone()),
734 );
735 input.template_visible_bound_targets.extend(
736 extractor
737 .binding_target_names()
738 .iter()
739 .filter(|(local, _)| !local.starts_with("this."))
740 .filter_map(|(local, target)| {
741 target
742 .class_name()
743 .map(|class_name| (local.clone(), class_name.to_string()))
744 }),
745 );
746 input.template_visible_iterable_types.extend(
750 extractor
751 .array_binding_element_types()
752 .iter()
753 .filter(|(local, _)| !local.starts_with("this."))
754 .map(|(local, element)| (local.clone(), element.clone())),
755 );
756}
757
758fn merge_svelte_props_into(
762 combined: &mut ModuleInfo,
763 program: &oxc_ast::ast::Program<'_>,
764 byte_offset: usize,
765) {
766 let harvest = crate::sfc_props::harvest_svelte_props(program);
767 if harvest.has_unharvestable_props {
768 combined.has_unharvestable_props = true;
769 }
770 if harvest.has_props_attrs_fallthrough {
771 combined.has_props_attrs_fallthrough = true;
772 }
773 for mut prop in harvest.props {
774 prop.span_start += byte_offset as u32;
775 combined.component_props.push(prop);
776 }
777}
778
779fn merge_vue_props_emits_into(
786 input: &mut SfcScriptMergeInput<'_>,
787 program: &oxc_ast::ast::Program<'_>,
788 extractor: &mut ModuleInfoExtractor,
789) {
790 let byte_offset = input.script.byte_offset as u32;
791 if input.script.is_setup {
792 apply_props_harvest(
793 input,
794 crate::sfc_props::harvest_define_props(program),
795 byte_offset,
796 extractor,
797 );
798 apply_emits_harvest(
799 input,
800 crate::sfc_props::harvest_define_emits(program),
801 byte_offset,
802 );
803 } else {
804 apply_props_harvest(
805 input,
806 crate::sfc_props::harvest_options_api_props(program),
807 byte_offset,
808 extractor,
809 );
810 apply_emits_harvest(
811 input,
812 crate::sfc_props::harvest_options_api_emits(program),
813 byte_offset,
814 );
815 }
816}
817
818fn apply_props_harvest(
824 input: &mut SfcScriptMergeInput<'_>,
825 harvest: crate::sfc_props::DefinePropsHarvest,
826 byte_offset: u32,
827 extractor: &mut ModuleInfoExtractor,
828) {
829 if harvest.has_unharvestable_props {
830 input.combined.has_unharvestable_props = true;
831 }
832 if harvest.has_props_attrs_fallthrough {
833 input.combined.has_props_attrs_fallthrough = true;
834 }
835 if harvest.has_define_expose {
836 input.combined.has_define_expose = true;
837 }
838 if harvest.has_define_model {
839 input.combined.has_define_model = true;
840 }
841 if let Some(binding) = harvest.props_return_binding {
842 *input.props_return_binding = Some(binding);
843 }
844 for (field_name, element_type) in harvest.props_array_element_types {
852 extractor
853 .array_binding_element_types_mut()
854 .insert(format!("props.{field_name}"), element_type);
855 }
856 for mut prop in harvest.props {
857 prop.span_start += byte_offset;
858 input.combined.component_props.push(prop);
859 }
860}
861
862fn apply_emits_harvest(
868 input: &mut SfcScriptMergeInput<'_>,
869 harvest: crate::sfc_props::DefineEmitsHarvest,
870 byte_offset: u32,
871) {
872 if harvest.has_unharvestable_emits {
873 input.combined.has_unharvestable_emits = true;
874 }
875 if harvest.has_dynamic_emit {
876 input.combined.has_dynamic_emit = true;
877 }
878 if harvest.has_emit_whole_object_use {
879 input.combined.has_emit_whole_object_use = true;
880 }
881 if let Some(binding) = harvest.emit_binding {
882 *input.emit_return_binding = Some(binding);
883 }
884 for mut emit in harvest.emits {
885 emit.span_start += byte_offset;
886 input.combined.component_emits.push(emit);
887 }
888}
889
890fn translate_script_complexity(
891 script: &SfcScript,
892 program: &oxc_ast::ast::Program<'_>,
893 sfc_line_offsets: &[u32],
894) -> Vec<FunctionComplexity> {
895 let script_line_offsets = compute_line_offsets(&script.body);
896 let mut complexity =
897 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
898 let (body_start_line, body_start_col) =
899 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
900
901 for function in &mut complexity {
902 function.line = body_start_line + function.line.saturating_sub(1);
903 if function.line == body_start_line {
904 function.col += body_start_col;
905 }
906 }
907
908 complexity
909}
910
911fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
912 let span = source_span.unwrap_or_default();
913 module.imports.push(ImportInfo {
914 source: normalize_asset_url(source),
915 imported_name: ImportedName::SideEffect,
916 local_name: String::new(),
917 is_type_only: false,
918 is_type_only_star: false,
919 from_style: false,
920 span,
921 source_span: span,
922 });
923}
924
925fn style_lang_is_scss(lang: Option<&str>) -> bool {
931 matches!(lang, Some("scss" | "sass"))
932}
933
934fn style_lang_is_css_like(lang: Option<&str>) -> bool {
935 lang.is_none() || matches!(lang, Some("css"))
936}
937
938fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
939 if let Some(src) = &style.src {
940 let span = style.src_span.unwrap_or_default();
941 combined.imports.push(ImportInfo {
942 source: normalize_asset_url(src),
943 imported_name: ImportedName::SideEffect,
944 local_name: String::new(),
945 is_type_only: false,
946 is_type_only_star: false,
947 from_style: true,
948 span,
949 source_span: span,
950 });
951 }
952
953 let lang = style.lang.as_deref();
954 let is_scss = style_lang_is_scss(lang);
955 let is_css_like = style_lang_is_css_like(lang);
956 if !is_scss && !is_css_like {
957 return;
958 }
959
960 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
961 let source_span = Span::new(
962 style.byte_offset as u32 + source.span.start,
963 style.byte_offset as u32 + source.span.end,
964 );
965 combined.imports.push(ImportInfo {
966 source: source.normalized,
967 imported_name: if source.is_plugin {
968 ImportedName::Default
969 } else {
970 ImportedName::SideEffect
971 },
972 local_name: String::new(),
973 is_type_only: false,
974 is_type_only_star: false,
975 from_style: true,
976 span: source_span,
977 source_span,
978 });
979 }
980}
981
982fn source_type_for_script(script: &SfcScript) -> SourceType {
983 match (script.is_typescript, script.is_jsx) {
984 (true, true) => SourceType::tsx(),
985 (true, false) => SourceType::ts(),
986 (false, true) => SourceType::jsx(),
987 (false, false) => SourceType::mjs(),
988 }
989}
990
991fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
997 let constraint = script.generic_attr.as_deref()?.trim();
998 if constraint.is_empty() {
999 return None;
1000 }
1001 Some(format!(
1002 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
1003 script.body, constraint,
1004 ))
1005}
1006
1007struct TemplateUsageInput<'a> {
1008 kind: SfcKind,
1009 source: &'a str,
1010 template_visible_imports: &'a FxHashSet<String>,
1011 template_visible_bound_targets: &'a FxHashMap<String, String>,
1012 template_visible_iterable_types: &'a FxHashMap<String, String>,
1013 props_return_binding: Option<&'a str>,
1014 credit_load_data: bool,
1015 combined: &'a mut ModuleInfo,
1016}
1017
1018fn apply_template_usage(input: TemplateUsageInput<'_>) {
1019 let TemplateUsageInput {
1020 kind,
1021 source,
1022 template_visible_imports,
1023 template_visible_bound_targets,
1024 template_visible_iterable_types,
1025 props_return_binding,
1026 credit_load_data,
1027 combined,
1028 } = input;
1029 let credited = build_template_credited_set(
1030 template_visible_imports,
1031 props_return_binding,
1032 credit_load_data,
1033 source,
1034 combined,
1035 );
1036 let template_usage = compute_template_usage(
1037 kind,
1038 source,
1039 &credited,
1040 template_visible_bound_targets,
1041 template_visible_iterable_types,
1042 credit_load_data,
1043 );
1044 apply_prop_template_credit(&template_usage, props_return_binding, combined);
1045 merge_template_usage_into_combined(template_usage, combined);
1046}
1047
1048fn build_template_credited_set(
1054 template_visible_imports: &FxHashSet<String>,
1055 props_return_binding: Option<&str>,
1056 credit_load_data: bool,
1057 source: &str,
1058 combined: &mut ModuleInfo,
1059) -> FxHashSet<String> {
1060 let mut credited: FxHashSet<String> = template_visible_imports.clone();
1061 if credit_load_data {
1066 credited.insert("data".to_string());
1067 if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
1070 combined.has_load_data_whole_use = true;
1071 }
1072 }
1073 if !combined.component_props.is_empty() {
1074 for prop in &combined.component_props {
1075 credited.insert(prop.name.clone());
1078 credited.insert(prop.local.clone());
1079 }
1080 credited.insert("$props".to_string());
1083 if let Some(binding) = props_return_binding {
1084 credited.insert(binding.to_string());
1085 }
1086 }
1087 credited
1088}
1089
1090fn compute_template_usage(
1095 kind: SfcKind,
1096 source: &str,
1097 credited: &FxHashSet<String>,
1098 template_visible_bound_targets: &FxHashMap<String, String>,
1099 template_visible_iterable_types: &FxHashMap<String, String>,
1100 credit_load_data: bool,
1101) -> crate::template_usage::TemplateUsage {
1102 if credit_load_data && template_visible_bound_targets.contains_key("data") {
1103 let mut filtered = template_visible_bound_targets.clone();
1104 filtered.remove("data");
1105 collect_template_usage_with_bound_targets(
1106 kind,
1107 source,
1108 credited,
1109 &filtered,
1110 template_visible_iterable_types,
1111 )
1112 } else {
1113 collect_template_usage_with_bound_targets(
1114 kind,
1115 source,
1116 credited,
1117 template_visible_bound_targets,
1118 template_visible_iterable_types,
1119 )
1120 }
1121}
1122
1123fn apply_prop_template_credit(
1128 template_usage: &crate::template_usage::TemplateUsage,
1129 props_return_binding: Option<&str>,
1130 combined: &mut ModuleInfo,
1131) {
1132 if !combined.component_props.is_empty() {
1133 let member_used: FxHashSet<&str> = template_usage
1134 .member_accesses
1135 .iter()
1136 .filter(|access| {
1137 access.object == "$props"
1138 || props_return_binding.is_some_and(|binding| access.object == binding)
1139 })
1140 .map(|access| access.member.as_str())
1141 .collect();
1142 for prop in &mut combined.component_props {
1143 if template_usage.used_bindings.contains(&prop.name)
1144 || template_usage.used_bindings.contains(&prop.local)
1145 || member_used.contains(prop.name.as_str())
1146 {
1147 prop.used_in_template = true;
1148 }
1149 }
1150 }
1151
1152 if let Some(binding) = props_return_binding
1153 && (template_usage.used_bindings.contains(binding)
1154 || template_usage
1155 .whole_object_uses
1156 .iter()
1157 .any(|used| used == binding))
1158 {
1159 combined.has_props_attrs_fallthrough = true;
1160 }
1161}
1162
1163fn merge_template_usage_into_combined(
1168 template_usage: crate::template_usage::TemplateUsage,
1169 combined: &mut ModuleInfo,
1170) {
1171 combined
1172 .unused_import_bindings
1173 .retain(|binding| !template_usage.used_bindings.contains(binding));
1174 let mut member_accesses = std::mem::take(&mut combined.member_accesses).to_vec();
1175 member_accesses.extend(template_usage.member_accesses);
1176 combined.member_accesses = member_accesses.into();
1177 let mut whole_object_uses = std::mem::take(&mut combined.whole_object_uses).to_vec();
1178 whole_object_uses.extend(template_usage.whole_object_uses);
1179 combined.whole_object_uses = whole_object_uses.into();
1180 combined
1181 .security_sinks
1182 .extend(template_usage.security_sinks);
1183 if !template_usage.unresolved_tag_names.is_empty() {
1184 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
1185 names.sort_unstable();
1186 combined.auto_import_candidates.extend(names);
1187 combined.auto_import_candidates.dedup();
1188 }
1189}
1190
1191fn apply_template_emit_usage(
1209 source: &str,
1210 emit_return_binding: Option<&str>,
1211 combined: &mut ModuleInfo,
1212) {
1213 let masked = mask_non_markup_regions(source);
1214 let mut used: FxHashSet<String> = FxHashSet::default();
1215 let mut dynamic = false;
1216
1217 for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
1218 let Some(callee) = caps.get(1) else {
1219 continue;
1220 };
1221 let callee = callee.as_str();
1222 let is_emit_call =
1223 callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
1224 if !is_emit_call {
1225 continue;
1226 }
1227 if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
1228 used.insert(event.as_str().to_string());
1231 } else if caps.get(4).is_some() {
1232 dynamic = true;
1235 }
1236 }
1237
1238 if dynamic {
1239 combined.has_dynamic_emit = true;
1240 }
1241 if !used.is_empty() {
1242 for emit in &mut combined.component_emits {
1243 if used.contains(&emit.name) {
1244 emit.used = true;
1245 }
1246 }
1247 }
1248}
1249
1250fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
1251 match kind {
1252 SfcKind::Vue => script.is_setup,
1253 SfcKind::Svelte => !script.is_context_module,
1254 }
1255}
1256
1257#[cfg(all(test, not(miri)))]
1258mod tests {
1259 use super::*;
1260 use fallow_types::extract::{
1261 ClassThisMemberAccessFact, ClassThisWholeObjectUseFact, SemanticFactView,
1262 };
1263
1264 #[test]
1265 fn is_sfc_file_vue() {
1266 assert!(is_sfc_file(Path::new("App.vue")));
1267 }
1268
1269 #[test]
1270 fn is_sfc_file_svelte() {
1271 assert!(is_sfc_file(Path::new("Counter.svelte")));
1272 }
1273
1274 #[test]
1275 fn is_sfc_file_rejects_ts() {
1276 assert!(!is_sfc_file(Path::new("utils.ts")));
1277 }
1278
1279 #[test]
1280 fn is_sfc_file_rejects_jsx() {
1281 assert!(!is_sfc_file(Path::new("App.jsx")));
1282 }
1283
1284 #[test]
1285 fn is_sfc_file_rejects_astro() {
1286 assert!(!is_sfc_file(Path::new("Layout.astro")));
1287 }
1288
1289 #[test]
1290 fn single_plain_script() {
1291 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1292 assert_eq!(scripts.len(), 1);
1293 assert_eq!(scripts[0].body, "const x = 1;");
1294 assert!(!scripts[0].is_typescript);
1295 assert!(!scripts[0].is_jsx);
1296 assert!(scripts[0].src.is_none());
1297 }
1298
1299 #[test]
1300 fn single_ts_script() {
1301 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1302 assert_eq!(scripts.len(), 1);
1303 assert!(scripts[0].is_typescript);
1304 assert!(!scripts[0].is_jsx);
1305 }
1306
1307 #[test]
1308 fn single_tsx_script() {
1309 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1310 assert_eq!(scripts.len(), 1);
1311 assert!(scripts[0].is_typescript);
1312 assert!(scripts[0].is_jsx);
1313 }
1314
1315 #[test]
1316 fn single_jsx_script() {
1317 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1318 assert_eq!(scripts.len(), 1);
1319 assert!(!scripts[0].is_typescript);
1320 assert!(scripts[0].is_jsx);
1321 }
1322
1323 #[test]
1324 fn two_script_blocks() {
1325 let source = r#"
1326<script lang="ts">
1327export default {};
1328</script>
1329<script setup lang="ts">
1330const count = 0;
1331</script>
1332"#;
1333 let scripts = extract_sfc_scripts(source);
1334 assert_eq!(scripts.len(), 2);
1335 assert!(scripts[0].body.contains("export default"));
1336 assert!(scripts[1].body.contains("count"));
1337 }
1338
1339 #[test]
1340 fn script_setup_extracted() {
1341 let scripts =
1342 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1343 assert_eq!(scripts.len(), 1);
1344 assert!(scripts[0].body.contains("import"));
1345 assert!(scripts[0].is_typescript);
1346 }
1347
1348 #[test]
1349 fn script_src_detected() {
1350 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1351 assert_eq!(scripts.len(), 1);
1352 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1353 }
1354
1355 #[test]
1358 fn svelte4_context_module_is_module_context() {
1359 let scripts =
1360 extract_sfc_scripts(r#"<script context="module">export const x = 1;</script>"#);
1361 assert_eq!(scripts.len(), 1);
1362 assert!(scripts[0].is_context_module);
1363 }
1364
1365 #[test]
1366 fn svelte5_bare_module_attr_is_module_context() {
1367 let scripts = extract_sfc_scripts(r"<script module>export const x = 1;</script>");
1368 assert_eq!(scripts.len(), 1);
1369 assert!(scripts[0].is_context_module);
1370 }
1371
1372 #[test]
1373 fn svelte5_module_with_lang_is_module_context() {
1374 let scripts =
1375 extract_sfc_scripts(r#"<script module lang="ts">export const x = 1;</script>"#);
1376 assert_eq!(scripts.len(), 1);
1377 assert!(scripts[0].is_context_module);
1378 assert!(scripts[0].is_typescript);
1379 }
1380
1381 #[test]
1382 fn plain_script_is_not_module_context() {
1383 let scripts = extract_sfc_scripts(r"<script>const x = 1;</script>");
1384 assert_eq!(scripts.len(), 1);
1385 assert!(!scripts[0].is_context_module);
1386 }
1387
1388 #[test]
1389 fn lang_ts_script_is_not_module_context() {
1390 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x = 1;</script>"#);
1391 assert_eq!(scripts.len(), 1);
1392 assert!(!scripts[0].is_context_module);
1393 }
1394
1395 #[test]
1396 fn data_module_attr_is_not_module_context() {
1397 let scripts =
1399 extract_sfc_scripts(r#"<script data-module="x" lang="ts">const x = 1;</script>"#);
1400 assert_eq!(scripts.len(), 1);
1401 assert!(!scripts[0].is_context_module);
1402 }
1403
1404 #[test]
1405 fn bare_module_script_is_not_template_visible() {
1406 let module_script = SfcScript {
1409 body: String::new(),
1410 is_typescript: false,
1411 is_jsx: false,
1412 byte_offset: 0,
1413 src: None,
1414 src_span: None,
1415 is_setup: false,
1416 is_context_module: true,
1417 generic_attr: None,
1418 };
1419 assert!(!is_template_visible_script(SfcKind::Svelte, &module_script));
1420 let instance_script = SfcScript {
1421 is_context_module: false,
1422 ..module_script
1423 };
1424 assert!(is_template_visible_script(
1425 SfcKind::Svelte,
1426 &instance_script
1427 ));
1428 }
1429
1430 #[test]
1431 fn data_src_not_treated_as_src() {
1432 let scripts =
1433 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1434 assert_eq!(scripts.len(), 1);
1435 assert!(scripts[0].src.is_none());
1436 }
1437
1438 #[test]
1439 fn script_inside_html_comment_filtered() {
1440 let source = r#"
1441<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1442<script lang="ts">import { good } from 'good';</script>
1443"#;
1444 let scripts = extract_sfc_scripts(source);
1445 assert_eq!(scripts.len(), 1);
1446 assert!(scripts[0].body.contains("good"));
1447 }
1448
1449 #[test]
1450 fn spanning_comment_filters_script() {
1451 let source = r#"
1452<!-- disabled:
1453<script lang="ts">import { bad } from 'bad';</script>
1454-->
1455<script lang="ts">const ok = true;</script>
1456"#;
1457 let scripts = extract_sfc_scripts(source);
1458 assert_eq!(scripts.len(), 1);
1459 assert!(scripts[0].body.contains("ok"));
1460 }
1461
1462 #[test]
1463 fn string_containing_comment_markers_not_corrupted() {
1464 let source = r#"
1465<script setup lang="ts">
1466const marker = "<!-- not a comment -->";
1467import { ref } from 'vue';
1468</script>
1469"#;
1470 let scripts = extract_sfc_scripts(source);
1471 assert_eq!(scripts.len(), 1);
1472 assert!(scripts[0].body.contains("import"));
1473 }
1474
1475 #[test]
1476 fn generic_attr_with_angle_bracket() {
1477 let source =
1478 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1479 let scripts = extract_sfc_scripts(source);
1480 assert_eq!(scripts.len(), 1);
1481 assert_eq!(scripts[0].body, "const x = 1;");
1482 }
1483
1484 #[test]
1485 fn nested_generic_attr() {
1486 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1487 let scripts = extract_sfc_scripts(source);
1488 assert_eq!(scripts.len(), 1);
1489 assert_eq!(scripts[0].body, "const x = 1;");
1490 }
1491
1492 #[test]
1493 fn lang_single_quoted() {
1494 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1495 assert_eq!(scripts.len(), 1);
1496 assert!(scripts[0].is_typescript);
1497 }
1498
1499 #[test]
1500 fn uppercase_script_tag() {
1501 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1502 assert_eq!(scripts.len(), 1);
1503 assert!(scripts[0].is_typescript);
1504 }
1505
1506 #[test]
1507 fn no_script_block() {
1508 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1509 assert!(scripts.is_empty());
1510 }
1511
1512 #[test]
1513 fn empty_script_body() {
1514 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1515 assert_eq!(scripts.len(), 1);
1516 assert!(scripts[0].body.is_empty());
1517 }
1518
1519 #[test]
1520 fn whitespace_only_script() {
1521 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
1522 assert_eq!(scripts.len(), 1);
1523 assert!(scripts[0].body.trim().is_empty());
1524 }
1525
1526 #[test]
1527 fn byte_offset_is_set() {
1528 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1529 let scripts = extract_sfc_scripts(source);
1530 assert_eq!(scripts.len(), 1);
1531 let offset = scripts[0].byte_offset;
1532 assert_eq!(&source[offset..offset + 4], "code");
1533 }
1534
1535 #[test]
1536 fn script_with_extra_attributes() {
1537 let scripts = extract_sfc_scripts(
1538 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1539 );
1540 assert_eq!(scripts.len(), 1);
1541 assert!(scripts[0].is_typescript);
1542 assert!(scripts[0].src.is_none());
1543 }
1544
1545 #[test]
1546 fn multiple_script_blocks_exports_combined() {
1547 let source = r#"
1548<script lang="ts">
1549export const version = '1.0';
1550</script>
1551<script setup lang="ts">
1552import { ref } from 'vue';
1553const count = ref(0);
1554</script>
1555"#;
1556 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1557 assert!(
1558 info.exports
1559 .iter()
1560 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1561 "export from <script> block should be extracted"
1562 );
1563 assert!(
1564 info.imports.iter().any(|i| i.source == "vue"),
1565 "import from <script setup> block should be extracted"
1566 );
1567 }
1568
1569 #[test]
1570 fn class_this_facts_survive_sfc_script_merge() {
1571 let source = r#"
1572<script lang="ts">
1573export class Service {
1574 client!: Client;
1575
1576 run() {
1577 this.client.execute();
1578 Object.keys(this.client);
1579 }
1580}
1581</script>
1582"#;
1583 let info = parse_sfc_to_module(FileId(0), Path::new("Service.vue"), source, 0, false);
1584 let facts = SemanticFactView::new(&info.semantic_facts, &info.member_accesses);
1585
1586 assert_eq!(
1587 facts.class_this_member_accesses(),
1588 vec![ClassThisMemberAccessFact {
1589 class_local_name: "Service".to_string(),
1590 object: "this.client".to_string(),
1591 member: "execute".to_string(),
1592 }]
1593 );
1594 assert_eq!(
1595 facts.class_this_whole_object_uses(),
1596 vec![ClassThisWholeObjectUseFact {
1597 class_local_name: "Service".to_string(),
1598 object: "this.client".to_string(),
1599 }]
1600 );
1601 }
1602
1603 #[test]
1604 fn lang_tsx_detected_as_typescript_jsx() {
1605 let scripts =
1606 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1607 assert_eq!(scripts.len(), 1);
1608 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1609 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1610 }
1611
1612 #[test]
1613 fn multiline_html_comment_filters_all_script_blocks_inside() {
1614 let source = r#"
1615<!--
1616 This whole section is disabled:
1617 <script lang="ts">import { bad1 } from 'bad1';</script>
1618 <script lang="ts">import { bad2 } from 'bad2';</script>
1619-->
1620<script lang="ts">import { good } from 'good';</script>
1621"#;
1622 let scripts = extract_sfc_scripts(source);
1623 assert_eq!(scripts.len(), 1);
1624 assert!(scripts[0].body.contains("good"));
1625 }
1626
1627 #[test]
1628 fn script_src_generates_side_effect_import() {
1629 let info = parse_sfc_to_module(
1630 FileId(0),
1631 Path::new("External.vue"),
1632 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1633 0,
1634 false,
1635 );
1636 assert!(
1637 info.imports
1638 .iter()
1639 .any(|i| i.source == "./external-logic.ts"
1640 && matches!(i.imported_name, ImportedName::SideEffect)),
1641 "script src should generate a side-effect import"
1642 );
1643 }
1644
1645 #[test]
1646 fn parse_sfc_no_script_returns_empty_module() {
1647 let info = parse_sfc_to_module(
1648 FileId(0),
1649 Path::new("Empty.vue"),
1650 "<template><div>Hello</div></template>",
1651 42,
1652 false,
1653 );
1654 assert!(info.imports.is_empty());
1655 assert!(info.exports.is_empty());
1656 assert_eq!(info.content_hash, 42);
1657 assert_eq!(info.file_id, FileId(0));
1658 }
1659
1660 #[test]
1661 fn parse_sfc_has_line_offsets() {
1662 let info = parse_sfc_to_module(
1663 FileId(0),
1664 Path::new("LineOffsets.vue"),
1665 r#"<script lang="ts">const x = 1;</script>"#,
1666 0,
1667 false,
1668 );
1669 assert!(!info.line_offsets.is_empty());
1670 }
1671
1672 #[test]
1673 fn parse_sfc_has_suppressions() {
1674 let info = parse_sfc_to_module(
1675 FileId(0),
1676 Path::new("Suppressions.vue"),
1677 r#"<script lang="ts">
1678// fallow-ignore-file
1679export const foo = 1;
1680</script>"#,
1681 0,
1682 false,
1683 );
1684 assert!(!info.suppressions.is_empty());
1685 }
1686
1687 #[test]
1688 fn source_type_jsx_detection() {
1689 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1690 assert_eq!(scripts.len(), 1);
1691 assert!(!scripts[0].is_typescript);
1692 assert!(scripts[0].is_jsx);
1693 }
1694
1695 #[test]
1696 fn source_type_plain_js_detection() {
1697 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1698 assert_eq!(scripts.len(), 1);
1699 assert!(!scripts[0].is_typescript);
1700 assert!(!scripts[0].is_jsx);
1701 }
1702
1703 #[test]
1704 fn is_sfc_file_rejects_no_extension() {
1705 assert!(!is_sfc_file(Path::new("Makefile")));
1706 }
1707
1708 #[test]
1709 fn is_sfc_file_rejects_mdx() {
1710 assert!(!is_sfc_file(Path::new("post.mdx")));
1711 }
1712
1713 #[test]
1714 fn is_sfc_file_rejects_css() {
1715 assert!(!is_sfc_file(Path::new("styles.css")));
1716 }
1717
1718 #[test]
1719 fn multiple_script_blocks_both_have_offsets() {
1720 let source = r#"<script lang="ts">const a = 1;</script>
1721<script setup lang="ts">const b = 2;</script>"#;
1722 let scripts = extract_sfc_scripts(source);
1723 assert_eq!(scripts.len(), 2);
1724 let offset0 = scripts[0].byte_offset;
1725 let offset1 = scripts[1].byte_offset;
1726 assert_eq!(
1727 &source[offset0..offset0 + "const a = 1;".len()],
1728 "const a = 1;"
1729 );
1730 assert_eq!(
1731 &source[offset1..offset1 + "const b = 2;".len()],
1732 "const b = 2;"
1733 );
1734 }
1735
1736 #[test]
1737 fn script_with_src_and_lang() {
1738 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1739 assert_eq!(scripts.len(), 1);
1740 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1741 assert!(scripts[0].is_typescript);
1742 assert!(scripts[0].is_jsx);
1743 }
1744
1745 #[test]
1746 fn extract_style_block_lang_scss() {
1747 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1748 let styles = extract_sfc_styles(source);
1749 assert_eq!(styles.len(), 1);
1750 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1751 assert!(styles[0].body.contains("@import"));
1752 assert!(styles[0].src.is_none());
1753 }
1754
1755 #[test]
1756 fn extract_style_block_with_src() {
1757 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1758 let styles = extract_sfc_styles(source);
1759 assert_eq!(styles.len(), 1);
1760 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1761 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1762 }
1763
1764 #[test]
1765 fn extract_style_block_plain_no_lang() {
1766 let source = r"<style>.foo { color: red; }</style>";
1767 let styles = extract_sfc_styles(source);
1768 assert_eq!(styles.len(), 1);
1769 assert!(styles[0].lang.is_none());
1770 }
1771
1772 #[test]
1773 fn extract_multiple_style_blocks() {
1774 let source = r#"<style lang="scss">@import 'a';</style>
1775<style scoped lang="scss">@import 'b';</style>"#;
1776 let styles = extract_sfc_styles(source);
1777 assert_eq!(styles.len(), 2);
1778 }
1779
1780 #[test]
1781 fn style_block_inside_html_comment_filtered() {
1782 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1783<style lang="scss">@import 'good';</style>"#;
1784 let styles = extract_sfc_styles(source);
1785 assert_eq!(styles.len(), 1);
1786 assert!(styles[0].body.contains("good"));
1787 }
1788
1789 #[test]
1790 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1791 let info = parse_sfc_to_module(
1792 FileId(0),
1793 Path::new("Foo.vue"),
1794 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1795 0,
1796 false,
1797 );
1798 let style_import = info
1799 .imports
1800 .iter()
1801 .find(|i| i.source == "./Foo")
1802 .expect("scss @import 'Foo' should be normalized to ./Foo");
1803 assert!(
1804 style_import.from_style,
1805 "imports from <style> blocks must carry from_style=true so the resolver \
1806 enables SCSS partial fallback for the SFC importer"
1807 );
1808 assert!(matches!(
1809 style_import.imported_name,
1810 ImportedName::SideEffect
1811 ));
1812 }
1813
1814 #[test]
1815 fn parse_sfc_extracts_style_plugin_as_default_import() {
1816 let info = parse_sfc_to_module(
1817 FileId(0),
1818 Path::new("Foo.vue"),
1819 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1820 0,
1821 false,
1822 );
1823 let plugin_import = info
1824 .imports
1825 .iter()
1826 .find(|i| i.source == "./tailwind-plugin.js")
1827 .expect("style @plugin should create an import");
1828 assert!(plugin_import.from_style);
1829 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1830 }
1831
1832 #[test]
1833 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1834 let info = parse_sfc_to_module(
1835 FileId(0),
1836 Path::new("Bar.vue"),
1837 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1838 0,
1839 false,
1840 );
1841 let style_src = info
1842 .imports
1843 .iter()
1844 .find(|i| i.source == "./Bar.scss")
1845 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1846 assert!(style_src.from_style);
1847 }
1848
1849 #[test]
1850 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1851 let info = parse_sfc_to_module(
1852 FileId(0),
1853 Path::new("Baz.vue"),
1854 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1855 0,
1856 false,
1857 );
1858 assert!(
1859 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1860 "src reference should still be seeded for unsupported lang"
1861 );
1862 assert!(
1863 !info.imports.iter().any(|i| i.source.contains("skipped")),
1864 "postcss body should not be scanned for @import directives"
1865 );
1866 }
1867
1868 fn asset_refs(source: &str) -> Vec<String> {
1869 super::collect_template_asset_refs(source)
1870 .into_iter()
1871 .map(|(s, _)| s)
1872 .collect()
1873 }
1874
1875 #[test]
1876 fn captures_static_relative_template_asset_refs() {
1877 assert_eq!(
1878 asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1879 vec!["./logo.png".to_string()]
1880 );
1881 assert_eq!(
1882 asset_refs(r#"<source src="../media/clip.mp4">"#),
1883 vec!["../media/clip.mp4".to_string()]
1884 );
1885 assert_eq!(
1886 asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1887 vec!["./thumb.jpg".to_string()]
1888 );
1889 }
1890
1891 #[test]
1892 fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1893 assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1895 assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1896 assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1897 assert!(asset_refs(r"<img src={logo} />").is_empty());
1898 assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1899 assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1901 assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1902 assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1903 assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1905 assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1907 }
1908
1909 #[test]
1910 fn skips_custom_component_src_prop() {
1911 assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1913 assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1914 }
1915
1916 #[test]
1917 fn skips_asset_refs_inside_script_style_and_comments() {
1918 assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1920 assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1921 assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1922 }
1923
1924 #[test]
1925 fn parse_sfc_emits_template_asset_as_side_effect_import() {
1926 let info = parse_sfc_to_module(
1927 FileId(0),
1928 Path::new("Hero.vue"),
1929 r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1930 0,
1931 false,
1932 );
1933 assert!(
1934 info.imports.iter().any(|i| i.source == "./hero.png"
1935 && matches!(i.imported_name, ImportedName::SideEffect)
1936 && !i.from_style),
1937 "template <img src> should seed a SideEffect import: {:?}",
1938 info.imports
1939 );
1940 }
1941
1942 fn svelte_props(source: &str) -> Vec<crate::ModuleInfo> {
1945 vec![parse_sfc_to_module(
1946 FileId(0),
1947 Path::new("Component.svelte"),
1948 source,
1949 0,
1950 false,
1951 )]
1952 }
1953
1954 fn prop_names(info: &crate::ModuleInfo) -> Vec<String> {
1955 let mut names: Vec<String> = info
1956 .component_props
1957 .iter()
1958 .map(|p| p.name.clone())
1959 .collect();
1960 names.sort();
1961 names
1962 }
1963
1964 #[test]
1965 fn svelte_shorthand_props_harvested() {
1966 let info = &svelte_props(r"<script>let { a, b } = $props();</script>")[0];
1968 assert_eq!(prop_names(info), vec!["a", "b"]);
1969 for prop in &info.component_props {
1970 assert_eq!(prop.local, prop.name);
1971 }
1972 }
1973
1974 #[test]
1975 fn svelte_renamed_prop_tracks_local_and_script_use() {
1976 let info =
1979 &svelte_props(r"<script>let { a: alias } = $props(); console.log(alias);</script>")[0];
1980 assert_eq!(prop_names(info), vec!["a"]);
1981 let prop = &info.component_props[0];
1982 assert_eq!(prop.local, "alias");
1983 assert!(
1984 prop.used_in_script,
1985 "alias is referenced, so a is used in script"
1986 );
1987 }
1988
1989 #[test]
1990 fn svelte_unreferenced_prop_is_unused_in_script() {
1991 let info = &svelte_props(r"<script>let { a } = $props();</script>")[0];
1992 assert_eq!(prop_names(info), vec!["a"]);
1993 assert!(!info.component_props[0].used_in_script);
1994 }
1995
1996 #[test]
1997 fn svelte_default_prop_peeled() {
1998 let info = &svelte_props(r"<script>let { a = 1 } = $props();</script>")[0];
2000 assert_eq!(prop_names(info), vec!["a"]);
2001 }
2002
2003 #[test]
2004 fn svelte_bindable_default_peeled() {
2005 let info = &svelte_props(r"<script>let { a = $bindable() } = $props();</script>")[0];
2008 assert_eq!(prop_names(info), vec!["a"]);
2009 }
2010
2011 #[test]
2012 fn svelte_rest_element_sets_fallthrough_abstain() {
2013 let info = &svelte_props(r"<script>let { a, ...rest } = $props();</script>")[0];
2015 assert!(info.has_props_attrs_fallthrough);
2016 }
2017
2018 #[test]
2019 fn svelte_bare_identifier_binding_sets_unharvestable_abstain() {
2020 let info = &svelte_props(r"<script>let p = $props(); console.log(p.x);</script>")[0];
2022 assert!(info.has_unharvestable_props);
2023 assert!(info.component_props.is_empty());
2024 }
2025
2026 #[test]
2027 fn svelte_nested_destructure_sets_unharvestable_abstain() {
2028 let info = &svelte_props(r"<script>let { a: { x } } = $props();</script>")[0];
2030 assert!(info.has_unharvestable_props);
2031 }
2032
2033 #[test]
2034 fn svelte_prop_used_only_in_markup_credited_as_template_root() {
2035 let info = &svelte_props(r"<script>let { a } = $props();</script><p>{a}</p>")[0];
2038 assert_eq!(prop_names(info), vec!["a"]);
2039 assert!(
2040 info.component_props[0].used_in_template,
2041 "a is used in markup, so used_in_template should be true"
2042 );
2043 }
2044
2045 #[test]
2046 fn svelte_module_script_props_not_harvested() {
2047 let info = &svelte_props(
2049 r"<script module>let { a } = $props();</script><script>let { b } = $props();</script>",
2050 )[0];
2051 assert_eq!(prop_names(info), vec!["b"]);
2053 }
2054
2055 fn dispatched_names(info: &crate::ModuleInfo) -> Vec<String> {
2058 let mut names: Vec<String> = info
2059 .svelte_dispatched_events
2060 .iter()
2061 .map(|e| e.name.clone())
2062 .collect();
2063 names.sort();
2064 names
2065 }
2066
2067 #[test]
2068 fn svelte_dispatch_literal_event_is_harvested() {
2069 let info = &svelte_props(
2070 r"<script>import { createEventDispatcher } from 'svelte';
2071 const dispatch = createEventDispatcher();
2072 function save() { dispatch('save'); }</script>",
2073 )[0];
2074 assert_eq!(dispatched_names(info), vec!["save"]);
2075 assert!(!info.has_dynamic_dispatch);
2076 }
2077
2078 #[test]
2079 fn svelte_dispatch_without_svelte_import_is_ignored() {
2080 let info = &svelte_props(
2083 r"<script>function createEventDispatcher() { return () => {}; }
2084 const dispatch = createEventDispatcher();
2085 dispatch('save');</script>",
2086 )[0];
2087 assert!(info.svelte_dispatched_events.is_empty());
2088 }
2089
2090 #[test]
2091 fn svelte_dynamic_dispatch_sets_abstain() {
2092 let info = &svelte_props(
2093 r"<script>import { createEventDispatcher } from 'svelte';
2094 const dispatch = createEventDispatcher();
2095 function fire(name) { dispatch(name); }</script>",
2096 )[0];
2097 assert!(
2098 info.has_dynamic_dispatch,
2099 "a non-literal dispatch arg must set the abstain flag"
2100 );
2101 }
2102
2103 #[test]
2104 fn svelte_dispatch_whole_value_use_sets_abstain() {
2105 let info = &svelte_props(
2106 r"<script>import { createEventDispatcher } from 'svelte';
2107 const dispatch = createEventDispatcher();
2108 forward(dispatch);</script>",
2109 )[0];
2110 assert!(
2111 info.has_dynamic_dispatch,
2112 "passing the dispatch binding as a whole value must set the abstain flag"
2113 );
2114 }
2115
2116 #[test]
2117 fn svelte_listened_event_on_component_is_harvested() {
2118 let info =
2119 &svelte_props(r"<script>import Child from './Child.svelte';</script><Child on:save />")
2120 [0];
2121 assert!(info.svelte_listened_events.contains(&"save".to_string()));
2122 }
2123}