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