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