1use std::path::Path;
11
12use fallow_types::discover::FileId;
13use fallow_types::extract::{ExportName, ImportedName};
14use fallow_types::trace::{SearchGapReason, SearchVisibility};
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use crate::graph::{ExportNamespace, ModuleGraph, ReferenceKind, SymbolReference};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SearchCheck {
22 pub visibility: SearchVisibility,
24 pub reason: Option<SearchGapReason>,
26 pub search_as: Option<String>,
28 pub line: Option<u32>,
30}
31
32struct Source {
34 text: String,
35 offsets: Vec<u32>,
36}
37
38pub(crate) struct SourceCache<'g> {
40 graph: &'g ModuleGraph,
41 sources: FxHashMap<FileId, Option<Source>>,
42 forwarded: FxHashMap<FileId, FxHashSet<String>>,
43}
44
45impl<'g> SourceCache<'g> {
46 pub(crate) fn new(graph: &'g ModuleGraph) -> Self {
47 Self {
48 graph,
49 sources: FxHashMap::default(),
50 forwarded: FxHashMap::default(),
51 }
52 }
53
54 fn only_forwards(&mut self, file_id: FileId, local: &str) -> bool {
58 if !self.forwarded.contains_key(&file_id)
59 && !self
60 .get(file_id)
61 .is_some_and(|source| may_export_binding(&source.text, local))
62 {
63 return false;
64 }
65 if !self.forwarded.contains_key(&file_id) {
66 let path = self
67 .graph
68 .modules
69 .get(file_id.0 as usize)
70 .map(|module| module.path.clone());
71 let names = match (path, self.get(file_id)) {
72 (Some(path), Some(source)) => forwarded_import_bindings(&path, &source.text),
73 _ => FxHashSet::default(),
74 };
75 self.forwarded.insert(file_id, names);
76 }
77 self.forwarded
78 .get(&file_id)
79 .is_some_and(|names| names.contains(local))
80 }
81
82 fn get(&mut self, file_id: FileId) -> Option<&Source> {
83 let graph = self.graph;
84 self.sources
85 .entry(file_id)
86 .or_insert_with(|| {
87 let module = graph.modules.get(file_id.0 as usize)?;
88 let text = std::fs::read_to_string(&module.path).ok()?;
89 let offsets = fallow_types::extract::compute_line_offsets(&text);
90 Some(Source { text, offsets })
91 })
92 .as_ref()
93 }
94}
95
96pub(crate) struct SearchTarget {
99 term: Option<String>,
100 exposures: FxHashMap<FileId, Vec<String>>,
101}
102
103impl SearchTarget {
104 pub(crate) fn new(
111 graph: &ModuleGraph,
112 sources: &mut SourceCache<'_>,
113 file_id: FileId,
114 export_name: &str,
115 namespace: ExportNamespace,
116 ) -> Self {
117 let term = if export_name == "default" {
118 default_export_term(graph, sources, file_id)
119 } else {
120 Some(export_name.to_string())
121 };
122 let mut exposures: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
123 exposures
124 .entry(file_id)
125 .or_default()
126 .push(export_name.to_string());
127 for route in graph.effective_re_export_routes(file_id, export_name, namespace) {
128 let names = exposures.entry(route.barrel_file()).or_default();
129 if !names.iter().any(|name| name == route.exported_name()) {
130 names.push(route.exported_name().to_string());
131 }
132 }
133 Self { term, exposures }
134 }
135
136 pub(crate) fn check(
139 &self,
140 graph: &ModuleGraph,
141 sources: &mut SourceCache<'_>,
142 reference: &SymbolReference,
143 ) -> Option<SearchCheck> {
144 let source = sources.get(reference.from_file)?;
145 let found = self
146 .term
147 .as_deref()
148 .is_some_and(|term| contains_whole_word(&source.text, term));
149 let line = (reference.import_span.end > reference.import_span.start).then(|| {
150 fallow_types::extract::byte_offset_to_line_col(
151 &source.offsets,
152 reference.import_span.start,
153 )
154 .0
155 });
156 let binding = self.binding(graph, reference);
157 let forwards = match &binding {
158 Some(Binding::ReExport) => true,
159 Some(Binding::Named { imported, local }) => {
160 let name = if local.is_empty() { imported } else { local };
161 sources.only_forwards(reference.from_file, name)
162 }
163 Some(Binding::Default { local }) => {
164 !local.is_empty() && sources.only_forwards(reference.from_file, local)
165 }
166 Some(Binding::Namespace { .. }) | None => false,
169 };
170 let (visibility, reason, search_as) = if forwards {
171 (
172 SearchVisibility::Found,
173 Some(SearchGapReason::PassThrough),
174 None,
175 )
176 } else if found {
177 self.classify_found(binding.as_ref())
178 } else {
179 self.classify_missed(binding.as_ref(), reference.kind)
180 };
181 Some(SearchCheck {
182 visibility,
183 reason,
184 search_as,
185 line,
186 })
187 }
188
189 fn is_term(&self, name: &str) -> bool {
190 self.term.as_deref() == Some(name)
191 }
192
193 fn classify_found(&self, binding: Option<&Binding<'_>>) -> Classification {
194 match binding {
195 Some(Binding::Named { imported, local })
196 if !local.is_empty() && !self.is_term(local) =>
197 {
198 let reason = if self.is_term(imported) {
199 SearchGapReason::ImportAlias
200 } else {
201 SearchGapReason::ReExportAlias
202 };
203 renamed(reason, local)
204 }
205 Some(Binding::Default { local }) if !local.is_empty() && !self.is_term(local) => {
206 renamed(SearchGapReason::DefaultImport, local)
207 }
208 _ => (SearchVisibility::Found, None, None),
209 }
210 }
211
212 fn classify_missed(
213 &self,
214 binding: Option<&Binding<'_>>,
215 kind: ReferenceKind,
216 ) -> Classification {
217 let (reason, search_as) = match binding {
218 _ if kind == ReferenceKind::DynamicImport => (SearchGapReason::DynamicImport, None),
219 Some(Binding::Named { imported, local }) => {
220 let reason = if self.is_term(imported) {
221 SearchGapReason::ImportAlias
222 } else {
223 SearchGapReason::ReExportAlias
224 };
225 let name = if local.is_empty() { imported } else { local };
226 (reason, Some((*name).to_string()))
227 }
228 Some(Binding::Default { local }) => (SearchGapReason::DefaultImport, non_empty(local)),
229 Some(Binding::Namespace { local }) => (SearchGapReason::NamespaceUse, non_empty(local)),
230 Some(Binding::ReExport) | None => (reason_for_kind(kind), None),
231 };
232 (SearchVisibility::Missed, Some(reason), search_as)
233 }
234
235 fn binding<'g>(
242 &self,
243 graph: &'g ModuleGraph,
244 reference: &SymbolReference,
245 ) -> Option<Binding<'g>> {
246 self.import_binding(graph, reference.from_file, Some(reference.import_span))
247 .or_else(|| self.import_binding(graph, reference.from_file, None))
248 .or_else(|| self.re_export_binding(graph, reference))
249 }
250
251 fn import_binding<'g>(
254 &self,
255 graph: &'g ModuleGraph,
256 from_file: FileId,
257 span: Option<oxc_span::Span>,
258 ) -> Option<Binding<'g>> {
259 let mut namespace_binding = None;
260 for (target, symbols) in graph.outgoing_symbol_edges(from_file) {
261 let Some(names) = self.exposures.get(&target) else {
262 continue;
263 };
264 for symbol in symbols
265 .iter()
266 .filter(|symbol| span.is_none_or(|span| symbol.import_span == span))
267 {
268 match &symbol.imported_name {
269 ImportedName::Named(imported) if names.contains(imported) => {
270 return Some(Binding::Named {
271 imported,
272 local: &symbol.local_name,
273 });
274 }
275 ImportedName::Default if names.iter().any(|name| name == "default") => {
276 return Some(Binding::Default {
277 local: &symbol.local_name,
278 });
279 }
280 ImportedName::Namespace => {
281 namespace_binding.get_or_insert(Binding::Namespace {
282 local: &symbol.local_name,
283 });
284 }
285 _ => {}
286 }
287 }
288 }
289 namespace_binding
290 }
291
292 fn re_export_binding<'g>(
293 &self,
294 graph: &'g ModuleGraph,
295 reference: &SymbolReference,
296 ) -> Option<Binding<'g>> {
297 let module = graph.modules.get(reference.from_file.0 as usize)?;
298 let forwards = |re_export: &&crate::graph::ReExportEdge| {
299 self.exposures
300 .get(&re_export.source_file)
301 .is_some_and(|names| {
302 re_export.imported_name == "*" || names.contains(&re_export.imported_name)
303 })
304 };
305 module
306 .re_exports
307 .iter()
308 .any(|re_export| forwards(&re_export))
309 .then_some(Binding::ReExport)
310 }
311}
312
313type Classification = (SearchVisibility, Option<SearchGapReason>, Option<String>);
314
315fn renamed(reason: SearchGapReason, local: &str) -> Classification {
316 (
317 SearchVisibility::Renamed,
318 Some(reason),
319 Some(local.to_string()),
320 )
321}
322
323fn non_empty(name: &str) -> Option<String> {
324 (!name.is_empty()).then(|| name.to_string())
325}
326
327const fn reason_for_kind(kind: ReferenceKind) -> SearchGapReason {
328 match kind {
329 ReferenceKind::DefaultImport => SearchGapReason::DefaultImport,
330 ReferenceKind::NamespaceImport => SearchGapReason::NamespaceUse,
331 ReferenceKind::DynamicImport | ReferenceKind::SideEffectImport => {
332 SearchGapReason::DynamicImport
333 }
334 ReferenceKind::NamedImport | ReferenceKind::ReExport => SearchGapReason::ReExportAlias,
335 }
336}
337
338enum Binding<'g> {
340 Named {
341 imported: &'g str,
342 local: &'g str,
343 },
344 Default {
345 local: &'g str,
346 },
347 Namespace {
348 local: &'g str,
349 },
350 ReExport,
352}
353
354fn may_export_binding(source: &str, local: &str) -> bool {
363 if source.contains("\\u") {
364 return true;
365 }
366 let bytes = source.as_bytes();
367 for (start, keyword) in source.match_indices("export") {
368 let end = start + keyword.len();
369 if start
372 .checked_sub(1)
373 .is_some_and(|index| is_ascii_identifier_byte(bytes[index]))
374 || bytes
375 .get(end)
376 .copied()
377 .is_some_and(is_ascii_identifier_byte)
378 {
379 continue;
380 }
381 let mut rest = trim_script_whitespace(&source[end..]);
382 if let Some(after_type) = strip_keyword(rest, "type") {
383 rest = trim_script_whitespace(after_type);
384 }
385 if rest.starts_with('/') {
386 return true;
387 }
388 let forwarded_expression = strip_keyword(rest, "default")
389 .or_else(|| rest.strip_prefix('=').map(trim_script_whitespace));
390 if forwarded_expression.is_some_and(|expression| {
391 expression.starts_with('/') || identifier_prefix(expression) == local
392 }) {
393 return true;
394 }
395 let Some(list) = rest.strip_prefix('{') else {
396 continue;
397 };
398 let Some(close) = list.find('}') else {
399 return true;
400 };
401 let list = &list[..close];
402 if !list.is_ascii()
403 || list.contains(['/', '"', '\'', '`'])
404 || contains_whole_word(list, local)
405 {
406 return true;
407 }
408 }
409 false
410}
411
412const fn is_ascii_identifier_byte(byte: u8) -> bool {
413 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'
414}
415
416fn trim_script_whitespace(text: &str) -> &str {
419 text.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}')
420}
421
422#[must_use]
433fn forwarded_import_bindings(path: &Path, source: &str) -> FxHashSet<String> {
434 let Ok(source_type) = oxc_span::SourceType::from_path(path) else {
435 return FxHashSet::default();
436 };
437 fallow_extract::parse_bounded(source, || {
438 forwarded_import_bindings_unbounded(source, source_type)
439 })
440 .unwrap_or_default()
441}
442
443fn forwarded_import_bindings_unbounded(
444 source: &str,
445 source_type: oxc_span::SourceType,
446) -> FxHashSet<String> {
447 use oxc_ast::ast::{ImportDeclarationSpecifier, Statement};
448 use oxc_ast_visit::Visit;
449
450 let allocator = oxc_allocator::Allocator::default();
451 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
452 if parsed.fatal_error {
453 return FxHashSet::default();
454 }
455 let imports: FxHashSet<&str> = parsed
456 .program
457 .body
458 .iter()
459 .filter_map(|statement| match statement {
460 Statement::ImportDeclaration(import) => import.specifiers.as_ref(),
461 _ => None,
462 })
463 .flatten()
464 .map(|specifier| match specifier {
465 ImportDeclarationSpecifier::ImportSpecifier(named) => named.local.name.as_str(),
466 ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => {
467 default.local.name.as_str()
468 }
469 ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => {
470 namespace.local.name.as_str()
471 }
472 })
473 .collect();
474 if imports.is_empty() {
475 return FxHashSet::default();
476 }
477 let top_level_exports = parsed
478 .program
479 .body
480 .iter()
481 .filter_map(|statement| match statement {
482 Statement::ExportNamedDeclaration(export) => Some(export.span.start),
483 Statement::ExportDefaultDeclaration(export) => Some(export.span.start),
484 Statement::TSExportAssignment(export) => Some(export.span.start),
485 _ => None,
486 })
487 .collect();
488 let mut uses = BindingUses {
489 imports,
490 top_level_exports,
491 exported: FxHashSet::default(),
492 used: FxHashSet::default(),
493 };
494 uses.visit_program(&parsed.program);
495 uses.exported
496 .difference(&uses.used)
497 .map(|name| (*name).to_string())
498 .collect()
499}
500
501struct BindingUses<'s> {
504 imports: FxHashSet<&'s str>,
505 top_level_exports: FxHashSet<u32>,
509 exported: FxHashSet<&'s str>,
510 used: FxHashSet<&'s str>,
511}
512
513impl BindingUses<'_> {
514 fn record(&mut self, name: &str, exported: bool) {
515 let Some(&name) = self.imports.get(name) else {
516 return;
517 };
518 if exported {
519 self.exported.insert(name);
520 } else {
521 self.used.insert(name);
522 }
523 }
524}
525
526impl<'a> oxc_ast_visit::Visit<'a> for BindingUses<'_> {
527 fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) {
528 self.record(it.name.as_str(), false);
529 }
530
531 fn visit_export_named_declaration(&mut self, it: &oxc_ast::ast::ExportNamedDeclaration<'a>) {
532 let exported = self.top_level_exports.contains(&it.span.start);
533 for specifier in &it.specifiers {
534 self.record(specifier.local.name().as_str(), exported);
535 }
536 }
537
538 fn visit_export_default_declaration(
539 &mut self,
540 it: &oxc_ast::ast::ExportDefaultDeclaration<'a>,
541 ) {
542 if let oxc_ast::ast::ExportDefaultDeclarationKind::Identifier(identifier) = &it.declaration
543 && self.top_level_exports.contains(&it.span.start)
544 {
545 self.record(identifier.name.as_str(), true);
546 return;
547 }
548 oxc_ast_visit::walk::walk_export_default_declaration(self, it);
549 }
550
551 fn visit_ts_export_assignment(&mut self, it: &oxc_ast::ast::TSExportAssignment<'a>) {
552 if let oxc_ast::ast::Expression::Identifier(identifier) = &it.expression
553 && self.top_level_exports.contains(&it.span.start)
554 {
555 self.record(identifier.name.as_str(), true);
556 return;
557 }
558 oxc_ast_visit::walk::walk_ts_export_assignment(self, it);
559 }
560}
561
562fn default_export_term(
565 graph: &ModuleGraph,
566 sources: &mut SourceCache<'_>,
567 file_id: FileId,
568) -> Option<String> {
569 let export = graph
570 .modules
571 .get(file_id.0 as usize)?
572 .exports
573 .iter()
574 .find(|export| matches!(export.name, ExportName::Default))?;
575 let source = sources.get(file_id)?;
576 default_declaration_name(&source.text, export.span.start as usize).map(str::to_string)
577}
578
579const fn is_word_byte(byte: u8) -> bool {
582 byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80
583}
584
585#[must_use]
589pub fn contains_whole_word(source: &str, word: &str) -> bool {
590 if word.is_empty() {
591 return false;
592 }
593 let bytes = source.as_bytes();
594 source.match_indices(word).any(|(start, _)| {
595 let end = start + word.len();
596 let before = start.checked_sub(1).map(|index| bytes[index]);
597 let after = bytes.get(end).copied();
598 !before.is_some_and(is_word_byte) && !after.is_some_and(is_word_byte)
599 })
600}
601
602fn identifier_prefix(text: &str) -> &str {
603 let end = text
604 .char_indices()
605 .find(|(_, ch)| !(ch.is_alphanumeric() || *ch == '_' || *ch == '$'))
606 .map_or(text.len(), |(index, _)| index);
607 &text[..end]
608}
609
610fn strip_keyword<'a>(text: &'a str, keyword: &str) -> Option<&'a str> {
612 let rest = text.strip_prefix(keyword)?;
613 let next = rest.chars().next();
614 if next.is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') {
615 return None;
616 }
617 Some(rest.trim_start())
618}
619
620#[must_use]
626pub fn default_declaration_name(source: &str, start: usize) -> Option<&str> {
627 let text = source.get(start..)?.trim_start();
628 let Some(after_export) = strip_keyword(text, "export") else {
629 let name = identifier_prefix(text);
630 let rest = text[name.len()..].trim_start();
631 return (!name.is_empty() && strip_keyword(rest, "as")?.starts_with("default"))
632 .then_some(name);
633 };
634 let mut rest = strip_keyword(after_export, "default")?;
635 let mut declaration = false;
636 loop {
637 if let Some(next) = ["async", "function", "abstract", "class"]
638 .iter()
639 .find_map(|keyword| strip_keyword(rest, keyword))
640 {
641 declaration = true;
642 rest = next;
643 } else if let Some(next) = rest.strip_prefix('*') {
644 rest = next.trim_start();
645 } else {
646 break;
647 }
648 }
649 let name = identifier_prefix(rest);
650 if name.is_empty() {
651 return None;
652 }
653 if declaration {
654 return Some(name);
655 }
656 let after = rest[name.len()..].trim_start();
657 (after.is_empty() || after.starts_with(';') || after.starts_with('\n')).then_some(name)
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub struct ExportDeclaration {
663 pub lines: (u32, u32),
665 pub literal_value: bool,
668 pub is_re_export: bool,
672}
673
674#[must_use]
683pub fn declaration_line_ranges(
684 path: &Path,
685 source: &str,
686 export_starts: &[u32],
687) -> FxHashMap<u32, (u32, u32)> {
688 export_declarations(path, source, export_starts)
689 .into_iter()
690 .map(|(start, declaration)| (start, declaration.lines))
691 .collect()
692}
693
694#[must_use]
699pub fn export_declarations(
700 path: &Path,
701 source: &str,
702 export_starts: &[u32],
703) -> FxHashMap<u32, ExportDeclaration> {
704 let statements = match fallow_extract::embedded_script_sections(path, source) {
705 Some(sections) => sections
706 .iter()
707 .flat_map(|section| {
708 top_level_statements(§ion.extraction.body, section.source_type)
709 .into_iter()
710 .map(|statement| statement.remapped(§ion.extraction))
711 })
712 .collect(),
713 None => oxc_span::SourceType::from_path(path)
714 .map(|source_type| top_level_statements(source, source_type))
715 .unwrap_or_default(),
716 };
717 let offsets = fallow_types::extract::compute_line_offsets(source);
718 let line = |offset: u32| fallow_types::extract::byte_offset_to_line_col(&offsets, offset).0;
719 let mut declarations = FxHashMap::default();
720 for &start in export_starts {
721 let Some(containing) = statements
722 .iter()
723 .find(|statement| statement.start <= start && start < statement.end)
724 else {
725 continue;
726 };
727 let local = source
728 .get(start as usize..)
729 .map(identifier_prefix)
730 .unwrap_or_default();
731 let statement = if containing.is_specifier_list {
732 statements
733 .iter()
734 .find(|statement| statement.declared.iter().any(|name| name == local))
735 .unwrap_or(containing)
736 } else {
737 containing
738 };
739 declarations.insert(
740 start,
741 ExportDeclaration {
742 lines: (line(statement.start), line(statement.end.saturating_sub(1))),
743 literal_value: !local.is_empty()
744 && statement.literal_names.iter().any(|name| name == local),
745 is_re_export: containing.is_re_export,
746 },
747 );
748 }
749 declarations
750}
751
752struct TopLevelStatement {
753 start: u32,
754 end: u32,
755 declared: Vec<String>,
756 literal_names: Vec<String>,
758 is_specifier_list: bool,
759 is_re_export: bool,
760}
761
762impl TopLevelStatement {
763 fn remapped(self, extraction: &fallow_extract::ExtractionResult) -> Self {
765 let span = extraction.remap_span(oxc_span::Span::new(self.start, self.end));
766 Self {
767 start: span.start,
768 end: span.end,
769 ..self
770 }
771 }
772}
773
774fn top_level_statements(source: &str, source_type: oxc_span::SourceType) -> Vec<TopLevelStatement> {
777 fallow_extract::parse_bounded(source, || {
778 top_level_statements_unbounded(source, source_type)
779 })
780 .unwrap_or_default()
781}
782
783fn top_level_statements_unbounded(
784 source: &str,
785 source_type: oxc_span::SourceType,
786) -> Vec<TopLevelStatement> {
787 let allocator = oxc_allocator::Allocator::default();
788 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
789 if parsed.fatal_error {
790 return Vec::new();
791 }
792 parsed
793 .program
794 .body
795 .iter()
796 .map(top_level_statement)
797 .collect()
798}
799
800fn top_level_statement(statement: &oxc_ast::ast::Statement<'_>) -> TopLevelStatement {
801 use oxc_ast::ast::Statement;
802 use oxc_span::GetSpan;
803
804 let span = statement.span();
805 let declaration = match statement {
806 Statement::ExportDeclaration(export) => Some(&export.declaration),
807 _ => statement.as_declaration(),
808 };
809 TopLevelStatement {
810 start: span.start,
811 end: span.end,
812 declared: declaration.map(declared_names).unwrap_or_default(),
813 literal_names: declaration.map(literal_names).unwrap_or_default(),
814 is_specifier_list: matches!(statement, Statement::ExportNamedDeclaration(_)),
815 is_re_export: matches!(
816 statement,
817 Statement::ExportFromDeclaration(_) | Statement::ExportAllDeclaration(_)
818 ),
819 }
820}
821
822fn literal_names(declaration: &oxc_ast::ast::Declaration<'_>) -> Vec<String> {
824 let oxc_ast::ast::Declaration::VariableDeclaration(variable) = declaration else {
825 return Vec::new();
826 };
827 variable
828 .declarations
829 .iter()
830 .filter(|declarator| {
831 declarator
832 .init
833 .as_ref()
834 .is_some_and(|init| is_literal_value(init, 0))
835 })
836 .filter_map(|declarator| declarator.id.get_binding_identifier())
837 .map(|identifier| identifier.name.to_string())
838 .collect()
839}
840
841const LITERAL_DEPTH_LIMIT: usize = 16;
843
844fn is_literal_value(expression: &oxc_ast::ast::Expression<'_>, depth: usize) -> bool {
847 use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, UnaryOperator};
848
849 if depth > LITERAL_DEPTH_LIMIT {
850 return false;
851 }
852 match expression {
853 Expression::StringLiteral(_)
854 | Expression::NumericLiteral(_)
855 | Expression::BooleanLiteral(_)
856 | Expression::NullLiteral(_)
857 | Expression::BigIntLiteral(_) => true,
858 Expression::TemplateLiteral(template) => template.expressions.is_empty(),
859 Expression::UnaryExpression(unary) => {
860 matches!(
861 unary.operator,
862 UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus
863 ) && matches!(unary.argument, Expression::NumericLiteral(_))
864 }
865 Expression::ParenthesizedExpression(inner) => is_literal_value(&inner.expression, depth),
866 Expression::TSAsExpression(inner) => is_literal_value(&inner.expression, depth),
867 Expression::TSSatisfiesExpression(inner) => is_literal_value(&inner.expression, depth),
868 Expression::ArrayExpression(array) => array.elements.iter().all(|element| match element {
869 ArrayExpressionElement::SpreadElement(_) | ArrayExpressionElement::Elision(_) => false,
870 element => element
871 .as_expression()
872 .is_some_and(|value| is_literal_value(value, depth + 1)),
873 }),
874 Expression::ObjectExpression(object) => {
875 object.properties.iter().all(|property| match property {
876 ObjectPropertyKind::ObjectProperty(property) => {
877 !property.computed
878 && !property.method
879 && is_literal_value(&property.value, depth + 1)
880 }
881 ObjectPropertyKind::SpreadProperty(_) => false,
882 })
883 }
884 _ => false,
885 }
886}
887
888fn declared_names(declaration: &oxc_ast::ast::Declaration<'_>) -> Vec<String> {
889 use oxc_ast::ast::Declaration;
890
891 match declaration {
892 Declaration::VariableDeclaration(variable) => variable
893 .declarations
894 .iter()
895 .filter_map(|declarator| declarator.id.get_binding_identifier())
896 .map(|identifier| identifier.name.to_string())
897 .collect(),
898 Declaration::FunctionDeclaration(function) => function
899 .id
900 .iter()
901 .map(|identifier| identifier.name.to_string())
902 .collect(),
903 Declaration::ClassDeclaration(class) => class
904 .id
905 .iter()
906 .map(|identifier| identifier.name.to_string())
907 .collect(),
908 Declaration::TSTypeAliasDeclaration(alias) => vec![alias.id.name.to_string()],
909 Declaration::TSInterfaceDeclaration(interface) => vec![interface.id.name.to_string()],
910 Declaration::TSEnumDeclaration(enumeration) => vec![enumeration.id.name.to_string()],
911 _ => Vec::new(),
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn whole_word_matches_like_grep_w() {
921 assert!(contains_whole_word("import { foo } from './x'", "foo"));
922 assert!(contains_whole_word("ns.foo()", "foo"));
923 assert!(contains_whole_word("$foo", "foo"));
924 assert!(contains_whole_word("// foo is used", "foo"));
925 assert!(!contains_whole_word("foobar()", "foo"));
926 assert!(!contains_whole_word("my_foo()", "foo"));
927 assert!(!contains_whole_word("foo2", "foo"));
928 assert!(!contains_whole_word("", "foo"));
929 assert!(!contains_whole_word("foo", ""));
930 assert!(contains_whole_word("a foobar foo", "foo"));
931 }
932
933 fn forwarded(path: &str, source: &str) -> Vec<String> {
934 let mut names: Vec<String> = forwarded_import_bindings(Path::new(path), source)
935 .into_iter()
936 .collect();
937 names.sort();
938 names
939 }
940
941 #[test]
942 fn export_scan_admits_every_list_that_can_name_the_binding() {
943 for source in [
944 "import { a } from './x';\nexport { a };\n",
945 "import { a } from './x';\nexport {\n b,\n a as c,\n};\n",
946 "import type { a } from './x';\nexport type { a };\n",
947 "import { a } from './x';\nexport /* note */ { a };\n",
948 "import { a } from './x';\nexport { /* } */ a };\n",
949 "import { a } from './x';\nexport { b as \"}\", a };\n",
950 "import { \\u0061 } from './x';\nexport { \\u0061 };\n",
951 "import { a } from './x';\nexport { a",
952 "import { a } from './x';\n\u{a0}export { a };\n",
953 "import { a } from './x';\nexport\u{a0}{ a };\n",
954 "import { a } from './x';\u{2028}export { a };\n",
955 "import { a } from './x';\nexport\u{2028}{ a };\n",
956 "import { a } from './x';\nexport\u{feff}{ a };\n",
957 "\u{feff}export { a };\nimport { a } from './x';\n",
958 "import { a } from './x';\nexport type\u{a0}{ a };\n",
959 "import { a } from './x';\nexport {\u{a0}a\u{a0}};\n",
960 "import { a } from './x';\nexport default a;\n",
961 "import { a } from './x';\nexport =\na;\n",
962 "import { a } from './x';\nexport default /* note */ a;\n",
963 ] {
964 assert!(may_export_binding(source, "a"), "{source}");
965 }
966 }
967
968 #[test]
969 fn export_scan_rejects_text_without_a_list_naming_the_binding() {
970 for source in [
971 "import { a } from './x';\nexport const b = a;\n",
972 "import { a } from './x';\nexport { b };\nconsole.log(a);\n",
973 "import { a } from './x';\nexports.a = a;\nreexport { a };\n",
974 "import { ab } from './x';\nexport { ab };\n",
975 "import { ab } from './x';\nexport default ab;\n",
976 "import { a } from './x';\nexport = ab;\n",
977 ] {
978 assert!(!may_export_binding(source, "a"), "{source}");
979 }
980 }
981
982 #[test]
983 fn forwarded_bindings_are_only_exported_again() {
984 assert_eq!(
985 forwarded("a.ts", "import { a } from './x';\nexport { a };\n"),
986 ["a"]
987 );
988 assert_eq!(
989 forwarded(
990 "a.ts",
991 "import { a as c } from './x';\nexport { c as d };\n"
992 ),
993 ["c"]
994 );
995 assert_eq!(
996 forwarded(
997 "a.ts",
998 "import type { T } from './x';\nexport type { T };\n"
999 ),
1000 ["T"]
1001 );
1002 assert_eq!(
1005 forwarded("a.ts", "import b from './x';\nexport default b;\n"),
1006 ["b"]
1007 );
1008 assert_eq!(
1009 forwarded(
1010 "a.ts",
1011 "import b = require('./x');\nimport c from './y';\nexport = c;\n"
1012 ),
1013 ["c"]
1014 );
1015 }
1016
1017 #[test]
1018 fn forwarded_bindings_exclude_local_uses_and_unused_imports() {
1019 let empty: [&str; 0] = [];
1020 assert_eq!(
1021 forwarded(
1022 "a.ts",
1023 "import { a } from './x';\nexport { a };\nexport const b = a * 2;\n"
1024 ),
1025 empty
1026 );
1027 assert_eq!(
1028 forwarded(
1029 "a.tsx",
1030 "import A from './x';\nexport { A };\nconst v = <A />;\n"
1031 ),
1032 empty
1033 );
1034 assert_eq!(
1035 forwarded("a.ts", "import { a } from './x';\nexport default a + 1;\n"),
1036 empty
1037 );
1038 assert_eq!(forwarded("a.ts", "import { a } from './x';\n"), empty);
1039 assert_eq!(
1040 forwarded(
1041 "a.ts",
1042 "import b from './x';\nexport default b;\nconsole.log(b);\n"
1043 ),
1044 empty
1045 );
1046 assert_eq!(
1047 forwarded("a.ts", "import b from './x';\nexport default (b);\n"),
1048 empty
1049 );
1050 assert_eq!(
1051 forwarded(
1052 "a.ts",
1053 "import b from './x';\ndeclare module 'm' {\n export = b;\n}\n"
1054 ),
1055 empty
1056 );
1057 assert_eq!(
1058 forwarded(
1059 "a.ts",
1060 "import { a } from './x';\nexport namespace N {\n export { a };\n}\n"
1061 ),
1062 empty
1063 );
1064 assert_eq!(
1065 forwarded(
1066 "a.ts",
1067 "import { a } from './x';\ndeclare module 'm' {\n export { a };\n}\n"
1068 ),
1069 empty
1070 );
1071 assert_eq!(
1072 forwarded(
1073 "a.ts",
1074 "import { a } from './x';\nexport { a } from './y';\n"
1075 ),
1076 empty
1077 );
1078 assert_eq!(
1079 forwarded(
1080 "A.vue",
1081 "<script>\nimport a from './x';\nexport { a };\n</script>\n"
1082 ),
1083 empty
1084 );
1085 }
1086
1087 #[test]
1088 fn default_declaration_name_reads_named_declarations() {
1089 let name = |source: &'static str| default_declaration_name(source, 0);
1090 assert_eq!(name("export default function Widget() {}"), Some("Widget"));
1091 assert_eq!(
1092 name("export default async function load() {}"),
1093 Some("load")
1094 );
1095 assert_eq!(name("export default function* gen() {}"), Some("gen"));
1096 assert_eq!(name("export default class Store {}"), Some("Store"));
1097 assert_eq!(name("export default abstract class Base {}"), Some("Base"));
1098 assert_eq!(name("export default config;"), Some("config"));
1099 assert_eq!(name("export default config\n"), Some("config"));
1100 assert_eq!(name("Widget as default"), Some("Widget"));
1101 }
1102
1103 #[test]
1104 fn default_declaration_name_rejects_anonymous_and_expressions() {
1105 let name = |source: &'static str| default_declaration_name(source, 0);
1106 assert_eq!(name("export default function () {}"), None);
1107 assert_eq!(name("export default class {}"), None);
1108 assert_eq!(name("export default defineConfig({});"), None);
1109 assert_eq!(name("export default { a: 1 };"), None);
1110 assert_eq!(name("Widget as Other"), None);
1111 }
1112
1113 #[test]
1114 fn declaration_ranges_cover_the_whole_statement() {
1115 let source = "export const a = 1;\n\nexport function b() {\n return 2;\n}\n\nconst c = 3;\nexport { c };\n";
1116 let a = source.find("a =").unwrap() as u32;
1117 let b = source.find("b()").unwrap() as u32;
1118 let c = source.rfind('c').unwrap() as u32;
1119 let ranges = declaration_line_ranges(Path::new("x.ts"), source, &[a, b, c]);
1120 assert_eq!(ranges.get(&a), Some(&(1, 1)));
1121 assert_eq!(ranges.get(&b), Some(&(3, 5)));
1122 assert_eq!(ranges.get(&c), Some(&(7, 7)));
1123 }
1124
1125 fn literal_of(source: &str, needle: &str) -> bool {
1126 let start = source.find(needle).unwrap() as u32;
1127 export_declarations(Path::new("page.tsx"), source, &[start])
1128 .get(&start)
1129 .is_some_and(|declaration| declaration.literal_value)
1130 }
1131
1132 #[test]
1133 fn export_declarations_flag_literal_initializers() {
1134 assert!(literal_of(
1135 "export const dynamic = 'force-dynamic';\n",
1136 "dynamic"
1137 ));
1138 assert!(literal_of("export const revalidate = 60;\n", "revalidate"));
1139 assert!(literal_of(
1140 "export const maxDuration = -1;\n",
1141 "maxDuration"
1142 ));
1143 assert!(literal_of(
1144 "export const metadata: Metadata = { title: 'A', robots: { index: false }, keywords: ['a'] };\n",
1145 "metadata"
1146 ));
1147 assert!(literal_of(
1148 "export const config = { runtime: `edge` } as const;\n",
1149 "config"
1150 ));
1151 assert!(literal_of("const a = 1;\nexport { a };\n", "a }"));
1152 }
1153
1154 #[test]
1155 fn export_declarations_reject_computed_values() {
1156 assert!(!literal_of(
1157 "export const metadata = { title: buildTitle('A') };\n",
1158 "metadata"
1159 ));
1160 assert!(!literal_of("export const metadata = base;\n", "metadata"));
1161 assert!(!literal_of(
1162 "export const Route = createFileRoute('/')({ loader });\n",
1163 "Route"
1164 ));
1165 assert!(!literal_of("export function GET() {}\n", "GET"));
1166 assert!(!literal_of("export const x = { ...base };\n", "x"));
1167 assert!(!literal_of("export const x = `a${b}`;\n", "x"));
1168 }
1169
1170 #[test]
1171 fn export_declarations_flag_re_export_statements() {
1172 let source =
1173 "export { a } from './a';\nexport * as ns from './ns';\nconst b = 1;\nexport { b };\n";
1174 let a = source.find("a }").unwrap() as u32;
1175 let ns = source.find("ns ").unwrap() as u32;
1176 let b = source.rfind("b }").unwrap() as u32;
1177 let declarations = export_declarations(Path::new("x.ts"), source, &[a, ns, b]);
1178 assert!(declarations[&a].is_re_export);
1179 assert!(declarations[&ns].is_re_export);
1180 assert!(!declarations[&b].is_re_export);
1181 assert_eq!(declarations[&b].lines, (3, 3));
1182 }
1183
1184 #[test]
1185 fn export_declarations_flag_each_declarator() {
1186 let source = "export const a = 1, b = make();\n";
1187 let a = source.find("a =").unwrap() as u32;
1188 let b = source.find("b =").unwrap() as u32;
1189 let declarations = export_declarations(Path::new("x.ts"), source, &[a, b]);
1190 assert!(declarations[&a].literal_value);
1191 assert!(!declarations[&b].literal_value);
1192 assert_eq!(declarations[&a].lines, (1, 1));
1193 assert_eq!(declarations[&b].lines, (1, 1));
1194 }
1195
1196 fn range_of(path: &str, source: &str, needle: &str) -> Option<(u32, u32)> {
1197 let start = source.find(needle)? as u32;
1198 declaration_line_ranges(Path::new(path), source, &[start])
1199 .get(&start)
1200 .copied()
1201 }
1202
1203 #[test]
1204 fn declaration_ranges_read_the_script_blocks_of_an_sfc() {
1205 let vue = "<template>\n <div />\n</template>\n\n<script lang=\"ts\">\nexport function scale(n: number): number {\n return n * 2;\n}\n</script>\n";
1206 assert_eq!(range_of("Gauge.vue", vue, "scale("), Some((6, 8)));
1207
1208 let svelte = "<script context=\"module\">\nexport const size = {\n width: 1,\n};\n</script>\n\n<script>\n let open = false;\n</script>\n\n<div />\n";
1209 assert_eq!(range_of("Box.svelte", svelte, "size ="), Some((2, 4)));
1210 }
1211
1212 #[test]
1213 fn declaration_ranges_read_astro_frontmatter_and_mdx_statements() {
1214 let astro =
1215 "---\nexport function title(): string {\n return 'a';\n}\n---\n<h1>{title()}</h1>\n";
1216 assert_eq!(range_of("Page.astro", astro, "title()"), Some((2, 4)));
1217
1218 let mdx =
1219 "# Post\n\nSome prose.\n\nexport const meta = {\n title: 'Post',\n};\n\nMore prose.\n";
1220 assert_eq!(range_of("post.mdx", mdx, "meta ="), Some((5, 7)));
1221 }
1222}