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