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