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