Skip to main content

brokk_bifrost_python/
usage_index.rs

1//! Analyzer-level re-export + importer index for Python, so both usage paths
2//! resolve references through analyzer state. Built once from the analyzer's own
3//! module index + `export_index_of` / `import_binder_of` and cached on
4//! [`PythonAnalyzer`] (dropped on `update`/`update_all` like the other caches).
5//!
6//! Forward export seeds follow re-export chains
7//! ([`PythonUsageIndex::seeds_for_target`]), and the reverse importer index
8//! resolves which local names in an importer bind a seed
9//! ([`PythonUsageIndex::matching_edges_for_importer`]). Candidate-file narrowing
10//! stays in the forward path's scoped import closure (`PythonProjectGraph`), not
11//! here. Module resolution reuses the analyzer's existing [`python_module_name`]
12//! + [`resolve_python_relative_module`].
13
14use brokk_bifrost_core::analyzer::query_token::QueryToken;
15use brokk_bifrost_core::analyzer::usages::local_inference::LocalBindingsSnapshot;
16use brokk_bifrost_core::analyzer::usages::model::{
17    ExportEntry, ExportIndex, ImportBinder, ImportBinding, ImportKind,
18};
19use brokk_bifrost_core::analyzer::usages::reexport_seeds;
20use brokk_bifrost_core::analyzer::usages::{ImportEdge, ImportEdgeKind};
21use brokk_bifrost_core::analyzer::{CodeUnit, Language, ProjectFile};
22use brokk_bifrost_core::hash::{HashMap, HashSet};
23use std::collections::BTreeSet;
24use std::sync::{Arc, Mutex};
25
26use crate::declarations::python_module_name;
27use crate::graph_support::{
28    PythonModuleSpellings, PythonSource, PythonUsageSource, export_index_from_file_facts,
29    import_binder_from_imports, import_bindings_from_imports, python_module_file_under_source_root,
30    retain_modules_for_importer,
31};
32use crate::imports::{
33    literal_importlib_modules, module_replacement_of, resolve_python_module_specifier,
34};
35
36/// Re-export and reverse-import indices over the Python workspace.
37#[derive(Debug, Default)]
38pub struct PythonUsageIndex {
39    module_index: HashMap<String, Vec<ProjectFile>>,
40    module_spellings: Arc<PythonModuleSpellings>,
41    exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>>,
42    reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>>,
43    star_reexports: HashMap<ProjectFile, Vec<ProjectFile>>,
44    importer_reverse: HashMap<ProjectFile, Vec<ImportEdge>>,
45    literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>>,
46    module_binding_timelines: Mutex<HashMap<ProjectFile, Arc<ModuleBindingTimeline>>>,
47    scope_facts_by_file: Mutex<HashMap<ProjectFile, Arc<PythonScopeFacts>>>,
48}
49
50pub type ModuleBindingTimeline = HashMap<String, Vec<ModuleBindingEvent>>;
51pub type PythonScopeFacts = HashMap<CodeUnit, LocalBindingsSnapshot<String>>;
52
53#[derive(Clone, Debug)]
54pub struct ModuleBindingEvent {
55    pub visible_from: usize,
56    pub conditional: bool,
57    pub kind: ModuleBindingEventKind,
58}
59
60#[derive(Clone, Debug)]
61pub enum ModuleBindingEventKind {
62    ImportModule {
63        module: String,
64        consumed_attributes: usize,
65    },
66    FromImport {
67        module: String,
68        imported_name: String,
69    },
70    Other,
71}
72
73/// Keep events from the last unconditional binding through `cutoff`.
74pub fn visible_module_binding_suffix(
75    events: &[ModuleBindingEvent],
76    cutoff: usize,
77) -> impl Iterator<Item = &ModuleBindingEvent> {
78    let visible: Vec<_> = events
79        .iter()
80        .filter(|event| event.visible_from <= cutoff)
81        .collect();
82    let start = visible
83        .iter()
84        .rposition(|event| !event.conditional)
85        .unwrap_or(0);
86    visible.into_iter().skip(start)
87}
88
89/// Resolve a module specifier to the files defining it: a leading-dot specifier
90/// is made absolute against the importing file's package, then looked up in the
91/// module index.
92fn resolve_module(
93    module_index: &HashMap<String, Vec<ProjectFile>>,
94    module_spellings: &PythonModuleSpellings,
95    importing_file: &ProjectFile,
96    module_specifier: &str,
97) -> Vec<ProjectFile> {
98    let Some(project) = module_spellings.project() else {
99        return Vec::new();
100    };
101    let resolved_module =
102        resolve_python_module_specifier(project, importing_file, module_specifier);
103    let Some(resolved_module) = resolved_module else {
104        return Vec::new();
105    };
106    let mut files = module_index
107        .get(&resolved_module)
108        .cloned()
109        .unwrap_or_default();
110    // Two sibling source roots can index the same module name, so the root
111    // that owns the importing file decides which of them its import means.
112    retain_modules_for_importer(project, importing_file, &mut files, |file| file);
113    if files.is_empty() {
114        // No module name in the index spells the specifier, because it is
115        // written against a source root the workspace does not state (#3506).
116        files.extend(python_module_file_under_source_root(
117            module_spellings,
118            importing_file,
119            &resolved_module,
120        ));
121    }
122    files
123}
124
125fn is_sys_namespace_binding(binding: &ImportBinding) -> bool {
126    is_namespace_binding_for_module(binding, "sys")
127}
128
129fn is_importlib_namespace_binding(binding: &ImportBinding) -> bool {
130    is_namespace_binding_for_module(binding, "importlib")
131}
132
133fn is_namespace_binding_for_module(binding: &ImportBinding, module: &str) -> bool {
134    binding.kind == ImportKind::Namespace
135        && binding
136            .namespace_imported_module
137            .as_deref()
138            .unwrap_or(&binding.module_specifier)
139            == module
140}
141
142impl PythonUsageIndex {
143    /// Takes [`PythonSource`], not [`PythonUsageSource`]: the cell this
144    /// build fills is only reachable through the latter, so the narrower
145    /// parameter is what stops the build from re-entering it.
146    pub fn build(python: &dyn PythonSource, token: QueryToken<'_>) -> Self {
147        let _scope = brokk_bifrost_core::profiling::scope("PythonUsageIndex::build");
148        let mut files: Vec<ProjectFile> = python
149            .project()
150            .analyzable_files(Language::Python)
151            .map(|set| set.into_iter().collect())
152            .unwrap_or_default();
153        files.sort();
154        files.dedup();
155
156        let module_spellings = python.module_spellings();
157        let mut module_index: HashMap<String, Vec<ProjectFile>> = HashMap::default();
158        let mut exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>> = HashMap::default();
159        let mut binders_by_file: HashMap<ProjectFile, Arc<ImportBinder>> = HashMap::default();
160        let mut import_bindings_by_file: HashMap<ProjectFile, Vec<(String, ImportBinding)>> =
161            HashMap::default();
162        let mut literal_importlib_modules_by_file: HashMap<ProjectFile, HashSet<String>> =
163            HashMap::default();
164        let mut replacement_modules: HashMap<ProjectFile, String> = HashMap::default();
165        python.visit_file_facts(&files, &mut |file, facts| {
166            let module_name = facts
167                .and_then(|facts| {
168                    facts
169                        .top_level_declarations()
170                        .iter()
171                        .find(|unit| unit.is_module())
172                })
173                .map(|unit| unit.fq_name().to_string())
174                .unwrap_or_else(|| python_module_name(python.project(), file));
175            module_index
176                .entry(module_name.clone())
177                .or_default()
178                .push(file.clone());
179            if let Some(facts) = facts {
180                let import_bindings = import_bindings_from_imports(python, file, facts.imports());
181                let binder = Arc::new(import_binder_from_imports(python, file, facts.imports()));
182                if binder.bindings.values().any(is_importlib_namespace_binding) {
183                    literal_importlib_modules_by_file.insert(
184                        file.clone(),
185                        literal_importlib_modules(facts.source(), &binder.bindings),
186                    );
187                }
188                if binder.bindings.values().any(is_sys_namespace_binding)
189                    && let Some(replacement) =
190                        module_replacement_of(python, file, facts.source(), facts.imports())
191                {
192                    replacement_modules.insert(file.clone(), replacement.target_module);
193                }
194                exports_by_file.insert(
195                    file.clone(),
196                    Arc::new(export_index_from_file_facts(
197                        python,
198                        file,
199                        facts,
200                        &module_name,
201                    )),
202                );
203                import_bindings_by_file.insert(file.clone(), import_bindings);
204                binders_by_file.insert(file.clone(), binder);
205            } else {
206                exports_by_file.insert(file.clone(), python.export_index_of(file));
207                let binder = python.import_binder_of(file);
208                let imports = python.import_info_of(token, file);
209                if let Ok(source) = python.project().read_source(file) {
210                    if binder.bindings.values().any(is_importlib_namespace_binding) {
211                        literal_importlib_modules_by_file.insert(
212                            file.clone(),
213                            literal_importlib_modules(&source, &binder.bindings),
214                        );
215                    }
216                    if binder.bindings.values().any(is_sys_namespace_binding)
217                        && let Some(replacement) =
218                            module_replacement_of(python, file, &source, &imports)
219                    {
220                        replacement_modules.insert(file.clone(), replacement.target_module);
221                    }
222                }
223                import_bindings_by_file.insert(
224                    file.clone(),
225                    import_bindings_from_imports(python, file, &imports),
226                );
227                binders_by_file.insert(file.clone(), binder);
228            }
229        });
230        for resolved in module_index.values_mut() {
231            resolved.sort();
232            resolved.dedup();
233        }
234
235        let mut raw_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
236        for (file, target_module) in replacement_modules {
237            let mut targets =
238                resolve_module(&module_index, &module_spellings, &file, &target_module);
239            if targets.len() != 1 {
240                continue;
241            }
242            let target = targets.pop().expect("one module replacement target");
243            if target != file {
244                raw_replacements.insert(file, target);
245            }
246        }
247
248        let mut canonical_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
249        for file in raw_replacements.keys() {
250            if let Some(target) = canonical_module_replacement(file, &raw_replacements) {
251                canonical_replacements.insert(file.clone(), target);
252            }
253        }
254        for resolved in module_index.values_mut() {
255            let mut seen = HashSet::default();
256            resolved.retain_mut(|file| {
257                if let Some(canonical) = canonical_replacements.get(file) {
258                    *file = canonical.clone();
259                }
260                seen.insert(file.clone())
261            });
262        }
263
264        let mut reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>> =
265            HashMap::default();
266        let mut star_reexports: HashMap<ProjectFile, Vec<ProjectFile>> = HashMap::default();
267        for (file, exports) in &exports_by_file {
268            for (exported_name, entry) in &exports.exports_by_name {
269                match entry {
270                    ExportEntry::Local { local_name } => {
271                        let Some(binder) = binders_by_file.get(file) else {
272                            continue;
273                        };
274                        let Some(binding) = binder.bindings.get(local_name) else {
275                            continue;
276                        };
277                        let Some(imported_name) = binding.imported_name.as_ref() else {
278                            continue;
279                        };
280                        for resolved_file in resolve_module(
281                            &module_index,
282                            &module_spellings,
283                            file,
284                            &binding.module_specifier,
285                        ) {
286                            reexport_edges
287                                .entry((resolved_file, imported_name.clone()))
288                                .or_default()
289                                .push((file.clone(), exported_name.clone()));
290                        }
291                    }
292                    // A re-exported module is a terminal binding: it carries
293                    // no member name, so it opens no name-to-name edge.
294                    ExportEntry::Default { .. } | ExportEntry::ReexportedModule { .. } => {}
295                    ExportEntry::ReexportedNamed {
296                        module_specifier,
297                        imported_name,
298                    } => {
299                        for resolved_file in
300                            resolve_module(&module_index, &module_spellings, file, module_specifier)
301                        {
302                            reexport_edges
303                                .entry((resolved_file, imported_name.clone()))
304                                .or_default()
305                                .push((file.clone(), exported_name.clone()));
306                        }
307                    }
308                }
309            }
310            for star in &exports.reexport_stars {
311                for resolved_file in resolve_module(
312                    &module_index,
313                    &module_spellings,
314                    file,
315                    &star.module_specifier,
316                ) {
317                    star_reexports
318                        .entry(resolved_file)
319                        .or_default()
320                        .push(file.clone());
321                }
322            }
323        }
324
325        let importer_reverse = build_importer_reverse(
326            &module_index,
327            &module_spellings,
328            &files,
329            &import_bindings_by_file,
330            &exports_by_file,
331        );
332        let mut literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>> =
333            HashMap::default();
334        for (importer, modules) in literal_importlib_modules_by_file {
335            for module in modules {
336                for target_file in
337                    resolve_module(&module_index, &module_spellings, &importer, &module)
338                {
339                    literal_importlib_importers
340                        .entry(target_file.clone())
341                        .or_default()
342                        .push(importer.clone());
343                }
344            }
345        }
346        for importers in literal_importlib_importers.values_mut() {
347            importers.sort();
348            importers.dedup();
349        }
350
351        Self {
352            module_index,
353            module_spellings,
354            exports_by_file,
355            reexport_edges,
356            star_reexports,
357            importer_reverse,
358            literal_importlib_importers,
359            module_binding_timelines: Mutex::new(HashMap::default()),
360            scope_facts_by_file: Mutex::new(HashMap::default()),
361        }
362    }
363
364    pub fn seeds_for_target(
365        &self,
366        target_file: &ProjectFile,
367        target_short: &str,
368    ) -> BTreeSet<(ProjectFile, String)> {
369        let mut seeds: BTreeSet<(ProjectFile, String)> = BTreeSet::new();
370
371        if let Some(exports) = self.exports_by_file.get(target_file) {
372            for (exported_name, entry) in &exports.exports_by_name {
373                let local = match entry {
374                    ExportEntry::Local { local_name } => Some(local_name.as_str()),
375                    ExportEntry::Default { local_name } => local_name.as_deref(),
376                    ExportEntry::ReexportedNamed { .. } | ExportEntry::ReexportedModule { .. } => {
377                        None
378                    }
379                };
380                if let Some(local_name) = local
381                    && local_name == target_short
382                {
383                    seeds.insert((target_file.clone(), exported_name.clone()));
384                }
385            }
386        }
387
388        reexport_seeds::expand_reexport_seeds(
389            seeds,
390            &self.reexport_edges,
391            &self.star_reexports,
392            |name| !name.starts_with('_'),
393        )
394    }
395
396    pub fn matching_edges_for_importer(
397        &self,
398        importer: &ProjectFile,
399        seeds: &BTreeSet<(ProjectFile, String)>,
400    ) -> Vec<ImportEdge> {
401        reexport_seeds::matching_edges_for_importer(&self.importer_reverse, importer, seeds)
402    }
403
404    pub fn importer_files_for_seeds(
405        &self,
406        seeds: &BTreeSet<(ProjectFile, String)>,
407    ) -> HashSet<ProjectFile> {
408        let mut importers = HashSet::default();
409        for (target_file, _) in seeds {
410            if let Some(edges) = self.importer_reverse.get(target_file) {
411                importers.extend(
412                    edges
413                        .iter()
414                        .filter(|edge| reexport_seeds::edge_matches_seed(edge, seeds))
415                        .map(|edge| edge.importer.clone()),
416                );
417            }
418            if let Some(dynamic_importers) = self.literal_importlib_importers.get(target_file) {
419                importers.extend(dynamic_importers.iter().cloned());
420            }
421        }
422        importers
423    }
424
425    pub fn resolve_module_files(
426        &self,
427        importing_file: &ProjectFile,
428        module_specifier: &str,
429    ) -> Vec<ProjectFile> {
430        resolve_module(
431            &self.module_index,
432            &self.module_spellings,
433            importing_file,
434            module_specifier,
435        )
436    }
437
438    pub fn module_binding_timeline(
439        &self,
440        file: &ProjectFile,
441        build: impl FnOnce() -> ModuleBindingTimeline,
442    ) -> Arc<ModuleBindingTimeline> {
443        if let Some(cached) = self
444            .module_binding_timelines
445            .lock()
446            .expect("Python module-binding timeline cache mutex poisoned")
447            .get(file)
448            .cloned()
449        {
450            return cached;
451        }
452
453        let timeline = Arc::new(build());
454        self.module_binding_timelines
455            .lock()
456            .expect("Python module-binding timeline cache mutex poisoned")
457            .entry(file.clone())
458            .or_insert_with(|| timeline.clone())
459            .clone()
460    }
461
462    pub fn scope_facts(
463        &self,
464        file: &ProjectFile,
465        build: impl FnOnce() -> Option<PythonScopeFacts>,
466    ) -> Option<Arc<PythonScopeFacts>> {
467        if let Some(cached) = self
468            .scope_facts_by_file
469            .lock()
470            .expect("Python scope-facts cache mutex poisoned")
471            .get(file)
472            .cloned()
473        {
474            return Some(cached);
475        }
476
477        let facts = Arc::new(build()?);
478        Some(
479            self.scope_facts_by_file
480                .lock()
481                .expect("Python scope-facts cache mutex poisoned")
482                .entry(file.clone())
483                .or_insert_with(|| facts.clone())
484                .clone(),
485        )
486    }
487}
488
489fn canonical_module_replacement(
490    file: &ProjectFile,
491    replacements: &HashMap<ProjectFile, ProjectFile>,
492) -> Option<ProjectFile> {
493    let mut seen = BTreeSet::new();
494    let mut current = file.clone();
495    while let Some(target) = replacements.get(&current) {
496        if !seen.insert(current) {
497            return None;
498        }
499        current = target.clone();
500    }
501    Some(current)
502}
503
504fn build_importer_reverse(
505    module_index: &HashMap<String, Vec<ProjectFile>>,
506    module_spellings: &PythonModuleSpellings,
507    files: &[ProjectFile],
508    bindings_by_file: &HashMap<ProjectFile, Vec<(String, ImportBinding)>>,
509    exports_by_file: &HashMap<ProjectFile, Arc<ExportIndex>>,
510) -> HashMap<ProjectFile, Vec<ImportEdge>> {
511    let mut reverse: HashMap<ProjectFile, Vec<ImportEdge>> = HashMap::default();
512    for file in files {
513        let Some(bindings) = bindings_by_file.get(file) else {
514            continue;
515        };
516        for (local_name, binding) in bindings {
517            let imported_module = binding
518                .namespace_imported_module
519                .as_deref()
520                .unwrap_or(&binding.module_specifier);
521            for target_file in resolve_module(module_index, module_spellings, file, imported_module)
522            {
523                // A glob `from m import *` binds every export of the target file
524                // as a named edge, mirroring the graph it replaces.
525                if matches!(binding.kind, ImportKind::Glob) {
526                    let Some(exports) = exports_by_file.get(&target_file) else {
527                        continue;
528                    };
529                    for export_name in exports.exports_by_name.keys() {
530                        if export_name.starts_with('_') {
531                            continue;
532                        }
533                        reverse
534                            .entry(target_file.clone())
535                            .or_default()
536                            .push(ImportEdge {
537                                importer: file.clone(),
538                                local_name: export_name.clone(),
539                                target_file: target_file.clone(),
540                                kind: ImportEdgeKind::Named(export_name.clone()),
541                            });
542                    }
543                    continue;
544                }
545                let kind = match (binding.kind, binding.imported_name.as_deref()) {
546                    (ImportKind::Default, _) => ImportEdgeKind::Default,
547                    (ImportKind::Namespace, _) => ImportEdgeKind::Namespace,
548                    (ImportKind::Named, Some(name)) => ImportEdgeKind::Named(name.to_string()),
549                    (ImportKind::Named, None) => ImportEdgeKind::Named(local_name.clone()),
550                    // Python binders never emit CommonJsRequire; glob handled above.
551                    (ImportKind::CommonJsRequire, _) | (ImportKind::Glob, _) => continue,
552                };
553                reverse
554                    .entry(target_file.clone())
555                    .or_default()
556                    .push(ImportEdge {
557                        importer: file.clone(),
558                        local_name: local_name.clone(),
559                        target_file,
560                        kind,
561                    });
562            }
563        }
564    }
565    reverse
566}
567
568/// Export seeds for the target, following re-export chains.
569pub fn usage_seeds(
570    python: &dyn PythonUsageSource,
571    target_file: &ProjectFile,
572    target_short: &str,
573) -> BTreeSet<(ProjectFile, String)> {
574    python
575        .usage_index()
576        .seeds_for_target(target_file, target_short)
577}
578
579/// The import edges in `importer` that bind one of the `seeds`.
580pub fn usage_matching_edges(
581    python: &dyn PythonUsageSource,
582    importer: &ProjectFile,
583    seeds: &BTreeSet<(ProjectFile, String)>,
584) -> Vec<ImportEdge> {
585    python
586        .usage_index()
587        .matching_edges_for_importer(importer, seeds)
588}
589
590pub fn usage_importer_files(
591    python: &dyn PythonUsageSource,
592    seeds: &BTreeSet<(ProjectFile, String)>,
593) -> HashSet<ProjectFile> {
594    python.usage_index().importer_files_for_seeds(seeds)
595}
596
597pub fn usage_resolve_module_files(
598    python: &dyn PythonUsageSource,
599    importing_file: &ProjectFile,
600    module_specifier: &str,
601) -> Vec<ProjectFile> {
602    python
603        .usage_index()
604        .resolve_module_files(importing_file, module_specifier)
605}
606
607pub fn usage_module_binding_timeline(
608    python: &dyn PythonUsageSource,
609    file: &ProjectFile,
610    build: impl FnOnce() -> ModuleBindingTimeline,
611) -> Arc<ModuleBindingTimeline> {
612    python.usage_index().module_binding_timeline(file, build)
613}
614
615pub fn usage_scope_facts(
616    python: &dyn PythonUsageSource,
617    file: &ProjectFile,
618    build: impl FnOnce() -> Option<PythonScopeFacts>,
619) -> Option<Arc<PythonScopeFacts>> {
620    python.usage_index().scope_facts(file, build)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use brokk_bifrost_test_macros::hardwon;
627
628    #[test]
629    #[hardwon("59dfb80c3bb8: module replacement chains failed to canonicalize or reject cycles")]
630    fn module_replacement_chains_canonicalize_and_cycles_are_rejected() {
631        let root = tempfile::tempdir().expect("temporary project root");
632        let first = ProjectFile::new(root.path(), "first.py");
633        let second = ProjectFile::new(root.path(), "second.py");
634        let canonical = ProjectFile::new(root.path(), "canonical.py");
635        let chain = HashMap::from_iter([
636            (first.clone(), second.clone()),
637            (second.clone(), canonical.clone()),
638        ]);
639
640        assert_eq!(
641            canonical_module_replacement(&first, &chain),
642            Some(canonical)
643        );
644
645        let cycle = HashMap::from_iter([(first.clone(), second.clone()), (second, first.clone())]);
646        assert_eq!(canonical_module_replacement(&first, &cycle), None);
647    }
648
649    #[test]
650    #[hardwon(
651        "ddf65ec23b34: repeated graph queries rebuilt generation-stable Python binding facts"
652    )]
653    fn module_binding_timeline_is_reused_within_index_generation() {
654        let root = tempfile::tempdir().expect("temporary project root");
655        let file = ProjectFile::new(root.path(), "consumer.py");
656        let index = PythonUsageIndex::default();
657        let first = index.module_binding_timeline(&file, || {
658            ModuleBindingTimeline::from_iter([(
659                "target".to_string(),
660                vec![ModuleBindingEvent {
661                    visible_from: 12,
662                    conditional: false,
663                    kind: ModuleBindingEventKind::Other,
664                }],
665            )])
666        });
667        let second = index.module_binding_timeline(&file, || {
668            panic!("cached timeline should avoid rebuilding the file")
669        });
670
671        assert!(Arc::ptr_eq(&first, &second));
672
673        let first_facts = index
674            .scope_facts(&file, || Some(PythonScopeFacts::default()))
675            .unwrap();
676        let second_facts = index
677            .scope_facts(&file, || {
678                panic!("cached scope facts should avoid rebuilding the file")
679            })
680            .unwrap();
681        assert!(Arc::ptr_eq(&first_facts, &second_facts));
682    }
683}