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