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