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