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 parsed = 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: parsed.suppressions,
290 unknown_suppression_kinds: parsed.unknown_kinds,
291 unused_import_bindings: Vec::new(),
292 type_referenced_import_bindings: Vec::new(),
293 value_referenced_import_bindings: Vec::new(),
294 line_offsets: compute_line_offsets(source),
295 complexity: Vec::new(),
296 flag_uses: Vec::new(),
297 class_heritage: vec![],
298 local_type_declarations: Vec::new(),
299 public_signature_type_references: Vec::new(),
300 namespace_object_aliases: Vec::new(),
301 iconify_prefixes: Vec::new(),
302 auto_import_candidates: Vec::new(),
303 }
304}
305
306fn merge_script_into_module(
307 kind: SfcKind,
308 script: &SfcScript,
309 combined: &mut ModuleInfo,
310 template_visible_imports: &mut FxHashSet<String>,
311 template_visible_bound_targets: &mut FxHashMap<String, String>,
312 need_complexity: bool,
313) {
314 if let Some(src) = &script.src {
315 add_script_src_import(combined, src);
316 }
317
318 let allocator = Allocator::default();
319 let parser_return =
320 Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
321 let mut extractor = ModuleInfoExtractor::new();
322 extractor.visit_program(&parser_return.program);
323
324 let augmented_body = build_generic_attr_probe_source(script);
330 let empty_template_used = rustc_hash::FxHashSet::default();
336 let binding_usage = if let Some(augmented) = augmented_body.as_deref() {
337 let augmented_return =
338 Parser::new(&allocator, augmented, source_type_for_script(script)).parse();
339 compute_import_binding_usage(
340 &augmented_return.program,
341 &extractor.imports,
342 &empty_template_used,
343 )
344 } else {
345 compute_import_binding_usage(
346 &parser_return.program,
347 &extractor.imports,
348 &empty_template_used,
349 )
350 };
351 combined
352 .unused_import_bindings
353 .extend(binding_usage.unused.iter().cloned());
354 combined
355 .type_referenced_import_bindings
356 .extend(binding_usage.type_referenced.iter().cloned());
357 combined
358 .value_referenced_import_bindings
359 .extend(binding_usage.value_referenced.iter().cloned());
360 if need_complexity {
361 combined.complexity.extend(translate_script_complexity(
362 script,
363 &parser_return.program,
364 &combined.line_offsets,
365 ));
366 }
367
368 if is_template_visible_script(kind, script) {
369 template_visible_imports.extend(
370 extractor
371 .imports
372 .iter()
373 .filter(|import| !import.local_name.is_empty())
374 .map(|import| import.local_name.clone()),
375 );
376 template_visible_bound_targets.extend(
377 extractor
378 .binding_target_names()
379 .iter()
380 .filter(|(local, _)| !local.starts_with("this."))
381 .map(|(local, target)| (local.clone(), target.clone())),
382 );
383 }
384
385 extractor.merge_into(combined);
386}
387
388fn translate_script_complexity(
389 script: &SfcScript,
390 program: &oxc_ast::ast::Program<'_>,
391 sfc_line_offsets: &[u32],
392) -> Vec<FunctionComplexity> {
393 let script_line_offsets = compute_line_offsets(&script.body);
394 let mut complexity =
395 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
396 let (body_start_line, body_start_col) =
397 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
398
399 for function in &mut complexity {
400 function.line = body_start_line + function.line.saturating_sub(1);
401 if function.line == body_start_line {
402 function.col += body_start_col;
403 }
404 }
405
406 complexity
407}
408
409fn add_script_src_import(module: &mut ModuleInfo, source: &str) {
410 module.imports.push(ImportInfo {
413 source: normalize_asset_url(source),
414 imported_name: ImportedName::SideEffect,
415 local_name: String::new(),
416 is_type_only: false,
417 from_style: false,
418 span: Span::default(),
419 source_span: Span::default(),
420 });
421}
422
423fn style_lang_is_scss(lang: Option<&str>) -> bool {
429 matches!(lang, Some("scss" | "sass"))
430}
431
432fn style_lang_is_css_like(lang: Option<&str>) -> bool {
433 lang.is_none() || matches!(lang, Some("css"))
434}
435
436fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
437 if let Some(src) = &style.src {
442 combined.imports.push(ImportInfo {
443 source: normalize_asset_url(src),
444 imported_name: ImportedName::SideEffect,
445 local_name: String::new(),
446 is_type_only: false,
447 from_style: true,
448 span: Span::default(),
449 source_span: Span::default(),
450 });
451 }
452
453 let lang = style.lang.as_deref();
454 let is_scss = style_lang_is_scss(lang);
455 let is_css_like = style_lang_is_css_like(lang);
456 if !is_scss && !is_css_like {
457 return;
458 }
459
460 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
461 combined.imports.push(ImportInfo {
462 source: source.normalized,
463 imported_name: if source.is_plugin {
464 ImportedName::Default
465 } else {
466 ImportedName::SideEffect
467 },
468 local_name: String::new(),
469 is_type_only: false,
470 from_style: true,
471 span: Span::default(),
472 source_span: Span::default(),
473 });
474 }
475}
476
477fn source_type_for_script(script: &SfcScript) -> SourceType {
478 match (script.is_typescript, script.is_jsx) {
479 (true, true) => SourceType::tsx(),
480 (true, false) => SourceType::ts(),
481 (false, true) => SourceType::jsx(),
482 (false, false) => SourceType::mjs(),
483 }
484}
485
486fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
492 let constraint = script.generic_attr.as_deref()?.trim();
493 if constraint.is_empty() {
494 return None;
495 }
496 Some(format!(
497 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
498 script.body, constraint,
499 ))
500}
501
502fn apply_template_usage(
503 kind: SfcKind,
504 source: &str,
505 template_visible_imports: &FxHashSet<String>,
506 template_visible_bound_targets: &FxHashMap<String, String>,
507 combined: &mut ModuleInfo,
508) {
509 let template_usage = collect_template_usage_with_bound_targets(
516 kind,
517 source,
518 template_visible_imports,
519 template_visible_bound_targets,
520 );
521 combined
522 .unused_import_bindings
523 .retain(|binding| !template_usage.used_bindings.contains(binding));
524 combined
525 .member_accesses
526 .extend(template_usage.member_accesses);
527 combined
528 .whole_object_uses
529 .extend(template_usage.whole_object_uses);
530 if !template_usage.unresolved_tag_names.is_empty() {
531 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
532 names.sort_unstable();
533 combined.auto_import_candidates.extend(names);
534 combined.auto_import_candidates.dedup();
535 }
536}
537
538fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
539 match kind {
540 SfcKind::Vue => script.is_setup,
541 SfcKind::Svelte => !script.is_context_module,
542 }
543}
544
545#[cfg(all(test, not(miri)))]
548mod tests {
549 use super::*;
550
551 #[test]
554 fn is_sfc_file_vue() {
555 assert!(is_sfc_file(Path::new("App.vue")));
556 }
557
558 #[test]
559 fn is_sfc_file_svelte() {
560 assert!(is_sfc_file(Path::new("Counter.svelte")));
561 }
562
563 #[test]
564 fn is_sfc_file_rejects_ts() {
565 assert!(!is_sfc_file(Path::new("utils.ts")));
566 }
567
568 #[test]
569 fn is_sfc_file_rejects_jsx() {
570 assert!(!is_sfc_file(Path::new("App.jsx")));
571 }
572
573 #[test]
574 fn is_sfc_file_rejects_astro() {
575 assert!(!is_sfc_file(Path::new("Layout.astro")));
576 }
577
578 #[test]
581 fn single_plain_script() {
582 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
583 assert_eq!(scripts.len(), 1);
584 assert_eq!(scripts[0].body, "const x = 1;");
585 assert!(!scripts[0].is_typescript);
586 assert!(!scripts[0].is_jsx);
587 assert!(scripts[0].src.is_none());
588 }
589
590 #[test]
591 fn single_ts_script() {
592 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
593 assert_eq!(scripts.len(), 1);
594 assert!(scripts[0].is_typescript);
595 assert!(!scripts[0].is_jsx);
596 }
597
598 #[test]
599 fn single_tsx_script() {
600 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
601 assert_eq!(scripts.len(), 1);
602 assert!(scripts[0].is_typescript);
603 assert!(scripts[0].is_jsx);
604 }
605
606 #[test]
607 fn single_jsx_script() {
608 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
609 assert_eq!(scripts.len(), 1);
610 assert!(!scripts[0].is_typescript);
611 assert!(scripts[0].is_jsx);
612 }
613
614 #[test]
617 fn two_script_blocks() {
618 let source = r#"
619<script lang="ts">
620export default {};
621</script>
622<script setup lang="ts">
623const count = 0;
624</script>
625"#;
626 let scripts = extract_sfc_scripts(source);
627 assert_eq!(scripts.len(), 2);
628 assert!(scripts[0].body.contains("export default"));
629 assert!(scripts[1].body.contains("count"));
630 }
631
632 #[test]
635 fn script_setup_extracted() {
636 let scripts =
637 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
638 assert_eq!(scripts.len(), 1);
639 assert!(scripts[0].body.contains("import"));
640 assert!(scripts[0].is_typescript);
641 }
642
643 #[test]
646 fn script_src_detected() {
647 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
648 assert_eq!(scripts.len(), 1);
649 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
650 }
651
652 #[test]
653 fn data_src_not_treated_as_src() {
654 let scripts =
655 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
656 assert_eq!(scripts.len(), 1);
657 assert!(scripts[0].src.is_none());
658 }
659
660 #[test]
663 fn script_inside_html_comment_filtered() {
664 let source = r#"
665<!-- <script lang="ts">import { bad } from 'bad';</script> -->
666<script lang="ts">import { good } from 'good';</script>
667"#;
668 let scripts = extract_sfc_scripts(source);
669 assert_eq!(scripts.len(), 1);
670 assert!(scripts[0].body.contains("good"));
671 }
672
673 #[test]
674 fn spanning_comment_filters_script() {
675 let source = r#"
676<!-- disabled:
677<script lang="ts">import { bad } from 'bad';</script>
678-->
679<script lang="ts">const ok = true;</script>
680"#;
681 let scripts = extract_sfc_scripts(source);
682 assert_eq!(scripts.len(), 1);
683 assert!(scripts[0].body.contains("ok"));
684 }
685
686 #[test]
687 fn string_containing_comment_markers_not_corrupted() {
688 let source = r#"
690<script setup lang="ts">
691const marker = "<!-- not a comment -->";
692import { ref } from 'vue';
693</script>
694"#;
695 let scripts = extract_sfc_scripts(source);
696 assert_eq!(scripts.len(), 1);
697 assert!(scripts[0].body.contains("import"));
698 }
699
700 #[test]
703 fn generic_attr_with_angle_bracket() {
704 let source =
705 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
706 let scripts = extract_sfc_scripts(source);
707 assert_eq!(scripts.len(), 1);
708 assert_eq!(scripts[0].body, "const x = 1;");
709 }
710
711 #[test]
712 fn nested_generic_attr() {
713 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
714 let scripts = extract_sfc_scripts(source);
715 assert_eq!(scripts.len(), 1);
716 assert_eq!(scripts[0].body, "const x = 1;");
717 }
718
719 #[test]
722 fn lang_single_quoted() {
723 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
724 assert_eq!(scripts.len(), 1);
725 assert!(scripts[0].is_typescript);
726 }
727
728 #[test]
731 fn uppercase_script_tag() {
732 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
733 assert_eq!(scripts.len(), 1);
734 assert!(scripts[0].is_typescript);
735 }
736
737 #[test]
740 fn no_script_block() {
741 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
742 assert!(scripts.is_empty());
743 }
744
745 #[test]
746 fn empty_script_body() {
747 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
748 assert_eq!(scripts.len(), 1);
749 assert!(scripts[0].body.is_empty());
750 }
751
752 #[test]
753 fn whitespace_only_script() {
754 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
755 assert_eq!(scripts.len(), 1);
756 assert!(scripts[0].body.trim().is_empty());
757 }
758
759 #[test]
760 fn byte_offset_is_set() {
761 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
762 let scripts = extract_sfc_scripts(source);
763 assert_eq!(scripts.len(), 1);
764 let offset = scripts[0].byte_offset;
766 assert_eq!(&source[offset..offset + 4], "code");
767 }
768
769 #[test]
770 fn script_with_extra_attributes() {
771 let scripts = extract_sfc_scripts(
772 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
773 );
774 assert_eq!(scripts.len(), 1);
775 assert!(scripts[0].is_typescript);
776 assert!(scripts[0].src.is_none());
777 }
778
779 #[test]
782 fn multiple_script_blocks_exports_combined() {
783 let source = r#"
784<script lang="ts">
785export const version = '1.0';
786</script>
787<script setup lang="ts">
788import { ref } from 'vue';
789const count = ref(0);
790</script>
791"#;
792 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
793 assert!(
795 info.exports
796 .iter()
797 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
798 "export from <script> block should be extracted"
799 );
800 assert!(
802 info.imports.iter().any(|i| i.source == "vue"),
803 "import from <script setup> block should be extracted"
804 );
805 }
806
807 #[test]
810 fn lang_tsx_detected_as_typescript_jsx() {
811 let scripts =
812 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
813 assert_eq!(scripts.len(), 1);
814 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
815 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
816 }
817
818 #[test]
821 fn multiline_html_comment_filters_all_script_blocks_inside() {
822 let source = r#"
823<!--
824 This whole section is disabled:
825 <script lang="ts">import { bad1 } from 'bad1';</script>
826 <script lang="ts">import { bad2 } from 'bad2';</script>
827-->
828<script lang="ts">import { good } from 'good';</script>
829"#;
830 let scripts = extract_sfc_scripts(source);
831 assert_eq!(scripts.len(), 1);
832 assert!(scripts[0].body.contains("good"));
833 }
834
835 #[test]
838 fn script_src_generates_side_effect_import() {
839 let info = parse_sfc_to_module(
840 FileId(0),
841 Path::new("External.vue"),
842 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
843 0,
844 false,
845 );
846 assert!(
847 info.imports
848 .iter()
849 .any(|i| i.source == "./external-logic.ts"
850 && matches!(i.imported_name, ImportedName::SideEffect)),
851 "script src should generate a side-effect import"
852 );
853 }
854
855 #[test]
858 fn parse_sfc_no_script_returns_empty_module() {
859 let info = parse_sfc_to_module(
860 FileId(0),
861 Path::new("Empty.vue"),
862 "<template><div>Hello</div></template>",
863 42,
864 false,
865 );
866 assert!(info.imports.is_empty());
867 assert!(info.exports.is_empty());
868 assert_eq!(info.content_hash, 42);
869 assert_eq!(info.file_id, FileId(0));
870 }
871
872 #[test]
873 fn parse_sfc_has_line_offsets() {
874 let info = parse_sfc_to_module(
875 FileId(0),
876 Path::new("LineOffsets.vue"),
877 r#"<script lang="ts">const x = 1;</script>"#,
878 0,
879 false,
880 );
881 assert!(!info.line_offsets.is_empty());
882 }
883
884 #[test]
885 fn parse_sfc_has_suppressions() {
886 let info = parse_sfc_to_module(
887 FileId(0),
888 Path::new("Suppressions.vue"),
889 r#"<script lang="ts">
890// fallow-ignore-file
891export const foo = 1;
892</script>"#,
893 0,
894 false,
895 );
896 assert!(!info.suppressions.is_empty());
897 }
898
899 #[test]
900 fn source_type_jsx_detection() {
901 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
902 assert_eq!(scripts.len(), 1);
903 assert!(!scripts[0].is_typescript);
904 assert!(scripts[0].is_jsx);
905 }
906
907 #[test]
908 fn source_type_plain_js_detection() {
909 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
910 assert_eq!(scripts.len(), 1);
911 assert!(!scripts[0].is_typescript);
912 assert!(!scripts[0].is_jsx);
913 }
914
915 #[test]
916 fn is_sfc_file_rejects_no_extension() {
917 assert!(!is_sfc_file(Path::new("Makefile")));
918 }
919
920 #[test]
921 fn is_sfc_file_rejects_mdx() {
922 assert!(!is_sfc_file(Path::new("post.mdx")));
923 }
924
925 #[test]
926 fn is_sfc_file_rejects_css() {
927 assert!(!is_sfc_file(Path::new("styles.css")));
928 }
929
930 #[test]
931 fn multiple_script_blocks_both_have_offsets() {
932 let source = r#"<script lang="ts">const a = 1;</script>
933<script setup lang="ts">const b = 2;</script>"#;
934 let scripts = extract_sfc_scripts(source);
935 assert_eq!(scripts.len(), 2);
936 let offset0 = scripts[0].byte_offset;
938 let offset1 = scripts[1].byte_offset;
939 assert_eq!(
940 &source[offset0..offset0 + "const a = 1;".len()],
941 "const a = 1;"
942 );
943 assert_eq!(
944 &source[offset1..offset1 + "const b = 2;".len()],
945 "const b = 2;"
946 );
947 }
948
949 #[test]
950 fn script_with_src_and_lang() {
951 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
953 assert_eq!(scripts.len(), 1);
954 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
955 assert!(scripts[0].is_typescript);
956 assert!(scripts[0].is_jsx);
957 }
958
959 #[test]
962 fn extract_style_block_lang_scss() {
963 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
964 let styles = extract_sfc_styles(source);
965 assert_eq!(styles.len(), 1);
966 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
967 assert!(styles[0].body.contains("@import"));
968 assert!(styles[0].src.is_none());
969 }
970
971 #[test]
972 fn extract_style_block_with_src() {
973 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
974 let styles = extract_sfc_styles(source);
975 assert_eq!(styles.len(), 1);
976 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
977 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
978 }
979
980 #[test]
981 fn extract_style_block_plain_no_lang() {
982 let source = r"<style>.foo { color: red; }</style>";
983 let styles = extract_sfc_styles(source);
984 assert_eq!(styles.len(), 1);
985 assert!(styles[0].lang.is_none());
986 }
987
988 #[test]
989 fn extract_multiple_style_blocks() {
990 let source = r#"<style lang="scss">@import 'a';</style>
991<style scoped lang="scss">@import 'b';</style>"#;
992 let styles = extract_sfc_styles(source);
993 assert_eq!(styles.len(), 2);
994 }
995
996 #[test]
997 fn style_block_inside_html_comment_filtered() {
998 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
999<style lang="scss">@import 'good';</style>"#;
1000 let styles = extract_sfc_styles(source);
1001 assert_eq!(styles.len(), 1);
1002 assert!(styles[0].body.contains("good"));
1003 }
1004
1005 #[test]
1006 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1007 let info = parse_sfc_to_module(
1008 FileId(0),
1009 Path::new("Foo.vue"),
1010 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1011 0,
1012 false,
1013 );
1014 let style_import = info
1015 .imports
1016 .iter()
1017 .find(|i| i.source == "./Foo")
1018 .expect("scss @import 'Foo' should be normalized to ./Foo");
1019 assert!(
1020 style_import.from_style,
1021 "imports from <style> blocks must carry from_style=true so the resolver \
1022 enables SCSS partial fallback for the SFC importer"
1023 );
1024 assert!(matches!(
1025 style_import.imported_name,
1026 ImportedName::SideEffect
1027 ));
1028 }
1029
1030 #[test]
1031 fn parse_sfc_extracts_style_plugin_as_default_import() {
1032 let info = parse_sfc_to_module(
1033 FileId(0),
1034 Path::new("Foo.vue"),
1035 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1036 0,
1037 false,
1038 );
1039 let plugin_import = info
1040 .imports
1041 .iter()
1042 .find(|i| i.source == "./tailwind-plugin.js")
1043 .expect("style @plugin should create an import");
1044 assert!(plugin_import.from_style);
1045 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1046 }
1047
1048 #[test]
1049 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1050 let info = parse_sfc_to_module(
1051 FileId(0),
1052 Path::new("Bar.vue"),
1053 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1054 0,
1055 false,
1056 );
1057 let style_src = info
1058 .imports
1059 .iter()
1060 .find(|i| i.source == "./Bar.scss")
1061 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1062 assert!(style_src.from_style);
1063 }
1064
1065 #[test]
1066 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1067 let info = parse_sfc_to_module(
1069 FileId(0),
1070 Path::new("Baz.vue"),
1071 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1072 0,
1073 false,
1074 );
1075 assert!(
1076 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1077 "src reference should still be seeded for unsupported lang"
1078 );
1079 assert!(
1080 !info.imports.iter().any(|i| i.source.contains("skipped")),
1081 "postcss body should not be scanned for @import directives"
1082 );
1083 }
1084}