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