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