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