Skip to main content

fallow_graph/resolve/
auto_imports.rs

1//! Synthetic graph edges for convention auto-imports.
2//!
3//! A framework such as Nuxt makes a file's exports available by name with no
4//! `import` statement. The plugin run turns the convention directories into
5//! [`AutoImportRule`]s, and this pass matches the names each module references
6//! against those rules. A match adds a [`ResolvedImport`] with a
7//! [`ResolveResult::SyntheticAutoImport`] target, so the graph builder credits
8//! the edge like any other import.
9
10use std::path::{Path, PathBuf};
11
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use fallow_config::{AutoImportKind, AutoImportRule};
15use fallow_types::discover::FileId;
16use fallow_types::extract::{ImportedName, ModuleInfo, ReExportInfo};
17
18use super::types::{ResolveResult, ResolvedImport, ResolvedModule};
19use super::{is_auto_import_builtin, synthetic_auto_import_info};
20
21/// Framework modules that re-export a convention auto-import surface.
22///
23/// Nuxt generates `#components` and `#imports` at build time, and importing a
24/// name from one is the explicit spelling of the bare reference the scanners
25/// already credit (issue #2737).
26const AUTO_IMPORT_VIRTUAL_MODULES: &[&str] = &[COMPONENTS_MODULE, IMPORTS_MODULE];
27const COMPONENTS_MODULE: &str = "#components";
28const IMPORTS_MODULE: &str = "#imports";
29
30/// One file that reads an auto-import virtual module in a way the graph cannot
31/// follow to single names, such as a spread of a `#components` namespace.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct UnreadableAutoImportRead {
34    /// The file that holds the read.
35    pub file_id: FileId,
36    /// The virtual module it reads (`#components` or `#imports`).
37    pub module: &'static str,
38}
39
40/// One provider of an auto-import name.
41struct AutoImportTarget<'a> {
42    file_id: FileId,
43    kind: AutoImportKind,
44    /// The roots whose files see the name, merged over every rule for this
45    /// file and kind. `None` means every file.
46    scope: Option<Vec<&'a Path>>,
47}
48
49type AutoImportTable<'a> = FxHashMap<&'a str, Vec<AutoImportTarget<'a>>>;
50
51/// What one module reads from the auto-import virtual modules.
52#[derive(Default)]
53struct VirtualModuleReads<'a> {
54    /// Names read one by one, from either module.
55    names: Vec<&'a str>,
56    /// Modules read in a way the graph cannot narrow to names, with the file
57    /// that holds each read.
58    unreadable: Vec<UnreadableAutoImportRead>,
59}
60
61/// Synthesize module-graph edges for convention auto-imports.
62///
63/// For each module, every captured `auto_import_candidates` name is matched
64/// against the active plugins' auto-import table; on a hit a synthetic
65/// [`ResolvedImport`] is added so the existing graph builder credits the edge.
66/// A rule credits only a module under one of the roots in its scope, so a name
67/// in one app does not credit the file of the same name in a sibling app
68/// (issue #2752). Inside that scope, a name collision across files credits
69/// every match, which keeps each provider reachable. Resolution is recomputed
70/// from the live file index each run.
71///
72/// A name a module reads from a framework's auto-import module
73/// ([`AUTO_IMPORT_VIRTUAL_MODULES`]) is credited through the same table: the
74/// module is generated at build time, so no resolver can follow the specifier,
75/// while the name means exactly what the bare reference means. A read that the
76/// graph cannot narrow to names credits every name of that module.
77pub(super) fn synthesize_auto_import_edges(
78    resolved: &mut [ResolvedModule],
79    modules: &[ModuleInfo],
80    auto_imports: &[AutoImportRule],
81    path_to_id: &FxHashMap<&Path, FileId>,
82    raw_path_to_id: &FxHashMap<&Path, FileId>,
83) {
84    if auto_imports.is_empty() {
85        return;
86    }
87
88    let mut table: AutoImportTable<'_> = FxHashMap::default();
89    for rule in auto_imports {
90        let source = rule.source.as_path();
91        let Some(file_id) = raw_path_to_id
92            .get(source)
93            .or_else(|| path_to_id.get(source))
94            .copied()
95        else {
96            continue;
97        };
98        add_table_target(&mut table, rule, file_id);
99    }
100    if table.is_empty() {
101        return;
102    }
103
104    let virtual_reads: Vec<(usize, Vec<String>, Vec<&'static str>)> =
105        collect_virtual_module_reads(resolved)
106            .into_iter()
107            .filter_map(|(index, reads)| {
108                let names: Vec<String> = reads.names.into_iter().map(str::to_owned).collect();
109                let mut modules: Vec<&'static str> =
110                    reads.unreadable.iter().map(|read| read.module).collect();
111                modules.sort_unstable();
112                modules.dedup();
113                (!names.is_empty() || !modules.is_empty()).then_some((index, names, modules))
114            })
115            .collect();
116    let mut sorted_names: Vec<&str> = table.keys().copied().collect();
117    sorted_names.sort_unstable();
118
119    let candidates: FxHashMap<FileId, &[String]> = modules
120        .iter()
121        .filter(|module| !module.auto_import_candidates.is_empty())
122        .map(|module| (module.file_id, module.auto_import_candidates.as_slice()))
123        .collect();
124    for module in resolved.iter_mut() {
125        if let Some(names) = candidates.get(&module.file_id) {
126            for name in *names {
127                credit_auto_import_name(module, name, &table, None);
128            }
129        }
130    }
131    for (index, names, modules) in virtual_reads {
132        let module = &mut resolved[index];
133        for name in &names {
134            credit_auto_import_name(module, name, &table, None);
135        }
136        for virtual_module in modules {
137            for name in &sorted_names {
138                credit_auto_import_name(module, name, &table, Some(virtual_module));
139            }
140        }
141    }
142}
143
144/// Add one rule to the table. Rules for the same file and kind share one
145/// target, whose scope is the union of theirs, so a name earns one edge per
146/// provider however many roots declared it.
147fn add_table_target<'a>(
148    table: &mut AutoImportTable<'a>,
149    rule: &'a AutoImportRule,
150    file_id: FileId,
151) {
152    let targets = table.entry(rule.name.as_str()).or_default();
153    let rule_scope: Option<Vec<&Path>> =
154        (!rule.scope.is_empty()).then(|| rule.scope.iter().map(PathBuf::as_path).collect());
155    let Some(target) = targets
156        .iter_mut()
157        .find(|target| target.file_id == file_id && target.kind == rule.kind)
158    else {
159        targets.push(AutoImportTarget {
160            file_id,
161            kind: rule.kind,
162            scope: rule_scope,
163        });
164        return;
165    };
166    match (&mut target.scope, rule_scope) {
167        (Some(scope), Some(extra)) => {
168            for root in extra {
169                if !scope.contains(&root) {
170                    scope.push(root);
171                }
172            }
173        }
174        (scope, _) => *scope = None,
175    }
176}
177
178/// The reads of an auto-import virtual module that the graph cannot narrow to
179/// single names, in file order. Each one credits every name of its module and
180/// earns the file a `plugin-effect-not-modeled` diagnostic.
181#[must_use]
182pub fn unreadable_auto_import_reads(resolved: &[ResolvedModule]) -> Vec<UnreadableAutoImportRead> {
183    let mut reads: Vec<UnreadableAutoImportRead> = collect_virtual_module_reads(resolved)
184        .into_iter()
185        .flat_map(|(_, reads)| reads.unreadable)
186        .collect();
187    reads.sort_unstable_by(|a, b| a.file_id.0.cmp(&b.file_id.0).then(a.module.cmp(b.module)));
188    reads.dedup();
189    reads
190}
191
192/// The virtual-module reads of every module that reads one, keyed by the
193/// module's index in `resolved`.
194fn collect_virtual_module_reads(
195    resolved: &[ResolvedModule],
196) -> Vec<(usize, VirtualModuleReads<'_>)> {
197    let forwards_star = resolved.iter().any(|module| {
198        module
199            .re_exports
200            .iter()
201            .any(|re| is_star_from_virtual_module(&re.info))
202    });
203    let importers = forwards_star.then(|| ImporterIndex::build(resolved));
204
205    resolved
206        .iter()
207        .enumerate()
208        .filter_map(|(index, module)| {
209            let reads = virtual_module_reads(module, resolved, importers.as_ref());
210            (!reads.names.is_empty() || !reads.unreadable.is_empty()).then_some((index, reads))
211        })
212        .collect()
213}
214
215/// The names one module reads from an auto-import virtual module: a named
216/// import or re-export, a member access on a namespace import, and, for a
217/// module that holds `export * from '#components'`, the names its importers
218/// take from it.
219fn virtual_module_reads<'a>(
220    module: &'a ResolvedModule,
221    resolved: &'a [ResolvedModule],
222    importers: Option<&ImporterIndex>,
223) -> VirtualModuleReads<'a> {
224    let mut reads = VirtualModuleReads::default();
225    for import in &module.resolved_imports {
226        let Some(virtual_module) = auto_import_virtual_module(&import.info.source) else {
227            continue;
228        };
229        match &import.info.imported_name {
230            ImportedName::Named(name) => reads.names.push(name.as_str()),
231            ImportedName::Namespace => {
232                read_namespace(module, &import.info.local_name, virtual_module, &mut reads);
233            }
234            ImportedName::Default | ImportedName::SideEffect => {}
235        }
236    }
237    for re_export in &module.re_exports {
238        let Some(virtual_module) = auto_import_virtual_module(&re_export.info.source) else {
239            continue;
240        };
241        if re_export.info.imported_name != "*" {
242            reads.names.push(re_export.info.imported_name.as_str());
243        } else if re_export.info.exported_name == "*" {
244            if let Some(importers) = importers {
245                let mut visited = FxHashSet::default();
246                forwarded_reads(
247                    module,
248                    virtual_module,
249                    resolved,
250                    importers,
251                    &mut visited,
252                    &mut reads,
253                );
254            }
255        } else {
256            reads.unreadable.push(UnreadableAutoImportRead {
257                file_id: module.file_id,
258                module: virtual_module,
259            });
260        }
261    }
262    reads
263}
264
265/// Record what a module reads through the namespace binding `local`: each
266/// member access, or the whole module when the binding escapes as an object.
267fn read_namespace<'a>(
268    module: &'a ResolvedModule,
269    local: &str,
270    virtual_module: &'static str,
271    reads: &mut VirtualModuleReads<'a>,
272) {
273    if module.unused_import_bindings.contains(local) {
274        return;
275    }
276    let mut accessed = false;
277    for access in module.member_accesses.iter() {
278        if access.object == local {
279            reads.names.push(access.member.as_str());
280            accessed = true;
281        }
282    }
283    let whole = module.whole_object_uses.iter().any(|name| name == local)
284        || module
285            .exports
286            .iter()
287            .any(|export| export.local_name.as_deref() == Some(local));
288    if whole || !accessed {
289        reads.unreadable.push(UnreadableAutoImportRead {
290            file_id: module.file_id,
291            module: virtual_module,
292        });
293    }
294}
295
296/// Record the names that the importers of `forwarder` take from it. The
297/// forwarder passes these names on from `virtual_module` through a star
298/// re-export, so each name is a read of that module. An importer that takes
299/// the forwarder as a whole object records an unreadable read on its own file.
300fn forwarded_reads<'a>(
301    forwarder: &ResolvedModule,
302    virtual_module: &'static str,
303    resolved: &'a [ResolvedModule],
304    importers: &ImporterIndex,
305    visited: &mut FxHashSet<FileId>,
306    reads: &mut VirtualModuleReads<'a>,
307) {
308    if !visited.insert(forwarder.file_id) {
309        return;
310    }
311    for &index in importers.of(forwarder.file_id) {
312        let importer = &resolved[index];
313        let edges = importer
314            .resolved_imports
315            .iter()
316            .chain(&importer.resolved_dynamic_imports)
317            .filter(|import| imports_file(import, forwarder.file_id));
318        for import in edges {
319            match &import.info.imported_name {
320                ImportedName::Named(name) => reads.names.push(name.as_str()),
321                ImportedName::Namespace => {
322                    let mut importer_reads = VirtualModuleReads::default();
323                    read_namespace(
324                        importer,
325                        &import.info.local_name,
326                        virtual_module,
327                        &mut importer_reads,
328                    );
329                    reads.names.extend(importer_reads.names);
330                    reads.unreadable.extend(importer_reads.unreadable);
331                }
332                ImportedName::Default | ImportedName::SideEffect => {}
333            }
334        }
335        for re_export in &importer.re_exports {
336            if re_export.target.internal_file_id() != Some(forwarder.file_id) {
337                continue;
338            }
339            if re_export.info.imported_name != "*" {
340                reads.names.push(re_export.info.imported_name.as_str());
341            } else if re_export.info.exported_name == "*" {
342                forwarded_reads(
343                    importer,
344                    virtual_module,
345                    resolved,
346                    importers,
347                    visited,
348                    reads,
349                );
350            } else {
351                reads.unreadable.push(UnreadableAutoImportRead {
352                    file_id: importer.file_id,
353                    module: virtual_module,
354                });
355            }
356        }
357    }
358}
359
360/// Whether a real (not synthetic) import edge targets `file_id`.
361fn imports_file(import: &ResolvedImport, file_id: FileId) -> bool {
362    !import.target.is_synthetic_auto_import() && import.target.internal_file_id() == Some(file_id)
363}
364
365/// For each file, the indexes of the modules that import or re-export it.
366struct ImporterIndex {
367    importers: FxHashMap<FileId, Vec<usize>>,
368}
369
370impl ImporterIndex {
371    fn build(resolved: &[ResolvedModule]) -> Self {
372        let mut importers: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
373        for (index, module) in resolved.iter().enumerate() {
374            let targets = module
375                .resolved_imports
376                .iter()
377                .chain(&module.resolved_dynamic_imports)
378                .filter(|import| !import.target.is_synthetic_auto_import())
379                .map(|import| &import.target)
380                .chain(module.re_exports.iter().map(|re| &re.target))
381                .filter_map(ResolveResult::internal_file_id);
382            for target in targets {
383                let entry = importers.entry(target).or_default();
384                if entry.last() != Some(&index) {
385                    entry.push(index);
386                }
387            }
388        }
389        Self { importers }
390    }
391
392    fn of(&self, file_id: FileId) -> &[usize] {
393        self.importers.get(&file_id).map_or(&[], Vec::as_slice)
394    }
395}
396
397/// Whether a re-export is `export * from` an auto-import virtual module.
398fn is_star_from_virtual_module(info: &ReExportInfo) -> bool {
399    info.imported_name == "*"
400        && info.exported_name == "*"
401        && auto_import_virtual_module(&info.source).is_some()
402}
403
404/// The auto-import virtual module a specifier names, if any.
405fn auto_import_virtual_module(source: &str) -> Option<&'static str> {
406    if !source.starts_with('#') {
407        return None;
408    }
409    AUTO_IMPORT_VIRTUAL_MODULES
410        .iter()
411        .copied()
412        .find(|module| *module == source)
413}
414
415/// Whether a rule kind is a name that `virtual_module` provides: components
416/// come from `#components`, composables and utils from `#imports`.
417fn kind_belongs_to(kind: AutoImportKind, virtual_module: &str) -> bool {
418    matches!(kind, AutoImportKind::DefaultComponent) == (virtual_module == COMPONENTS_MODULE)
419}
420
421/// Whether a target scope covers `path`. `None` covers every file.
422fn scope_covers(scope: Option<&[&Path]>, path: &Path) -> bool {
423    scope.is_none_or(|roots| roots.iter().any(|root| path.starts_with(root)))
424}
425
426/// Add the synthetic edges one referenced name earns from the auto-import
427/// table. With `only_module` set, only the rules of that virtual module count.
428fn credit_auto_import_name(
429    module: &mut ResolvedModule,
430    name: &str,
431    table: &AutoImportTable<'_>,
432    only_module: Option<&str>,
433) {
434    if is_auto_import_builtin(name) {
435        return;
436    }
437    let Some(targets) = table.get(name) else {
438        return;
439    };
440    for target in targets {
441        if target.file_id == module.file_id
442            || !scope_covers(target.scope.as_deref(), &module.path)
443            || only_module
444                .is_some_and(|virtual_module| !kind_belongs_to(target.kind, virtual_module))
445        {
446            continue;
447        }
448        module.resolved_imports.push(ResolvedImport {
449            info: synthetic_auto_import_info(name, target.kind),
450            target: ResolveResult::SyntheticAutoImport(target.file_id),
451        });
452    }
453}