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::source_map::ExtractionResult;
23use crate::visitor::ModuleInfoExtractor;
24use crate::{ImportInfo, ImportedName, ModuleInfo};
25use fallow_types::discover::FileId;
26use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
27use oxc_span::Span;
28
29static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
32 regex::Regex::new(
33 r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
34 )
35 .expect("valid regex")
36});
37
38static LANG_ATTR_RE: LazyLock<regex::Regex> =
40 LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
41
42static SRC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
45 regex::Regex::new(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#).expect("valid regex")
46});
47
48static SETUP_ATTR_RE: LazyLock<regex::Regex> =
50 LazyLock::new(|| regex::Regex::new(r"(?:^|\s)setup(?:\s|$)").expect("valid regex"));
51
52static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
54 LazyLock::new(|| regex::Regex::new(r#"context\s*=\s*["']module["']"#).expect("valid regex"));
55
56static VUE_GENERIC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
60 regex::Regex::new(r#"(?:^|\s)generic\s*=\s*"([^"]*)"|(?:^|\s)generic\s*=\s*'([^']*)'"#)
61 .expect("valid regex")
62});
63
64static SVELTE_GENERICS_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
67 regex::Regex::new(r#"(?:^|\s)generics\s*=\s*"([^"]*)"|(?:^|\s)generics\s*=\s*'([^']*)'"#)
68 .expect("valid regex")
69});
70
71static HTML_COMMENT_RE: LazyLock<regex::Regex> =
73 LazyLock::new(|| regex::Regex::new(r"(?s)<!--.*?-->").expect("valid regex"));
74
75static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
79 regex::Regex::new(
80 r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
81 )
82 .expect("valid regex")
83});
84
85pub struct SfcScript {
87 pub body: String,
89 pub is_typescript: bool,
91 pub is_jsx: bool,
93 pub byte_offset: usize,
95 pub src: Option<String>,
97 pub src_span: Option<Span>,
99 pub is_setup: bool,
101 pub is_context_module: bool,
103 pub generic_attr: Option<String>,
107}
108
109pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
111 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
115 .find_iter(source)
116 .map(|m| (m.start(), m.end()))
117 .collect();
118
119 SCRIPT_BLOCK_RE
120 .captures_iter(source)
121 .filter(|cap| {
122 let start = cap.get(0).map_or(0, |m| m.start());
123 !comment_ranges
124 .iter()
125 .any(|&(cs, ce)| start >= cs && start < ce)
126 })
127 .map(|cap| {
128 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
129 let body_match = cap.name("body");
130 let byte_offset = body_match.map_or(0, |m| m.start());
131 let body = body_match.map_or("", |m| m.as_str()).to_string();
132 let lang = LANG_ATTR_RE
133 .captures(attrs)
134 .and_then(|c| c.get(1))
135 .map(|m| m.as_str());
136 let is_typescript = matches!(lang, Some("ts" | "tsx"));
137 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
138 let src = SRC_ATTR_RE
139 .captures(attrs)
140 .and_then(|c| c.get(1))
141 .map(|m| m.as_str().to_string());
142 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
143 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
144 Span::new(
145 (attrs_start + m.start()) as u32,
146 (attrs_start + m.end()) as u32,
147 )
148 });
149 let is_setup = SETUP_ATTR_RE.is_match(attrs);
150 let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
151 let generic_attr = VUE_GENERIC_ATTR_RE
152 .captures(attrs)
153 .or_else(|| SVELTE_GENERICS_ATTR_RE.captures(attrs))
154 .and_then(|cap| cap.get(1).or_else(|| cap.get(2)))
155 .map(|m| m.as_str().to_string())
156 .filter(|value| !value.trim().is_empty());
157 SfcScript {
158 body,
159 is_typescript,
160 is_jsx,
161 byte_offset,
162 src,
163 src_span,
164 is_setup,
165 is_context_module,
166 generic_attr,
167 }
168 })
169 .collect()
170}
171
172pub struct SfcStyle {
174 pub body: String,
176 pub lang: Option<String>,
179 pub src: Option<String>,
181 pub src_span: Option<Span>,
183 pub byte_offset: usize,
185}
186
187pub fn extract_sfc_styles(source: &str) -> Vec<SfcStyle> {
194 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
195 .find_iter(source)
196 .map(|m| (m.start(), m.end()))
197 .collect();
198
199 STYLE_BLOCK_RE
200 .captures_iter(source)
201 .filter(|cap| {
202 let start = cap.get(0).map_or(0, |m| m.start());
203 !comment_ranges
204 .iter()
205 .any(|&(cs, ce)| start >= cs && start < ce)
206 })
207 .map(|cap| {
208 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
209 let body = cap.name("body").map_or("", |m| m.as_str()).to_string();
210 let byte_offset = cap.name("body").map_or(0, |m| m.start());
211 let lang = LANG_ATTR_RE
212 .captures(attrs)
213 .and_then(|c| c.get(1))
214 .map(|m| m.as_str().to_string());
215 let src = SRC_ATTR_RE
216 .captures(attrs)
217 .and_then(|c| c.get(1))
218 .map(|m| m.as_str().to_string());
219 let attrs_start = cap.name("attrs").map_or(0, |m| m.start());
220 let src_span = SRC_ATTR_RE.captures(attrs).and_then(|c| c.get(1)).map(|m| {
221 Span::new(
222 (attrs_start + m.start()) as u32,
223 (attrs_start + m.end()) as u32,
224 )
225 });
226 SfcStyle {
227 body,
228 lang,
229 src,
230 src_span,
231 byte_offset,
232 }
233 })
234 .collect()
235}
236
237#[must_use]
239pub fn is_sfc_file(path: &Path) -> bool {
240 path.extension()
241 .and_then(|e| e.to_str())
242 .is_some_and(|ext| ext == "vue" || ext == "svelte")
243}
244
245pub(crate) fn parse_sfc_to_module(
247 file_id: FileId,
248 path: &Path,
249 source: &str,
250 content_hash: u64,
251 need_complexity: bool,
252) -> ModuleInfo {
253 let scripts = extract_sfc_scripts(source);
254 let styles = extract_sfc_styles(source);
255 let kind = sfc_kind(path);
256 let mut combined = empty_sfc_module(file_id, source, content_hash);
257 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
258 let mut template_visible_bound_targets: FxHashMap<String, String> = FxHashMap::default();
259
260 for script in &scripts {
261 merge_script_into_module(
262 kind,
263 script,
264 &mut combined,
265 &mut template_visible_imports,
266 &mut template_visible_bound_targets,
267 need_complexity,
268 );
269 }
270
271 for style in &styles {
272 merge_style_into_module(style, &mut combined);
273 }
274
275 apply_template_usage(
276 kind,
277 source,
278 &template_visible_imports,
279 &template_visible_bound_targets,
280 &mut combined,
281 );
282 combined.unused_import_bindings.sort_unstable();
283 combined.unused_import_bindings.dedup();
284 combined.type_referenced_import_bindings.sort_unstable();
285 combined.type_referenced_import_bindings.dedup();
286 combined.value_referenced_import_bindings.sort_unstable();
287 combined.value_referenced_import_bindings.dedup();
288
289 combined
290}
291
292fn sfc_kind(path: &Path) -> SfcKind {
293 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
294 SfcKind::Vue
295 } else {
296 SfcKind::Svelte
297 }
298}
299
300fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
301 let parsed = crate::suppress::parse_suppressions_from_source(source);
304
305 ModuleInfo {
306 file_id,
307 exports: Vec::new(),
308 imports: Vec::new(),
309 re_exports: Vec::new(),
310 dynamic_imports: Vec::new(),
311 dynamic_import_patterns: Vec::new(),
312 require_calls: Vec::new(),
313 member_accesses: Vec::new(),
314 whole_object_uses: Vec::new(),
315 has_cjs_exports: false,
316 has_angular_component_template_url: false,
317 content_hash,
318 suppressions: parsed.suppressions,
319 unknown_suppression_kinds: parsed.unknown_kinds,
320 unused_import_bindings: Vec::new(),
321 type_referenced_import_bindings: Vec::new(),
322 value_referenced_import_bindings: Vec::new(),
323 line_offsets: compute_line_offsets(source),
324 complexity: Vec::new(),
325 flag_uses: Vec::new(),
326 class_heritage: vec![],
327 local_type_declarations: Vec::new(),
328 public_signature_type_references: Vec::new(),
329 namespace_object_aliases: Vec::new(),
330 iconify_prefixes: Vec::new(),
331 auto_import_candidates: Vec::new(),
332 }
333}
334
335fn merge_script_into_module(
336 kind: SfcKind,
337 script: &SfcScript,
338 combined: &mut ModuleInfo,
339 template_visible_imports: &mut FxHashSet<String>,
340 template_visible_bound_targets: &mut FxHashMap<String, String>,
341 need_complexity: bool,
342) {
343 if let Some(src) = &script.src {
344 add_script_src_import(combined, src, script.src_span);
345 }
346
347 let allocator = Allocator::default();
348 let parser_return =
349 Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
350 let mut extractor = ModuleInfoExtractor::new();
351 extractor.visit_program(&parser_return.program);
352 let extraction = ExtractionResult::contiguous(&script.body, script.byte_offset);
353 extractor.remap_spans_with(|span| extraction.remap_span(span));
354 extractor.resolve_typed_destructure_bindings();
360
361 let augmented_body = build_generic_attr_probe_source(script);
367 let empty_template_used = rustc_hash::FxHashSet::default();
373 let binding_usage = if let Some(augmented) = augmented_body.as_deref() {
374 let augmented_return =
375 Parser::new(&allocator, augmented, source_type_for_script(script)).parse();
376 compute_import_binding_usage(
377 &augmented_return.program,
378 &extractor.imports,
379 &empty_template_used,
380 )
381 } else {
382 compute_import_binding_usage(
383 &parser_return.program,
384 &extractor.imports,
385 &empty_template_used,
386 )
387 };
388 combined
389 .unused_import_bindings
390 .extend(binding_usage.unused.iter().cloned());
391 combined
392 .type_referenced_import_bindings
393 .extend(binding_usage.type_referenced.iter().cloned());
394 combined
395 .value_referenced_import_bindings
396 .extend(binding_usage.value_referenced.iter().cloned());
397 if need_complexity {
398 combined.complexity.extend(translate_script_complexity(
399 script,
400 &parser_return.program,
401 &combined.line_offsets,
402 ));
403 }
404
405 if is_template_visible_script(kind, script) {
406 template_visible_imports.extend(
407 extractor
408 .imports
409 .iter()
410 .filter(|import| !import.local_name.is_empty())
411 .map(|import| import.local_name.clone()),
412 );
413 template_visible_bound_targets.extend(
414 extractor
415 .binding_target_names()
416 .iter()
417 .filter(|(local, _)| !local.starts_with("this."))
418 .map(|(local, target)| (local.clone(), target.clone())),
419 );
420 }
421
422 extractor.merge_into(combined);
423}
424
425fn translate_script_complexity(
426 script: &SfcScript,
427 program: &oxc_ast::ast::Program<'_>,
428 sfc_line_offsets: &[u32],
429) -> Vec<FunctionComplexity> {
430 let script_line_offsets = compute_line_offsets(&script.body);
431 let mut complexity =
432 crate::complexity::compute_complexity(program, &script.body, &script_line_offsets);
433 let (body_start_line, body_start_col) =
434 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
435
436 for function in &mut complexity {
437 function.line = body_start_line + function.line.saturating_sub(1);
438 if function.line == body_start_line {
439 function.col += body_start_col;
440 }
441 }
442
443 complexity
444}
445
446fn add_script_src_import(module: &mut ModuleInfo, source: &str, source_span: Option<Span>) {
447 let span = source_span.unwrap_or_default();
450 module.imports.push(ImportInfo {
451 source: normalize_asset_url(source),
452 imported_name: ImportedName::SideEffect,
453 local_name: String::new(),
454 is_type_only: false,
455 from_style: false,
456 span,
457 source_span: span,
458 });
459}
460
461fn style_lang_is_scss(lang: Option<&str>) -> bool {
467 matches!(lang, Some("scss" | "sass"))
468}
469
470fn style_lang_is_css_like(lang: Option<&str>) -> bool {
471 lang.is_none() || matches!(lang, Some("css"))
472}
473
474fn merge_style_into_module(style: &SfcStyle, combined: &mut ModuleInfo) {
475 if let Some(src) = &style.src {
480 let span = style.src_span.unwrap_or_default();
481 combined.imports.push(ImportInfo {
482 source: normalize_asset_url(src),
483 imported_name: ImportedName::SideEffect,
484 local_name: String::new(),
485 is_type_only: false,
486 from_style: true,
487 span,
488 source_span: span,
489 });
490 }
491
492 let lang = style.lang.as_deref();
493 let is_scss = style_lang_is_scss(lang);
494 let is_css_like = style_lang_is_css_like(lang);
495 if !is_scss && !is_css_like {
496 return;
497 }
498
499 for source in crate::css::extract_css_import_sources(&style.body, is_scss) {
500 let source_span = Span::new(
501 style.byte_offset as u32 + source.span.start,
502 style.byte_offset as u32 + source.span.end,
503 );
504 combined.imports.push(ImportInfo {
505 source: source.normalized,
506 imported_name: if source.is_plugin {
507 ImportedName::Default
508 } else {
509 ImportedName::SideEffect
510 },
511 local_name: String::new(),
512 is_type_only: false,
513 from_style: true,
514 span: source_span,
515 source_span,
516 });
517 }
518}
519
520fn source_type_for_script(script: &SfcScript) -> SourceType {
521 match (script.is_typescript, script.is_jsx) {
522 (true, true) => SourceType::tsx(),
523 (true, false) => SourceType::ts(),
524 (false, true) => SourceType::jsx(),
525 (false, false) => SourceType::mjs(),
526 }
527}
528
529fn build_generic_attr_probe_source(script: &SfcScript) -> Option<String> {
535 let constraint = script.generic_attr.as_deref()?.trim();
536 if constraint.is_empty() {
537 return None;
538 }
539 Some(format!(
540 "{}\n;type __FALLOW_GENERIC_ATTR_PROBE<{}> = unknown;\n",
541 script.body, constraint,
542 ))
543}
544
545fn apply_template_usage(
546 kind: SfcKind,
547 source: &str,
548 template_visible_imports: &FxHashSet<String>,
549 template_visible_bound_targets: &FxHashMap<String, String>,
550 combined: &mut ModuleInfo,
551) {
552 let template_usage = collect_template_usage_with_bound_targets(
559 kind,
560 source,
561 template_visible_imports,
562 template_visible_bound_targets,
563 );
564 combined
565 .unused_import_bindings
566 .retain(|binding| !template_usage.used_bindings.contains(binding));
567 combined
568 .member_accesses
569 .extend(template_usage.member_accesses);
570 combined
571 .whole_object_uses
572 .extend(template_usage.whole_object_uses);
573 if !template_usage.unresolved_tag_names.is_empty() {
574 let mut names: Vec<String> = template_usage.unresolved_tag_names.into_iter().collect();
575 names.sort_unstable();
576 combined.auto_import_candidates.extend(names);
577 combined.auto_import_candidates.dedup();
578 }
579}
580
581fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
582 match kind {
583 SfcKind::Vue => script.is_setup,
584 SfcKind::Svelte => !script.is_context_module,
585 }
586}
587
588#[cfg(all(test, not(miri)))]
591mod tests {
592 use super::*;
593
594 #[test]
597 fn is_sfc_file_vue() {
598 assert!(is_sfc_file(Path::new("App.vue")));
599 }
600
601 #[test]
602 fn is_sfc_file_svelte() {
603 assert!(is_sfc_file(Path::new("Counter.svelte")));
604 }
605
606 #[test]
607 fn is_sfc_file_rejects_ts() {
608 assert!(!is_sfc_file(Path::new("utils.ts")));
609 }
610
611 #[test]
612 fn is_sfc_file_rejects_jsx() {
613 assert!(!is_sfc_file(Path::new("App.jsx")));
614 }
615
616 #[test]
617 fn is_sfc_file_rejects_astro() {
618 assert!(!is_sfc_file(Path::new("Layout.astro")));
619 }
620
621 #[test]
624 fn single_plain_script() {
625 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
626 assert_eq!(scripts.len(), 1);
627 assert_eq!(scripts[0].body, "const x = 1;");
628 assert!(!scripts[0].is_typescript);
629 assert!(!scripts[0].is_jsx);
630 assert!(scripts[0].src.is_none());
631 }
632
633 #[test]
634 fn single_ts_script() {
635 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
636 assert_eq!(scripts.len(), 1);
637 assert!(scripts[0].is_typescript);
638 assert!(!scripts[0].is_jsx);
639 }
640
641 #[test]
642 fn single_tsx_script() {
643 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
644 assert_eq!(scripts.len(), 1);
645 assert!(scripts[0].is_typescript);
646 assert!(scripts[0].is_jsx);
647 }
648
649 #[test]
650 fn single_jsx_script() {
651 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
652 assert_eq!(scripts.len(), 1);
653 assert!(!scripts[0].is_typescript);
654 assert!(scripts[0].is_jsx);
655 }
656
657 #[test]
660 fn two_script_blocks() {
661 let source = r#"
662<script lang="ts">
663export default {};
664</script>
665<script setup lang="ts">
666const count = 0;
667</script>
668"#;
669 let scripts = extract_sfc_scripts(source);
670 assert_eq!(scripts.len(), 2);
671 assert!(scripts[0].body.contains("export default"));
672 assert!(scripts[1].body.contains("count"));
673 }
674
675 #[test]
678 fn script_setup_extracted() {
679 let scripts =
680 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
681 assert_eq!(scripts.len(), 1);
682 assert!(scripts[0].body.contains("import"));
683 assert!(scripts[0].is_typescript);
684 }
685
686 #[test]
689 fn script_src_detected() {
690 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
691 assert_eq!(scripts.len(), 1);
692 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
693 }
694
695 #[test]
696 fn data_src_not_treated_as_src() {
697 let scripts =
698 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
699 assert_eq!(scripts.len(), 1);
700 assert!(scripts[0].src.is_none());
701 }
702
703 #[test]
706 fn script_inside_html_comment_filtered() {
707 let source = r#"
708<!-- <script lang="ts">import { bad } from 'bad';</script> -->
709<script lang="ts">import { good } from 'good';</script>
710"#;
711 let scripts = extract_sfc_scripts(source);
712 assert_eq!(scripts.len(), 1);
713 assert!(scripts[0].body.contains("good"));
714 }
715
716 #[test]
717 fn spanning_comment_filters_script() {
718 let source = r#"
719<!-- disabled:
720<script lang="ts">import { bad } from 'bad';</script>
721-->
722<script lang="ts">const ok = true;</script>
723"#;
724 let scripts = extract_sfc_scripts(source);
725 assert_eq!(scripts.len(), 1);
726 assert!(scripts[0].body.contains("ok"));
727 }
728
729 #[test]
730 fn string_containing_comment_markers_not_corrupted() {
731 let source = r#"
733<script setup lang="ts">
734const marker = "<!-- not a comment -->";
735import { ref } from 'vue';
736</script>
737"#;
738 let scripts = extract_sfc_scripts(source);
739 assert_eq!(scripts.len(), 1);
740 assert!(scripts[0].body.contains("import"));
741 }
742
743 #[test]
746 fn generic_attr_with_angle_bracket() {
747 let source =
748 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
749 let scripts = extract_sfc_scripts(source);
750 assert_eq!(scripts.len(), 1);
751 assert_eq!(scripts[0].body, "const x = 1;");
752 }
753
754 #[test]
755 fn nested_generic_attr() {
756 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
757 let scripts = extract_sfc_scripts(source);
758 assert_eq!(scripts.len(), 1);
759 assert_eq!(scripts[0].body, "const x = 1;");
760 }
761
762 #[test]
765 fn lang_single_quoted() {
766 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
767 assert_eq!(scripts.len(), 1);
768 assert!(scripts[0].is_typescript);
769 }
770
771 #[test]
774 fn uppercase_script_tag() {
775 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
776 assert_eq!(scripts.len(), 1);
777 assert!(scripts[0].is_typescript);
778 }
779
780 #[test]
783 fn no_script_block() {
784 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
785 assert!(scripts.is_empty());
786 }
787
788 #[test]
789 fn empty_script_body() {
790 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
791 assert_eq!(scripts.len(), 1);
792 assert!(scripts[0].body.is_empty());
793 }
794
795 #[test]
796 fn whitespace_only_script() {
797 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
798 assert_eq!(scripts.len(), 1);
799 assert!(scripts[0].body.trim().is_empty());
800 }
801
802 #[test]
803 fn byte_offset_is_set() {
804 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
805 let scripts = extract_sfc_scripts(source);
806 assert_eq!(scripts.len(), 1);
807 let offset = scripts[0].byte_offset;
809 assert_eq!(&source[offset..offset + 4], "code");
810 }
811
812 #[test]
813 fn script_with_extra_attributes() {
814 let scripts = extract_sfc_scripts(
815 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
816 );
817 assert_eq!(scripts.len(), 1);
818 assert!(scripts[0].is_typescript);
819 assert!(scripts[0].src.is_none());
820 }
821
822 #[test]
825 fn multiple_script_blocks_exports_combined() {
826 let source = r#"
827<script lang="ts">
828export const version = '1.0';
829</script>
830<script setup lang="ts">
831import { ref } from 'vue';
832const count = ref(0);
833</script>
834"#;
835 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
836 assert!(
838 info.exports
839 .iter()
840 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
841 "export from <script> block should be extracted"
842 );
843 assert!(
845 info.imports.iter().any(|i| i.source == "vue"),
846 "import from <script setup> block should be extracted"
847 );
848 }
849
850 #[test]
853 fn lang_tsx_detected_as_typescript_jsx() {
854 let scripts =
855 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
856 assert_eq!(scripts.len(), 1);
857 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
858 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
859 }
860
861 #[test]
864 fn multiline_html_comment_filters_all_script_blocks_inside() {
865 let source = r#"
866<!--
867 This whole section is disabled:
868 <script lang="ts">import { bad1 } from 'bad1';</script>
869 <script lang="ts">import { bad2 } from 'bad2';</script>
870-->
871<script lang="ts">import { good } from 'good';</script>
872"#;
873 let scripts = extract_sfc_scripts(source);
874 assert_eq!(scripts.len(), 1);
875 assert!(scripts[0].body.contains("good"));
876 }
877
878 #[test]
881 fn script_src_generates_side_effect_import() {
882 let info = parse_sfc_to_module(
883 FileId(0),
884 Path::new("External.vue"),
885 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
886 0,
887 false,
888 );
889 assert!(
890 info.imports
891 .iter()
892 .any(|i| i.source == "./external-logic.ts"
893 && matches!(i.imported_name, ImportedName::SideEffect)),
894 "script src should generate a side-effect import"
895 );
896 }
897
898 #[test]
901 fn parse_sfc_no_script_returns_empty_module() {
902 let info = parse_sfc_to_module(
903 FileId(0),
904 Path::new("Empty.vue"),
905 "<template><div>Hello</div></template>",
906 42,
907 false,
908 );
909 assert!(info.imports.is_empty());
910 assert!(info.exports.is_empty());
911 assert_eq!(info.content_hash, 42);
912 assert_eq!(info.file_id, FileId(0));
913 }
914
915 #[test]
916 fn parse_sfc_has_line_offsets() {
917 let info = parse_sfc_to_module(
918 FileId(0),
919 Path::new("LineOffsets.vue"),
920 r#"<script lang="ts">const x = 1;</script>"#,
921 0,
922 false,
923 );
924 assert!(!info.line_offsets.is_empty());
925 }
926
927 #[test]
928 fn parse_sfc_has_suppressions() {
929 let info = parse_sfc_to_module(
930 FileId(0),
931 Path::new("Suppressions.vue"),
932 r#"<script lang="ts">
933// fallow-ignore-file
934export const foo = 1;
935</script>"#,
936 0,
937 false,
938 );
939 assert!(!info.suppressions.is_empty());
940 }
941
942 #[test]
943 fn source_type_jsx_detection() {
944 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
945 assert_eq!(scripts.len(), 1);
946 assert!(!scripts[0].is_typescript);
947 assert!(scripts[0].is_jsx);
948 }
949
950 #[test]
951 fn source_type_plain_js_detection() {
952 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
953 assert_eq!(scripts.len(), 1);
954 assert!(!scripts[0].is_typescript);
955 assert!(!scripts[0].is_jsx);
956 }
957
958 #[test]
959 fn is_sfc_file_rejects_no_extension() {
960 assert!(!is_sfc_file(Path::new("Makefile")));
961 }
962
963 #[test]
964 fn is_sfc_file_rejects_mdx() {
965 assert!(!is_sfc_file(Path::new("post.mdx")));
966 }
967
968 #[test]
969 fn is_sfc_file_rejects_css() {
970 assert!(!is_sfc_file(Path::new("styles.css")));
971 }
972
973 #[test]
974 fn multiple_script_blocks_both_have_offsets() {
975 let source = r#"<script lang="ts">const a = 1;</script>
976<script setup lang="ts">const b = 2;</script>"#;
977 let scripts = extract_sfc_scripts(source);
978 assert_eq!(scripts.len(), 2);
979 let offset0 = scripts[0].byte_offset;
981 let offset1 = scripts[1].byte_offset;
982 assert_eq!(
983 &source[offset0..offset0 + "const a = 1;".len()],
984 "const a = 1;"
985 );
986 assert_eq!(
987 &source[offset1..offset1 + "const b = 2;".len()],
988 "const b = 2;"
989 );
990 }
991
992 #[test]
993 fn script_with_src_and_lang() {
994 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
996 assert_eq!(scripts.len(), 1);
997 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
998 assert!(scripts[0].is_typescript);
999 assert!(scripts[0].is_jsx);
1000 }
1001
1002 #[test]
1005 fn extract_style_block_lang_scss() {
1006 let source = r#"<template/><style lang="scss">@import 'Foo';</style>"#;
1007 let styles = extract_sfc_styles(source);
1008 assert_eq!(styles.len(), 1);
1009 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1010 assert!(styles[0].body.contains("@import"));
1011 assert!(styles[0].src.is_none());
1012 }
1013
1014 #[test]
1015 fn extract_style_block_with_src() {
1016 let source = r#"<style src="./theme.scss" lang="scss"></style>"#;
1017 let styles = extract_sfc_styles(source);
1018 assert_eq!(styles.len(), 1);
1019 assert_eq!(styles[0].src.as_deref(), Some("./theme.scss"));
1020 assert_eq!(styles[0].lang.as_deref(), Some("scss"));
1021 }
1022
1023 #[test]
1024 fn extract_style_block_plain_no_lang() {
1025 let source = r"<style>.foo { color: red; }</style>";
1026 let styles = extract_sfc_styles(source);
1027 assert_eq!(styles.len(), 1);
1028 assert!(styles[0].lang.is_none());
1029 }
1030
1031 #[test]
1032 fn extract_multiple_style_blocks() {
1033 let source = r#"<style lang="scss">@import 'a';</style>
1034<style scoped lang="scss">@import 'b';</style>"#;
1035 let styles = extract_sfc_styles(source);
1036 assert_eq!(styles.len(), 2);
1037 }
1038
1039 #[test]
1040 fn style_block_inside_html_comment_filtered() {
1041 let source = r#"<!-- <style lang="scss">@import 'bad';</style> -->
1042<style lang="scss">@import 'good';</style>"#;
1043 let styles = extract_sfc_styles(source);
1044 assert_eq!(styles.len(), 1);
1045 assert!(styles[0].body.contains("good"));
1046 }
1047
1048 #[test]
1049 fn parse_sfc_extracts_style_imports_with_from_style_flag() {
1050 let info = parse_sfc_to_module(
1051 FileId(0),
1052 Path::new("Foo.vue"),
1053 r#"<template/><style lang="scss">@import 'Foo';</style>"#,
1054 0,
1055 false,
1056 );
1057 let style_import = info
1058 .imports
1059 .iter()
1060 .find(|i| i.source == "./Foo")
1061 .expect("scss @import 'Foo' should be normalized to ./Foo");
1062 assert!(
1063 style_import.from_style,
1064 "imports from <style> blocks must carry from_style=true so the resolver \
1065 enables SCSS partial fallback for the SFC importer"
1066 );
1067 assert!(matches!(
1068 style_import.imported_name,
1069 ImportedName::SideEffect
1070 ));
1071 }
1072
1073 #[test]
1074 fn parse_sfc_extracts_style_plugin_as_default_import() {
1075 let info = parse_sfc_to_module(
1076 FileId(0),
1077 Path::new("Foo.vue"),
1078 r#"<template/><style>@plugin "./tailwind-plugin.js";</style>"#,
1079 0,
1080 false,
1081 );
1082 let plugin_import = info
1083 .imports
1084 .iter()
1085 .find(|i| i.source == "./tailwind-plugin.js")
1086 .expect("style @plugin should create an import");
1087 assert!(plugin_import.from_style);
1088 assert!(matches!(plugin_import.imported_name, ImportedName::Default));
1089 }
1090
1091 #[test]
1092 fn parse_sfc_extracts_style_src_with_from_style_flag() {
1093 let info = parse_sfc_to_module(
1094 FileId(0),
1095 Path::new("Bar.vue"),
1096 r#"<style src="./Bar.scss" lang="scss"></style>"#,
1097 0,
1098 false,
1099 );
1100 let style_src = info
1101 .imports
1102 .iter()
1103 .find(|i| i.source == "./Bar.scss")
1104 .expect("<style src=\"./Bar.scss\"> should produce a side-effect import");
1105 assert!(style_src.from_style);
1106 }
1107
1108 #[test]
1109 fn parse_sfc_skips_unsupported_style_lang_body_but_keeps_src() {
1110 let info = parse_sfc_to_module(
1112 FileId(0),
1113 Path::new("Baz.vue"),
1114 r#"<style lang="postcss" src="./Baz.pcss">@custom-rule "skipped";</style>"#,
1115 0,
1116 false,
1117 );
1118 assert!(
1119 info.imports.iter().any(|i| i.source == "./Baz.pcss"),
1120 "src reference should still be seeded for unsupported lang"
1121 );
1122 assert!(
1123 !info.imports.iter().any(|i| i.source.contains("skipped")),
1124 "postcss body should not be scanned for @import directives"
1125 );
1126 }
1127}