1use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_allocator::Allocator;
10use oxc_ast_visit::Visit;
11use oxc_parser::Parser;
12use oxc_span::SourceType;
13use rustc_hash::FxHashSet;
14
15use crate::asset_url::normalize_asset_url;
16use crate::parse::compute_import_binding_usage;
17use crate::sfc_template::{SfcKind, collect_template_usage};
18use crate::visitor::ModuleInfoExtractor;
19use crate::{ImportInfo, ImportedName, ModuleInfo};
20use fallow_types::discover::FileId;
21use fallow_types::extract::{FunctionComplexity, byte_offset_to_line_col, compute_line_offsets};
22use oxc_span::Span;
23
24static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
27 regex::Regex::new(
28 r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
29 )
30 .expect("valid regex")
31});
32
33static LANG_ATTR_RE: LazyLock<regex::Regex> =
35 LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
36
37static SRC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
40 regex::Regex::new(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#).expect("valid regex")
41});
42
43static SETUP_ATTR_RE: LazyLock<regex::Regex> =
45 LazyLock::new(|| regex::Regex::new(r"(?:^|\s)setup(?:\s|$)").expect("valid regex"));
46
47static CONTEXT_MODULE_ATTR_RE: LazyLock<regex::Regex> =
49 LazyLock::new(|| regex::Regex::new(r#"context\s*=\s*["']module["']"#).expect("valid regex"));
50
51static HTML_COMMENT_RE: LazyLock<regex::Regex> =
53 LazyLock::new(|| regex::Regex::new(r"(?s)<!--.*?-->").expect("valid regex"));
54
55pub struct SfcScript {
57 pub body: String,
59 pub is_typescript: bool,
61 pub is_jsx: bool,
63 pub byte_offset: usize,
65 pub src: Option<String>,
67 pub is_setup: bool,
69 pub is_context_module: bool,
71}
72
73pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
75 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
79 .find_iter(source)
80 .map(|m| (m.start(), m.end()))
81 .collect();
82
83 SCRIPT_BLOCK_RE
84 .captures_iter(source)
85 .filter(|cap| {
86 let start = cap.get(0).map_or(0, |m| m.start());
87 !comment_ranges
88 .iter()
89 .any(|&(cs, ce)| start >= cs && start < ce)
90 })
91 .map(|cap| {
92 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
93 let body_match = cap.name("body");
94 let byte_offset = body_match.map_or(0, |m| m.start());
95 let body = body_match.map_or("", |m| m.as_str()).to_string();
96 let lang = LANG_ATTR_RE
97 .captures(attrs)
98 .and_then(|c| c.get(1))
99 .map(|m| m.as_str());
100 let is_typescript = matches!(lang, Some("ts" | "tsx"));
101 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
102 let src = SRC_ATTR_RE
103 .captures(attrs)
104 .and_then(|c| c.get(1))
105 .map(|m| m.as_str().to_string());
106 let is_setup = SETUP_ATTR_RE.is_match(attrs);
107 let is_context_module = CONTEXT_MODULE_ATTR_RE.is_match(attrs);
108 SfcScript {
109 body,
110 is_typescript,
111 is_jsx,
112 byte_offset,
113 src,
114 is_setup,
115 is_context_module,
116 }
117 })
118 .collect()
119}
120
121#[must_use]
123pub fn is_sfc_file(path: &Path) -> bool {
124 path.extension()
125 .and_then(|e| e.to_str())
126 .is_some_and(|ext| ext == "vue" || ext == "svelte")
127}
128
129pub(crate) fn parse_sfc_to_module(
131 file_id: FileId,
132 path: &Path,
133 source: &str,
134 content_hash: u64,
135 need_complexity: bool,
136) -> ModuleInfo {
137 let scripts = extract_sfc_scripts(source);
138 let kind = sfc_kind(path);
139 let mut combined = empty_sfc_module(file_id, source, content_hash);
140 let mut template_visible_imports: FxHashSet<String> = FxHashSet::default();
141
142 for script in &scripts {
143 merge_script_into_module(
144 kind,
145 script,
146 &mut combined,
147 &mut template_visible_imports,
148 need_complexity,
149 );
150 }
151
152 apply_template_usage(kind, source, &template_visible_imports, &mut combined);
153 combined.unused_import_bindings.sort_unstable();
154 combined.unused_import_bindings.dedup();
155 combined.type_referenced_import_bindings.sort_unstable();
156 combined.type_referenced_import_bindings.dedup();
157 combined.value_referenced_import_bindings.sort_unstable();
158 combined.value_referenced_import_bindings.dedup();
159
160 combined
161}
162
163fn sfc_kind(path: &Path) -> SfcKind {
164 if path.extension().and_then(|ext| ext.to_str()) == Some("vue") {
165 SfcKind::Vue
166 } else {
167 SfcKind::Svelte
168 }
169}
170
171fn empty_sfc_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
172 let suppressions = crate::suppress::parse_suppressions_from_source(source);
175
176 ModuleInfo {
177 file_id,
178 exports: Vec::new(),
179 imports: Vec::new(),
180 re_exports: Vec::new(),
181 dynamic_imports: Vec::new(),
182 dynamic_import_patterns: Vec::new(),
183 require_calls: Vec::new(),
184 member_accesses: Vec::new(),
185 whole_object_uses: Vec::new(),
186 has_cjs_exports: false,
187 content_hash,
188 suppressions,
189 unused_import_bindings: Vec::new(),
190 type_referenced_import_bindings: Vec::new(),
191 value_referenced_import_bindings: Vec::new(),
192 line_offsets: compute_line_offsets(source),
193 complexity: Vec::new(),
194 flag_uses: Vec::new(),
195 class_heritage: vec![],
196 }
197}
198
199fn merge_script_into_module(
200 kind: SfcKind,
201 script: &SfcScript,
202 combined: &mut ModuleInfo,
203 template_visible_imports: &mut FxHashSet<String>,
204 need_complexity: bool,
205) {
206 if let Some(src) = &script.src {
207 add_script_src_import(combined, src);
208 }
209
210 let allocator = Allocator::default();
211 let parser_return =
212 Parser::new(&allocator, &script.body, source_type_for_script(script)).parse();
213 let mut extractor = ModuleInfoExtractor::new();
214 extractor.visit_program(&parser_return.program);
215
216 let binding_usage = compute_import_binding_usage(&parser_return.program, &extractor.imports);
217 combined
218 .unused_import_bindings
219 .extend(binding_usage.unused.iter().cloned());
220 combined
221 .type_referenced_import_bindings
222 .extend(binding_usage.type_referenced.iter().cloned());
223 combined
224 .value_referenced_import_bindings
225 .extend(binding_usage.value_referenced.iter().cloned());
226 if need_complexity {
227 combined.complexity.extend(translate_script_complexity(
228 script,
229 &parser_return.program,
230 &combined.line_offsets,
231 ));
232 }
233
234 if is_template_visible_script(kind, script) {
235 template_visible_imports.extend(
236 extractor
237 .imports
238 .iter()
239 .filter(|import| !import.local_name.is_empty())
240 .map(|import| import.local_name.clone()),
241 );
242 }
243
244 extractor.merge_into(combined);
245}
246
247fn translate_script_complexity(
248 script: &SfcScript,
249 program: &oxc_ast::ast::Program<'_>,
250 sfc_line_offsets: &[u32],
251) -> Vec<FunctionComplexity> {
252 let script_line_offsets = compute_line_offsets(&script.body);
253 let mut complexity = crate::complexity::compute_complexity(program, &script_line_offsets);
254 let (body_start_line, body_start_col) =
255 byte_offset_to_line_col(sfc_line_offsets, script.byte_offset as u32);
256
257 for function in &mut complexity {
258 function.line = body_start_line + function.line.saturating_sub(1);
259 if function.line == body_start_line {
260 function.col += body_start_col;
261 }
262 }
263
264 complexity
265}
266
267fn add_script_src_import(module: &mut ModuleInfo, source: &str) {
268 module.imports.push(ImportInfo {
271 source: normalize_asset_url(source),
272 imported_name: ImportedName::SideEffect,
273 local_name: String::new(),
274 is_type_only: false,
275 span: Span::default(),
276 source_span: Span::default(),
277 });
278}
279
280fn source_type_for_script(script: &SfcScript) -> SourceType {
281 match (script.is_typescript, script.is_jsx) {
282 (true, true) => SourceType::tsx(),
283 (true, false) => SourceType::ts(),
284 (false, true) => SourceType::jsx(),
285 (false, false) => SourceType::mjs(),
286 }
287}
288
289fn apply_template_usage(
290 kind: SfcKind,
291 source: &str,
292 template_visible_imports: &FxHashSet<String>,
293 combined: &mut ModuleInfo,
294) {
295 if template_visible_imports.is_empty() {
296 return;
297 }
298
299 let template_usage = collect_template_usage(kind, source, template_visible_imports);
300 combined
301 .unused_import_bindings
302 .retain(|binding| !template_usage.used_bindings.contains(binding));
303 combined
304 .member_accesses
305 .extend(template_usage.member_accesses);
306 combined
307 .whole_object_uses
308 .extend(template_usage.whole_object_uses);
309}
310
311fn is_template_visible_script(kind: SfcKind, script: &SfcScript) -> bool {
312 match kind {
313 SfcKind::Vue => script.is_setup,
314 SfcKind::Svelte => !script.is_context_module,
315 }
316}
317
318#[cfg(all(test, not(miri)))]
321mod tests {
322 use super::*;
323
324 #[test]
327 fn is_sfc_file_vue() {
328 assert!(is_sfc_file(Path::new("App.vue")));
329 }
330
331 #[test]
332 fn is_sfc_file_svelte() {
333 assert!(is_sfc_file(Path::new("Counter.svelte")));
334 }
335
336 #[test]
337 fn is_sfc_file_rejects_ts() {
338 assert!(!is_sfc_file(Path::new("utils.ts")));
339 }
340
341 #[test]
342 fn is_sfc_file_rejects_jsx() {
343 assert!(!is_sfc_file(Path::new("App.jsx")));
344 }
345
346 #[test]
347 fn is_sfc_file_rejects_astro() {
348 assert!(!is_sfc_file(Path::new("Layout.astro")));
349 }
350
351 #[test]
354 fn single_plain_script() {
355 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
356 assert_eq!(scripts.len(), 1);
357 assert_eq!(scripts[0].body, "const x = 1;");
358 assert!(!scripts[0].is_typescript);
359 assert!(!scripts[0].is_jsx);
360 assert!(scripts[0].src.is_none());
361 }
362
363 #[test]
364 fn single_ts_script() {
365 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
366 assert_eq!(scripts.len(), 1);
367 assert!(scripts[0].is_typescript);
368 assert!(!scripts[0].is_jsx);
369 }
370
371 #[test]
372 fn single_tsx_script() {
373 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
374 assert_eq!(scripts.len(), 1);
375 assert!(scripts[0].is_typescript);
376 assert!(scripts[0].is_jsx);
377 }
378
379 #[test]
380 fn single_jsx_script() {
381 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
382 assert_eq!(scripts.len(), 1);
383 assert!(!scripts[0].is_typescript);
384 assert!(scripts[0].is_jsx);
385 }
386
387 #[test]
390 fn two_script_blocks() {
391 let source = r#"
392<script lang="ts">
393export default {};
394</script>
395<script setup lang="ts">
396const count = 0;
397</script>
398"#;
399 let scripts = extract_sfc_scripts(source);
400 assert_eq!(scripts.len(), 2);
401 assert!(scripts[0].body.contains("export default"));
402 assert!(scripts[1].body.contains("count"));
403 }
404
405 #[test]
408 fn script_setup_extracted() {
409 let scripts =
410 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
411 assert_eq!(scripts.len(), 1);
412 assert!(scripts[0].body.contains("import"));
413 assert!(scripts[0].is_typescript);
414 }
415
416 #[test]
419 fn script_src_detected() {
420 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
421 assert_eq!(scripts.len(), 1);
422 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
423 }
424
425 #[test]
426 fn data_src_not_treated_as_src() {
427 let scripts =
428 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
429 assert_eq!(scripts.len(), 1);
430 assert!(scripts[0].src.is_none());
431 }
432
433 #[test]
436 fn script_inside_html_comment_filtered() {
437 let source = r#"
438<!-- <script lang="ts">import { bad } from 'bad';</script> -->
439<script lang="ts">import { good } from 'good';</script>
440"#;
441 let scripts = extract_sfc_scripts(source);
442 assert_eq!(scripts.len(), 1);
443 assert!(scripts[0].body.contains("good"));
444 }
445
446 #[test]
447 fn spanning_comment_filters_script() {
448 let source = r#"
449<!-- disabled:
450<script lang="ts">import { bad } from 'bad';</script>
451-->
452<script lang="ts">const ok = true;</script>
453"#;
454 let scripts = extract_sfc_scripts(source);
455 assert_eq!(scripts.len(), 1);
456 assert!(scripts[0].body.contains("ok"));
457 }
458
459 #[test]
460 fn string_containing_comment_markers_not_corrupted() {
461 let source = r#"
463<script setup lang="ts">
464const marker = "<!-- not a comment -->";
465import { ref } from 'vue';
466</script>
467"#;
468 let scripts = extract_sfc_scripts(source);
469 assert_eq!(scripts.len(), 1);
470 assert!(scripts[0].body.contains("import"));
471 }
472
473 #[test]
476 fn generic_attr_with_angle_bracket() {
477 let source =
478 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
479 let scripts = extract_sfc_scripts(source);
480 assert_eq!(scripts.len(), 1);
481 assert_eq!(scripts[0].body, "const x = 1;");
482 }
483
484 #[test]
485 fn nested_generic_attr() {
486 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
487 let scripts = extract_sfc_scripts(source);
488 assert_eq!(scripts.len(), 1);
489 assert_eq!(scripts[0].body, "const x = 1;");
490 }
491
492 #[test]
495 fn lang_single_quoted() {
496 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
497 assert_eq!(scripts.len(), 1);
498 assert!(scripts[0].is_typescript);
499 }
500
501 #[test]
504 fn uppercase_script_tag() {
505 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
506 assert_eq!(scripts.len(), 1);
507 assert!(scripts[0].is_typescript);
508 }
509
510 #[test]
513 fn no_script_block() {
514 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
515 assert!(scripts.is_empty());
516 }
517
518 #[test]
519 fn empty_script_body() {
520 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
521 assert_eq!(scripts.len(), 1);
522 assert!(scripts[0].body.is_empty());
523 }
524
525 #[test]
526 fn whitespace_only_script() {
527 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
528 assert_eq!(scripts.len(), 1);
529 assert!(scripts[0].body.trim().is_empty());
530 }
531
532 #[test]
533 fn byte_offset_is_set() {
534 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
535 let scripts = extract_sfc_scripts(source);
536 assert_eq!(scripts.len(), 1);
537 let offset = scripts[0].byte_offset;
539 assert_eq!(&source[offset..offset + 4], "code");
540 }
541
542 #[test]
543 fn script_with_extra_attributes() {
544 let scripts = extract_sfc_scripts(
545 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
546 );
547 assert_eq!(scripts.len(), 1);
548 assert!(scripts[0].is_typescript);
549 assert!(scripts[0].src.is_none());
550 }
551
552 #[test]
555 fn multiple_script_blocks_exports_combined() {
556 let source = r#"
557<script lang="ts">
558export const version = '1.0';
559</script>
560<script setup lang="ts">
561import { ref } from 'vue';
562const count = ref(0);
563</script>
564"#;
565 let info = parse_sfc_to_module(FileId(0), Path::new("Dual.vue"), source, 0, false);
566 assert!(
568 info.exports
569 .iter()
570 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
571 "export from <script> block should be extracted"
572 );
573 assert!(
575 info.imports.iter().any(|i| i.source == "vue"),
576 "import from <script setup> block should be extracted"
577 );
578 }
579
580 #[test]
583 fn lang_tsx_detected_as_typescript_jsx() {
584 let scripts =
585 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
586 assert_eq!(scripts.len(), 1);
587 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
588 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
589 }
590
591 #[test]
594 fn multiline_html_comment_filters_all_script_blocks_inside() {
595 let source = r#"
596<!--
597 This whole section is disabled:
598 <script lang="ts">import { bad1 } from 'bad1';</script>
599 <script lang="ts">import { bad2 } from 'bad2';</script>
600-->
601<script lang="ts">import { good } from 'good';</script>
602"#;
603 let scripts = extract_sfc_scripts(source);
604 assert_eq!(scripts.len(), 1);
605 assert!(scripts[0].body.contains("good"));
606 }
607
608 #[test]
611 fn script_src_generates_side_effect_import() {
612 let info = parse_sfc_to_module(
613 FileId(0),
614 Path::new("External.vue"),
615 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
616 0,
617 false,
618 );
619 assert!(
620 info.imports
621 .iter()
622 .any(|i| i.source == "./external-logic.ts"
623 && matches!(i.imported_name, ImportedName::SideEffect)),
624 "script src should generate a side-effect import"
625 );
626 }
627
628 #[test]
631 fn parse_sfc_no_script_returns_empty_module() {
632 let info = parse_sfc_to_module(
633 FileId(0),
634 Path::new("Empty.vue"),
635 "<template><div>Hello</div></template>",
636 42,
637 false,
638 );
639 assert!(info.imports.is_empty());
640 assert!(info.exports.is_empty());
641 assert_eq!(info.content_hash, 42);
642 assert_eq!(info.file_id, FileId(0));
643 }
644
645 #[test]
646 fn parse_sfc_has_line_offsets() {
647 let info = parse_sfc_to_module(
648 FileId(0),
649 Path::new("LineOffsets.vue"),
650 r#"<script lang="ts">const x = 1;</script>"#,
651 0,
652 false,
653 );
654 assert!(!info.line_offsets.is_empty());
655 }
656
657 #[test]
658 fn parse_sfc_has_suppressions() {
659 let info = parse_sfc_to_module(
660 FileId(0),
661 Path::new("Suppressions.vue"),
662 r#"<script lang="ts">
663// fallow-ignore-file
664export const foo = 1;
665</script>"#,
666 0,
667 false,
668 );
669 assert!(!info.suppressions.is_empty());
670 }
671
672 #[test]
673 fn source_type_jsx_detection() {
674 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
675 assert_eq!(scripts.len(), 1);
676 assert!(!scripts[0].is_typescript);
677 assert!(scripts[0].is_jsx);
678 }
679
680 #[test]
681 fn source_type_plain_js_detection() {
682 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
683 assert_eq!(scripts.len(), 1);
684 assert!(!scripts[0].is_typescript);
685 assert!(!scripts[0].is_jsx);
686 }
687
688 #[test]
689 fn is_sfc_file_rejects_no_extension() {
690 assert!(!is_sfc_file(Path::new("Makefile")));
691 }
692
693 #[test]
694 fn is_sfc_file_rejects_mdx() {
695 assert!(!is_sfc_file(Path::new("post.mdx")));
696 }
697
698 #[test]
699 fn is_sfc_file_rejects_css() {
700 assert!(!is_sfc_file(Path::new("styles.css")));
701 }
702
703 #[test]
704 fn multiple_script_blocks_both_have_offsets() {
705 let source = r#"<script lang="ts">const a = 1;</script>
706<script setup lang="ts">const b = 2;</script>"#;
707 let scripts = extract_sfc_scripts(source);
708 assert_eq!(scripts.len(), 2);
709 let offset0 = scripts[0].byte_offset;
711 let offset1 = scripts[1].byte_offset;
712 assert_eq!(
713 &source[offset0..offset0 + "const a = 1;".len()],
714 "const a = 1;"
715 );
716 assert_eq!(
717 &source[offset1..offset1 + "const b = 2;".len()],
718 "const b = 2;"
719 );
720 }
721
722 #[test]
723 fn script_with_src_and_lang() {
724 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
726 assert_eq!(scripts.len(), 1);
727 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
728 assert!(scripts[0].is_typescript);
729 assert!(scripts[0].is_jsx);
730 }
731}