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