1use std::path::Path;
2use std::sync::LazyLock;
3
4use fallow_config::ResolvedConfig;
5use oxc_allocator::Allocator;
6use oxc_ast::ast::*;
7use oxc_ast_visit::Visit;
8use oxc_ast_visit::walk;
9use oxc_parser::Parser;
10use oxc_span::{SourceType, Span};
11use rayon::prelude::*;
12
13use crate::cache::CacheStore;
14use crate::discover::{DiscoveredFile, FileId};
15use crate::suppress::Suppression;
16
17#[derive(Debug, Clone)]
19pub struct ModuleInfo {
20 pub file_id: FileId,
21 pub exports: Vec<ExportInfo>,
22 pub imports: Vec<ImportInfo>,
23 pub re_exports: Vec<ReExportInfo>,
24 pub dynamic_imports: Vec<DynamicImportInfo>,
25 pub dynamic_import_patterns: Vec<DynamicImportPattern>,
26 pub require_calls: Vec<RequireCallInfo>,
27 pub member_accesses: Vec<MemberAccess>,
28 pub whole_object_uses: Vec<String>,
31 pub has_cjs_exports: bool,
32 pub content_hash: u64,
33 pub suppressions: Vec<Suppression>,
35}
36
37#[derive(Debug, Clone)]
39pub struct DynamicImportPattern {
40 pub prefix: String,
42 pub suffix: Option<String>,
44 pub span: Span,
45}
46
47#[derive(Debug, Clone, serde::Serialize)]
49pub struct ExportInfo {
50 pub name: ExportName,
51 pub local_name: Option<String>,
52 pub is_type_only: bool,
53 #[serde(serialize_with = "serialize_span")]
54 pub span: Span,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub members: Vec<MemberInfo>,
58}
59
60#[derive(Debug, Clone, serde::Serialize)]
62pub struct MemberInfo {
63 pub name: String,
64 pub kind: MemberKind,
65 #[serde(serialize_with = "serialize_span")]
66 pub span: Span,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MemberKind {
73 EnumMember,
74 ClassMethod,
75 ClassProperty,
76}
77
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bincode::Encode, bincode::Decode)]
80pub struct MemberAccess {
81 pub object: String,
83 pub member: String,
85}
86
87fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
88 use serde::ser::SerializeMap;
89 let mut map = serializer.serialize_map(Some(2))?;
90 map.serialize_entry("start", &span.start)?;
91 map.serialize_entry("end", &span.end)?;
92 map.end()
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
97pub enum ExportName {
98 Named(String),
99 Default,
100}
101
102impl std::fmt::Display for ExportName {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 Self::Named(n) => write!(f, "{n}"),
106 Self::Default => write!(f, "default"),
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
113pub struct ImportInfo {
114 pub source: String,
115 pub imported_name: ImportedName,
116 pub local_name: String,
117 pub is_type_only: bool,
118 pub span: Span,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ImportedName {
124 Named(String),
125 Default,
126 Namespace,
127 SideEffect,
128}
129
130#[derive(Debug, Clone)]
132pub struct ReExportInfo {
133 pub source: String,
134 pub imported_name: String,
135 pub exported_name: String,
136 pub is_type_only: bool,
137}
138
139#[derive(Debug, Clone)]
141pub struct DynamicImportInfo {
142 pub source: String,
143 pub span: Span,
144 pub destructured_names: Vec<String>,
148 pub local_name: Option<String>,
151}
152
153#[derive(Debug, Clone)]
155pub struct RequireCallInfo {
156 pub source: String,
157 pub span: Span,
158 pub destructured_names: Vec<String>,
162 pub local_name: Option<String>,
165}
166
167pub fn parse_all_files(
170 files: &[DiscoveredFile],
171 _config: &ResolvedConfig,
172 cache: Option<&CacheStore>,
173) -> Vec<ModuleInfo> {
174 use std::sync::atomic::{AtomicUsize, Ordering};
175 let cache_hits = AtomicUsize::new(0);
176 let cache_misses = AtomicUsize::new(0);
177
178 let result: Vec<ModuleInfo> = files
179 .par_iter()
180 .filter_map(|file| parse_single_file_cached(file, cache, &cache_hits, &cache_misses))
181 .collect();
182
183 let hits = cache_hits.load(Ordering::Relaxed);
184 let misses = cache_misses.load(Ordering::Relaxed);
185 if hits > 0 || misses > 0 {
186 tracing::info!(
187 cache_hits = hits,
188 cache_misses = misses,
189 "incremental cache stats"
190 );
191 }
192
193 result
194}
195
196fn parse_single_file_cached(
198 file: &DiscoveredFile,
199 cache: Option<&CacheStore>,
200 cache_hits: &std::sync::atomic::AtomicUsize,
201 cache_misses: &std::sync::atomic::AtomicUsize,
202) -> Option<ModuleInfo> {
203 use std::sync::atomic::Ordering;
204
205 let source = std::fs::read_to_string(&file.path).ok()?;
206 let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
207
208 if let Some(store) = cache
210 && let Some(cached) = store.get(&file.path, content_hash)
211 {
212 cache_hits.fetch_add(1, Ordering::Relaxed);
213 return Some(crate::cache::cached_to_module(cached, file.id));
214 }
215 cache_misses.fetch_add(1, Ordering::Relaxed);
216
217 Some(parse_source_to_module(
219 file.id,
220 &file.path,
221 &source,
222 content_hash,
223 ))
224}
225
226pub fn parse_single_file(file: &DiscoveredFile) -> Option<ModuleInfo> {
228 let source = std::fs::read_to_string(&file.path).ok()?;
229 let content_hash = xxhash_rust::xxh3::xxh3_64(source.as_bytes());
230 Some(parse_source_to_module(
231 file.id,
232 &file.path,
233 &source,
234 content_hash,
235 ))
236}
237
238static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
240 regex::Regex::new(r#"(?is)<script\b(?P<attrs>[^>]*)>(?P<body>[\s\S]*?)</script>"#)
241 .expect("valid regex")
242});
243
244static LANG_ATTR_RE: LazyLock<regex::Regex> =
246 LazyLock::new(|| regex::Regex::new(r#"lang\s*=\s*["'](\w+)["']"#).expect("valid regex"));
247
248pub(crate) struct SfcScript {
249 pub body: String,
250 pub is_typescript: bool,
251 pub byte_offset: usize,
253}
254
255pub(crate) fn extract_sfc_scripts(source: &str) -> Vec<SfcScript> {
256 SCRIPT_BLOCK_RE
257 .captures_iter(source)
258 .map(|cap| {
259 let attrs = cap.name("attrs").map(|m| m.as_str()).unwrap_or("");
260 let body_match = cap.name("body");
261 let byte_offset = body_match.map(|m| m.start()).unwrap_or(0);
262 let body = body_match.map(|m| m.as_str()).unwrap_or("").to_string();
263 let is_typescript = LANG_ATTR_RE
264 .captures(attrs)
265 .and_then(|c| c.get(1))
266 .map(|m| matches!(m.as_str(), "ts" | "tsx"))
267 .unwrap_or(false);
268 SfcScript {
269 body,
270 is_typescript,
271 byte_offset,
272 }
273 })
274 .collect()
275}
276
277pub(crate) fn is_sfc_file(path: &Path) -> bool {
278 path.extension()
279 .and_then(|e| e.to_str())
280 .is_some_and(|ext| ext == "vue" || ext == "svelte")
281}
282
283fn parse_sfc_to_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
285 let scripts = extract_sfc_scripts(source);
286
287 let suppressions = crate::suppress::parse_suppressions_from_source(source);
290
291 let mut combined = ModuleInfo {
292 file_id,
293 exports: Vec::new(),
294 imports: Vec::new(),
295 re_exports: Vec::new(),
296 dynamic_imports: Vec::new(),
297 dynamic_import_patterns: Vec::new(),
298 require_calls: Vec::new(),
299 member_accesses: Vec::new(),
300 whole_object_uses: Vec::new(),
301 has_cjs_exports: false,
302 content_hash,
303 suppressions,
304 };
305
306 for script in &scripts {
307 let source_type = if script.is_typescript {
308 SourceType::ts()
309 } else {
310 SourceType::mjs()
311 };
312 let allocator = Allocator::default();
313 let parser_return = Parser::new(&allocator, &script.body, source_type).parse();
314 let mut extractor = ModuleInfoExtractor::new();
315 extractor.visit_program(&parser_return.program);
316
317 combined.imports.extend(extractor.imports);
318 combined.exports.extend(extractor.exports);
319 combined.re_exports.extend(extractor.re_exports);
320 combined.dynamic_imports.extend(extractor.dynamic_imports);
321 combined
322 .dynamic_import_patterns
323 .extend(extractor.dynamic_import_patterns);
324 combined.require_calls.extend(extractor.require_calls);
325 combined.member_accesses.extend(extractor.member_accesses);
326 combined
327 .whole_object_uses
328 .extend(extractor.whole_object_uses);
329 combined.has_cjs_exports |= extractor.has_cjs_exports;
330 }
331
332 combined
333}
334
335fn parse_source_to_module(
337 file_id: FileId,
338 path: &Path,
339 source: &str,
340 content_hash: u64,
341) -> ModuleInfo {
342 if is_sfc_file(path) {
343 return parse_sfc_to_module(file_id, source, content_hash);
344 }
345
346 let source_type = SourceType::from_path(path).unwrap_or_default();
347 let allocator = Allocator::default();
348 let parser_return = Parser::new(&allocator, source, source_type).parse();
349
350 let suppressions = crate::suppress::parse_suppressions(&parser_return.program.comments, source);
352
353 let mut extractor = ModuleInfoExtractor::new();
355 extractor.visit_program(&parser_return.program);
356
357 let total_extracted =
360 extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
361 if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
362 let jsx_type = if source_type.is_typescript() {
363 SourceType::tsx()
364 } else {
365 SourceType::jsx()
366 };
367 let allocator2 = Allocator::default();
368 let retry_return = Parser::new(&allocator2, source, jsx_type).parse();
369 let mut retry_extractor = ModuleInfoExtractor::new();
370 retry_extractor.visit_program(&retry_return.program);
371 let retry_total = retry_extractor.exports.len()
372 + retry_extractor.imports.len()
373 + retry_extractor.re_exports.len();
374 if retry_total > total_extracted {
375 extractor = retry_extractor;
376 }
377 }
378
379 ModuleInfo {
380 file_id,
381 exports: extractor.exports,
382 imports: extractor.imports,
383 re_exports: extractor.re_exports,
384 dynamic_imports: extractor.dynamic_imports,
385 dynamic_import_patterns: extractor.dynamic_import_patterns,
386 require_calls: extractor.require_calls,
387 member_accesses: extractor.member_accesses,
388 whole_object_uses: extractor.whole_object_uses,
389 has_cjs_exports: extractor.has_cjs_exports,
390 content_hash,
391 suppressions,
392 }
393}
394
395pub fn parse_from_content(file_id: FileId, path: &Path, content: &str) -> ModuleInfo {
397 let content_hash = xxhash_rust::xxh3::xxh3_64(content.as_bytes());
398 parse_source_to_module(file_id, path, content, content_hash)
399}
400
401fn extract_class_members(class: &Class<'_>) -> Vec<MemberInfo> {
403 let mut members = Vec::new();
404 for element in &class.body.body {
405 match element {
406 ClassElement::MethodDefinition(method) => {
407 if let Some(name) = method.key.static_name() {
408 let name_str = name.to_string();
409 if name_str != "constructor"
411 && !matches!(
412 method.accessibility,
413 Some(oxc_ast::ast::TSAccessibility::Private)
414 | Some(oxc_ast::ast::TSAccessibility::Protected)
415 )
416 {
417 members.push(MemberInfo {
418 name: name_str,
419 kind: MemberKind::ClassMethod,
420 span: method.span,
421 });
422 }
423 }
424 }
425 ClassElement::PropertyDefinition(prop) => {
426 if let Some(name) = prop.key.static_name()
427 && !matches!(
428 prop.accessibility,
429 Some(oxc_ast::ast::TSAccessibility::Private)
430 | Some(oxc_ast::ast::TSAccessibility::Protected)
431 )
432 {
433 members.push(MemberInfo {
434 name: name.to_string(),
435 kind: MemberKind::ClassProperty,
436 span: prop.span,
437 });
438 }
439 }
440 _ => {}
441 }
442 }
443 members
444}
445
446fn is_meta_url_arg(arg: &Argument<'_>) -> bool {
448 if let Argument::StaticMemberExpression(member) = arg
449 && member.property.name == "url"
450 && matches!(member.object, Expression::MetaProperty(_))
451 {
452 return true;
453 }
454 false
455}
456
457struct ModuleInfoExtractor {
459 exports: Vec<ExportInfo>,
460 imports: Vec<ImportInfo>,
461 re_exports: Vec<ReExportInfo>,
462 dynamic_imports: Vec<DynamicImportInfo>,
463 dynamic_import_patterns: Vec<DynamicImportPattern>,
464 require_calls: Vec<RequireCallInfo>,
465 member_accesses: Vec<MemberAccess>,
466 whole_object_uses: Vec<String>,
467 has_cjs_exports: bool,
468 handled_require_spans: Vec<Span>,
470 handled_import_spans: Vec<Span>,
472}
473
474impl ModuleInfoExtractor {
475 fn new() -> Self {
476 Self {
477 exports: Vec::new(),
478 imports: Vec::new(),
479 re_exports: Vec::new(),
480 dynamic_imports: Vec::new(),
481 dynamic_import_patterns: Vec::new(),
482 require_calls: Vec::new(),
483 member_accesses: Vec::new(),
484 whole_object_uses: Vec::new(),
485 has_cjs_exports: false,
486 handled_require_spans: Vec::new(),
487 handled_import_spans: Vec::new(),
488 }
489 }
490
491 fn extract_declaration_exports(&mut self, decl: &Declaration<'_>, is_type_only: bool) {
492 match decl {
493 Declaration::VariableDeclaration(var) => {
494 for declarator in &var.declarations {
495 self.extract_binding_pattern_names(&declarator.id, is_type_only);
496 }
497 }
498 Declaration::FunctionDeclaration(func) => {
499 if let Some(id) = func.id.as_ref() {
500 self.exports.push(ExportInfo {
501 name: ExportName::Named(id.name.to_string()),
502 local_name: Some(id.name.to_string()),
503 is_type_only,
504 span: id.span,
505 members: vec![],
506 });
507 }
508 }
509 Declaration::ClassDeclaration(class) => {
510 if let Some(id) = class.id.as_ref() {
511 let members = extract_class_members(class);
512 self.exports.push(ExportInfo {
513 name: ExportName::Named(id.name.to_string()),
514 local_name: Some(id.name.to_string()),
515 is_type_only,
516 span: id.span,
517 members,
518 });
519 }
520 }
521 Declaration::TSTypeAliasDeclaration(alias) => {
522 self.exports.push(ExportInfo {
523 name: ExportName::Named(alias.id.name.to_string()),
524 local_name: Some(alias.id.name.to_string()),
525 is_type_only: true,
526 span: alias.id.span,
527 members: vec![],
528 });
529 }
530 Declaration::TSInterfaceDeclaration(iface) => {
531 self.exports.push(ExportInfo {
532 name: ExportName::Named(iface.id.name.to_string()),
533 local_name: Some(iface.id.name.to_string()),
534 is_type_only: true,
535 span: iface.id.span,
536 members: vec![],
537 });
538 }
539 Declaration::TSEnumDeclaration(enumd) => {
540 let members: Vec<MemberInfo> = enumd
541 .body
542 .members
543 .iter()
544 .filter_map(|member| {
545 let name = match &member.id {
546 TSEnumMemberName::Identifier(id) => id.name.to_string(),
547 TSEnumMemberName::String(s) | TSEnumMemberName::ComputedString(s) => {
548 s.value.to_string()
549 }
550 TSEnumMemberName::ComputedTemplateString(_) => return None,
551 };
552 Some(MemberInfo {
553 name,
554 kind: MemberKind::EnumMember,
555 span: member.span,
556 })
557 })
558 .collect();
559 self.exports.push(ExportInfo {
560 name: ExportName::Named(enumd.id.name.to_string()),
561 local_name: Some(enumd.id.name.to_string()),
562 is_type_only,
563 span: enumd.id.span,
564 members,
565 });
566 }
567 Declaration::TSModuleDeclaration(module) => match &module.id {
568 TSModuleDeclarationName::Identifier(id) => {
569 self.exports.push(ExportInfo {
570 name: ExportName::Named(id.name.to_string()),
571 local_name: Some(id.name.to_string()),
572 is_type_only: true,
573 span: id.span,
574 members: vec![],
575 });
576 }
577 TSModuleDeclarationName::StringLiteral(lit) => {
578 self.exports.push(ExportInfo {
579 name: ExportName::Named(lit.value.to_string()),
580 local_name: Some(lit.value.to_string()),
581 is_type_only: true,
582 span: lit.span,
583 members: vec![],
584 });
585 }
586 },
587 _ => {}
588 }
589 }
590
591 fn extract_binding_pattern_names(&mut self, pattern: &BindingPattern<'_>, is_type_only: bool) {
592 match pattern {
593 BindingPattern::BindingIdentifier(id) => {
594 self.exports.push(ExportInfo {
595 name: ExportName::Named(id.name.to_string()),
596 local_name: Some(id.name.to_string()),
597 is_type_only,
598 span: id.span,
599 members: vec![],
600 });
601 }
602 BindingPattern::ObjectPattern(obj) => {
603 for prop in &obj.properties {
604 self.extract_binding_pattern_names(&prop.value, is_type_only);
605 }
606 }
607 BindingPattern::ArrayPattern(arr) => {
608 for elem in arr.elements.iter().flatten() {
609 self.extract_binding_pattern_names(elem, is_type_only);
610 }
611 }
612 BindingPattern::AssignmentPattern(assign) => {
613 self.extract_binding_pattern_names(&assign.left, is_type_only);
614 }
615 }
616 }
617}
618
619impl<'a> Visit<'a> for ModuleInfoExtractor {
620 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
621 let source = decl.source.value.to_string();
622 let is_type_only = decl.import_kind.is_type();
623
624 if let Some(specifiers) = &decl.specifiers {
625 for spec in specifiers {
626 match spec {
627 ImportDeclarationSpecifier::ImportSpecifier(s) => {
628 self.imports.push(ImportInfo {
629 source: source.clone(),
630 imported_name: ImportedName::Named(s.imported.name().to_string()),
631 local_name: s.local.name.to_string(),
632 is_type_only: is_type_only || s.import_kind.is_type(),
633 span: s.span,
634 });
635 }
636 ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
637 self.imports.push(ImportInfo {
638 source: source.clone(),
639 imported_name: ImportedName::Default,
640 local_name: s.local.name.to_string(),
641 is_type_only,
642 span: s.span,
643 });
644 }
645 ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
646 self.imports.push(ImportInfo {
647 source: source.clone(),
648 imported_name: ImportedName::Namespace,
649 local_name: s.local.name.to_string(),
650 is_type_only,
651 span: s.span,
652 });
653 }
654 }
655 }
656 } else {
657 self.imports.push(ImportInfo {
659 source,
660 imported_name: ImportedName::SideEffect,
661 local_name: String::new(),
662 is_type_only: false,
663 span: decl.span,
664 });
665 }
666 }
667
668 fn visit_export_named_declaration(&mut self, decl: &ExportNamedDeclaration<'a>) {
669 let is_type_only = decl.export_kind.is_type();
670
671 if let Some(source) = &decl.source {
672 for spec in &decl.specifiers {
674 self.re_exports.push(ReExportInfo {
675 source: source.value.to_string(),
676 imported_name: spec.local.name().to_string(),
677 exported_name: spec.exported.name().to_string(),
678 is_type_only: is_type_only || spec.export_kind.is_type(),
679 });
680 }
681 } else {
682 if let Some(declaration) = &decl.declaration {
684 self.extract_declaration_exports(declaration, is_type_only);
685 }
686 for spec in &decl.specifiers {
687 self.exports.push(ExportInfo {
688 name: ExportName::Named(spec.exported.name().to_string()),
689 local_name: Some(spec.local.name().to_string()),
690 is_type_only: is_type_only || spec.export_kind.is_type(),
691 span: spec.span,
692 members: vec![],
693 });
694 }
695 }
696
697 walk::walk_export_named_declaration(self, decl);
698 }
699
700 fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) {
701 self.exports.push(ExportInfo {
702 name: ExportName::Default,
703 local_name: None,
704 is_type_only: false,
705 span: decl.span,
706 members: vec![],
707 });
708
709 walk::walk_export_default_declaration(self, decl);
710 }
711
712 fn visit_export_all_declaration(&mut self, decl: &ExportAllDeclaration<'a>) {
713 let exported_name = decl
714 .exported
715 .as_ref()
716 .map(|e| e.name().to_string())
717 .unwrap_or_else(|| "*".to_string());
718
719 self.re_exports.push(ReExportInfo {
720 source: decl.source.value.to_string(),
721 imported_name: "*".to_string(),
722 exported_name,
723 is_type_only: decl.export_kind.is_type(),
724 });
725
726 walk::walk_export_all_declaration(self, decl);
727 }
728
729 fn visit_import_expression(&mut self, expr: &ImportExpression<'a>) {
730 if self.handled_import_spans.contains(&expr.span) {
732 walk::walk_import_expression(self, expr);
733 return;
734 }
735
736 match &expr.source {
737 Expression::StringLiteral(lit) => {
738 self.dynamic_imports.push(DynamicImportInfo {
739 source: lit.value.to_string(),
740 span: expr.span,
741 destructured_names: Vec::new(),
742 local_name: None,
743 });
744 }
745 Expression::TemplateLiteral(tpl)
746 if !tpl.quasis.is_empty() && !tpl.expressions.is_empty() =>
747 {
748 let first_quasi = tpl.quasis[0].value.raw.to_string();
752 if first_quasi.starts_with("./") || first_quasi.starts_with("../") {
753 let prefix = if tpl.expressions.len() > 1 {
754 format!("{first_quasi}**/")
756 } else {
757 first_quasi
758 };
759 let suffix = if tpl.quasis.len() > 1 {
760 let last = &tpl.quasis[tpl.quasis.len() - 1];
761 let s = last.value.raw.to_string();
762 if s.is_empty() { None } else { Some(s) }
763 } else {
764 None
765 };
766 self.dynamic_import_patterns.push(DynamicImportPattern {
767 prefix,
768 suffix,
769 span: expr.span,
770 });
771 }
772 }
773 Expression::TemplateLiteral(tpl)
774 if !tpl.quasis.is_empty() && tpl.expressions.is_empty() =>
775 {
776 let value = tpl.quasis[0].value.raw.to_string();
778 if !value.is_empty() {
779 self.dynamic_imports.push(DynamicImportInfo {
780 source: value,
781 span: expr.span,
782 destructured_names: Vec::new(),
783 local_name: None,
784 });
785 }
786 }
787 Expression::BinaryExpression(bin)
788 if bin.operator == oxc_ast::ast::BinaryOperator::Addition =>
789 {
790 if let Some((prefix, suffix)) = extract_concat_parts(bin)
791 && (prefix.starts_with("./") || prefix.starts_with("../"))
792 {
793 self.dynamic_import_patterns.push(DynamicImportPattern {
794 prefix,
795 suffix,
796 span: expr.span,
797 });
798 }
799 }
800 _ => {}
801 }
802
803 walk::walk_import_expression(self, expr);
804 }
805
806 fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'a>) {
807 for declarator in &decl.declarations {
808 let Some(init) = &declarator.init else {
809 continue;
810 };
811
812 if let Expression::CallExpression(call) = init
814 && let Expression::Identifier(callee) = &call.callee
815 && callee.name == "require"
816 && let Some(Argument::StringLiteral(lit)) = call.arguments.first()
817 {
818 let source = lit.value.to_string();
819 match &declarator.id {
820 BindingPattern::ObjectPattern(obj_pat) => {
821 if obj_pat.rest.is_some() {
822 self.require_calls.push(RequireCallInfo {
823 source,
824 span: call.span,
825 destructured_names: Vec::new(),
826 local_name: None,
827 });
828 } else {
829 let names: Vec<String> = obj_pat
830 .properties
831 .iter()
832 .filter_map(|prop| prop.key.static_name().map(|n| n.to_string()))
833 .collect();
834 self.require_calls.push(RequireCallInfo {
835 source,
836 span: call.span,
837 destructured_names: names,
838 local_name: None,
839 });
840 }
841 self.handled_require_spans.push(call.span);
842 }
843 BindingPattern::BindingIdentifier(id) => {
844 self.require_calls.push(RequireCallInfo {
846 source,
847 span: call.span,
848 destructured_names: Vec::new(),
849 local_name: Some(id.name.to_string()),
850 });
851 self.handled_require_spans.push(call.span);
852 }
853 _ => {}
854 }
855 continue;
856 }
857
858 let import_expr = match init {
861 Expression::AwaitExpression(await_expr) => {
862 if let Expression::ImportExpression(imp) = &await_expr.argument {
863 Some(imp)
864 } else {
865 None
866 }
867 }
868 Expression::ImportExpression(imp) => Some(imp),
869 _ => None,
870 };
871
872 let Some(import_expr) = import_expr else {
873 continue;
874 };
875
876 let Expression::StringLiteral(lit) = &import_expr.source else {
877 continue;
878 };
879
880 let source = lit.value.to_string();
881
882 match &declarator.id {
883 BindingPattern::ObjectPattern(obj_pat) => {
884 if obj_pat.rest.is_some() {
886 self.dynamic_imports.push(DynamicImportInfo {
888 source,
889 span: import_expr.span,
890 destructured_names: Vec::new(),
891 local_name: None,
892 });
893 } else {
894 let names: Vec<String> = obj_pat
895 .properties
896 .iter()
897 .filter_map(|prop| prop.key.static_name().map(|n| n.to_string()))
898 .collect();
899 self.dynamic_imports.push(DynamicImportInfo {
900 source,
901 span: import_expr.span,
902 destructured_names: names,
903 local_name: None,
904 });
905 }
906 self.handled_import_spans.push(import_expr.span);
907 }
908 BindingPattern::BindingIdentifier(id) => {
909 self.dynamic_imports.push(DynamicImportInfo {
911 source,
912 span: import_expr.span,
913 destructured_names: Vec::new(),
914 local_name: Some(id.name.to_string()),
915 });
916 self.handled_import_spans.push(import_expr.span);
917 }
918 _ => {}
919 }
920 }
921 walk::walk_variable_declaration(self, decl);
922 }
923
924 fn visit_call_expression(&mut self, expr: &CallExpression<'a>) {
925 if let Expression::Identifier(ident) = &expr.callee
927 && ident.name == "require"
928 && let Some(Argument::StringLiteral(lit)) = expr.arguments.first()
929 && !self.handled_require_spans.contains(&expr.span)
930 {
931 self.require_calls.push(RequireCallInfo {
932 source: lit.value.to_string(),
933 span: expr.span,
934 destructured_names: Vec::new(),
935 local_name: None,
936 });
937 }
938
939 if let Expression::StaticMemberExpression(member) = &expr.callee
941 && let Expression::Identifier(obj) = &member.object
942 && obj.name == "Object"
943 && matches!(member.property.name.as_str(), "values" | "keys" | "entries")
944 && let Some(Argument::Identifier(arg_ident)) = expr.arguments.first()
945 {
946 self.whole_object_uses.push(arg_ident.name.to_string());
947 }
948
949 if let Expression::StaticMemberExpression(member) = &expr.callee
951 && member.property.name == "glob"
952 && matches!(member.object, Expression::MetaProperty(_))
953 && let Some(first_arg) = expr.arguments.first()
954 {
955 match first_arg {
956 Argument::StringLiteral(lit) => {
957 let s = lit.value.to_string();
958 if s.starts_with("./") || s.starts_with("../") {
959 self.dynamic_import_patterns.push(DynamicImportPattern {
960 prefix: s,
961 suffix: None,
962 span: expr.span,
963 });
964 }
965 }
966 Argument::ArrayExpression(arr) => {
967 for elem in &arr.elements {
968 if let ArrayExpressionElement::StringLiteral(lit) = elem {
969 let s = lit.value.to_string();
970 if s.starts_with("./") || s.starts_with("../") {
971 self.dynamic_import_patterns.push(DynamicImportPattern {
972 prefix: s,
973 suffix: None,
974 span: expr.span,
975 });
976 }
977 }
978 }
979 }
980 _ => {}
981 }
982 }
983
984 if let Expression::StaticMemberExpression(member) = &expr.callee
986 && member.property.name == "context"
987 && let Expression::Identifier(obj) = &member.object
988 && obj.name == "require"
989 && let Some(Argument::StringLiteral(dir_lit)) = expr.arguments.first()
990 {
991 let dir = dir_lit.value.to_string();
992 if dir.starts_with("./") || dir.starts_with("../") {
993 let recursive = expr
994 .arguments
995 .get(1)
996 .is_some_and(|arg| matches!(arg, Argument::BooleanLiteral(b) if b.value));
997 let prefix = if recursive {
998 format!("{dir}/**/")
999 } else {
1000 format!("{dir}/")
1001 };
1002 self.dynamic_import_patterns.push(DynamicImportPattern {
1003 prefix,
1004 suffix: None,
1005 span: expr.span,
1006 });
1007 }
1008 }
1009
1010 walk::walk_call_expression(self, expr);
1011 }
1012
1013 fn visit_new_expression(&mut self, expr: &oxc_ast::ast::NewExpression<'a>) {
1014 if let Expression::Identifier(callee) = &expr.callee
1018 && callee.name == "URL"
1019 && expr.arguments.len() == 2
1020 && let Some(Argument::StringLiteral(path_lit)) = expr.arguments.first()
1021 && is_meta_url_arg(&expr.arguments[1])
1022 && (path_lit.value.starts_with("./") || path_lit.value.starts_with("../"))
1023 {
1024 self.dynamic_imports.push(DynamicImportInfo {
1025 source: path_lit.value.to_string(),
1026 span: expr.span,
1027 destructured_names: Vec::new(),
1028 local_name: None,
1029 });
1030 }
1031
1032 walk::walk_new_expression(self, expr);
1033 }
1034
1035 fn visit_assignment_expression(&mut self, expr: &AssignmentExpression<'a>) {
1036 if let AssignmentTarget::StaticMemberExpression(member) = &expr.left {
1038 if let Expression::Identifier(obj) = &member.object {
1039 if obj.name == "module" && member.property.name == "exports" {
1040 self.has_cjs_exports = true;
1041 if let Expression::ObjectExpression(obj_expr) = &expr.right {
1043 for prop in &obj_expr.properties {
1044 if let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(p) = prop
1045 && let Some(name) = p.key.static_name()
1046 {
1047 self.exports.push(ExportInfo {
1048 name: ExportName::Named(name.to_string()),
1049 local_name: None,
1050 is_type_only: false,
1051 span: p.span,
1052 members: vec![],
1053 });
1054 }
1055 }
1056 }
1057 }
1058 if obj.name == "exports" {
1059 self.has_cjs_exports = true;
1060 self.exports.push(ExportInfo {
1061 name: ExportName::Named(member.property.name.to_string()),
1062 local_name: None,
1063 is_type_only: false,
1064 span: expr.span,
1065 members: vec![],
1066 });
1067 }
1068 }
1069 if matches!(member.object, Expression::ThisExpression(_)) {
1072 self.member_accesses.push(MemberAccess {
1073 object: "this".to_string(),
1074 member: member.property.name.to_string(),
1075 });
1076 }
1077 }
1078 walk::walk_assignment_expression(self, expr);
1079 }
1080
1081 fn visit_static_member_expression(&mut self, expr: &StaticMemberExpression<'a>) {
1082 if let Expression::Identifier(obj) = &expr.object {
1084 self.member_accesses.push(MemberAccess {
1085 object: obj.name.to_string(),
1086 member: expr.property.name.to_string(),
1087 });
1088 }
1089 if matches!(expr.object, Expression::ThisExpression(_)) {
1091 self.member_accesses.push(MemberAccess {
1092 object: "this".to_string(),
1093 member: expr.property.name.to_string(),
1094 });
1095 }
1096 walk::walk_static_member_expression(self, expr);
1097 }
1098
1099 fn visit_computed_member_expression(&mut self, expr: &ComputedMemberExpression<'a>) {
1100 if let Expression::Identifier(obj) = &expr.object {
1101 if let Expression::StringLiteral(lit) = &expr.expression {
1102 self.member_accesses.push(MemberAccess {
1104 object: obj.name.to_string(),
1105 member: lit.value.to_string(),
1106 });
1107 } else {
1108 self.whole_object_uses.push(obj.name.to_string());
1110 }
1111 }
1112 walk::walk_computed_member_expression(self, expr);
1113 }
1114
1115 fn visit_for_in_statement(&mut self, stmt: &ForInStatement<'a>) {
1116 if let Expression::Identifier(ident) = &stmt.right {
1117 self.whole_object_uses.push(ident.name.to_string());
1118 }
1119 walk::walk_for_in_statement(self, stmt);
1120 }
1121
1122 fn visit_spread_element(&mut self, elem: &SpreadElement<'a>) {
1123 if let Expression::Identifier(ident) = &elem.argument {
1124 self.whole_object_uses.push(ident.name.to_string());
1125 }
1126 walk::walk_spread_element(self, elem);
1127 }
1128}
1129
1130fn extract_concat_parts(expr: &BinaryExpression<'_>) -> Option<(String, Option<String>)> {
1132 let prefix = extract_leading_string(&expr.left)?;
1133 let suffix = extract_trailing_string(&expr.right);
1134 Some((prefix, suffix))
1135}
1136
1137fn extract_leading_string(expr: &Expression<'_>) -> Option<String> {
1138 match expr {
1139 Expression::StringLiteral(lit) => Some(lit.value.to_string()),
1140 Expression::BinaryExpression(bin)
1141 if bin.operator == oxc_ast::ast::BinaryOperator::Addition =>
1142 {
1143 extract_leading_string(&bin.left)
1144 }
1145 _ => None,
1146 }
1147}
1148
1149fn extract_trailing_string(expr: &Expression<'_>) -> Option<String> {
1150 match expr {
1151 Expression::StringLiteral(lit) => {
1152 let s = lit.value.to_string();
1153 if s.is_empty() { None } else { Some(s) }
1154 }
1155 _ => None,
1156 }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 fn parse_source(source: &str) -> ModuleInfo {
1164 parse_source_to_module(FileId(0), Path::new("test.ts"), source, 0)
1165 }
1166
1167 #[test]
1168 fn extracts_named_exports() {
1169 let info = parse_source("export const foo = 1; export function bar() {}");
1170 assert_eq!(info.exports.len(), 2);
1171 assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1172 assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
1173 }
1174
1175 #[test]
1176 fn extracts_default_export() {
1177 let info = parse_source("export default function main() {}");
1178 assert_eq!(info.exports.len(), 1);
1179 assert_eq!(info.exports[0].name, ExportName::Default);
1180 }
1181
1182 #[test]
1183 fn extracts_named_imports() {
1184 let info = parse_source("import { foo, bar } from './utils';");
1185 assert_eq!(info.imports.len(), 2);
1186 assert_eq!(
1187 info.imports[0].imported_name,
1188 ImportedName::Named("foo".to_string())
1189 );
1190 assert_eq!(info.imports[0].source, "./utils");
1191 }
1192
1193 #[test]
1194 fn extracts_namespace_import() {
1195 let info = parse_source("import * as utils from './utils';");
1196 assert_eq!(info.imports.len(), 1);
1197 assert_eq!(info.imports[0].imported_name, ImportedName::Namespace);
1198 }
1199
1200 #[test]
1201 fn extracts_side_effect_import() {
1202 let info = parse_source("import './styles.css';");
1203 assert_eq!(info.imports.len(), 1);
1204 assert_eq!(info.imports[0].imported_name, ImportedName::SideEffect);
1205 }
1206
1207 #[test]
1208 fn extracts_re_exports() {
1209 let info = parse_source("export { foo, bar as baz } from './module';");
1210 assert_eq!(info.re_exports.len(), 2);
1211 assert_eq!(info.re_exports[0].imported_name, "foo");
1212 assert_eq!(info.re_exports[0].exported_name, "foo");
1213 assert_eq!(info.re_exports[1].imported_name, "bar");
1214 assert_eq!(info.re_exports[1].exported_name, "baz");
1215 }
1216
1217 #[test]
1218 fn extracts_star_re_export() {
1219 let info = parse_source("export * from './module';");
1220 assert_eq!(info.re_exports.len(), 1);
1221 assert_eq!(info.re_exports[0].imported_name, "*");
1222 assert_eq!(info.re_exports[0].exported_name, "*");
1223 }
1224
1225 #[test]
1226 fn extracts_dynamic_import() {
1227 let info = parse_source("const mod = import('./lazy');");
1228 assert_eq!(info.dynamic_imports.len(), 1);
1229 assert_eq!(info.dynamic_imports[0].source, "./lazy");
1230 }
1231
1232 #[test]
1233 fn extracts_require_call() {
1234 let info = parse_source("const fs = require('fs');");
1235 assert_eq!(info.require_calls.len(), 1);
1236 assert_eq!(info.require_calls[0].source, "fs");
1237 }
1238
1239 #[test]
1240 fn extracts_type_exports() {
1241 let info = parse_source("export type Foo = string; export interface Bar { x: number; }");
1242 assert_eq!(info.exports.len(), 2);
1243 assert!(info.exports[0].is_type_only);
1244 assert!(info.exports[1].is_type_only);
1245 }
1246
1247 #[test]
1248 fn extracts_type_only_imports() {
1249 let info = parse_source("import type { Foo } from './types';");
1250 assert_eq!(info.imports.len(), 1);
1251 assert!(info.imports[0].is_type_only);
1252 }
1253
1254 #[test]
1255 fn detects_cjs_module_exports() {
1256 let info = parse_source("module.exports = { foo: 1 };");
1257 assert!(info.has_cjs_exports);
1258 }
1259
1260 #[test]
1261 fn detects_cjs_exports_property() {
1262 let info = parse_source("exports.foo = 42;");
1263 assert!(info.has_cjs_exports);
1264 assert_eq!(info.exports.len(), 1);
1265 assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1266 }
1267
1268 #[test]
1269 fn extracts_static_member_accesses() {
1270 let info = parse_source(
1271 "import { Status, MyClass } from './types';\nconsole.log(Status.Active);\nMyClass.create();",
1272 );
1273 assert!(info.member_accesses.len() >= 2);
1275 let has_status_active = info
1276 .member_accesses
1277 .iter()
1278 .any(|a| a.object == "Status" && a.member == "Active");
1279 let has_myclass_create = info
1280 .member_accesses
1281 .iter()
1282 .any(|a| a.object == "MyClass" && a.member == "create");
1283 assert!(has_status_active, "Should capture Status.Active");
1284 assert!(has_myclass_create, "Should capture MyClass.create");
1285 }
1286
1287 #[test]
1288 fn extracts_default_import() {
1289 let info = parse_source("import React from 'react';");
1290 assert_eq!(info.imports.len(), 1);
1291 assert_eq!(info.imports[0].imported_name, ImportedName::Default);
1292 assert_eq!(info.imports[0].local_name, "React");
1293 assert_eq!(info.imports[0].source, "react");
1294 }
1295
1296 #[test]
1297 fn extracts_mixed_import_default_and_named() {
1298 let info = parse_source("import React, { useState, useEffect } from 'react';");
1299 assert_eq!(info.imports.len(), 3);
1300 assert_eq!(info.imports[0].imported_name, ImportedName::Default);
1302 assert_eq!(info.imports[0].local_name, "React");
1303 assert_eq!(
1304 info.imports[1].imported_name,
1305 ImportedName::Named("useState".to_string())
1306 );
1307 assert_eq!(
1308 info.imports[2].imported_name,
1309 ImportedName::Named("useEffect".to_string())
1310 );
1311 }
1312
1313 #[test]
1314 fn extracts_import_with_alias() {
1315 let info = parse_source("import { foo as bar } from './utils';");
1316 assert_eq!(info.imports.len(), 1);
1317 assert_eq!(
1318 info.imports[0].imported_name,
1319 ImportedName::Named("foo".to_string())
1320 );
1321 assert_eq!(info.imports[0].local_name, "bar");
1322 }
1323
1324 #[test]
1325 fn extracts_export_specifier_list() {
1326 let info = parse_source("const foo = 1; const bar = 2; export { foo, bar };");
1327 assert_eq!(info.exports.len(), 2);
1328 assert_eq!(info.exports[0].name, ExportName::Named("foo".to_string()));
1329 assert_eq!(info.exports[1].name, ExportName::Named("bar".to_string()));
1330 }
1331
1332 #[test]
1333 fn extracts_export_with_alias() {
1334 let info = parse_source("const foo = 1; export { foo as myFoo };");
1335 assert_eq!(info.exports.len(), 1);
1336 assert_eq!(info.exports[0].name, ExportName::Named("myFoo".to_string()));
1337 }
1338
1339 #[test]
1340 fn extracts_star_re_export_with_alias() {
1341 let info = parse_source("export * as utils from './utils';");
1342 assert_eq!(info.re_exports.len(), 1);
1343 assert_eq!(info.re_exports[0].imported_name, "*");
1344 assert_eq!(info.re_exports[0].exported_name, "utils");
1345 }
1346
1347 #[test]
1348 fn extracts_export_class_declaration() {
1349 let info = parse_source("export class MyService { name: string = ''; }");
1350 assert_eq!(info.exports.len(), 1);
1351 assert_eq!(
1352 info.exports[0].name,
1353 ExportName::Named("MyService".to_string())
1354 );
1355 }
1356
1357 #[test]
1358 fn class_constructor_is_excluded() {
1359 let info = parse_source("export class Foo { constructor() {} greet() {} }");
1360 assert_eq!(info.exports.len(), 1);
1361 let members: Vec<&str> = info.exports[0]
1363 .members
1364 .iter()
1365 .map(|m| m.name.as_str())
1366 .collect();
1367 assert!(
1368 !members.contains(&"constructor"),
1369 "constructor should be excluded from members"
1370 );
1371 assert!(members.contains(&"greet"), "greet should be included");
1372 }
1373
1374 #[test]
1375 fn extracts_ts_enum_declaration() {
1376 let info = parse_source("export enum Direction { Up, Down, Left, Right }");
1377 assert_eq!(info.exports.len(), 1);
1378 assert_eq!(
1379 info.exports[0].name,
1380 ExportName::Named("Direction".to_string())
1381 );
1382 assert_eq!(info.exports[0].members.len(), 4);
1383 assert_eq!(info.exports[0].members[0].kind, MemberKind::EnumMember);
1384 }
1385
1386 #[test]
1387 fn extracts_ts_module_declaration() {
1388 let info = parse_source("export declare module 'my-module' {}");
1389 assert_eq!(info.exports.len(), 1);
1390 assert!(info.exports[0].is_type_only);
1391 }
1392
1393 #[test]
1394 fn extracts_type_only_named_import() {
1395 let info = parse_source("import { type Foo, Bar } from './types';");
1396 assert_eq!(info.imports.len(), 2);
1397 assert!(info.imports[0].is_type_only);
1398 assert!(!info.imports[1].is_type_only);
1399 }
1400
1401 #[test]
1402 fn extracts_type_re_export() {
1403 let info = parse_source("export type { Foo } from './types';");
1404 assert_eq!(info.re_exports.len(), 1);
1405 assert!(info.re_exports[0].is_type_only);
1406 }
1407
1408 #[test]
1409 fn extracts_destructured_array_export() {
1410 let info = parse_source("export const [first, second] = [1, 2];");
1411 assert_eq!(info.exports.len(), 2);
1412 assert_eq!(info.exports[0].name, ExportName::Named("first".to_string()));
1413 assert_eq!(
1414 info.exports[1].name,
1415 ExportName::Named("second".to_string())
1416 );
1417 }
1418
1419 #[test]
1420 fn extracts_nested_destructured_export() {
1421 let info = parse_source("export const { a, b: { c } } = obj;");
1422 assert_eq!(info.exports.len(), 2);
1423 assert_eq!(info.exports[0].name, ExportName::Named("a".to_string()));
1424 assert_eq!(info.exports[1].name, ExportName::Named("c".to_string()));
1425 }
1426
1427 #[test]
1428 fn extracts_default_export_function_expression() {
1429 let info = parse_source("export default function() { return 42; }");
1430 assert_eq!(info.exports.len(), 1);
1431 assert_eq!(info.exports[0].name, ExportName::Default);
1432 }
1433
1434 #[test]
1435 fn export_name_display() {
1436 assert_eq!(ExportName::Named("foo".to_string()).to_string(), "foo");
1437 assert_eq!(ExportName::Default.to_string(), "default");
1438 }
1439
1440 #[test]
1441 fn no_exports_no_imports() {
1442 let info = parse_source("const x = 1; console.log(x);");
1443 assert!(info.exports.is_empty());
1444 assert!(info.imports.is_empty());
1445 assert!(info.re_exports.is_empty());
1446 assert!(!info.has_cjs_exports);
1447 }
1448
1449 #[test]
1450 fn dynamic_import_non_string_ignored() {
1451 let info = parse_source("const mod = import(variable);");
1452 assert_eq!(info.dynamic_imports.len(), 0);
1454 }
1455
1456 #[test]
1457 fn multiple_require_calls() {
1458 let info =
1459 parse_source("const a = require('a'); const b = require('b'); const c = require('c');");
1460 assert_eq!(info.require_calls.len(), 3);
1461 }
1462
1463 #[test]
1464 fn extracts_ts_interface() {
1465 let info = parse_source("export interface Props { name: string; age: number; }");
1466 assert_eq!(info.exports.len(), 1);
1467 assert_eq!(info.exports[0].name, ExportName::Named("Props".to_string()));
1468 assert!(info.exports[0].is_type_only);
1469 }
1470
1471 #[test]
1472 fn extracts_ts_type_alias() {
1473 let info = parse_source("export type ID = string | number;");
1474 assert_eq!(info.exports.len(), 1);
1475 assert_eq!(info.exports[0].name, ExportName::Named("ID".to_string()));
1476 assert!(info.exports[0].is_type_only);
1477 }
1478
1479 #[test]
1480 fn extracts_member_accesses_inside_exported_functions() {
1481 let info = parse_source(
1482 "import { Color } from './types';\nexport const isRed = (c: Color) => c === Color.Red;",
1483 );
1484 let has_color_red = info
1485 .member_accesses
1486 .iter()
1487 .any(|a| a.object == "Color" && a.member == "Red");
1488 assert!(
1489 has_color_red,
1490 "Should capture Color.Red inside exported function body"
1491 );
1492 }
1493
1494 #[test]
1497 fn detects_object_values_whole_use() {
1498 let info = parse_source("import { Status } from './types';\nObject.values(Status);");
1499 assert!(info.whole_object_uses.contains(&"Status".to_string()));
1500 }
1501
1502 #[test]
1503 fn detects_object_keys_whole_use() {
1504 let info = parse_source("import { Dir } from './types';\nObject.keys(Dir);");
1505 assert!(info.whole_object_uses.contains(&"Dir".to_string()));
1506 }
1507
1508 #[test]
1509 fn detects_object_entries_whole_use() {
1510 let info = parse_source("import { E } from './types';\nObject.entries(E);");
1511 assert!(info.whole_object_uses.contains(&"E".to_string()));
1512 }
1513
1514 #[test]
1515 fn detects_for_in_whole_use() {
1516 let info = parse_source("import { Color } from './types';\nfor (const k in Color) {}");
1517 assert!(info.whole_object_uses.contains(&"Color".to_string()));
1518 }
1519
1520 #[test]
1521 fn detects_spread_whole_use() {
1522 let info = parse_source("import { X } from './types';\nconst y = { ...X };");
1523 assert!(info.whole_object_uses.contains(&"X".to_string()));
1524 }
1525
1526 #[test]
1527 fn computed_member_string_literal_resolves() {
1528 let info = parse_source("import { Status } from './types';\nStatus[\"Active\"];");
1529 let has_access = info
1530 .member_accesses
1531 .iter()
1532 .any(|a| a.object == "Status" && a.member == "Active");
1533 assert!(
1534 has_access,
1535 "Status[\"Active\"] should resolve to a static member access"
1536 );
1537 }
1538
1539 #[test]
1540 fn computed_member_variable_marks_whole_use() {
1541 let info = parse_source("import { Status } from './types';\nconst k = 'foo';\nStatus[k];");
1542 assert!(info.whole_object_uses.contains(&"Status".to_string()));
1543 }
1544
1545 #[test]
1548 fn extracts_template_literal_dynamic_import_pattern() {
1549 let info = parse_source("const m = import(`./locales/${lang}.json`);");
1550 assert_eq!(info.dynamic_import_patterns.len(), 1);
1551 assert_eq!(info.dynamic_import_patterns[0].prefix, "./locales/");
1552 assert_eq!(
1553 info.dynamic_import_patterns[0].suffix,
1554 Some(".json".to_string())
1555 );
1556 }
1557
1558 #[test]
1559 fn extracts_concat_dynamic_import_pattern() {
1560 let info = parse_source("const m = import('./pages/' + name);");
1561 assert_eq!(info.dynamic_import_patterns.len(), 1);
1562 assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
1563 assert!(info.dynamic_import_patterns[0].suffix.is_none());
1564 }
1565
1566 #[test]
1567 fn extracts_concat_with_suffix() {
1568 let info = parse_source("const m = import('./pages/' + name + '.tsx');");
1569 assert_eq!(info.dynamic_import_patterns.len(), 1);
1570 assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/");
1571 assert_eq!(
1572 info.dynamic_import_patterns[0].suffix,
1573 Some(".tsx".to_string())
1574 );
1575 }
1576
1577 #[test]
1578 fn no_substitution_template_treated_as_exact() {
1579 let info = parse_source("const m = import(`./exact-module`);");
1580 assert_eq!(info.dynamic_imports.len(), 1);
1581 assert_eq!(info.dynamic_imports[0].source, "./exact-module");
1582 assert!(info.dynamic_import_patterns.is_empty());
1583 }
1584
1585 #[test]
1586 fn fully_dynamic_import_still_ignored() {
1587 let info = parse_source("const m = import(variable);");
1588 assert!(info.dynamic_imports.is_empty());
1589 assert!(info.dynamic_import_patterns.is_empty());
1590 }
1591
1592 #[test]
1593 fn non_relative_template_ignored() {
1594 let info = parse_source("const m = import(`lodash/${fn}`);");
1595 assert!(info.dynamic_import_patterns.is_empty());
1596 }
1597
1598 #[test]
1599 fn multi_expression_template_uses_globstar() {
1600 let info = parse_source("const m = import(`./plugins/${cat}/${name}.js`);");
1602 assert_eq!(info.dynamic_import_patterns.len(), 1);
1603 assert_eq!(info.dynamic_import_patterns[0].prefix, "./plugins/**/");
1604 assert_eq!(
1605 info.dynamic_import_patterns[0].suffix,
1606 Some(".js".to_string())
1607 );
1608 }
1609
1610 fn parse_sfc(source: &str, filename: &str) -> ModuleInfo {
1613 parse_source_to_module(FileId(0), Path::new(filename), source, 0)
1614 }
1615
1616 #[test]
1617 fn extracts_vue_script_imports() {
1618 let info = parse_sfc(
1619 r#"
1620<script lang="ts">
1621import { ref } from 'vue';
1622import { helper } from './utils';
1623export default {};
1624</script>
1625<template><div></div></template>
1626"#,
1627 "App.vue",
1628 );
1629 assert_eq!(info.imports.len(), 2);
1630 assert!(info.imports.iter().any(|i| i.source == "vue"));
1631 assert!(info.imports.iter().any(|i| i.source == "./utils"));
1632 }
1633
1634 #[test]
1635 fn extracts_vue_script_setup_imports() {
1636 let info = parse_sfc(
1637 r#"
1638<script setup lang="ts">
1639import { ref } from 'vue';
1640const count = ref(0);
1641</script>
1642"#,
1643 "Comp.vue",
1644 );
1645 assert_eq!(info.imports.len(), 1);
1646 assert_eq!(info.imports[0].source, "vue");
1647 }
1648
1649 #[test]
1650 fn extracts_vue_both_scripts() {
1651 let info = parse_sfc(
1652 r#"
1653<script lang="ts">
1654import { defineComponent } from 'vue';
1655export default defineComponent({});
1656</script>
1657<script setup lang="ts">
1658import { ref } from 'vue';
1659const count = ref(0);
1660</script>
1661"#,
1662 "Dual.vue",
1663 );
1664 assert!(info.imports.len() >= 2);
1665 }
1666
1667 #[test]
1668 fn extracts_svelte_script_imports() {
1669 let info = parse_sfc(
1670 r#"
1671<script lang="ts">
1672import { onMount } from 'svelte';
1673import { helper } from './utils';
1674</script>
1675<p>Hello</p>
1676"#,
1677 "App.svelte",
1678 );
1679 assert_eq!(info.imports.len(), 2);
1680 assert!(info.imports.iter().any(|i| i.source == "svelte"));
1681 assert!(info.imports.iter().any(|i| i.source == "./utils"));
1682 }
1683
1684 #[test]
1685 fn vue_no_script_returns_empty() {
1686 let info = parse_sfc(
1687 "<template><div></div></template><style>div {}</style>",
1688 "NoScript.vue",
1689 );
1690 assert!(info.imports.is_empty());
1691 assert!(info.exports.is_empty());
1692 }
1693
1694 #[test]
1695 fn vue_js_default_lang() {
1696 let info = parse_sfc(
1697 r#"
1698<script>
1699import { createApp } from 'vue';
1700export default {};
1701</script>
1702"#,
1703 "JsVue.vue",
1704 );
1705 assert_eq!(info.imports.len(), 1);
1706 }
1707
1708 #[test]
1711 fn extracts_import_meta_glob_pattern() {
1712 let info = parse_source("const mods = import.meta.glob('./components/*.tsx');");
1713 assert_eq!(info.dynamic_import_patterns.len(), 1);
1714 assert_eq!(info.dynamic_import_patterns[0].prefix, "./components/*.tsx");
1715 }
1716
1717 #[test]
1718 fn extracts_import_meta_glob_array() {
1719 let info =
1720 parse_source("const mods = import.meta.glob(['./pages/*.ts', './layouts/*.ts']);");
1721 assert_eq!(info.dynamic_import_patterns.len(), 2);
1722 assert_eq!(info.dynamic_import_patterns[0].prefix, "./pages/*.ts");
1723 assert_eq!(info.dynamic_import_patterns[1].prefix, "./layouts/*.ts");
1724 }
1725
1726 #[test]
1727 fn extracts_require_context_pattern() {
1728 let info = parse_source("const ctx = require.context('./icons', false);");
1729 assert_eq!(info.dynamic_import_patterns.len(), 1);
1730 assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/");
1731 }
1732
1733 #[test]
1734 fn extracts_require_context_recursive() {
1735 let info = parse_source("const ctx = require.context('./icons', true);");
1736 assert_eq!(info.dynamic_import_patterns.len(), 1);
1737 assert_eq!(info.dynamic_import_patterns[0].prefix, "./icons/**/");
1738 }
1739
1740 #[test]
1743 fn dynamic_import_await_captures_local_name() {
1744 let info = parse_source(
1745 "async function f() { const mod = await import('./service'); mod.doStuff(); }",
1746 );
1747 assert_eq!(info.dynamic_imports.len(), 1);
1748 assert_eq!(info.dynamic_imports[0].source, "./service");
1749 assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1750 assert!(info.dynamic_imports[0].destructured_names.is_empty());
1751 }
1752
1753 #[test]
1754 fn dynamic_import_without_await_captures_local_name() {
1755 let info = parse_source("const mod = import('./service');");
1757 assert_eq!(info.dynamic_imports.len(), 1);
1758 assert_eq!(info.dynamic_imports[0].source, "./service");
1759 assert_eq!(info.dynamic_imports[0].local_name, Some("mod".to_string()));
1760 }
1761
1762 #[test]
1763 fn dynamic_import_destructured_captures_names() {
1764 let info =
1765 parse_source("async function f() { const { foo, bar } = await import('./module'); }");
1766 assert_eq!(info.dynamic_imports.len(), 1);
1767 assert_eq!(info.dynamic_imports[0].source, "./module");
1768 assert!(info.dynamic_imports[0].local_name.is_none());
1769 assert_eq!(
1770 info.dynamic_imports[0].destructured_names,
1771 vec!["foo", "bar"]
1772 );
1773 }
1774
1775 #[test]
1776 fn dynamic_import_destructured_with_rest_is_namespace() {
1777 let info = parse_source(
1778 "async function f() { const { foo, ...rest } = await import('./module'); }",
1779 );
1780 assert_eq!(info.dynamic_imports.len(), 1);
1781 assert_eq!(info.dynamic_imports[0].source, "./module");
1782 assert!(info.dynamic_imports[0].local_name.is_none());
1784 assert!(info.dynamic_imports[0].destructured_names.is_empty());
1785 }
1786
1787 #[test]
1788 fn dynamic_import_side_effect_only() {
1789 let info = parse_source("async function f() { await import('./side-effect'); }");
1791 assert_eq!(info.dynamic_imports.len(), 1);
1792 assert_eq!(info.dynamic_imports[0].source, "./side-effect");
1793 assert!(info.dynamic_imports[0].local_name.is_none());
1794 assert!(info.dynamic_imports[0].destructured_names.is_empty());
1795 }
1796
1797 #[test]
1798 fn dynamic_import_no_duplicate_entries() {
1799 let info = parse_source("async function f() { const mod = await import('./service'); }");
1802 assert_eq!(info.dynamic_imports.len(), 1);
1803 }
1804}