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 use oxc_ast::ast::{ImportDeclarationSpecifier, Statement};
435 use oxc_ast_visit::Visit;
436
437 let Ok(source_type) = oxc_span::SourceType::from_path(path) else {
438 return FxHashSet::default();
439 };
440 let allocator = oxc_allocator::Allocator::default();
441 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
442 if parsed.fatal_error {
443 return FxHashSet::default();
444 }
445 let imports: FxHashSet<&str> = parsed
446 .program
447 .body
448 .iter()
449 .filter_map(|statement| match statement {
450 Statement::ImportDeclaration(import) => import.specifiers.as_ref(),
451 _ => None,
452 })
453 .flatten()
454 .map(|specifier| match specifier {
455 ImportDeclarationSpecifier::ImportSpecifier(named) => named.local.name.as_str(),
456 ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => {
457 default.local.name.as_str()
458 }
459 ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => {
460 namespace.local.name.as_str()
461 }
462 })
463 .collect();
464 if imports.is_empty() {
465 return FxHashSet::default();
466 }
467 let top_level_exports = parsed
468 .program
469 .body
470 .iter()
471 .filter_map(|statement| match statement {
472 Statement::ExportNamedDeclaration(export) => Some(export.span.start),
473 Statement::ExportDefaultDeclaration(export) => Some(export.span.start),
474 Statement::TSExportAssignment(export) => Some(export.span.start),
475 _ => None,
476 })
477 .collect();
478 let mut uses = BindingUses {
479 imports,
480 top_level_exports,
481 exported: FxHashSet::default(),
482 used: FxHashSet::default(),
483 };
484 uses.visit_program(&parsed.program);
485 uses.exported
486 .difference(&uses.used)
487 .map(|name| (*name).to_string())
488 .collect()
489}
490
491struct BindingUses<'s> {
494 imports: FxHashSet<&'s str>,
495 top_level_exports: FxHashSet<u32>,
499 exported: FxHashSet<&'s str>,
500 used: FxHashSet<&'s str>,
501}
502
503impl BindingUses<'_> {
504 fn record(&mut self, name: &str, exported: bool) {
505 let Some(&name) = self.imports.get(name) else {
506 return;
507 };
508 if exported {
509 self.exported.insert(name);
510 } else {
511 self.used.insert(name);
512 }
513 }
514}
515
516impl<'a> oxc_ast_visit::Visit<'a> for BindingUses<'_> {
517 fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) {
518 self.record(it.name.as_str(), false);
519 }
520
521 fn visit_export_named_declaration(&mut self, it: &oxc_ast::ast::ExportNamedDeclaration<'a>) {
522 let exported = self.top_level_exports.contains(&it.span.start);
523 for specifier in &it.specifiers {
524 self.record(specifier.local.name().as_str(), exported);
525 }
526 }
527
528 fn visit_export_default_declaration(
529 &mut self,
530 it: &oxc_ast::ast::ExportDefaultDeclaration<'a>,
531 ) {
532 if let oxc_ast::ast::ExportDefaultDeclarationKind::Identifier(identifier) = &it.declaration
533 && self.top_level_exports.contains(&it.span.start)
534 {
535 self.record(identifier.name.as_str(), true);
536 return;
537 }
538 oxc_ast_visit::walk::walk_export_default_declaration(self, it);
539 }
540
541 fn visit_ts_export_assignment(&mut self, it: &oxc_ast::ast::TSExportAssignment<'a>) {
542 if let oxc_ast::ast::Expression::Identifier(identifier) = &it.expression
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_ts_export_assignment(self, it);
549 }
550}
551
552fn default_export_term(
555 graph: &ModuleGraph,
556 sources: &mut SourceCache<'_>,
557 file_id: FileId,
558) -> Option<String> {
559 let export = graph
560 .modules
561 .get(file_id.0 as usize)?
562 .exports
563 .iter()
564 .find(|export| matches!(export.name, ExportName::Default))?;
565 let source = sources.get(file_id)?;
566 default_declaration_name(&source.text, export.span.start as usize).map(str::to_string)
567}
568
569const fn is_word_byte(byte: u8) -> bool {
572 byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80
573}
574
575#[must_use]
579pub fn contains_whole_word(source: &str, word: &str) -> bool {
580 if word.is_empty() {
581 return false;
582 }
583 let bytes = source.as_bytes();
584 source.match_indices(word).any(|(start, _)| {
585 let end = start + word.len();
586 let before = start.checked_sub(1).map(|index| bytes[index]);
587 let after = bytes.get(end).copied();
588 !before.is_some_and(is_word_byte) && !after.is_some_and(is_word_byte)
589 })
590}
591
592fn identifier_prefix(text: &str) -> &str {
593 let end = text
594 .char_indices()
595 .find(|(_, ch)| !(ch.is_alphanumeric() || *ch == '_' || *ch == '$'))
596 .map_or(text.len(), |(index, _)| index);
597 &text[..end]
598}
599
600fn strip_keyword<'a>(text: &'a str, keyword: &str) -> Option<&'a str> {
602 let rest = text.strip_prefix(keyword)?;
603 let next = rest.chars().next();
604 if next.is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') {
605 return None;
606 }
607 Some(rest.trim_start())
608}
609
610#[must_use]
616pub fn default_declaration_name(source: &str, start: usize) -> Option<&str> {
617 let text = source.get(start..)?.trim_start();
618 let Some(after_export) = strip_keyword(text, "export") else {
619 let name = identifier_prefix(text);
620 let rest = text[name.len()..].trim_start();
621 return (!name.is_empty() && strip_keyword(rest, "as")?.starts_with("default"))
622 .then_some(name);
623 };
624 let mut rest = strip_keyword(after_export, "default")?;
625 let mut declaration = false;
626 loop {
627 if let Some(next) = ["async", "function", "abstract", "class"]
628 .iter()
629 .find_map(|keyword| strip_keyword(rest, keyword))
630 {
631 declaration = true;
632 rest = next;
633 } else if let Some(next) = rest.strip_prefix('*') {
634 rest = next.trim_start();
635 } else {
636 break;
637 }
638 }
639 let name = identifier_prefix(rest);
640 if name.is_empty() {
641 return None;
642 }
643 if declaration {
644 return Some(name);
645 }
646 let after = rest[name.len()..].trim_start();
647 (after.is_empty() || after.starts_with(';') || after.starts_with('\n')).then_some(name)
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub struct ExportDeclaration {
653 pub lines: (u32, u32),
655 pub literal_value: bool,
658 pub is_re_export: bool,
662}
663
664#[must_use]
673pub fn declaration_line_ranges(
674 path: &Path,
675 source: &str,
676 export_starts: &[u32],
677) -> FxHashMap<u32, (u32, u32)> {
678 export_declarations(path, source, export_starts)
679 .into_iter()
680 .map(|(start, declaration)| (start, declaration.lines))
681 .collect()
682}
683
684#[must_use]
689pub fn export_declarations(
690 path: &Path,
691 source: &str,
692 export_starts: &[u32],
693) -> FxHashMap<u32, ExportDeclaration> {
694 let statements = match fallow_extract::embedded_script_sections(path, source) {
695 Some(sections) => sections
696 .iter()
697 .flat_map(|section| {
698 top_level_statements(§ion.extraction.body, section.source_type)
699 .into_iter()
700 .map(|statement| statement.remapped(§ion.extraction))
701 })
702 .collect(),
703 None => oxc_span::SourceType::from_path(path)
704 .map(|source_type| top_level_statements(source, source_type))
705 .unwrap_or_default(),
706 };
707 let offsets = fallow_types::extract::compute_line_offsets(source);
708 let line = |offset: u32| fallow_types::extract::byte_offset_to_line_col(&offsets, offset).0;
709 let mut declarations = FxHashMap::default();
710 for &start in export_starts {
711 let Some(containing) = statements
712 .iter()
713 .find(|statement| statement.start <= start && start < statement.end)
714 else {
715 continue;
716 };
717 let local = source
718 .get(start as usize..)
719 .map(identifier_prefix)
720 .unwrap_or_default();
721 let statement = if containing.is_specifier_list {
722 statements
723 .iter()
724 .find(|statement| statement.declared.iter().any(|name| name == local))
725 .unwrap_or(containing)
726 } else {
727 containing
728 };
729 declarations.insert(
730 start,
731 ExportDeclaration {
732 lines: (line(statement.start), line(statement.end.saturating_sub(1))),
733 literal_value: !local.is_empty()
734 && statement.literal_names.iter().any(|name| name == local),
735 is_re_export: containing.is_re_export,
736 },
737 );
738 }
739 declarations
740}
741
742struct TopLevelStatement {
743 start: u32,
744 end: u32,
745 declared: Vec<String>,
746 literal_names: Vec<String>,
748 is_specifier_list: bool,
749 is_re_export: bool,
750}
751
752impl TopLevelStatement {
753 fn remapped(self, extraction: &fallow_extract::ExtractionResult) -> Self {
755 let span = extraction.remap_span(oxc_span::Span::new(self.start, self.end));
756 Self {
757 start: span.start,
758 end: span.end,
759 ..self
760 }
761 }
762}
763
764fn top_level_statements(source: &str, source_type: oxc_span::SourceType) -> Vec<TopLevelStatement> {
767 let allocator = oxc_allocator::Allocator::default();
768 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
769 if parsed.fatal_error {
770 return Vec::new();
771 }
772 parsed
773 .program
774 .body
775 .iter()
776 .map(top_level_statement)
777 .collect()
778}
779
780fn top_level_statement(statement: &oxc_ast::ast::Statement<'_>) -> TopLevelStatement {
781 use oxc_ast::ast::Statement;
782 use oxc_span::GetSpan;
783
784 let span = statement.span();
785 let declaration = match statement {
786 Statement::ExportDeclaration(export) => Some(&export.declaration),
787 _ => statement.as_declaration(),
788 };
789 TopLevelStatement {
790 start: span.start,
791 end: span.end,
792 declared: declaration.map(declared_names).unwrap_or_default(),
793 literal_names: declaration.map(literal_names).unwrap_or_default(),
794 is_specifier_list: matches!(statement, Statement::ExportNamedDeclaration(_)),
795 is_re_export: matches!(
796 statement,
797 Statement::ExportFromDeclaration(_) | Statement::ExportAllDeclaration(_)
798 ),
799 }
800}
801
802fn literal_names(declaration: &oxc_ast::ast::Declaration<'_>) -> Vec<String> {
804 let oxc_ast::ast::Declaration::VariableDeclaration(variable) = declaration else {
805 return Vec::new();
806 };
807 variable
808 .declarations
809 .iter()
810 .filter(|declarator| {
811 declarator
812 .init
813 .as_ref()
814 .is_some_and(|init| is_literal_value(init, 0))
815 })
816 .filter_map(|declarator| declarator.id.get_binding_identifier())
817 .map(|identifier| identifier.name.to_string())
818 .collect()
819}
820
821const LITERAL_DEPTH_LIMIT: usize = 16;
823
824fn is_literal_value(expression: &oxc_ast::ast::Expression<'_>, depth: usize) -> bool {
827 use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, UnaryOperator};
828
829 if depth > LITERAL_DEPTH_LIMIT {
830 return false;
831 }
832 match expression {
833 Expression::StringLiteral(_)
834 | Expression::NumericLiteral(_)
835 | Expression::BooleanLiteral(_)
836 | Expression::NullLiteral(_)
837 | Expression::BigIntLiteral(_) => true,
838 Expression::TemplateLiteral(template) => template.expressions.is_empty(),
839 Expression::UnaryExpression(unary) => {
840 matches!(
841 unary.operator,
842 UnaryOperator::UnaryNegation | UnaryOperator::UnaryPlus
843 ) && matches!(unary.argument, Expression::NumericLiteral(_))
844 }
845 Expression::ParenthesizedExpression(inner) => is_literal_value(&inner.expression, depth),
846 Expression::TSAsExpression(inner) => is_literal_value(&inner.expression, depth),
847 Expression::TSSatisfiesExpression(inner) => is_literal_value(&inner.expression, depth),
848 Expression::ArrayExpression(array) => array.elements.iter().all(|element| match element {
849 ArrayExpressionElement::SpreadElement(_) | ArrayExpressionElement::Elision(_) => false,
850 element => element
851 .as_expression()
852 .is_some_and(|value| is_literal_value(value, depth + 1)),
853 }),
854 Expression::ObjectExpression(object) => {
855 object.properties.iter().all(|property| match property {
856 ObjectPropertyKind::ObjectProperty(property) => {
857 !property.computed
858 && !property.method
859 && is_literal_value(&property.value, depth + 1)
860 }
861 ObjectPropertyKind::SpreadProperty(_) => false,
862 })
863 }
864 _ => false,
865 }
866}
867
868fn declared_names(declaration: &oxc_ast::ast::Declaration<'_>) -> Vec<String> {
869 use oxc_ast::ast::Declaration;
870
871 match declaration {
872 Declaration::VariableDeclaration(variable) => variable
873 .declarations
874 .iter()
875 .filter_map(|declarator| declarator.id.get_binding_identifier())
876 .map(|identifier| identifier.name.to_string())
877 .collect(),
878 Declaration::FunctionDeclaration(function) => function
879 .id
880 .iter()
881 .map(|identifier| identifier.name.to_string())
882 .collect(),
883 Declaration::ClassDeclaration(class) => class
884 .id
885 .iter()
886 .map(|identifier| identifier.name.to_string())
887 .collect(),
888 Declaration::TSTypeAliasDeclaration(alias) => vec![alias.id.name.to_string()],
889 Declaration::TSInterfaceDeclaration(interface) => vec![interface.id.name.to_string()],
890 Declaration::TSEnumDeclaration(enumeration) => vec![enumeration.id.name.to_string()],
891 _ => Vec::new(),
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn whole_word_matches_like_grep_w() {
901 assert!(contains_whole_word("import { foo } from './x'", "foo"));
902 assert!(contains_whole_word("ns.foo()", "foo"));
903 assert!(contains_whole_word("$foo", "foo"));
904 assert!(contains_whole_word("// foo is used", "foo"));
905 assert!(!contains_whole_word("foobar()", "foo"));
906 assert!(!contains_whole_word("my_foo()", "foo"));
907 assert!(!contains_whole_word("foo2", "foo"));
908 assert!(!contains_whole_word("", "foo"));
909 assert!(!contains_whole_word("foo", ""));
910 assert!(contains_whole_word("a foobar foo", "foo"));
911 }
912
913 fn forwarded(path: &str, source: &str) -> Vec<String> {
914 let mut names: Vec<String> = forwarded_import_bindings(Path::new(path), source)
915 .into_iter()
916 .collect();
917 names.sort();
918 names
919 }
920
921 #[test]
922 fn export_scan_admits_every_list_that_can_name_the_binding() {
923 for source in [
924 "import { a } from './x';\nexport { a };\n",
925 "import { a } from './x';\nexport {\n b,\n a as c,\n};\n",
926 "import type { a } from './x';\nexport type { a };\n",
927 "import { a } from './x';\nexport /* note */ { a };\n",
928 "import { a } from './x';\nexport { /* } */ a };\n",
929 "import { a } from './x';\nexport { b as \"}\", a };\n",
930 "import { \\u0061 } from './x';\nexport { \\u0061 };\n",
931 "import { a } from './x';\nexport { a",
932 "import { a } from './x';\n\u{a0}export { a };\n",
933 "import { a } from './x';\nexport\u{a0}{ a };\n",
934 "import { a } from './x';\u{2028}export { a };\n",
935 "import { a } from './x';\nexport\u{2028}{ a };\n",
936 "import { a } from './x';\nexport\u{feff}{ a };\n",
937 "\u{feff}export { a };\nimport { a } from './x';\n",
938 "import { a } from './x';\nexport type\u{a0}{ a };\n",
939 "import { a } from './x';\nexport {\u{a0}a\u{a0}};\n",
940 "import { a } from './x';\nexport default a;\n",
941 "import { a } from './x';\nexport =\na;\n",
942 "import { a } from './x';\nexport default /* note */ a;\n",
943 ] {
944 assert!(may_export_binding(source, "a"), "{source}");
945 }
946 }
947
948 #[test]
949 fn export_scan_rejects_text_without_a_list_naming_the_binding() {
950 for source in [
951 "import { a } from './x';\nexport const b = a;\n",
952 "import { a } from './x';\nexport { b };\nconsole.log(a);\n",
953 "import { a } from './x';\nexports.a = a;\nreexport { a };\n",
954 "import { ab } from './x';\nexport { ab };\n",
955 "import { ab } from './x';\nexport default ab;\n",
956 "import { a } from './x';\nexport = ab;\n",
957 ] {
958 assert!(!may_export_binding(source, "a"), "{source}");
959 }
960 }
961
962 #[test]
963 fn forwarded_bindings_are_only_exported_again() {
964 assert_eq!(
965 forwarded("a.ts", "import { a } from './x';\nexport { a };\n"),
966 ["a"]
967 );
968 assert_eq!(
969 forwarded(
970 "a.ts",
971 "import { a as c } from './x';\nexport { c as d };\n"
972 ),
973 ["c"]
974 );
975 assert_eq!(
976 forwarded(
977 "a.ts",
978 "import type { T } from './x';\nexport type { T };\n"
979 ),
980 ["T"]
981 );
982 assert_eq!(
985 forwarded("a.ts", "import b from './x';\nexport default b;\n"),
986 ["b"]
987 );
988 assert_eq!(
989 forwarded(
990 "a.ts",
991 "import b = require('./x');\nimport c from './y';\nexport = c;\n"
992 ),
993 ["c"]
994 );
995 }
996
997 #[test]
998 fn forwarded_bindings_exclude_local_uses_and_unused_imports() {
999 let empty: [&str; 0] = [];
1000 assert_eq!(
1001 forwarded(
1002 "a.ts",
1003 "import { a } from './x';\nexport { a };\nexport const b = a * 2;\n"
1004 ),
1005 empty
1006 );
1007 assert_eq!(
1008 forwarded(
1009 "a.tsx",
1010 "import A from './x';\nexport { A };\nconst v = <A />;\n"
1011 ),
1012 empty
1013 );
1014 assert_eq!(
1015 forwarded("a.ts", "import { a } from './x';\nexport default a + 1;\n"),
1016 empty
1017 );
1018 assert_eq!(forwarded("a.ts", "import { a } from './x';\n"), empty);
1019 assert_eq!(
1020 forwarded(
1021 "a.ts",
1022 "import b from './x';\nexport default b;\nconsole.log(b);\n"
1023 ),
1024 empty
1025 );
1026 assert_eq!(
1027 forwarded("a.ts", "import b from './x';\nexport default (b);\n"),
1028 empty
1029 );
1030 assert_eq!(
1031 forwarded(
1032 "a.ts",
1033 "import b from './x';\ndeclare module 'm' {\n export = b;\n}\n"
1034 ),
1035 empty
1036 );
1037 assert_eq!(
1038 forwarded(
1039 "a.ts",
1040 "import { a } from './x';\nexport namespace N {\n export { a };\n}\n"
1041 ),
1042 empty
1043 );
1044 assert_eq!(
1045 forwarded(
1046 "a.ts",
1047 "import { a } from './x';\ndeclare module 'm' {\n export { a };\n}\n"
1048 ),
1049 empty
1050 );
1051 assert_eq!(
1052 forwarded(
1053 "a.ts",
1054 "import { a } from './x';\nexport { a } from './y';\n"
1055 ),
1056 empty
1057 );
1058 assert_eq!(
1059 forwarded(
1060 "A.vue",
1061 "<script>\nimport a from './x';\nexport { a };\n</script>\n"
1062 ),
1063 empty
1064 );
1065 }
1066
1067 #[test]
1068 fn default_declaration_name_reads_named_declarations() {
1069 let name = |source: &'static str| default_declaration_name(source, 0);
1070 assert_eq!(name("export default function Widget() {}"), Some("Widget"));
1071 assert_eq!(
1072 name("export default async function load() {}"),
1073 Some("load")
1074 );
1075 assert_eq!(name("export default function* gen() {}"), Some("gen"));
1076 assert_eq!(name("export default class Store {}"), Some("Store"));
1077 assert_eq!(name("export default abstract class Base {}"), Some("Base"));
1078 assert_eq!(name("export default config;"), Some("config"));
1079 assert_eq!(name("export default config\n"), Some("config"));
1080 assert_eq!(name("Widget as default"), Some("Widget"));
1081 }
1082
1083 #[test]
1084 fn default_declaration_name_rejects_anonymous_and_expressions() {
1085 let name = |source: &'static str| default_declaration_name(source, 0);
1086 assert_eq!(name("export default function () {}"), None);
1087 assert_eq!(name("export default class {}"), None);
1088 assert_eq!(name("export default defineConfig({});"), None);
1089 assert_eq!(name("export default { a: 1 };"), None);
1090 assert_eq!(name("Widget as Other"), None);
1091 }
1092
1093 #[test]
1094 fn declaration_ranges_cover_the_whole_statement() {
1095 let source = "export const a = 1;\n\nexport function b() {\n return 2;\n}\n\nconst c = 3;\nexport { c };\n";
1096 let a = source.find("a =").unwrap() as u32;
1097 let b = source.find("b()").unwrap() as u32;
1098 let c = source.rfind('c').unwrap() as u32;
1099 let ranges = declaration_line_ranges(Path::new("x.ts"), source, &[a, b, c]);
1100 assert_eq!(ranges.get(&a), Some(&(1, 1)));
1101 assert_eq!(ranges.get(&b), Some(&(3, 5)));
1102 assert_eq!(ranges.get(&c), Some(&(7, 7)));
1103 }
1104
1105 fn literal_of(source: &str, needle: &str) -> bool {
1106 let start = source.find(needle).unwrap() as u32;
1107 export_declarations(Path::new("page.tsx"), source, &[start])
1108 .get(&start)
1109 .is_some_and(|declaration| declaration.literal_value)
1110 }
1111
1112 #[test]
1113 fn export_declarations_flag_literal_initializers() {
1114 assert!(literal_of(
1115 "export const dynamic = 'force-dynamic';\n",
1116 "dynamic"
1117 ));
1118 assert!(literal_of("export const revalidate = 60;\n", "revalidate"));
1119 assert!(literal_of(
1120 "export const maxDuration = -1;\n",
1121 "maxDuration"
1122 ));
1123 assert!(literal_of(
1124 "export const metadata: Metadata = { title: 'A', robots: { index: false }, keywords: ['a'] };\n",
1125 "metadata"
1126 ));
1127 assert!(literal_of(
1128 "export const config = { runtime: `edge` } as const;\n",
1129 "config"
1130 ));
1131 assert!(literal_of("const a = 1;\nexport { a };\n", "a }"));
1132 }
1133
1134 #[test]
1135 fn export_declarations_reject_computed_values() {
1136 assert!(!literal_of(
1137 "export const metadata = { title: buildTitle('A') };\n",
1138 "metadata"
1139 ));
1140 assert!(!literal_of("export const metadata = base;\n", "metadata"));
1141 assert!(!literal_of(
1142 "export const Route = createFileRoute('/')({ loader });\n",
1143 "Route"
1144 ));
1145 assert!(!literal_of("export function GET() {}\n", "GET"));
1146 assert!(!literal_of("export const x = { ...base };\n", "x"));
1147 assert!(!literal_of("export const x = `a${b}`;\n", "x"));
1148 }
1149
1150 #[test]
1151 fn export_declarations_flag_re_export_statements() {
1152 let source =
1153 "export { a } from './a';\nexport * as ns from './ns';\nconst b = 1;\nexport { b };\n";
1154 let a = source.find("a }").unwrap() as u32;
1155 let ns = source.find("ns ").unwrap() as u32;
1156 let b = source.rfind("b }").unwrap() as u32;
1157 let declarations = export_declarations(Path::new("x.ts"), source, &[a, ns, b]);
1158 assert!(declarations[&a].is_re_export);
1159 assert!(declarations[&ns].is_re_export);
1160 assert!(!declarations[&b].is_re_export);
1161 assert_eq!(declarations[&b].lines, (3, 3));
1162 }
1163
1164 #[test]
1165 fn export_declarations_flag_each_declarator() {
1166 let source = "export const a = 1, b = make();\n";
1167 let a = source.find("a =").unwrap() as u32;
1168 let b = source.find("b =").unwrap() as u32;
1169 let declarations = export_declarations(Path::new("x.ts"), source, &[a, b]);
1170 assert!(declarations[&a].literal_value);
1171 assert!(!declarations[&b].literal_value);
1172 assert_eq!(declarations[&a].lines, (1, 1));
1173 assert_eq!(declarations[&b].lines, (1, 1));
1174 }
1175
1176 fn range_of(path: &str, source: &str, needle: &str) -> Option<(u32, u32)> {
1177 let start = source.find(needle)? as u32;
1178 declaration_line_ranges(Path::new(path), source, &[start])
1179 .get(&start)
1180 .copied()
1181 }
1182
1183 #[test]
1184 fn declaration_ranges_read_the_script_blocks_of_an_sfc() {
1185 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";
1186 assert_eq!(range_of("Gauge.vue", vue, "scale("), Some((6, 8)));
1187
1188 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";
1189 assert_eq!(range_of("Box.svelte", svelte, "size ="), Some((2, 4)));
1190 }
1191
1192 #[test]
1193 fn declaration_ranges_read_astro_frontmatter_and_mdx_statements() {
1194 let astro =
1195 "---\nexport function title(): string {\n return 'a';\n}\n---\n<h1>{title()}</h1>\n";
1196 assert_eq!(range_of("Page.astro", astro, "title()"), Some((2, 4)));
1197
1198 let mdx =
1199 "# Post\n\nSome prose.\n\nexport const meta = {\n title: 'Post',\n};\n\nMore prose.\n";
1200 assert_eq!(range_of("post.mdx", mdx, "meta ="), Some((5, 7)));
1201 }
1202}