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