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::FxHashSet;
17
18use crate::asset_url::normalize_asset_url;
19use crate::parse::compute_import_binding_usage;
20use crate::sfc_template::{SfcKind, collect_template_usage};
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
203 for script in &scripts {
204 merge_script_into_module(
205 kind,
206 script,
207 &mut combined,
208 &mut template_visible_imports,
209 need_complexity,
210 );
211 }
212
213 for style in &styles {
214 merge_style_into_module(style, &mut combined);
215 }
216
217 apply_template_usage(kind, source, &template_visible_imports, &mut combined);
218 combined.unused_import_bindings.sort_unstable();
219 combined.unused_import_bindings.dedup();
220 combined.type_referenced_import_bindings.sort_unstable();
221 combined.type_referenced_import_bindings.dedup();
222 combined.value_referenced_import_bindings.sort_unstable();
223 combined.value_referenced_import_bindings.dedup();
224
225 combined
226}
227
228fn sfc_kind(path: &Path) -> SfcKind {
229 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
230 SfcKind::Vue
231 } else {
232 SfcKind::Svelte
233 }
234}
235
236fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
237 let suppressions = crate::suppress::parse_suppressions_from_source(source);
240
241 ModuleInfo {
242 file_id,
243 exports: Vec::new(),
244 imports: Vec::new(),
245 re_exports: Vec::new(),
246 dynamic_imports: Vec::new(),
247 dynamic_import_patterns: Vec::new(),
248 require_calls: Vec::new(),
249 member_accesses: Vec::new(),
250 whole_object_uses: Vec::new(),
251 has_cjs_exports: false,
252 content_hash,
253 suppressions,
254 unused_import_bindings: Vec::new(),
255 type_referenced_import_bindings: Vec::new(),
256 value_referenced_import_bindings: Vec::new(),
257 line_offsets: compute_line_offsets(source),
258 complexity: Vec::new(),
259 flag_uses: Vec::new(),
260 class_heritage: vec![],
261 }
262}
263
264fn merge_script_into_module(
265 kind: SfcKind,
266 script: &SfcScript,
267 combined: &mut ModuleInfo,
268 template_visible_imports: &mut FxHashSet<String>,
269 need_complexity: bool,
270) {
271 if let Some(src) = &script.src {
272 add_script_src_import(combined, src);
273 }
274
275 let allocator = Allocator::default();
276 let parser_return =
277 Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
278 let mut extractor = ModuleInfoExtractor::new();
279 extractor.visit_program(&parser_return.program);
280
281 let binding_usage = compute_import_binding_usage(&parser_return.program, &extractor.imports);
282 combined
283 .unused_import_bindings
284 .extend(binding_usage.unused.iter().cloned());
285 combined
286 .type_referenced_import_bindings
287 .extend(binding_usage.type_referenced.iter().cloned());
288 combined
289 .value_referenced_import_bindings
290 .extend(binding_usage.value_referenced.iter().cloned());
291 if need_complexity {
292 combined.complexity.extend(translate_script_complexity(
293 script,
294 &parser_return.program,
295 &combined.line_offsets,
296 ));
297 }
298
299 if is_template_visible_script(kind, script) {
300 template_visible_imports.extend(
301 extractor
302 .imports
303 .iter()
304 .filter(|import| !import.local_name.is_empty())
305 .map(|import| import.local_name.clone()),
306 );
307 }
308
309 extractor.merge_into(combined);
310}
311
312fn translate_script_complexity(
313 script: &SfcScript,
314 program: &oxc_ast::ast::Program<'_>,
315 sfc_line_offsets: &[u32],
316) -> Vec<FunctionComplexity> {
317 let script_line_offsets = compute_line_offsets(&script.body);
318 let mut complexity = crate::complexity::compute_complexity(program, &script_line_offsets);
319 let (body_start_line, body_start_col) =
320 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
321
322 for function in &mut complexity {
323 function.line = body_start_line + function.line.saturating_sub(1);
324 if function.line == body_start_line {
325 function.col += body_start_col;
326 }
327 }
328
329 complexity
330}
331
332fn add_script_src_import(module: &mut ModuleInfo, source: &str) {
333 module.imports.push(ImportInfo {
336 source: normalize_asset_url(source),
337 imported_name: ImportedName::SideEffect,
338 local_name: String::new(),
339 is_type_only: false,
340 from_style: false,
341 span: Span::default(),
342 source_span: Span::default(),
343 });
344}
345
346fn style_lang_is_scss(lang: Option<&str>) -> bool {
352 matches!(lang, Some("scss" | "sass"))
353}
354
355fn style_lang_is_css_like(lang: Option<&str>) -> bool {
356 lang.is_none() || matches!(lang, Some("css"))
357}
358
359fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
360 if let Some(src) = &style.src {
365 combined.imports.push(ImportInfo {
366 source: normalize_asset_url(src),
367 imported_name: ImportedName::SideEffect,
368 local_name: String::new(),
369 is_type_only: false,
370 from_style: true,
371 span: Span::default(),
372 source_span: Span::default(),
373 });
374 }
375
376 let lang = style.lang.as_deref();
377 let is_scss = style_lang_is_scss(lang);
378 let is_css_like = style_lang_is_css_like(lang);
379 if !is_scss && !is_css_like {
380 return;
381 }
382
383 for spec in crate::css::extract_css_imports(&style.body, is_scss) {
384 combined.imports.push(ImportInfo {
385 source: spec,
386 imported_name: ImportedName::SideEffect,
387 local_name: String::new(),
388 is_type_only: false,
389 from_style: true,
390 span: Span::default(),
391 source_span: Span::default(),
392 });
393 }
394}
395
396fn source_type_for_script(script: &SfcScript) -> SourceType {
397 match (script.is_typescript, script.is_jsx) {
398 (true, true) => SourceType::tsx(),
399 (true, false) => SourceType::ts(),
400 (false, true) => SourceType::jsx(),
401 (false, false) => SourceType::mjs(),
402 }
403}
404
405fn apply_template_usage(
406 kind: SfcKind,
407 source: &str,
408 template_visible_imports: &FxHashSet<String>,
409 combined: &mut ModuleInfo,
410) {
411 if template_visible_imports.is_empty() {
412 return;
413 }
414
415 let template_usage = collect_template_usage(kind, source, template_visible_imports);
416 combined
417 .unused_import_bindings
418 .retain(|binding| !template_usage.used_bindings.contains(binding));
419 combined
420 .member_accesses
421 .extend(template_usage.member_accesses);
422 combined
423 .whole_object_uses
424 .extend(template_usage.whole_object_uses);
425}
426
427fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
428 match kind {
429 SfcKind::Vue => script.is_setup,
430 SfcKind::Svelte => !script.is_context_module,
431 }
432}
433
434#[cfg(all(test, not(miri)))]
437mod tests {
438 use super::*;
439
440 #[test]
443 fn is_sfc_file_vue() {
444 assert!(is_sfc_file(Path::new("App.vue")));
445 }
446
447 #[test]
448 fn is_sfc_file_svelte() {
449 assert!(is_sfc_file(Path::new("Counter.svelte")));
450 }
451
452 #[test]
453 fn is_sfc_file_rejects_ts() {
454 assert!(!is_sfc_file(Path::new("utils.ts")));
455 }
456
457 #[test]
458 fn is_sfc_file_rejects_jsx() {
459 assert!(!is_sfc_file(Path::new("App.jsx")));
460 }
461
462 #[test]
463 fn is_sfc_file_rejects_astro() {
464 assert!(!is_sfc_file(Path::new("Layout.astro")));
465 }
466
467 #[test]
470 fn single_plain_script() {
471 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
472 assert_eq!(scripts.len(), 1);
473 assert_eq!(scripts[0].body, "const x = 1;");
474 assert!(!scripts[0].is_typescript);
475 assert!(!scripts[0].is_jsx);
476 assert!(scripts[0].src.is_none());
477 }
478
479 #[test]
480 fn single_ts_script() {
481 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
482 assert_eq!(scripts.len(), 1);
483 assert!(scripts[0].is_typescript);
484 assert!(!scripts[0].is_jsx);
485 }
486
487 #[test]
488 fn single_tsx_script() {
489 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
490 assert_eq!(scripts.len(), 1);
491 assert!(scripts[0].is_typescript);
492 assert!(scripts[0].is_jsx);
493 }
494
495 #[test]
496 fn single_jsx_script() {
497 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
498 assert_eq!(scripts.len(), 1);
499 assert!(!scripts[0].is_typescript);
500 assert!(scripts[0].is_jsx);
501 }
502
503 #[test]
506 fn two_script_blocks() {
507 let source = r#"
508<script lang="ts">
509export default {};
510</script>
511<script setup lang="ts">
512const count = 0;
513</script>
514"#;
515 let scripts = extract_sfc_scripts(source);
516 assert_eq!(scripts.len(), 2);
517 assert!(scripts[0].body.contains("export default"));
518 assert!(scripts[1].body.contains("count"));
519 }
520
521 #[test]
524 fn script_setup_extracted() {
525 let scripts =
526 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
527 assert_eq!(scripts.len(), 1);
528 assert!(scripts[0].body.contains("import"));
529 assert!(scripts[0].is_typescript);
530 }
531
532 #[test]
535 fn script_src_detected() {
536 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
537 assert_eq!(scripts.len(), 1);
538 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
539 }
540
541 #[test]
542 fn data_src_not_treated_as_src() {
543 let scripts =
544 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
545 assert_eq!(scripts.len(), 1);
546 assert!(scripts[0].src.is_none());
547 }
548
549 #[test]
552 fn script_inside_html_comment_filtered() {
553 let source = r#"
554<!-- <script lang="ts">import { bad } from 'bad';</script> -->
555<script lang="ts">import { good } from 'good';</script>
556"#;
557 let scripts = extract_sfc_scripts(source);
558 assert_eq!(scripts.len(), 1);
559 assert!(scripts[0].body.contains("good"));
560 }
561
562 #[test]
563 fn spanning_comment_filters_script() {
564 let source = r#"
565<!-- disabled:
566<script lang="ts">import { bad } from 'bad';</script>
567-->
568<script lang="ts">const ok = true;</script>
569"#;
570 let scripts = extract_sfc_scripts(source);
571 assert_eq!(scripts.len(), 1);
572 assert!(scripts[0].body.contains("ok"));
573 }
574
575 #[test]
576 fn string_containing_comment_markers_not_corrupted() {
577 let source = r#"
579<script setup lang="ts">
580const marker = "<!-- not a comment -->";
581import { ref } from 'vue';
582</script>
583"#;
584 let scripts = extract_sfc_scripts(source);
585 assert_eq!(scripts.len(), 1);
586 assert!(scripts[0].body.contains("import"));
587 }
588
589 #[test]
592 fn generic_attr_with_angle_bracket() {
593 let source =
594 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
595 let scripts = extract_sfc_scripts(source);
596 assert_eq!(scripts.len(), 1);
597 assert_eq!(scripts[0].body, "const x = 1;");
598 }
599
600 #[test]
601 fn nested_generic_attr() {
602 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
603 let scripts = extract_sfc_scripts(source);
604 assert_eq!(scripts.len(), 1);
605 assert_eq!(scripts[0].body, "const x = 1;");
606 }
607
608 #[test]
611 fn lang_single_quoted() {
612 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
613 assert_eq!(scripts.len(), 1);
614 assert!(scripts[0].is_typescript);
615 }
616
617 #[test]
620 fn uppercase_script_tag() {
621 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
622 assert_eq!(scripts.len(), 1);
623 assert!(scripts[0].is_typescript);
624 }
625
626 #[test]
629 fn no_script_block() {
630 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
631 assert!(scripts.is_empty());
632 }
633
634 #[test]
635 fn empty_script_body() {
636 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
637 assert_eq!(scripts.len(), 1);
638 assert!(scripts[0].body.is_empty());
639 }
640
641 #[test]
642 fn whitespace_only_script() {
643 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
644 assert_eq!(scripts.len(), 1);
645 assert!(scripts[0].body.trim().is_empty());
646 }
647
648 #[test]
649 fn byte_offset_is_set() {
650 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
651 let scripts = extract_sfc_scripts(source);
652 assert_eq!(scripts.len(), 1);
653 let offset = scripts[0].byte_offset;
655 assert_eq!(&source[offset..offset + 4], "code");
656 }
657
658 #[test]
659 fn script_with_extra_attributes() {
660 let scripts = extract_sfc_scripts(
661 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
662 );
663 assert_eq!(scripts.len(), 1);
664 assert!(scripts[0].is_typescript);
665 assert!(scripts[0].src.is_none());
666 }
667
668 #[test]
671 fn multiple_script_blocks_exports_combined() {
672 let source = r#"
673<script lang="ts">
674export const version = '1.0';
675</script>
676<script setup lang="ts">
677import { ref } from 'vue';
678const count = ref(0);
679</script>
680"#;
681 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
682 assert!(
684 info.exports
685 .iter()
686 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
687 "export from <script> block should be extracted"
688 );
689 assert!(
691 info.imports.iter().any(|i| i.source == "vue"),
692 "import from <script setup> block should be extracted"
693 );
694 }
695
696 #[test]
699 fn lang_tsx_detected_as_typescript_jsx() {
700 let scripts =
701 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
702 assert_eq!(scripts.len(), 1);
703 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
704 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
705 }
706
707 #[test]
710 fn multiline_html_comment_filters_all_script_blocks_inside() {
711 let source = r#"
712<!--
713 This whole section is disabled:
714 <script lang="ts">import { bad1 } from 'bad1';</script>
715 <script lang="ts">import { bad2 } from 'bad2';</script>
716-->
717<script lang="ts">import { good } from 'good';</script>
718"#;
719 let scripts = extract_sfc_scripts(source);
720 assert_eq!(scripts.len(), 1);
721 assert!(scripts[0].body.contains("good"));
722 }
723
724 #[test]
727 fn script_src_generates_side_effect_import() {
728 let info = parse_sfc_to_module(
729 FileId(0),
730 Path::new("External.vue"),
731 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
732 0,
733 false,
734 );
735 assert!(
736 info.imports
737 .iter()
738 .any(|i| i.source == "./external-logic.ts"
739 && matches!(i.imported_name, ImportedName::SideEffect)),
740 "script src should generate a side-effect import"
741 );
742 }
743
744 #[test]
747 fn parse_sfc_no_script_returns_empty_module() {
748 let info = parse_sfc_to_module(
749 FileId(0),
750 Path::new("Empty.vue"),
751 "<template><div>Hello</div></template>",
752 42,
753 false,
754 );
755 assert!(info.imports.is_empty());
756 assert!(info.exports.is_empty());
757 assert_eq!(info.content_hash, 42);
758 assert_eq!(info.file_id, FileId(0));
759 }
760
761 #[test]
762 fn parse_sfc_has_line_offsets() {
763 let info = parse_sfc_to_module(
764 FileId(0),
765 Path::new("LineOffsets.vue"),
766 r#"<script lang="ts">const x = 1;</script>"#,
767 0,
768 false,
769 );
770 assert!(!info.line_offsets.is_empty());
771 }
772
773 #[test]
774 fn parse_sfc_has_suppressions() {
775 let info = parse_sfc_to_module(
776 FileId(0),
777 Path::new("Suppressions.vue"),
778 r#"<script lang="ts">
779// fallow-ignore-file
780export const foo = 1;
781</script>"#,
782 0,
783 false,
784 );
785 assert!(!info.suppressions.is_empty());
786 }
787
788 #[test]
789 fn source_type_jsx_detection() {
790 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
791 assert_eq!(scripts.len(), 1);
792 assert!(!scripts[0].is_typescript);
793 assert!(scripts[0].is_jsx);
794 }
795
796 #[test]
797 fn source_type_plain_js_detection() {
798 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
799 assert_eq!(scripts.len(), 1);
800 assert!(!scripts[0].is_typescript);
801 assert!(!scripts[0].is_jsx);
802 }
803
804 #[test]
805 fn is_sfc_file_rejects_no_extension() {
806 assert!(!is_sfc_file(Path::new("Makefile")));
807 }
808
809 #[test]
810 fn is_sfc_file_rejects_mdx() {
811 assert!(!is_sfc_file(Path::new("post.mdx")));
812 }
813
814 #[test]
815 fn is_sfc_file_rejects_css() {
816 assert!(!is_sfc_file(Path::new("styles.css")));
817 }
818
819 #[test]
820 fn multiple_script_blocks_both_have_offsets() {
821 let source = r#"<script lang="ts">const a = 1;</script>
822<script setup lang="ts">const b = 2;</script>"#;
823 let scripts = extract_sfc_scripts(source);
824 assert_eq!(scripts.len(), 2);
825 let offset0 = scripts[0].byte_offset;
827 let offset1 = scripts[1].byte_offset;
828 assert_eq!(
829 &source[offset0..offset0 + "const a = 1;".len()],
830 "const a = 1;"
831 );
832 assert_eq!(
833 &source[offset1..offset1 + "const b = 2;".len()],
834 "const b = 2;"
835 );
836 }
837
838 #[test]
839 fn script_with_src_and_lang() {
840 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
842 assert_eq!(scripts.len(), 1);
843 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
844 assert!(scripts[0].is_typescript);
845 assert!(scripts[0].is_jsx);
846 }
847
848 #[test]
851 fn extract_style_block_lang_scss() {
852 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
853 let styles = extract_sfc_styles(source);
854 assert_eq!(styles.len(), 1);
855 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
856 assert!(styles[0].body.contains("@import"));
857 assert!(styles[0].src.is_none());
858 }
859
860 #[test]
861 fn extract_style_block_with_src() {
862 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
863 let styles = extract_sfc_styles(source);
864 assert_eq!(styles.len(), 1);
865 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
866 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
867 }
868
869 #[test]
870 fn extract_style_block_plain_no_lang() {
871 let source = r"<style>.foo { color: red; }</style>";
872 let styles = extract_sfc_styles(source);
873 assert_eq!(styles.len(), 1);
874 assert!(styles[0].lang.is_none());
875 }
876
877 #[test]
878 fn extract_multiple_style_blocks() {
879 let source = r#"<style lang="scss">@import 'a';</style>
880<style scoped lang="scss">@import 'b';</style>"#;
881 let styles = extract_sfc_styles(source);
882 assert_eq!(styles.len(), 2);
883 }
884
885 #[test]
886 fn style_block_inside_html_comment_filtered() {
887 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
888<style lang="scss">@import 'good';</style>"#;
889 let styles = extract_sfc_styles(source);
890 assert_eq!(styles.len(), 1);
891 assert!(styles[0].body.contains("good"));
892 }
893
894 #[test]
895 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
896 let info = parse_sfc_to_module(
897 FileId(0),
898 Path::new("Foo.vue"),
899 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
900 0,
901 false,
902 );
903 let style_import = info
904 .imports
905 .iter()
906 .find(|i| i.source == "./Foo")
907 .expect("scss @import 'Foo' should be normalized to ./Foo");
908 assert!(
909 style_import.from_style,
910 "imports from <style> blocks must carry from_style=true so the resolver \
911 enables SCSS partial fallback for the SFC importer"
912 );
913 assert!(matches!(
914 style_import.imported_name,
915 ImportedName::SideEffect
916 ));
917 }
918
919 #[test]
920 fn parse_sfc_extracts_style_src_with_from_style_flag() {
921 let info = parse_sfc_to_module(
922 FileId(0),
923 Path::new("Bar.vue"),
924 r#"<style src="./Bar.scss" lang="scss"></style>"#,
925 0,
926 false,
927 );
928 let style_src = info
929 .imports
930 .iter()
931 .find(|i| i.source == "./Bar.scss")
932 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
933 assert!(style_src.from_style);
934 }
935
936 #[test]
937 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
938 let info = parse_sfc_to_module(
940 FileId(0),
941 Path::new("Baz.vue"),
942 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
943 0,
944 false,
945 );
946 assert!(
947 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
948 "src reference should still be seeded for unsupported lang"
949 );
950 assert!(
951 !info.imports.iter().any(|i| i.source.contains("skipped")),
952 "postcss body should not be scanned for @import directives"
953 );
954 }
955}