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