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 VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
62 crate::static_regex(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
63});
64
65static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
68 crate::static_regex(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
69});
70
71static HTML_COMMENT_RE: LazyLock<regex::Regex> =
73 LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
74
75static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
79 crate::static_regex(
80 r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
81 )
82});
83
84pub struct SfcScript {
86 pub body: String,
88 pub is_typescript: bool,
90 pub is_jsx: bool,
92 pub byte_offset: usize,
94 pub src: Option<String>,
96 pub src_span: Option<Span>,
98 pub is_setup: bool,
100 pub is_context_module: bool,
102 pub generic_attr: Option<String>,
106}
107
108pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
110 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
111 .find_iter(source)
112 .map(|m| (m.start(), m.end()))
113 .collect();
114
115 SCRIPT_BLOCK_RE
116 .captures_iter(source)
117 .filter(|cap| {
118 let start = cap.get(0).map_or(0, |m| m.start());
119 !comment_ranges
120 .iter()
121 .any(|&(cs, ce)| start >= cs && start < ce)
122 })
123 .map(|cap| {
124 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
125 let body_match = cap.name("body");
126 let byte_offset = body_match.map_or(0, |m| m.start());
127 let body = body_match.map_or("", |m| m.as_str()).to_string();
128 let lang = LANG_ATTR_RE
129 .captures(attrs)
130 .and_then(|c| c.get(1))
131 .map(|m| m.as_str());
132 let is_typescript = matches!(lang, Some("ts" | "tsx"));
133 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
134 let src = SRC_ATTR_RE
135 .captures(attrs)
136 .and_then(|c| c.get(1))
137 .map(|m| m.as_str().to_string());
138 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
139 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
140 Span::new(
141 (attrs_start + m.start()) as u32,
142 (attrs_start + m.end()) as u32,
143 )
144 });
145 let is_setup = SETUP_ATTR_RE.is_match(attrs);
146 let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
147 let generic_attr = VUE_GENERIC_ATTR_RE
148 .captures(attrs)
149 .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
150 .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
151 .map(|m| m.as_str().to_string())
152 .filter(|value| !value.trim().is_empty());
153 SfcScript {
154 body,
155 is_typescript,
156 is_jsx,
157 byte_offset,
158 src,
159 src_span,
160 is_setup,
161 is_context_module,
162 generic_attr,
163 }
164 })
165 .collect()
166}
167
168pub struct SfcStyle {
170 pub body: String,
172 pub lang: Option<String>,
175 pub src: Option<String>,
177 pub src_span: Option<Span>,
179 pub byte_offset: usize,
181}
182
183pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
190 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
191 .find_iter(source)
192 .map(|m| (m.start(), m.end()))
193 .collect();
194
195 STYLE_BLOCK_RE
196 .captures_iter(source)
197 .filter(|cap| {
198 let start = cap.get(0).map_or(0, |m| m.start());
199 !comment_ranges
200 .iter()
201 .any(|&(cs, ce)| start >= cs && start < ce)
202 })
203 .map(|cap| {
204 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
205 let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
206 let byte_offset = cap.name("body").map_or(0, |m| m.start());
207 let lang = LANG_ATTR_RE
208 .captures(attrs)
209 .and_then(|c| c.get(1))
210 .map(|m| m.as_str().to_string());
211 let src = SRC_ATTR_RE
212 .captures(attrs)
213 .and_then(|c| c.get(1))
214 .map(|m| m.as_str().to_string());
215 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
216 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
217 Span::new(
218 (attrs_start + m.start()) as u32,
219 (attrs_start + m.end()) as u32,
220 )
221 });
222 SfcStyle {
223 body,
224 lang,
225 src,
226 src_span,
227 byte_offset,
228 }
229 })
230 .collect()
231}
232
233#[must_use]
235pub fn is_sfc_file(path: &Path) -> bool {
236 path.extension()
237 .and_then(|e| e.to_str())
238 .is_some_and(|ext| ext == "vue" || ext == "svelte")
239}
240
241pub(crate) fn parse_sfc_to_module(
243 file_id: FileId,
244 path: &Path,
245 source: &str,
246 content_hash: u64,
247 need_complexity: bool,
248) -> ModuleInfo {
249 let scripts = extract_sfc_scripts(source);
250 let styles = extract_sfc_styles(source);
251 let kind = sfc_kind(path);
252 let mut combined = empty_sfc_module(file_id, source, content_hash);
253 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
254 let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
255
256 for script in &scripts {
257 merge_script_into_module(
258 kind,
259 script,
260 &mut combined,
261 &mut template_visible_imports,
262 &mut template_visible_bound_targets,
263 need_complexity,
264 );
265 }
266
267 for style in &styles {
268 merge_style_into_module(style, &mut combined);
269 }
270
271 apply_template_usage(
272 kind,
273 source,
274 &template_visible_imports,
275 &template_visible_bound_targets,
276 &mut combined,
277 );
278 combined.unused_import_bindings.sort_unstable();
279 combined.unused_import_bindings.dedup();
280 combined.type_referenced_import_bindings.sort_unstable();
281 combined.type_referenced_import_bindings.dedup();
282 combined.value_referenced_import_bindings.sort_unstable();
283 combined.value_referenced_import_bindings.dedup();
284 combined.auto_import_candidates.sort_unstable();
285 combined.auto_import_candidates.dedup();
286
287 combined
288}
289
290fn sfc_kind(path: &Path) -> SfcKind {
291 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
292 SfcKind::Vue
293 } else {
294 SfcKind::Svelte
295 }
296}
297
298fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
299 let parsed = crate::suppress::parse_suppressions_from_source(source);
300
301 ModuleInfo {
302 file_id,
303 exports: Vec::new(),
304 imports: Vec::new(),
305 re_exports: Vec::new(),
306 dynamic_imports: Vec::new(),
307 dynamic_import_patterns: Vec::new(),
308 require_calls: Vec::new(),
309 package_path_references: Vec::new(),
310 member_accesses: Vec::new(),
311 whole_object_uses: Vec::new(),
312 has_cjs_exports: false,
313 has_angular_component_template_url: false,
314 content_hash,
315 suppressions: parsed.suppressions,
316 unknown_suppression_kinds: parsed.unknown_kinds,
317 unused_import_bindings: Vec::new(),
318 type_referenced_import_bindings: Vec::new(),
319 value_referenced_import_bindings: Vec::new(),
320 line_offsets: compute_line_offsets(source),
321 complexity: Vec::new(),
322 flag_uses: Vec::new(),
323 class_heritage: vec![],
324 injection_tokens: vec![],
325 local_type_declarations: Vec::new(),
326 public_signature_type_references: Vec::new(),
327 namespace_object_aliases: Vec::new(),
328 iconify_prefixes: Vec::new(),
329 iconify_icon_names: Vec::new(),
330 auto_import_candidates: Vec::new(),
331 directives: Vec::new(),
332 security_sinks: Vec::new(),
333 security_sinks_skipped: 0,
334 security_unresolved_callee_sites: Vec::new(),
335 tainted_bindings: Vec::new(),
336 sanitized_sink_args: Vec::new(),
337 security_control_sites: Vec::new(),
338 callee_uses: Vec::new(),
339 }
340}
341
342fn merge_script_into_module(
343 kind: SfcKind,
344 script: &SfcScript,
345 combined: &mut ModuleInfo,
346 template_visible_imports: &mut FxHashSet<String>,
347 template_visible_bound_targets: &mut FxHashMap<String, String>,
348 need_complexity: bool,
349) {
350 if kind == SfcKind::Vue
351 && let Some(src) = &script.src
352 {
353 add_script_src_import(combined, src, script.src_span);
354 }
355
356 let allocator = Allocator::default();
357 let parser_return =
358 Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
359 let mut extractor = ModuleInfoExtractor::new();
360 extractor.visit_program(&parser_return.program);
361 let extraction = ExtractionResult::contiguous(&script.body, script.byte_offset);
362 extractor.remap_spans_with(|span| extraction.remap_span(span));
363 extractor.resolve_typed_destructure_bindings();
364
365 let augmented_body = build_generic_attr_probe_source(script);
366 let empty_template_used = rustc_hash::FxHashSet::default();
367 let (binding_usage, auto_import_candidates) = if let Some(augmented) = augmented_body.as_deref()
368 {
369 let augmented_return =
370 Parser::new(&allocator, augmented, source_type_for_script(script)).parse();
371 (
372 compute_import_binding_usage(
373 &augmented_return.program,
374 &extractor.imports,
375 &empty_template_used,
376 ),
377 compute_auto_import_candidates(&parser_return.program),
378 )
379 } else {
380 let semantic_usage = compute_semantic_usage(
381 &parser_return.program,
382 &extractor.imports,
383 &empty_template_used,
384 );
385 (
386 semantic_usage.import_binding_usage,
387 semantic_usage.auto_import_candidates,
388 )
389 };
390 combined
391 .unused_import_bindings
392 .extend(binding_usage.unused.iter().cloned());
393 combined
394 .type_referenced_import_bindings
395 .extend(binding_usage.type_referenced.iter().cloned());
396 combined
397 .value_referenced_import_bindings
398 .extend(binding_usage.value_referenced.iter().cloned());
399 combined
400 .auto_import_candidates
401 .extend(auto_import_candidates);
402 if need_complexity {
403 combined.complexity.extend(translate_script_complexity(
404 script,
405 &parser_return.program,
406 &combined.line_offsets,
407 ));
408 }
409
410 if is_template_visible_script(kind, script) {
411 template_visible_imports.extend(
412 extractor
413 .imports
414 .iter()
415 .filter(|import| !import.local_name.is_empty())
416 .map(|import| import.local_name.clone()),
417 );
418 template_visible_bound_targets.extend(
419 extractor
420 .binding_target_names()
421 .iter()
422 .filter(|(local, _)| !local.starts_with("this."))
423 .map(|(local, target)| (local.clone(), target.clone())),
424 );
425 }
426
427 extractor.merge_into(combined);
428}
429
430fn translate_script_complexity(
431 script: &SfcScript,
432 program: &oxc_ast::ast::Program<'_>,
433 sfc_line_offsets: &[u32],
434) -> Vec<FunctionComplexity> {
435 let script_line_offsets = compute_line_offsets(&script.body);
436 let mut complexity =
437 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
438 let (body_start_line, body_start_col) =
439 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
440
441 for function in &mut complexity {
442 function.line = body_start_line + function.line.saturating_sub(1);
443 if function.line == body_start_line {
444 function.col += body_start_col;
445 }
446 }
447
448 complexity
449}
450
451fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
452 let span = source_span.unwrap_or_default();
453 module.imports.push(ImportInfo {
454 source: normalize_asset_url(source),
455 imported_name: ImportedName::SideEffect,
456 local_name: String::new(),
457 is_type_only: false,
458 from_style: false,
459 span,
460 source_span: span,
461 });
462}
463
464fn style_lang_is_scss(lang: Option<&str>) -> bool {
470 matches!(lang, Some("scss" | "sass"))
471}
472
473fn style_lang_is_css_like(lang: Option<&str>) -> bool {
474 lang.is_none() || matches!(lang, Some("css"))
475}
476
477fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
478 if let Some(src) = &style.src {
479 let span = style.src_span.unwrap_or_default();
480 combined.imports.push(ImportInfo {
481 source: normalize_asset_url(src),
482 imported_name: ImportedName::SideEffect,
483 local_name: String::new(),
484 is_type_only: false,
485 from_style: true,
486 span,
487 source_span: span,
488 });
489 }
490
491 let lang = style.lang.as_deref();
492 let is_scss = style_lang_is_scss(lang);
493 let is_css_like = style_lang_is_css_like(lang);
494 if !is_scss && !is_css_like {
495 return;
496 }
497
498 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
499 let source_span = Span::new(
500 style.byte_offset as u32 + source.span.start,
501 style.byte_offset as u32 + source.span.end,
502 );
503 combined.imports.push(ImportInfo {
504 source: source.normalized,
505 imported_name: if source.is_plugin {
506 ImportedName::Default
507 } else {
508 ImportedName::SideEffect
509 },
510 local_name: String::new(),
511 is_type_only: false,
512 from_style: true,
513 span: source_span,
514 source_span,
515 });
516 }
517}
518
519fn source_type_for_script(script: &SfcScript) -> SourceType {
520 match (script.is_typescript, script.is_jsx) {
521 (true, true) => SourceType::tsx(),
522 (true, false) => SourceType::ts(),
523 (false, true) => SourceType::jsx(),
524 (false, false) => SourceType::mjs(),
525 }
526}
527
528fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
534 let constraint = script.generic_attr.as_deref()?.trim();
535 if constraint.is_empty() {
536 return None;
537 }
538 Some(format!(
539 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
540 script.body, constraint,
541 ))
542}
543
544fn apply_template_usage(
545 kind: SfcKind,
546 source: &str,
547 template_visible_imports: &FxHashSet<String>,
548 template_visible_bound_targets: &FxHashMap<String, String>,
549 combined: &mut ModuleInfo,
550) {
551 let template_usage = collect_template_usage_with_bound_targets(
552 kind,
553 source,
554 template_visible_imports,
555 template_visible_bound_targets,
556 );
557 combined
558 .unused_import_bindings
559 .retain(|binding| !template_usage.used_bindings.contains(binding));
560 combined
561 .member_accesses
562 .extend(template_usage.member_accesses);
563 combined
564 .whole_object_uses
565 .extend(template_usage.whole_object_uses);
566 combined
567 .security_sinks
568 .extend(template_usage.security_sinks);
569 if !template_usage.unresolved_tag_names.is_empty() {
570 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
571 names.sort_unstable();
572 combined.auto_import_candidates.extend(names);
573 combined.auto_import_candidates.dedup();
574 }
575}
576
577fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
578 match kind {
579 SfcKind::Vue => script.is_setup,
580 SfcKind::Svelte => !script.is_context_module,
581 }
582}
583
584#[cfg(all(test, not(miri)))]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn is_sfc_file_vue() {
590 assert!(is_sfc_file(Path::new("App.vue")));
591 }
592
593 #[test]
594 fn is_sfc_file_svelte() {
595 assert!(is_sfc_file(Path::new("Counter.svelte")));
596 }
597
598 #[test]
599 fn is_sfc_file_rejects_ts() {
600 assert!(!is_sfc_file(Path::new("utils.ts")));
601 }
602
603 #[test]
604 fn is_sfc_file_rejects_jsx() {
605 assert!(!is_sfc_file(Path::new("App.jsx")));
606 }
607
608 #[test]
609 fn is_sfc_file_rejects_astro() {
610 assert!(!is_sfc_file(Path::new("Layout.astro")));
611 }
612
613 #[test]
614 fn single_plain_script() {
615 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
616 assert_eq!(scripts.len(), 1);
617 assert_eq!(scripts[0].body, "const x = 1;");
618 assert!(!scripts[0].is_typescript);
619 assert!(!scripts[0].is_jsx);
620 assert!(scripts[0].src.is_none());
621 }
622
623 #[test]
624 fn single_ts_script() {
625 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
626 assert_eq!(scripts.len(), 1);
627 assert!(scripts[0].is_typescript);
628 assert!(!scripts[0].is_jsx);
629 }
630
631 #[test]
632 fn single_tsx_script() {
633 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
634 assert_eq!(scripts.len(), 1);
635 assert!(scripts[0].is_typescript);
636 assert!(scripts[0].is_jsx);
637 }
638
639 #[test]
640 fn single_jsx_script() {
641 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
642 assert_eq!(scripts.len(), 1);
643 assert!(!scripts[0].is_typescript);
644 assert!(scripts[0].is_jsx);
645 }
646
647 #[test]
648 fn two_script_blocks() {
649 let source = r#"
650<script lang="ts">
651export default {};
652</script>
653<script setup lang="ts">
654const count = 0;
655</script>
656"#;
657 let scripts = extract_sfc_scripts(source);
658 assert_eq!(scripts.len(), 2);
659 assert!(scripts[0].body.contains("export default"));
660 assert!(scripts[1].body.contains("count"));
661 }
662
663 #[test]
664 fn script_setup_extracted() {
665 let scripts =
666 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
667 assert_eq!(scripts.len(), 1);
668 assert!(scripts[0].body.contains("import"));
669 assert!(scripts[0].is_typescript);
670 }
671
672 #[test]
673 fn script_src_detected() {
674 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
675 assert_eq!(scripts.len(), 1);
676 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
677 }
678
679 #[test]
680 fn data_src_not_treated_as_src() {
681 let scripts =
682 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
683 assert_eq!(scripts.len(), 1);
684 assert!(scripts[0].src.is_none());
685 }
686
687 #[test]
688 fn script_inside_html_comment_filtered() {
689 let source = r#"
690<!-- <script lang="ts">import { bad } from 'bad';</script> -->
691<script lang="ts">import { good } from 'good';</script>
692"#;
693 let scripts = extract_sfc_scripts(source);
694 assert_eq!(scripts.len(), 1);
695 assert!(scripts[0].body.contains("good"));
696 }
697
698 #[test]
699 fn spanning_comment_filters_script() {
700 let source = r#"
701<!-- disabled:
702<script lang="ts">import { bad } from 'bad';</script>
703-->
704<script lang="ts">const ok = true;</script>
705"#;
706 let scripts = extract_sfc_scripts(source);
707 assert_eq!(scripts.len(), 1);
708 assert!(scripts[0].body.contains("ok"));
709 }
710
711 #[test]
712 fn string_containing_comment_markers_not_corrupted() {
713 let source = r#"
714<script setup lang="ts">
715const marker = "<!-- not a comment -->";
716import { ref } from 'vue';
717</script>
718"#;
719 let scripts = extract_sfc_scripts(source);
720 assert_eq!(scripts.len(), 1);
721 assert!(scripts[0].body.contains("import"));
722 }
723
724 #[test]
725 fn generic_attr_with_angle_bracket() {
726 let source =
727 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
728 let scripts = extract_sfc_scripts(source);
729 assert_eq!(scripts.len(), 1);
730 assert_eq!(scripts[0].body, "const x = 1;");
731 }
732
733 #[test]
734 fn nested_generic_attr() {
735 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
736 let scripts = extract_sfc_scripts(source);
737 assert_eq!(scripts.len(), 1);
738 assert_eq!(scripts[0].body, "const x = 1;");
739 }
740
741 #[test]
742 fn lang_single_quoted() {
743 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
744 assert_eq!(scripts.len(), 1);
745 assert!(scripts[0].is_typescript);
746 }
747
748 #[test]
749 fn uppercase_script_tag() {
750 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
751 assert_eq!(scripts.len(), 1);
752 assert!(scripts[0].is_typescript);
753 }
754
755 #[test]
756 fn no_script_block() {
757 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
758 assert!(scripts.is_empty());
759 }
760
761 #[test]
762 fn empty_script_body() {
763 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
764 assert_eq!(scripts.len(), 1);
765 assert!(scripts[0].body.is_empty());
766 }
767
768 #[test]
769 fn whitespace_only_script() {
770 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
771 assert_eq!(scripts.len(), 1);
772 assert!(scripts[0].body.trim().is_empty());
773 }
774
775 #[test]
776 fn byte_offset_is_set() {
777 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
778 let scripts = extract_sfc_scripts(source);
779 assert_eq!(scripts.len(), 1);
780 let offset = scripts[0].byte_offset;
781 assert_eq!(&source[offset..offset + 4], "code");
782 }
783
784 #[test]
785 fn script_with_extra_attributes() {
786 let scripts = extract_sfc_scripts(
787 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
788 );
789 assert_eq!(scripts.len(), 1);
790 assert!(scripts[0].is_typescript);
791 assert!(scripts[0].src.is_none());
792 }
793
794 #[test]
795 fn multiple_script_blocks_exports_combined() {
796 let source = r#"
797<script lang="ts">
798export const version = '1.0';
799</script>
800<script setup lang="ts">
801import { ref } from 'vue';
802const count = ref(0);
803</script>
804"#;
805 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
806 assert!(
807 info.exports
808 .iter()
809 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
810 "export from <script> block should be extracted"
811 );
812 assert!(
813 info.imports.iter().any(|i| i.source == "vue"),
814 "import from <script setup> block should be extracted"
815 );
816 }
817
818 #[test]
819 fn lang_tsx_detected_as_typescript_jsx() {
820 let scripts =
821 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
822 assert_eq!(scripts.len(), 1);
823 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
824 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
825 }
826
827 #[test]
828 fn multiline_html_comment_filters_all_script_blocks_inside() {
829 let source = r#"
830<!--
831 This whole section is disabled:
832 <script lang="ts">import { bad1 } from 'bad1';</script>
833 <script lang="ts">import { bad2 } from 'bad2';</script>
834-->
835<script lang="ts">import { good } from 'good';</script>
836"#;
837 let scripts = extract_sfc_scripts(source);
838 assert_eq!(scripts.len(), 1);
839 assert!(scripts[0].body.contains("good"));
840 }
841
842 #[test]
843 fn script_src_generates_side_effect_import() {
844 let info = parse_sfc_to_module(
845 FileId(0),
846 Path::new("External.vue"),
847 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
848 0,
849 false,
850 );
851 assert!(
852 info.imports
853 .iter()
854 .any(|i| i.source == "./external-logic.ts"
855 && matches!(i.imported_name, ImportedName::SideEffect)),
856 "script src should generate a side-effect import"
857 );
858 }
859
860 #[test]
861 fn parse_sfc_no_script_returns_empty_module() {
862 let info = parse_sfc_to_module(
863 FileId(0),
864 Path::new("Empty.vue"),
865 "<template><div>Hello</div></template>",
866 42,
867 false,
868 );
869 assert!(info.imports.is_empty());
870 assert!(info.exports.is_empty());
871 assert_eq!(info.content_hash, 42);
872 assert_eq!(info.file_id, FileId(0));
873 }
874
875 #[test]
876 fn parse_sfc_has_line_offsets() {
877 let info = parse_sfc_to_module(
878 FileId(0),
879 Path::new("LineOffsets.vue"),
880 r#"<script lang="ts">const x = 1;</script>"#,
881 0,
882 false,
883 );
884 assert!(!info.line_offsets.is_empty());
885 }
886
887 #[test]
888 fn parse_sfc_has_suppressions() {
889 let info = parse_sfc_to_module(
890 FileId(0),
891 Path::new("Suppressions.vue"),
892 r#"<script lang="ts">
893// fallow-ignore-file
894export const foo = 1;
895</script>"#,
896 0,
897 false,
898 );
899 assert!(!info.suppressions.is_empty());
900 }
901
902 #[test]
903 fn source_type_jsx_detection() {
904 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
905 assert_eq!(scripts.len(), 1);
906 assert!(!scripts[0].is_typescript);
907 assert!(scripts[0].is_jsx);
908 }
909
910 #[test]
911 fn source_type_plain_js_detection() {
912 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
913 assert_eq!(scripts.len(), 1);
914 assert!(!scripts[0].is_typescript);
915 assert!(!scripts[0].is_jsx);
916 }
917
918 #[test]
919 fn is_sfc_file_rejects_no_extension() {
920 assert!(!is_sfc_file(Path::new("Makefile")));
921 }
922
923 #[test]
924 fn is_sfc_file_rejects_mdx() {
925 assert!(!is_sfc_file(Path::new("post.mdx")));
926 }
927
928 #[test]
929 fn is_sfc_file_rejects_css() {
930 assert!(!is_sfc_file(Path::new("styles.css")));
931 }
932
933 #[test]
934 fn multiple_script_blocks_both_have_offsets() {
935 let source = r#"<script lang="ts">const a = 1;</script>
936<script setup lang="ts">const b = 2;</script>"#;
937 let scripts = extract_sfc_scripts(source);
938 assert_eq!(scripts.len(), 2);
939 let offset0 = scripts[0].byte_offset;
940 let offset1 = scripts[1].byte_offset;
941 assert_eq!(
942 &source[offset0..offset0 + "const a = 1;".len()],
943 "const a = 1;"
944 );
945 assert_eq!(
946 &source[offset1..offset1 + "const b = 2;".len()],
947 "const b = 2;"
948 );
949 }
950
951 #[test]
952 fn script_with_src_and_lang() {
953 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
954 assert_eq!(scripts.len(), 1);
955 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
956 assert!(scripts[0].is_typescript);
957 assert!(scripts[0].is_jsx);
958 }
959
960 #[test]
961 fn extract_style_block_lang_scss() {
962 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
963 let styles = extract_sfc_styles(source);
964 assert_eq!(styles.len(), 1);
965 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
966 assert!(styles[0].body.contains("@import"));
967 assert!(styles[0].src.is_none());
968 }
969
970 #[test]
971 fn extract_style_block_with_src() {
972 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
973 let styles = extract_sfc_styles(source);
974 assert_eq!(styles.len(), 1);
975 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
976 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
977 }
978
979 #[test]
980 fn extract_style_block_plain_no_lang() {
981 let source = r"<style>.foo { color: red; }</style>";
982 let styles = extract_sfc_styles(source);
983 assert_eq!(styles.len(), 1);
984 assert!(styles[0].lang.is_none());
985 }
986
987 #[test]
988 fn extract_multiple_style_blocks() {
989 let source = r#"<style lang="scss">@import 'a';</style>
990<style scoped lang="scss">@import 'b';</style>"#;
991 let styles = extract_sfc_styles(source);
992 assert_eq!(styles.len(), 2);
993 }
994
995 #[test]
996 fn style_block_inside_html_comment_filtered() {
997 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
998<style lang="scss">@import 'good';</style>"#;
999 let styles = extract_sfc_styles(source);
1000 assert_eq!(styles.len(), 1);
1001 assert!(styles[0].body.contains("good"));
1002 }
1003
1004 #[test]
1005 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1006 let info = parse_sfc_to_module(
1007 FileId(0),
1008 Path::new("Foo.vue"),
1009 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1010 0,
1011 false,
1012 );
1013 let style_import = info
1014 .imports
1015 .iter()
1016 .find(|i| i.source == "./Foo")
1017 .expect("scss @import 'Foo' should be normalized to ./Foo");
1018 assert!(
1019 style_import.from_style,
1020 "imports from <style> blocks must carry from_style=true so the resolver \
1021 enables SCSS partial fallback for the SFC importer"
1022 );
1023 assert!(matches!(
1024 style_import.imported_name,
1025 ImportedName::SideEffect
1026 ));
1027 }
1028
1029 #[test]
1030 fn parse_sfc_extracts_style_plugin_as_default_import() {
1031 let info = parse_sfc_to_module(
1032 FileId(0),
1033 Path::new("Foo.vue"),
1034 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1035 0,
1036 false,
1037 );
1038 let plugin_import = info
1039 .imports
1040 .iter()
1041 .find(|i| i.source == "./tailwind-plugin.js")
1042 .expect("style @plugin should create an import");
1043 assert!(plugin_import.from_style);
1044 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1045 }
1046
1047 #[test]
1048 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1049 let info = parse_sfc_to_module(
1050 FileId(0),
1051 Path::new("Bar.vue"),
1052 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1053 0,
1054 false,
1055 );
1056 let style_src = info
1057 .imports
1058 .iter()
1059 .find(|i| i.source == "./Bar.scss")
1060 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1061 assert!(style_src.from_style);
1062 }
1063
1064 #[test]
1065 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1066 let info = parse_sfc_to_module(
1067 FileId(0),
1068 Path::new("Baz.vue"),
1069 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1070 0,
1071 false,
1072 );
1073 assert!(
1074 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1075 "src reference should still be seeded for unsupported lang"
1076 );
1077 assert!(
1078 !info.imports.iter().any(|i| i.source.contains("skipped")),
1079 "postcss body should not be scanned for @import directives"
1080 );
1081 }
1082}