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;
13
14use crate::visitor::ModuleInfoExtractor;
15use crate::{ImportInfo, ImportedName, ModuleInfo};
16use fallow_types::discover::FileId;
17use oxc_span::Span;
18
19static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
22 regex::Regex::new(
23 r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
24 )
25 .expect("valid regex")
26});
27
28static LANG_ATTR_RE: LazyLock<regex::Regex> =
30 LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
31
32static SRC_ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
35 regex::Regex::new(r#"(?:^|\s)src\s*=\s*["']([^"']+)["']"#).expect("valid regex")
36});
37
38static HTML_COMMENT_RE: LazyLock<regex::Regex> =
40 LazyLock::new(|| regex::Regex::new(r"(?s)<!--.*?-->").expect("valid regex"));
41
42pub struct SfcScript {
44 pub body: String,
46 pub is_typescript: bool,
48 pub is_jsx: bool,
50 pub byte_offset: usize,
52 pub src: Option<String>,
54}
55
56pub fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
58 let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
62 .find_iter(source)
63 .map(|m| (m.start(), m.end()))
64 .collect();
65
66 SCRIPT_BLOCK_RE
67 .captures_iter(source)
68 .filter(|cap| {
69 let start = cap.get(0).map_or(0, |m| m.start());
70 !comment_ranges
71 .iter()
72 .any(|&(cs, ce)| start >= cs && start < ce)
73 })
74 .map(|cap| {
75 let attrs = cap.name("attrs").map_or("", |m| m.as_str());
76 let body_match = cap.name("body");
77 let byte_offset = body_match.map_or(0, |m| m.start());
78 let body = body_match.map_or("", |m| m.as_str()).to_string();
79 let lang = LANG_ATTR_RE
80 .captures(attrs)
81 .and_then(|c| c.get(1))
82 .map(|m| m.as_str());
83 let is_typescript = matches!(lang, Some("ts" | "tsx"));
84 let is_jsx = matches!(lang, Some("tsx" | "jsx"));
85 let src = SRC_ATTR_RE
86 .captures(attrs)
87 .and_then(|c| c.get(1))
88 .map(|m| m.as_str().to_string());
89 SfcScript {
90 body,
91 is_typescript,
92 is_jsx,
93 byte_offset,
94 src,
95 }
96 })
97 .collect()
98}
99
100pub fn is_sfc_file(path: &Path) -> bool {
102 path.extension()
103 .and_then(|e| e.to_str())
104 .is_some_and(|ext| ext == "vue" || ext == "svelte")
105}
106
107pub(crate) fn parse_sfc_to_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
109 let scripts = extract_sfc_scripts(source);
110
111 let suppressions = crate::suppress::parse_suppressions_from_source(source);
114
115 let mut combined = ModuleInfo {
116 file_id,
117 exports: Vec::new(),
118 imports: Vec::new(),
119 re_exports: Vec::new(),
120 dynamic_imports: Vec::new(),
121 dynamic_import_patterns: Vec::new(),
122 require_calls: Vec::new(),
123 member_accesses: Vec::new(),
124 whole_object_uses: Vec::new(),
125 has_cjs_exports: false,
126 content_hash,
127 suppressions,
128 unused_import_bindings: Vec::new(),
129 line_offsets: fallow_types::extract::compute_line_offsets(source),
130 complexity: Vec::new(),
131 };
132
133 for script in &scripts {
134 if let Some(src) = &script.src {
135 combined.imports.push(ImportInfo {
136 source: src.clone(),
137 imported_name: ImportedName::SideEffect,
138 local_name: String::new(),
139 is_type_only: false,
140 span: Span::default(),
141 source_span: Span::default(),
142 });
143 }
144
145 let source_type = match (script.is_typescript, script.is_jsx) {
146 (true, true) => SourceType::tsx(),
147 (true, false) => SourceType::ts(),
148 (false, true) => SourceType::jsx(),
149 (false, false) => SourceType::mjs(),
150 };
151 let allocator = Allocator::default();
152 let parser_return = Parser::new(&allocator, &script.body, source_type).parse();
153 let mut extractor = ModuleInfoExtractor::new();
154 extractor.visit_program(&parser_return.program);
155 extractor.merge_into(&mut combined);
156 }
157
158 combined
159}
160
161#[cfg(all(test, not(miri)))]
164mod tests {
165 use super::*;
166
167 #[test]
170 fn is_sfc_file_vue() {
171 assert!(is_sfc_file(Path::new("App.vue")));
172 }
173
174 #[test]
175 fn is_sfc_file_svelte() {
176 assert!(is_sfc_file(Path::new("Counter.svelte")));
177 }
178
179 #[test]
180 fn is_sfc_file_rejects_ts() {
181 assert!(!is_sfc_file(Path::new("utils.ts")));
182 }
183
184 #[test]
185 fn is_sfc_file_rejects_jsx() {
186 assert!(!is_sfc_file(Path::new("App.jsx")));
187 }
188
189 #[test]
190 fn is_sfc_file_rejects_astro() {
191 assert!(!is_sfc_file(Path::new("Layout.astro")));
192 }
193
194 #[test]
197 fn single_plain_script() {
198 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
199 assert_eq!(scripts.len(), 1);
200 assert_eq!(scripts[0].body, "const x = 1;");
201 assert!(!scripts[0].is_typescript);
202 assert!(!scripts[0].is_jsx);
203 assert!(scripts[0].src.is_none());
204 }
205
206 #[test]
207 fn single_ts_script() {
208 let scripts = extract_sfc_scripts(r#"<script lang="ts">const x: number = 1;</script>"#);
209 assert_eq!(scripts.len(), 1);
210 assert!(scripts[0].is_typescript);
211 assert!(!scripts[0].is_jsx);
212 }
213
214 #[test]
215 fn single_tsx_script() {
216 let scripts = extract_sfc_scripts(r#"<script lang="tsx">const el = <div />;</script>"#);
217 assert_eq!(scripts.len(), 1);
218 assert!(scripts[0].is_typescript);
219 assert!(scripts[0].is_jsx);
220 }
221
222 #[test]
223 fn single_jsx_script() {
224 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
225 assert_eq!(scripts.len(), 1);
226 assert!(!scripts[0].is_typescript);
227 assert!(scripts[0].is_jsx);
228 }
229
230 #[test]
233 fn two_script_blocks() {
234 let source = r#"
235<script lang="ts">
236export default {};
237</script>
238<script setup lang="ts">
239const count = 0;
240</script>
241"#;
242 let scripts = extract_sfc_scripts(source);
243 assert_eq!(scripts.len(), 2);
244 assert!(scripts[0].body.contains("export default"));
245 assert!(scripts[1].body.contains("count"));
246 }
247
248 #[test]
251 fn script_setup_extracted() {
252 let scripts =
253 extract_sfc_scripts(r#"<script setup lang="ts">import { ref } from 'vue';</script>"#);
254 assert_eq!(scripts.len(), 1);
255 assert!(scripts[0].body.contains("import"));
256 assert!(scripts[0].is_typescript);
257 }
258
259 #[test]
262 fn script_src_detected() {
263 let scripts = extract_sfc_scripts(r#"<script src="./component.ts" lang="ts"></script>"#);
264 assert_eq!(scripts.len(), 1);
265 assert_eq!(scripts[0].src.as_deref(), Some("./component.ts"));
266 }
267
268 #[test]
269 fn data_src_not_treated_as_src() {
270 let scripts =
271 extract_sfc_scripts(r#"<script lang="ts" data-src="./nope.ts">const x = 1;</script>"#);
272 assert_eq!(scripts.len(), 1);
273 assert!(scripts[0].src.is_none());
274 }
275
276 #[test]
279 fn script_inside_html_comment_filtered() {
280 let source = r#"
281<!-- <script lang="ts">import { bad } from 'bad';</script> -->
282<script lang="ts">import { good } from 'good';</script>
283"#;
284 let scripts = extract_sfc_scripts(source);
285 assert_eq!(scripts.len(), 1);
286 assert!(scripts[0].body.contains("good"));
287 }
288
289 #[test]
290 fn spanning_comment_filters_script() {
291 let source = r#"
292<!-- disabled:
293<script lang="ts">import { bad } from 'bad';</script>
294-->
295<script lang="ts">const ok = true;</script>
296"#;
297 let scripts = extract_sfc_scripts(source);
298 assert_eq!(scripts.len(), 1);
299 assert!(scripts[0].body.contains("ok"));
300 }
301
302 #[test]
303 fn string_containing_comment_markers_not_corrupted() {
304 let source = r#"
306<script setup lang="ts">
307const marker = "<!-- not a comment -->";
308import { ref } from 'vue';
309</script>
310"#;
311 let scripts = extract_sfc_scripts(source);
312 assert_eq!(scripts.len(), 1);
313 assert!(scripts[0].body.contains("import"));
314 }
315
316 #[test]
319 fn generic_attr_with_angle_bracket() {
320 let source =
321 r#"<script setup lang="ts" generic="T extends Foo<Bar>">const x = 1;</script>"#;
322 let scripts = extract_sfc_scripts(source);
323 assert_eq!(scripts.len(), 1);
324 assert_eq!(scripts[0].body, "const x = 1;");
325 }
326
327 #[test]
328 fn nested_generic_attr() {
329 let source = r#"<script setup lang="ts" generic="T extends Map<string, Set<number>>">const x = 1;</script>"#;
330 let scripts = extract_sfc_scripts(source);
331 assert_eq!(scripts.len(), 1);
332 assert_eq!(scripts[0].body, "const x = 1;");
333 }
334
335 #[test]
338 fn lang_single_quoted() {
339 let scripts = extract_sfc_scripts("<script lang='ts'>const x = 1;</script>");
340 assert_eq!(scripts.len(), 1);
341 assert!(scripts[0].is_typescript);
342 }
343
344 #[test]
347 fn uppercase_script_tag() {
348 let scripts = extract_sfc_scripts(r#"<SCRIPT lang="ts">const x = 1;</SCRIPT>"#);
349 assert_eq!(scripts.len(), 1);
350 assert!(scripts[0].is_typescript);
351 }
352
353 #[test]
356 fn no_script_block() {
357 let scripts = extract_sfc_scripts("<template><div>Hello</div></template>");
358 assert!(scripts.is_empty());
359 }
360
361 #[test]
362 fn empty_script_body() {
363 let scripts = extract_sfc_scripts(r#"<script lang="ts"></script>"#);
364 assert_eq!(scripts.len(), 1);
365 assert!(scripts[0].body.is_empty());
366 }
367
368 #[test]
369 fn whitespace_only_script() {
370 let scripts = extract_sfc_scripts("<script lang=\"ts\">\n \n</script>");
371 assert_eq!(scripts.len(), 1);
372 assert!(scripts[0].body.trim().is_empty());
373 }
374
375 #[test]
376 fn byte_offset_is_set() {
377 let source = r#"<template><div/></template><script lang="ts">code</script>"#;
378 let scripts = extract_sfc_scripts(source);
379 assert_eq!(scripts.len(), 1);
380 let offset = scripts[0].byte_offset;
382 assert_eq!(&source[offset..offset + 4], "code");
383 }
384
385 #[test]
386 fn script_with_extra_attributes() {
387 let scripts = extract_sfc_scripts(
388 r#"<script lang="ts" id="app" type="module" data-custom="val">const x = 1;</script>"#,
389 );
390 assert_eq!(scripts.len(), 1);
391 assert!(scripts[0].is_typescript);
392 assert!(scripts[0].src.is_none());
393 }
394
395 #[test]
398 fn multiple_script_blocks_exports_combined() {
399 let source = r#"
400<script lang="ts">
401export const version = '1.0';
402</script>
403<script setup lang="ts">
404import { ref } from 'vue';
405const count = ref(0);
406</script>
407"#;
408 let info = parse_sfc_to_module(FileId(0), source, 0);
409 assert!(
411 info.exports
412 .iter()
413 .any(|e| matches!(&e.name, crate::ExportName::Named(n) if n == "version")),
414 "export from <script> block should be extracted"
415 );
416 assert!(
418 info.imports.iter().any(|i| i.source == "vue"),
419 "import from <script setup> block should be extracted"
420 );
421 }
422
423 #[test]
426 fn lang_tsx_detected_as_typescript_jsx() {
427 let scripts =
428 extract_sfc_scripts(r#"<script lang="tsx">const el = <div>{x}</div>;</script>"#);
429 assert_eq!(scripts.len(), 1);
430 assert!(scripts[0].is_typescript, "lang=tsx should be typescript");
431 assert!(scripts[0].is_jsx, "lang=tsx should be jsx");
432 }
433
434 #[test]
437 fn multiline_html_comment_filters_all_script_blocks_inside() {
438 let source = r#"
439<!--
440 This whole section is disabled:
441 <script lang="ts">import { bad1 } from 'bad1';</script>
442 <script lang="ts">import { bad2 } from 'bad2';</script>
443-->
444<script lang="ts">import { good } from 'good';</script>
445"#;
446 let scripts = extract_sfc_scripts(source);
447 assert_eq!(scripts.len(), 1);
448 assert!(scripts[0].body.contains("good"));
449 }
450
451 #[test]
454 fn script_src_generates_side_effect_import() {
455 let info = parse_sfc_to_module(
456 FileId(0),
457 r#"<script src="./external-logic.ts" lang="ts"></script>"#,
458 0,
459 );
460 assert!(
461 info.imports
462 .iter()
463 .any(|i| i.source == "./external-logic.ts"
464 && matches!(i.imported_name, ImportedName::SideEffect)),
465 "script src should generate a side-effect import"
466 );
467 }
468
469 #[test]
472 fn parse_sfc_no_script_returns_empty_module() {
473 let info = parse_sfc_to_module(FileId(0), "<template><div>Hello</div></template>", 42);
474 assert!(info.imports.is_empty());
475 assert!(info.exports.is_empty());
476 assert_eq!(info.content_hash, 42);
477 assert_eq!(info.file_id, FileId(0));
478 }
479
480 #[test]
481 fn parse_sfc_has_line_offsets() {
482 let info = parse_sfc_to_module(FileId(0), r#"<script lang="ts">const x = 1;</script>"#, 0);
483 assert!(!info.line_offsets.is_empty());
484 }
485
486 #[test]
487 fn parse_sfc_has_suppressions() {
488 let info = parse_sfc_to_module(
489 FileId(0),
490 r#"<script lang="ts">
491// fallow-ignore-file
492export const foo = 1;
493</script>"#,
494 0,
495 );
496 assert!(!info.suppressions.is_empty());
497 }
498
499 #[test]
500 fn source_type_jsx_detection() {
501 let scripts = extract_sfc_scripts(r#"<script lang="jsx">const el = <div />;</script>"#);
502 assert_eq!(scripts.len(), 1);
503 assert!(!scripts[0].is_typescript);
504 assert!(scripts[0].is_jsx);
505 }
506
507 #[test]
508 fn source_type_plain_js_detection() {
509 let scripts = extract_sfc_scripts("<script>const x = 1;</script>");
510 assert_eq!(scripts.len(), 1);
511 assert!(!scripts[0].is_typescript);
512 assert!(!scripts[0].is_jsx);
513 }
514
515 #[test]
516 fn is_sfc_file_rejects_no_extension() {
517 assert!(!is_sfc_file(Path::new("Makefile")));
518 }
519
520 #[test]
521 fn is_sfc_file_rejects_mdx() {
522 assert!(!is_sfc_file(Path::new("post.mdx")));
523 }
524
525 #[test]
526 fn is_sfc_file_rejects_css() {
527 assert!(!is_sfc_file(Path::new("styles.css")));
528 }
529
530 #[test]
531 fn multiple_script_blocks_both_have_offsets() {
532 let source = r#"<script lang="ts">const a = 1;</script>
533<script setup lang="ts">const b = 2;</script>"#;
534 let scripts = extract_sfc_scripts(source);
535 assert_eq!(scripts.len(), 2);
536 let offset0 = scripts[0].byte_offset;
538 let offset1 = scripts[1].byte_offset;
539 assert_eq!(
540 &source[offset0..offset0 + "const a = 1;".len()],
541 "const a = 1;"
542 );
543 assert_eq!(
544 &source[offset1..offset1 + "const b = 2;".len()],
545 "const b = 2;"
546 );
547 }
548
549 #[test]
550 fn script_with_src_and_lang() {
551 let scripts = extract_sfc_scripts(r#"<script src="./logic.ts" lang="tsx"></script>"#);
553 assert_eq!(scripts.len(), 1);
554 assert_eq!(scripts[0].src.as_deref(), Some("./logic.ts"));
555 assert!(scripts[0].is_typescript);
556 assert!(scripts[0].is_jsx);
557 }
558}