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