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    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
501/// The uses of the import bindings of one file, split in export positions
502/// and all other positions.
503struct BindingUses<'s> {
504    imports: FxHashSet<&'s str>,
505    /// The start offsets of the top-level `export { .. }`, `export default`
506    /// and `export =` statements. An export in a TS namespace or an ambient
507    /// module is a normal use.
508    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
562/// The search term of the default export of `file_id`: the name of the
563/// declaration, when it has one.
564fn 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
579/// Whether `byte` belongs to a word, with the word characters of `grep -w`:
580/// letters, digits and the underscore. Non-ASCII bytes count as letters.
581const fn is_word_byte(byte: u8) -> bool {
582    byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80
583}
584
585/// Whether `source` contains `word` as a whole-word token, as `grep -w` sees
586/// it. Matches in comments and strings count, because a text search finds
587/// them too.
588#[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
610/// Strip `keyword` from the front of `text` when a non-word character follows.
611fn 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/// The declaration name of a default export whose source starts at `start`.
621///
622/// Handles `export default function Name`, `export default class Name`,
623/// `export default Name;` and the `Name as default` export specifier.
624/// Returns `None` for an anonymous declaration or an expression.
625#[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/// The top-level statement that declares one export.
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub struct ExportDeclaration {
663    /// 1-based first and last line of the statement.
664    pub lines: (u32, u32),
665    /// True when the export is a variable whose initializer is a literal, or
666    /// an object or array of literals.
667    pub literal_value: bool,
668    /// True when the export comes from a re-export statement
669    /// (`export { x } from`, `export * as ns from`). Such a statement declares
670    /// nothing.
671    pub is_re_export: bool,
672}
673
674/// The 1-based line range of the top-level statement that declares each
675/// export of a source file, keyed by the byte offset of the export span.
676///
677/// An `export { name }` specifier maps to the statement that declares `name`
678/// in the same file. For a Vue or Svelte SFC, an Astro component or an MDX
679/// document, only the script sections are parsed, and the statement offsets
680/// map back to the file. A file that does not parse maps to nothing, and the
681/// caller falls back to the export line.
682#[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/// The declaring statement of each export of a source file, keyed by the
695/// byte offset of the export span. One parse gives the line range and the
696/// literal-initializer flag. See [`declaration_line_ranges`] for the mapping
697/// rules.
698#[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(&section.extraction.body, section.source_type)
709                    .into_iter()
710                    .map(|statement| statement.remapped(&section.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    /// Declared variables whose initializer is a literal value.
757    literal_names: Vec<String>,
758    is_specifier_list: bool,
759    is_re_export: bool,
760}
761
762impl TopLevelStatement {
763    /// Map the statement span from the extracted script to the file.
764    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
774/// The top-level statements of a script. A script that does not parse has
775/// none.
776fn 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
822/// The variables of a declaration whose initializer is a literal value.
823fn 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
841/// How deep a literal object or array may nest before the check gives up.
842const LITERAL_DEPTH_LIMIT: usize = 16;
843
844/// Whether an expression is a literal, or an object or array of literals.
845/// A function call, an identifier or a computed key makes it a non-literal.
846fn 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        // The module graph credits the importers of `export default b` and
1003        // `export = c` to the origin export, the same as `export { .. }`.
1004        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}