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 .map(|(local, target)| (local.clone(), target.clone())),
704 );
705}
706
707fn merge_svelte_props_into(
711 combined: &mut ModuleInfo,
712 program: &oxc_ast::ast::Program<'_>,
713 byte_offset: usize,
714) {
715 let harvest = crate::sfc_props::harvest_svelte_props(program);
716 if harvest.has_unharvestable_props {
717 combined.has_unharvestable_props = true;
718 }
719 if harvest.has_props_attrs_fallthrough {
720 combined.has_props_attrs_fallthrough = true;
721 }
722 for mut prop in harvest.props {
723 prop.span_start += byte_offset as u32;
724 combined.component_props.push(prop);
725 }
726}
727
728fn merge_vue_props_emits_into(
735 input: &mut SfcScriptMergeInput<'_>,
736 program: &oxc_ast::ast::Program<'_>,
737) {
738 let byte_offset = input.script.byte_offset as u32;
739 if input.script.is_setup {
740 apply_props_harvest(
741 input,
742 crate::sfc_props::harvest_define_props(program),
743 byte_offset,
744 );
745 apply_emits_harvest(
746 input,
747 crate::sfc_props::harvest_define_emits(program),
748 byte_offset,
749 );
750 } else {
751 apply_props_harvest(
752 input,
753 crate::sfc_props::harvest_options_api_props(program),
754 byte_offset,
755 );
756 apply_emits_harvest(
757 input,
758 crate::sfc_props::harvest_options_api_emits(program),
759 byte_offset,
760 );
761 }
762}
763
764fn apply_props_harvest(
770 input: &mut SfcScriptMergeInput<'_>,
771 harvest: crate::sfc_props::DefinePropsHarvest,
772 byte_offset: u32,
773) {
774 if harvest.has_unharvestable_props {
775 input.combined.has_unharvestable_props = true;
776 }
777 if harvest.has_props_attrs_fallthrough {
778 input.combined.has_props_attrs_fallthrough = true;
779 }
780 if harvest.has_define_expose {
781 input.combined.has_define_expose = true;
782 }
783 if harvest.has_define_model {
784 input.combined.has_define_model = true;
785 }
786 if let Some(binding) = harvest.props_return_binding {
787 *input.props_return_binding = Some(binding);
788 }
789 for mut prop in harvest.props {
790 prop.span_start += byte_offset;
791 input.combined.component_props.push(prop);
792 }
793}
794
795fn apply_emits_harvest(
801 input: &mut SfcScriptMergeInput<'_>,
802 harvest: crate::sfc_props::DefineEmitsHarvest,
803 byte_offset: u32,
804) {
805 if harvest.has_unharvestable_emits {
806 input.combined.has_unharvestable_emits = true;
807 }
808 if harvest.has_dynamic_emit {
809 input.combined.has_dynamic_emit = true;
810 }
811 if harvest.has_emit_whole_object_use {
812 input.combined.has_emit_whole_object_use = true;
813 }
814 if let Some(binding) = harvest.emit_binding {
815 *input.emit_return_binding = Some(binding);
816 }
817 for mut emit in harvest.emits {
818 emit.span_start += byte_offset;
819 input.combined.component_emits.push(emit);
820 }
821}
822
823fn translate_script_complexity(
824 script: &SfcScript,
825 program: &oxc_ast::ast::Program<'_>,
826 sfc_line_offsets: &[u32],
827) -> Vec<FunctionComplexity> {
828 let script_line_offsets = compute_line_offsets(&script.body);
829 let mut complexity =
830 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
831 let (body_start_line, body_start_col) =
832 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
833
834 for function in &mut complexity {
835 function.line = body_start_line + function.line.saturating_sub(1);
836 if function.line == body_start_line {
837 function.col += body_start_col;
838 }
839 }
840
841 complexity
842}
843
844fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
845 let span = source_span.unwrap_or_default();
846 module.imports.push(ImportInfo {
847 source: normalize_asset_url(source),
848 imported_name: ImportedName::SideEffect,
849 local_name: String::new(),
850 is_type_only: false,
851 from_style: false,
852 span,
853 source_span: span,
854 });
855}
856
857fn style_lang_is_scss(lang: Option<&str>) -> bool {
863 matches!(lang, Some("scss" | "sass"))
864}
865
866fn style_lang_is_css_like(lang: Option<&str>) -> bool {
867 lang.is_none() || matches!(lang, Some("css"))
868}
869
870fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
871 if let Some(src) = &style.src {
872 let span = style.src_span.unwrap_or_default();
873 combined.imports.push(ImportInfo {
874 source: normalize_asset_url(src),
875 imported_name: ImportedName::SideEffect,
876 local_name: String::new(),
877 is_type_only: false,
878 from_style: true,
879 span,
880 source_span: span,
881 });
882 }
883
884 let lang = style.lang.as_deref();
885 let is_scss = style_lang_is_scss(lang);
886 let is_css_like = style_lang_is_css_like(lang);
887 if !is_scss && !is_css_like {
888 return;
889 }
890
891 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
892 let source_span = Span::new(
893 style.byte_offset as u32 + source.span.start,
894 style.byte_offset as u32 + source.span.end,
895 );
896 combined.imports.push(ImportInfo {
897 source: source.normalized,
898 imported_name: if source.is_plugin {
899 ImportedName::Default
900 } else {
901 ImportedName::SideEffect
902 },
903 local_name: String::new(),
904 is_type_only: false,
905 from_style: true,
906 span: source_span,
907 source_span,
908 });
909 }
910}
911
912fn source_type_for_script(script: &SfcScript) -> SourceType {
913 match (script.is_typescript, script.is_jsx) {
914 (true, true) => SourceType::tsx(),
915 (true, false) => SourceType::ts(),
916 (false, true) => SourceType::jsx(),
917 (false, false) => SourceType::mjs(),
918 }
919}
920
921fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
927 let constraint = script.generic_attr.as_deref()?.trim();
928 if constraint.is_empty() {
929 return None;
930 }
931 Some(format!(
932 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
933 script.body, constraint,
934 ))
935}
936
937struct TemplateUsageInput<'a> {
938 kind: SfcKind,
939 source: &'a str,
940 template_visible_imports: &'a FxHashSet<String>,
941 template_visible_bound_targets: &'a FxHashMap<String, String>,
942 props_return_binding: Option<&'a str>,
943 credit_load_data: bool,
944 combined: &'a mut ModuleInfo,
945}
946
947fn apply_template_usage(input: TemplateUsageInput<'_>) {
948 let TemplateUsageInput {
949 kind,
950 source,
951 template_visible_imports,
952 template_visible_bound_targets,
953 props_return_binding,
954 credit_load_data,
955 combined,
956 } = input;
957 let credited = build_template_credited_set(
958 template_visible_imports,
959 props_return_binding,
960 credit_load_data,
961 source,
962 combined,
963 );
964 let template_usage = compute_template_usage(
965 kind,
966 source,
967 &credited,
968 template_visible_bound_targets,
969 credit_load_data,
970 );
971 apply_prop_template_credit(&template_usage, props_return_binding, combined);
972 merge_template_usage_into_combined(template_usage, combined);
973}
974
975fn build_template_credited_set(
981 template_visible_imports: &FxHashSet<String>,
982 props_return_binding: Option<&str>,
983 credit_load_data: bool,
984 source: &str,
985 combined: &mut ModuleInfo,
986) -> FxHashSet<String> {
987 let mut credited: FxHashSet<String> = template_visible_imports.clone();
988 if credit_load_data {
993 credited.insert("data".to_string());
994 if SVELTE_TEMPLATE_DATA_WHOLE_USE_RE.is_match(source) {
997 combined.has_load_data_whole_use = true;
998 }
999 }
1000 if !combined.component_props.is_empty() {
1001 for prop in &combined.component_props {
1002 credited.insert(prop.name.clone());
1005 credited.insert(prop.local.clone());
1006 }
1007 credited.insert("$props".to_string());
1010 if let Some(binding) = props_return_binding {
1011 credited.insert(binding.to_string());
1012 }
1013 }
1014 credited
1015}
1016
1017fn compute_template_usage(
1022 kind: SfcKind,
1023 source: &str,
1024 credited: &FxHashSet<String>,
1025 template_visible_bound_targets: &FxHashMap<String, String>,
1026 credit_load_data: bool,
1027) -> crate::template_usage::TemplateUsage {
1028 if credit_load_data && template_visible_bound_targets.contains_key("data") {
1029 let mut filtered = template_visible_bound_targets.clone();
1030 filtered.remove("data");
1031 collect_template_usage_with_bound_targets(kind, source, credited, &filtered)
1032 } else {
1033 collect_template_usage_with_bound_targets(
1034 kind,
1035 source,
1036 credited,
1037 template_visible_bound_targets,
1038 )
1039 }
1040}
1041
1042fn apply_prop_template_credit(
1047 template_usage: &crate::template_usage::TemplateUsage,
1048 props_return_binding: Option<&str>,
1049 combined: &mut ModuleInfo,
1050) {
1051 if !combined.component_props.is_empty() {
1052 let member_used: FxHashSet<&str> = template_usage
1053 .member_accesses
1054 .iter()
1055 .filter(|access| {
1056 access.object == "$props"
1057 || props_return_binding.is_some_and(|binding| access.object == binding)
1058 })
1059 .map(|access| access.member.as_str())
1060 .collect();
1061 for prop in &mut combined.component_props {
1062 if template_usage.used_bindings.contains(&prop.name)
1063 || template_usage.used_bindings.contains(&prop.local)
1064 || member_used.contains(prop.name.as_str())
1065 {
1066 prop.used_in_template = true;
1067 }
1068 }
1069 }
1070
1071 if let Some(binding) = props_return_binding
1072 && (template_usage.used_bindings.contains(binding)
1073 || template_usage
1074 .whole_object_uses
1075 .iter()
1076 .any(|used| used == binding))
1077 {
1078 combined.has_props_attrs_fallthrough = true;
1079 }
1080}
1081
1082fn merge_template_usage_into_combined(
1087 template_usage: crate::template_usage::TemplateUsage,
1088 combined: &mut ModuleInfo,
1089) {
1090 combined
1091 .unused_import_bindings
1092 .retain(|binding| !template_usage.used_bindings.contains(binding));
1093 combined
1094 .member_accesses
1095 .extend(template_usage.member_accesses);
1096 combined
1097 .whole_object_uses
1098 .extend(template_usage.whole_object_uses);
1099 combined
1100 .security_sinks
1101 .extend(template_usage.security_sinks);
1102 if !template_usage.unresolved_tag_names.is_empty() {
1103 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
1104 names.sort_unstable();
1105 combined.auto_import_candidates.extend(names);
1106 combined.auto_import_candidates.dedup();
1107 }
1108}
1109
1110fn apply_template_emit_usage(
1128 source: &str,
1129 emit_return_binding: Option<&str>,
1130 combined: &mut ModuleInfo,
1131) {
1132 let masked = mask_non_markup_regions(source);
1133 let mut used: FxHashSet<String> = FxHashSet::default();
1134 let mut dynamic = false;
1135
1136 for caps in TEMPLATE_EMIT_CALL_RE.captures_iter(&masked) {
1137 let Some(callee) = caps.get(1) else {
1138 continue;
1139 };
1140 let callee = callee.as_str();
1141 let is_emit_call =
1142 callee == "$emit" || emit_return_binding.is_some_and(|binding| callee == binding);
1143 if !is_emit_call {
1144 continue;
1145 }
1146 if let Some(event) = caps.get(2).or_else(|| caps.get(3)) {
1147 used.insert(event.as_str().to_string());
1150 } else if caps.get(4).is_some() {
1151 dynamic = true;
1154 }
1155 }
1156
1157 if dynamic {
1158 combined.has_dynamic_emit = true;
1159 }
1160 if !used.is_empty() {
1161 for emit in &mut combined.component_emits {
1162 if used.contains(&emit.name) {
1163 emit.used = true;
1164 }
1165 }
1166 }
1167}
1168
1169fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
1170 match kind {
1171 SfcKind::Vue => script.is_setup,
1172 SfcKind::Svelte => !script.is_context_module,
1173 }
1174}
1175
1176#[cfg(all(test, not(miri)))]
1177mod tests {
1178 use super::*;
1179
1180 #[test]
1181 fn is_sfc_file_vue() {
1182 assert!(is_sfc_file(Path::new("App.vue")));
1183 }
1184
1185 #[test]
1186 fn is_sfc_file_svelte() {
1187 assert!(is_sfc_file(Path::new("Counter.svelte")));
1188 }
1189
1190 #[test]
1191 fn is_sfc_file_rejects_ts() {
1192 assert!(!is_sfc_file(Path::new("utils.ts")));
1193 }
1194
1195 #[test]
1196 fn is_sfc_file_rejects_jsx() {
1197 assert!(!is_sfc_file(Path::new("App.jsx")));
1198 }
1199
1200 #[test]
1201 fn is_sfc_file_rejects_astro() {
1202 assert!(!is_sfc_file(Path::new("Layout.astro")));
1203 }
1204
1205 #[test]
1206 fn single_plain_script() {
1207 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1208 assert_eq!(scripts.len(), 1);
1209 assert_eq!(scripts[0].body, "const x = 1;");
1210 assert!(!scripts[0].is_typescript);
1211 assert!(!scripts[0].is_jsx);
1212 assert!(scripts[0].src.is_none());
1213 }
1214
1215 #[test]
1216 fn single_ts_script() {
1217 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
1218 assert_eq!(scripts.len(), 1);
1219 assert!(scripts[0].is_typescript);
1220 assert!(!scripts[0].is_jsx);
1221 }
1222
1223 #[test]
1224 fn single_tsx_script() {
1225 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
1226 assert_eq!(scripts.len(), 1);
1227 assert!(scripts[0].is_typescript);
1228 assert!(scripts[0].is_jsx);
1229 }
1230
1231 #[test]
1232 fn single_jsx_script() {
1233 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1234 assert_eq!(scripts.len(), 1);
1235 assert!(!scripts[0].is_typescript);
1236 assert!(scripts[0].is_jsx);
1237 }
1238
1239 #[test]
1240 fn two_script_blocks() {
1241 let source = r#"
1242<script lang="ts">
1243export default {};
1244</script>
1245<script setup lang="ts">
1246const count = 0;
1247</script>
1248"#;
1249 let scripts = extract_sfc_scripts(source);
1250 assert_eq!(scripts.len(), 2);
1251 assert!(scripts[0].body.contains("export default"));
1252 assert!(scripts[1].body.contains("count"));
1253 }
1254
1255 #[test]
1256 fn script_setup_extracted() {
1257 let scripts =
1258 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
1259 assert_eq!(scripts.len(), 1);
1260 assert!(scripts[0].body.contains("import"));
1261 assert!(scripts[0].is_typescript);
1262 }
1263
1264 #[test]
1265 fn script_src_detected() {
1266 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
1267 assert_eq!(scripts.len(), 1);
1268 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
1269 }
1270
1271 #[test]
1274 fn svelte4_context_module_is_module_context() {
1275 let scripts =
1276 extract_sfc_scripts(r#"<script context="module">export const x = 1;</script>"#);
1277 assert_eq!(scripts.len(), 1);
1278 assert!(scripts[0].is_context_module);
1279 }
1280
1281 #[test]
1282 fn svelte5_bare_module_attr_is_module_context() {
1283 let scripts = extract_sfc_scripts(r"<script module>export const x = 1;</script>");
1284 assert_eq!(scripts.len(), 1);
1285 assert!(scripts[0].is_context_module);
1286 }
1287
1288 #[test]
1289 fn svelte5_module_with_lang_is_module_context() {
1290 let scripts =
1291 extract_sfc_scripts(r#"<script module lang="ts">export const x = 1;</script>"#);
1292 assert_eq!(scripts.len(), 1);
1293 assert!(scripts[0].is_context_module);
1294 assert!(scripts[0].is_typescript);
1295 }
1296
1297 #[test]
1298 fn plain_script_is_not_module_context() {
1299 let scripts = extract_sfc_scripts(r"<script>const x = 1;</script>");
1300 assert_eq!(scripts.len(), 1);
1301 assert!(!scripts[0].is_context_module);
1302 }
1303
1304 #[test]
1305 fn lang_ts_script_is_not_module_context() {
1306 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x = 1;</script>"#);
1307 assert_eq!(scripts.len(), 1);
1308 assert!(!scripts[0].is_context_module);
1309 }
1310
1311 #[test]
1312 fn data_module_attr_is_not_module_context() {
1313 let scripts =
1315 extract_sfc_scripts(r#"<script data-module="x" lang="ts">const x = 1;</script>"#);
1316 assert_eq!(scripts.len(), 1);
1317 assert!(!scripts[0].is_context_module);
1318 }
1319
1320 #[test]
1321 fn bare_module_script_is_not_template_visible() {
1322 let module_script = SfcScript {
1325 body: String::new(),
1326 is_typescript: false,
1327 is_jsx: false,
1328 byte_offset: 0,
1329 src: None,
1330 src_span: None,
1331 is_setup: false,
1332 is_context_module: true,
1333 generic_attr: None,
1334 };
1335 assert!(!is_template_visible_script(SfcKind::Svelte, &module_script));
1336 let instance_script = SfcScript {
1337 is_context_module: false,
1338 ..module_script
1339 };
1340 assert!(is_template_visible_script(
1341 SfcKind::Svelte,
1342 &instance_script
1343 ));
1344 }
1345
1346 #[test]
1347 fn data_src_not_treated_as_src() {
1348 let scripts =
1349 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
1350 assert_eq!(scripts.len(), 1);
1351 assert!(scripts[0].src.is_none());
1352 }
1353
1354 #[test]
1355 fn script_inside_html_comment_filtered() {
1356 let source = r#"
1357<!-- <script lang="ts">import { bad } from 'bad';</script> -->
1358<script lang="ts">import { good } from 'good';</script>
1359"#;
1360 let scripts = extract_sfc_scripts(source);
1361 assert_eq!(scripts.len(), 1);
1362 assert!(scripts[0].body.contains("good"));
1363 }
1364
1365 #[test]
1366 fn spanning_comment_filters_script() {
1367 let source = r#"
1368<!-- disabled:
1369<script lang="ts">import { bad } from 'bad';</script>
1370-->
1371<script lang="ts">const ok = true;</script>
1372"#;
1373 let scripts = extract_sfc_scripts(source);
1374 assert_eq!(scripts.len(), 1);
1375 assert!(scripts[0].body.contains("ok"));
1376 }
1377
1378 #[test]
1379 fn string_containing_comment_markers_not_corrupted() {
1380 let source = r#"
1381<script setup lang="ts">
1382const marker = "<!-- not a comment -->";
1383import { ref } from 'vue';
1384</script>
1385"#;
1386 let scripts = extract_sfc_scripts(source);
1387 assert_eq!(scripts.len(), 1);
1388 assert!(scripts[0].body.contains("import"));
1389 }
1390
1391 #[test]
1392 fn generic_attr_with_angle_bracket() {
1393 let source =
1394 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
1395 let scripts = extract_sfc_scripts(source);
1396 assert_eq!(scripts.len(), 1);
1397 assert_eq!(scripts[0].body, "const x = 1;");
1398 }
1399
1400 #[test]
1401 fn nested_generic_attr() {
1402 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
1403 let scripts = extract_sfc_scripts(source);
1404 assert_eq!(scripts.len(), 1);
1405 assert_eq!(scripts[0].body, "const x = 1;");
1406 }
1407
1408 #[test]
1409 fn lang_single_quoted() {
1410 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
1411 assert_eq!(scripts.len(), 1);
1412 assert!(scripts[0].is_typescript);
1413 }
1414
1415 #[test]
1416 fn uppercase_script_tag() {
1417 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
1418 assert_eq!(scripts.len(), 1);
1419 assert!(scripts[0].is_typescript);
1420 }
1421
1422 #[test]
1423 fn no_script_block() {
1424 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
1425 assert!(scripts.is_empty());
1426 }
1427
1428 #[test]
1429 fn empty_script_body() {
1430 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
1431 assert_eq!(scripts.len(), 1);
1432 assert!(scripts[0].body.is_empty());
1433 }
1434
1435 #[test]
1436 fn whitespace_only_script() {
1437 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
1438 assert_eq!(scripts.len(), 1);
1439 assert!(scripts[0].body.trim().is_empty());
1440 }
1441
1442 #[test]
1443 fn byte_offset_is_set() {
1444 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
1445 let scripts = extract_sfc_scripts(source);
1446 assert_eq!(scripts.len(), 1);
1447 let offset = scripts[0].byte_offset;
1448 assert_eq!(&source[offset..offset + 4], "code");
1449 }
1450
1451 #[test]
1452 fn script_with_extra_attributes() {
1453 let scripts = extract_sfc_scripts(
1454 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
1455 );
1456 assert_eq!(scripts.len(), 1);
1457 assert!(scripts[0].is_typescript);
1458 assert!(scripts[0].src.is_none());
1459 }
1460
1461 #[test]
1462 fn multiple_script_blocks_exports_combined() {
1463 let source = r#"
1464<script lang="ts">
1465export const version = '1.0';
1466</script>
1467<script setup lang="ts">
1468import { ref } from 'vue';
1469const count = ref(0);
1470</script>
1471"#;
1472 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
1473 assert!(
1474 info.exports
1475 .iter()
1476 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
1477 "export from <script> block should be extracted"
1478 );
1479 assert!(
1480 info.imports.iter().any(|i| i.source == "vue"),
1481 "import from <script setup> block should be extracted"
1482 );
1483 }
1484
1485 #[test]
1486 fn lang_tsx_detected_as_typescript_jsx() {
1487 let scripts =
1488 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
1489 assert_eq!(scripts.len(), 1);
1490 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
1491 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
1492 }
1493
1494 #[test]
1495 fn multiline_html_comment_filters_all_script_blocks_inside() {
1496 let source = r#"
1497<!--
1498 This whole section is disabled:
1499 <script lang="ts">import { bad1 } from 'bad1';</script>
1500 <script lang="ts">import { bad2 } from 'bad2';</script>
1501-->
1502<script lang="ts">import { good } from 'good';</script>
1503"#;
1504 let scripts = extract_sfc_scripts(source);
1505 assert_eq!(scripts.len(), 1);
1506 assert!(scripts[0].body.contains("good"));
1507 }
1508
1509 #[test]
1510 fn script_src_generates_side_effect_import() {
1511 let info = parse_sfc_to_module(
1512 FileId(0),
1513 Path::new("External.vue"),
1514 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
1515 0,
1516 false,
1517 );
1518 assert!(
1519 info.imports
1520 .iter()
1521 .any(|i| i.source == "./external-logic.ts"
1522 && matches!(i.imported_name, ImportedName::SideEffect)),
1523 "script src should generate a side-effect import"
1524 );
1525 }
1526
1527 #[test]
1528 fn parse_sfc_no_script_returns_empty_module() {
1529 let info = parse_sfc_to_module(
1530 FileId(0),
1531 Path::new("Empty.vue"),
1532 "<template><div>Hello</div></template>",
1533 42,
1534 false,
1535 );
1536 assert!(info.imports.is_empty());
1537 assert!(info.exports.is_empty());
1538 assert_eq!(info.content_hash, 42);
1539 assert_eq!(info.file_id, FileId(0));
1540 }
1541
1542 #[test]
1543 fn parse_sfc_has_line_offsets() {
1544 let info = parse_sfc_to_module(
1545 FileId(0),
1546 Path::new("LineOffsets.vue"),
1547 r#"<script lang="ts">const x = 1;</script>"#,
1548 0,
1549 false,
1550 );
1551 assert!(!info.line_offsets.is_empty());
1552 }
1553
1554 #[test]
1555 fn parse_sfc_has_suppressions() {
1556 let info = parse_sfc_to_module(
1557 FileId(0),
1558 Path::new("Suppressions.vue"),
1559 r#"<script lang="ts">
1560// fallow-ignore-file
1561export const foo = 1;
1562</script>"#,
1563 0,
1564 false,
1565 );
1566 assert!(!info.suppressions.is_empty());
1567 }
1568
1569 #[test]
1570 fn source_type_jsx_detection() {
1571 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
1572 assert_eq!(scripts.len(), 1);
1573 assert!(!scripts[0].is_typescript);
1574 assert!(scripts[0].is_jsx);
1575 }
1576
1577 #[test]
1578 fn source_type_plain_js_detection() {
1579 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
1580 assert_eq!(scripts.len(), 1);
1581 assert!(!scripts[0].is_typescript);
1582 assert!(!scripts[0].is_jsx);
1583 }
1584
1585 #[test]
1586 fn is_sfc_file_rejects_no_extension() {
1587 assert!(!is_sfc_file(Path::new("Makefile")));
1588 }
1589
1590 #[test]
1591 fn is_sfc_file_rejects_mdx() {
1592 assert!(!is_sfc_file(Path::new("post.mdx")));
1593 }
1594
1595 #[test]
1596 fn is_sfc_file_rejects_css() {
1597 assert!(!is_sfc_file(Path::new("styles.css")));
1598 }
1599
1600 #[test]
1601 fn multiple_script_blocks_both_have_offsets() {
1602 let source = r#"<script lang="ts">const a = 1;</script>
1603<script setup lang="ts">const b = 2;</script>"#;
1604 let scripts = extract_sfc_scripts(source);
1605 assert_eq!(scripts.len(), 2);
1606 let offset0 = scripts[0].byte_offset;
1607 let offset1 = scripts[1].byte_offset;
1608 assert_eq!(
1609 &source[offset0..offset0 + "const a = 1;".len()],
1610 "const a = 1;"
1611 );
1612 assert_eq!(
1613 &source[offset1..offset1 + "const b = 2;".len()],
1614 "const b = 2;"
1615 );
1616 }
1617
1618 #[test]
1619 fn script_with_src_and_lang() {
1620 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
1621 assert_eq!(scripts.len(), 1);
1622 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
1623 assert!(scripts[0].is_typescript);
1624 assert!(scripts[0].is_jsx);
1625 }
1626
1627 #[test]
1628 fn extract_style_block_lang_scss() {
1629 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1630 let styles = extract_sfc_styles(source);
1631 assert_eq!(styles.len(), 1);
1632 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1633 assert!(styles[0].body.contains("@import"));
1634 assert!(styles[0].src.is_none());
1635 }
1636
1637 #[test]
1638 fn extract_style_block_with_src() {
1639 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1640 let styles = extract_sfc_styles(source);
1641 assert_eq!(styles.len(), 1);
1642 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1643 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1644 }
1645
1646 #[test]
1647 fn extract_style_block_plain_no_lang() {
1648 let source = r"<style>.foo { color: red; }</style>";
1649 let styles = extract_sfc_styles(source);
1650 assert_eq!(styles.len(), 1);
1651 assert!(styles[0].lang.is_none());
1652 }
1653
1654 #[test]
1655 fn extract_multiple_style_blocks() {
1656 let source = r#"<style lang="scss">@import 'a';</style>
1657<style scoped lang="scss">@import 'b';</style>"#;
1658 let styles = extract_sfc_styles(source);
1659 assert_eq!(styles.len(), 2);
1660 }
1661
1662 #[test]
1663 fn style_block_inside_html_comment_filtered() {
1664 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1665<style lang="scss">@import 'good';</style>"#;
1666 let styles = extract_sfc_styles(source);
1667 assert_eq!(styles.len(), 1);
1668 assert!(styles[0].body.contains("good"));
1669 }
1670
1671 #[test]
1672 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1673 let info = parse_sfc_to_module(
1674 FileId(0),
1675 Path::new("Foo.vue"),
1676 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1677 0,
1678 false,
1679 );
1680 let style_import = info
1681 .imports
1682 .iter()
1683 .find(|i| i.source == "./Foo")
1684 .expect("scss @import 'Foo' should be normalized to ./Foo");
1685 assert!(
1686 style_import.from_style,
1687 "imports from <style> blocks must carry from_style=true so the resolver \
1688 enables SCSS partial fallback for the SFC importer"
1689 );
1690 assert!(matches!(
1691 style_import.imported_name,
1692 ImportedName::SideEffect
1693 ));
1694 }
1695
1696 #[test]
1697 fn parse_sfc_extracts_style_plugin_as_default_import() {
1698 let info = parse_sfc_to_module(
1699 FileId(0),
1700 Path::new("Foo.vue"),
1701 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1702 0,
1703 false,
1704 );
1705 let plugin_import = info
1706 .imports
1707 .iter()
1708 .find(|i| i.source == "./tailwind-plugin.js")
1709 .expect("style @plugin should create an import");
1710 assert!(plugin_import.from_style);
1711 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1712 }
1713
1714 #[test]
1715 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1716 let info = parse_sfc_to_module(
1717 FileId(0),
1718 Path::new("Bar.vue"),
1719 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1720 0,
1721 false,
1722 );
1723 let style_src = info
1724 .imports
1725 .iter()
1726 .find(|i| i.source == "./Bar.scss")
1727 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1728 assert!(style_src.from_style);
1729 }
1730
1731 #[test]
1732 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1733 let info = parse_sfc_to_module(
1734 FileId(0),
1735 Path::new("Baz.vue"),
1736 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1737 0,
1738 false,
1739 );
1740 assert!(
1741 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1742 "src reference should still be seeded for unsupported lang"
1743 );
1744 assert!(
1745 !info.imports.iter().any(|i| i.source.contains("skipped")),
1746 "postcss body should not be scanned for @import directives"
1747 );
1748 }
1749
1750 fn asset_refs(source: &str) -> Vec<String> {
1751 super::collect_template_asset_refs(source)
1752 .into_iter()
1753 .map(|(s, _)| s)
1754 .collect()
1755 }
1756
1757 #[test]
1758 fn captures_static_relative_template_asset_refs() {
1759 assert_eq!(
1760 asset_refs(r#"<template><img src="./logo.png" /></template>"#),
1761 vec!["./logo.png".to_string()]
1762 );
1763 assert_eq!(
1764 asset_refs(r#"<source src="../media/clip.mp4">"#),
1765 vec!["../media/clip.mp4".to_string()]
1766 );
1767 assert_eq!(
1768 asset_refs(r#"<video poster="./thumb.jpg"></video>"#),
1769 vec!["./thumb.jpg".to_string()]
1770 );
1771 }
1772
1773 #[test]
1774 fn skips_dynamic_alias_root_remote_and_query_asset_refs() {
1775 assert!(asset_refs(r#"<img :src="logo" />"#).is_empty());
1777 assert!(asset_refs(r#"<img v-bind:src="logo" />"#).is_empty());
1778 assert!(asset_refs(r#"<img bind:src="logo" />"#).is_empty());
1779 assert!(asset_refs(r"<img src={logo} />").is_empty());
1780 assert!(asset_refs(r#"<img data-src="./x.png" />"#).is_empty());
1781 assert!(asset_refs(r#"<img src="@/assets/x.png" />"#).is_empty());
1783 assert!(asset_refs(r#"<img src="/logo.png" />"#).is_empty());
1784 assert!(asset_refs(r#"<img src="https://cdn/x.png" />"#).is_empty());
1785 assert!(asset_refs(r#"<img src="./x.png?inline" />"#).is_empty());
1787 assert!(asset_refs(r#"<img src="{{ logo }}" />"#).is_empty());
1789 }
1790
1791 #[test]
1792 fn skips_custom_component_src_prop() {
1793 assert!(asset_refs(r#"<MyImage src="./x.png" />"#).is_empty());
1795 assert!(asset_refs(r#"<AppIcon src="../icons/y.svg" />"#).is_empty());
1796 }
1797
1798 #[test]
1799 fn skips_asset_refs_inside_script_style_and_comments() {
1800 assert!(asset_refs(r#"<script>const x = "<img src='./a.png'>"</script>"#).is_empty());
1802 assert!(asset_refs(r#"<style>/* <img src="./b.png"> */ .x{}</style>"#).is_empty());
1803 assert!(asset_refs(r#"<!-- <img src="./c.png" /> -->"#).is_empty());
1804 }
1805
1806 #[test]
1807 fn parse_sfc_emits_template_asset_as_side_effect_import() {
1808 let info = parse_sfc_to_module(
1809 FileId(0),
1810 Path::new("Hero.vue"),
1811 r#"<template><img src="./hero.png" /></template><script>let x=1</script>"#,
1812 0,
1813 false,
1814 );
1815 assert!(
1816 info.imports.iter().any(|i| i.source == "./hero.png"
1817 && matches!(i.imported_name, ImportedName::SideEffect)
1818 && !i.from_style),
1819 "template <img src> should seed a SideEffect import: {:?}",
1820 info.imports
1821 );
1822 }
1823
1824 fn svelte_props(source: &str) -> Vec<crate::ModuleInfo> {
1827 vec![parse_sfc_to_module(
1828 FileId(0),
1829 Path::new("Component.svelte"),
1830 source,
1831 0,
1832 false,
1833 )]
1834 }
1835
1836 fn prop_names(info: &crate::ModuleInfo) -> Vec<String> {
1837 let mut names: Vec<String> = info
1838 .component_props
1839 .iter()
1840 .map(|p| p.name.clone())
1841 .collect();
1842 names.sort();
1843 names
1844 }
1845
1846 #[test]
1847 fn svelte_shorthand_props_harvested() {
1848 let info = &svelte_props(r"<script>let { a, b } = $props();</script>")[0];
1850 assert_eq!(prop_names(info), vec!["a", "b"]);
1851 for prop in &info.component_props {
1852 assert_eq!(prop.local, prop.name);
1853 }
1854 }
1855
1856 #[test]
1857 fn svelte_renamed_prop_tracks_local_and_script_use() {
1858 let info =
1861 &svelte_props(r"<script>let { a: alias } = $props(); console.log(alias);</script>")[0];
1862 assert_eq!(prop_names(info), vec!["a"]);
1863 let prop = &info.component_props[0];
1864 assert_eq!(prop.local, "alias");
1865 assert!(
1866 prop.used_in_script,
1867 "alias is referenced, so a is used in script"
1868 );
1869 }
1870
1871 #[test]
1872 fn svelte_unreferenced_prop_is_unused_in_script() {
1873 let info = &svelte_props(r"<script>let { a } = $props();</script>")[0];
1874 assert_eq!(prop_names(info), vec!["a"]);
1875 assert!(!info.component_props[0].used_in_script);
1876 }
1877
1878 #[test]
1879 fn svelte_default_prop_peeled() {
1880 let info = &svelte_props(r"<script>let { a = 1 } = $props();</script>")[0];
1882 assert_eq!(prop_names(info), vec!["a"]);
1883 }
1884
1885 #[test]
1886 fn svelte_bindable_default_peeled() {
1887 let info = &svelte_props(r"<script>let { a = $bindable() } = $props();</script>")[0];
1890 assert_eq!(prop_names(info), vec!["a"]);
1891 }
1892
1893 #[test]
1894 fn svelte_rest_element_sets_fallthrough_abstain() {
1895 let info = &svelte_props(r"<script>let { a, ...rest } = $props();</script>")[0];
1897 assert!(info.has_props_attrs_fallthrough);
1898 }
1899
1900 #[test]
1901 fn svelte_bare_identifier_binding_sets_unharvestable_abstain() {
1902 let info = &svelte_props(r"<script>let p = $props(); console.log(p.x);</script>")[0];
1904 assert!(info.has_unharvestable_props);
1905 assert!(info.component_props.is_empty());
1906 }
1907
1908 #[test]
1909 fn svelte_nested_destructure_sets_unharvestable_abstain() {
1910 let info = &svelte_props(r"<script>let { a: { x } } = $props();</script>")[0];
1912 assert!(info.has_unharvestable_props);
1913 }
1914
1915 #[test]
1916 fn svelte_prop_used_only_in_markup_credited_as_template_root() {
1917 let info = &svelte_props(r"<script>let { a } = $props();</script><p>{a}</p>")[0];
1920 assert_eq!(prop_names(info), vec!["a"]);
1921 assert!(
1922 info.component_props[0].used_in_template,
1923 "a is used in markup, so used_in_template should be true"
1924 );
1925 }
1926
1927 #[test]
1928 fn svelte_module_script_props_not_harvested() {
1929 let info = &svelte_props(
1931 r"<script module>let { a } = $props();</script><script>let { b } = $props();</script>",
1932 )[0];
1933 assert_eq!(prop_names(info), vec!["b"]);
1935 }
1936
1937 fn dispatched_names(info: &crate::ModuleInfo) -> Vec<String> {
1940 let mut names: Vec<String> = info
1941 .svelte_dispatched_events
1942 .iter()
1943 .map(|e| e.name.clone())
1944 .collect();
1945 names.sort();
1946 names
1947 }
1948
1949 #[test]
1950 fn svelte_dispatch_literal_event_is_harvested() {
1951 let info = &svelte_props(
1952 r"<script>import { createEventDispatcher } from 'svelte';
1953 const dispatch = createEventDispatcher();
1954 function save() { dispatch('save'); }</script>",
1955 )[0];
1956 assert_eq!(dispatched_names(info), vec!["save"]);
1957 assert!(!info.has_dynamic_dispatch);
1958 }
1959
1960 #[test]
1961 fn svelte_dispatch_without_svelte_import_is_ignored() {
1962 let info = &svelte_props(
1965 r"<script>function createEventDispatcher() { return () => {}; }
1966 const dispatch = createEventDispatcher();
1967 dispatch('save');</script>",
1968 )[0];
1969 assert!(info.svelte_dispatched_events.is_empty());
1970 }
1971
1972 #[test]
1973 fn svelte_dynamic_dispatch_sets_abstain() {
1974 let info = &svelte_props(
1975 r"<script>import { createEventDispatcher } from 'svelte';
1976 const dispatch = createEventDispatcher();
1977 function fire(name) { dispatch(name); }</script>",
1978 )[0];
1979 assert!(
1980 info.has_dynamic_dispatch,
1981 "a non-literal dispatch arg must set the abstain flag"
1982 );
1983 }
1984
1985 #[test]
1986 fn svelte_dispatch_whole_value_use_sets_abstain() {
1987 let info = &svelte_props(
1988 r"<script>import { createEventDispatcher } from 'svelte';
1989 const dispatch = createEventDispatcher();
1990 forward(dispatch);</script>",
1991 )[0];
1992 assert!(
1993 info.has_dynamic_dispatch,
1994 "passing the dispatch binding as a whole value must set the abstain flag"
1995 );
1996 }
1997
1998 #[test]
1999 fn svelte_listened_event_on_component_is_harvested() {
2000 let info =
2001 &svelte_props(r"<script>import Child from './Child.svelte';</script><Child on:save />")
2002 [0];
2003 assert!(info.svelte_listened_events.contains(&"save".to_string()));
2004 }
2005}