Skip to main content

fallow_engine/
search_visibility.rs

1//! Literal-search check for the consumers of an export.
2//!
3//! An agent finds the users of an export with a whole-word text search for
4//! its name. The module graph knows every consumer, also the consumers that
5//! such a search does not find: a default import under another name, a barrel
6//! alias, a whole-namespace use. This module reads consumer sources on demand,
7//! only for the exports that a caller checks, and compares the graph edge with
8//! what the text search sees.
9
10use 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/// The result of the literal-search check for one consumer.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SearchCheck {
22    /// Whether a whole-word search for the export name finds the consumer.
23    pub visibility: SearchVisibility,
24    /// Why the search does not find the uses. `None` for `found`.
25    pub reason: Option<SearchGapReason>,
26    /// The name to search for instead, when one name finds the uses.
27    pub search_as: Option<String>,
28    /// 1-based line of the import statement in the consumer.
29    pub line: Option<u32>,
30}
31
32/// One source file read from disk, with its line offsets.
33struct Source {
34    text: String,
35    offsets: Vec<u32>,
36}
37
38/// Consumer sources, read once per file and only when a check needs them.
39pub(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    /// Whether `file_id` only exports its import binding `local` again and
55    /// has no other use of it. The file is parsed once, and only when its text
56    /// can export `local` again.
57    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
96/// The export under check: the name a search uses and the modules that expose
97/// the binding, each with the names it exposes.
98pub(crate) struct SearchTarget {
99    term: Option<String>,
100    exposures: FxHashMap<FileId, Vec<String>>,
101}
102
103impl SearchTarget {
104    /// Build the target for `export_name` of `file_id`.
105    ///
106    /// The search term of a named export is its name. The search term of a
107    /// default export is the name of its declaration (`export default function
108    /// Widget`), because nobody searches for `default`. An anonymous default
109    /// export has no search term.
110    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    /// Check one graph reference against a text search for the export name.
137    /// Returns `None` when the consumer source cannot be read.
138    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            // The graph does not credit the importers of a forwarded
167            // namespace binding, so the forwarding file stays a consumer.
168            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    /// Find the binding behind a graph reference. The import span and an
236    /// exposing module identify the binding, the same way the graph credits
237    /// the reference. When the span names no import, any import of the export
238    /// in the same file wins over a re-export: a barrel that also uses the
239    /// export is a consumer, and only a barrel without such an import passes
240    /// the export through.
241    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    /// The import binding of the export in `from_file`, restricted to the
252    /// import at `span` when one is given.
253    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
338/// How a consumer binds the export.
339enum 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    /// The consumer forwards the export and has no import of it.
351    ReExport,
352}
353
354/// Whether `source` can export `local` again: in an `export { .. }` list, or
355/// as the whole expression of `export default` or `export =`.
356///
357/// [`forwarded_import_bindings`] marks a binding as exported only in these
358/// positions. A `false` here proves that no such position names `local`, so
359/// the parse cannot return it. The scan answers `true` for any text that it
360/// cannot read with certainty: an escaped identifier, a comment after
361/// `export`, or a comment, string or non-ASCII character inside the braces.
362fn 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        // Only an ASCII identifier byte can join `export` to a longer name.
370        // JavaScript whitespace such as a no-break space is not ASCII.
371        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
416/// Strip leading whitespace as a script reads it: Unicode white space, line
417/// terminators and the byte order mark.
418fn trim_script_whitespace(text: &str) -> &str {
419    text.trim_start_matches(|ch: char| ch.is_whitespace() || ch == '\u{feff}')
420}
421
422/// The import bindings of a source file that the file only exports again
423/// with a top-level `export { .. }` (`import { a } from './x'; export { a }`),
424/// `export default a` or `export = a`, and does not use in any other place.
425///
426/// The module graph credits the importers of each of these forms to the
427/// origin export (issue #3392), so the file is not the only signal.
428///
429/// Only plain script files are read. A Vue, Svelte, Astro or MDX file can use
430/// a binding in its template, so it has no such bindings. A file that does not
431/// parse has none.
432#[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
491/// The uses of the import bindings of one file, split in export positions
492/// and all other positions.
493struct BindingUses<'s> {
494    imports: FxHashSet<&'s str>,
495    /// The start offsets of the top-level `export { .. }`, `export default`
496    /// and `export =` statements. An export in a TS namespace or an ambient
497    /// module is a normal use.
498    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
552/// The search term of the default export of `file_id`: the name of the
553/// declaration, when it has one.
554fn 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
569/// Whether `byte` belongs to a word, with the word characters of `grep -w`:
570/// letters, digits and the underscore. Non-ASCII bytes count as letters.
571const fn is_word_byte(byte: u8) -> bool {
572    byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80
573}
574
575/// Whether `source` contains `word` as a whole-word token, as `grep -w` sees
576/// it. Matches in comments and strings count, because a text search finds
577/// them too.
578#[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
600/// Strip `keyword` from the front of `text` when a non-word character follows.
601fn 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/// The declaration name of a default export whose source starts at `start`.
611///
612/// Handles `export default function Name`, `export default class Name`,
613/// `export default Name;` and the `Name as default` export specifier.
614/// Returns `None` for an anonymous declaration or an expression.
615#[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/// The top-level statement that declares one export.
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub struct ExportDeclaration {
653    /// 1-based first and last line of the statement.
654    pub lines: (u32, u32),
655    /// True when the export is a variable whose initializer is a literal, or
656    /// an object or array of literals.
657    pub literal_value: bool,
658    /// True when the export comes from a re-export statement
659    /// (`export { x } from`, `export * as ns from`). Such a statement declares
660    /// nothing.
661    pub is_re_export: bool,
662}
663
664/// The 1-based line range of the top-level statement that declares each
665/// export of a source file, keyed by the byte offset of the export span.
666///
667/// An `export { name }` specifier maps to the statement that declares `name`
668/// in the same file. For a Vue or Svelte SFC, an Astro component or an MDX
669/// document, only the script sections are parsed, and the statement offsets
670/// map back to the file. A file that does not parse maps to nothing, and the
671/// caller falls back to the export line.
672#[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/// The declaring statement of each export of a source file, keyed by the
685/// byte offset of the export span. One parse gives the line range and the
686/// literal-initializer flag. See [`declaration_line_ranges`] for the mapping
687/// rules.
688#[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(&section.extraction.body, section.source_type)
699                    .into_iter()
700                    .map(|statement| statement.remapped(&section.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    /// Declared variables whose initializer is a literal value.
747    literal_names: Vec<String>,
748    is_specifier_list: bool,
749    is_re_export: bool,
750}
751
752impl TopLevelStatement {
753    /// Map the statement span from the extracted script to the file.
754    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
764/// The top-level statements of a script. A script that does not parse has
765/// none.
766fn 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
802/// The variables of a declaration whose initializer is a literal value.
803fn 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
821/// How deep a literal object or array may nest before the check gives up.
822const LITERAL_DEPTH_LIMIT: usize = 16;
823
824/// Whether an expression is a literal, or an object or array of literals.
825/// A function call, an identifier or a computed key makes it a non-literal.
826fn 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        // The module graph credits the importers of `export default b` and
983        // `export = c` to the origin export, the same as `export { .. }`.
984        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}