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(python: &dyn PythonSource, token: QueryToken<'_>) -> Self {
147 let _scope = brokk_bifrost_core::profiling::scope("PythonUsageIndex::build");
148 let mut files: Vec<ProjectFile> = python
149 .project()
150 .analyzable_files(Language::Python)
151 .map(|set| set.into_iter().collect())
152 .unwrap_or_default();
153 files.sort();
154 files.dedup();
155
156 let module_spellings = python.module_spellings();
157 let mut module_index: HashMap<String, Vec<ProjectFile>> = HashMap::default();
158 let mut exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>> = HashMap::default();
159 let mut binders_by_file: HashMap<ProjectFile, Arc<ImportBinder>> = HashMap::default();
160 let mut import_bindings_by_file: HashMap<ProjectFile, Vec<(String, ImportBinding)>> =
161 HashMap::default();
162 let mut literal_importlib_modules_by_file: HashMap<ProjectFile, HashSet<String>> =
163 HashMap::default();
164 let mut replacement_modules: HashMap<ProjectFile, String> = HashMap::default();
165 python.visit_file_facts(&files, &mut |file, facts| {
166 let module_name = facts
167 .and_then(|facts| {
168 facts
169 .top_level_declarations()
170 .iter()
171 .find(|unit| unit.is_module())
172 })
173 .map(|unit| unit.fq_name().to_string())
174 .unwrap_or_else(|| python_module_name(python.project(), file));
175 module_index
176 .entry(module_name.clone())
177 .or_default()
178 .push(file.clone());
179 if let Some(facts) = facts {
180 let import_bindings = import_bindings_from_imports(python, file, facts.imports());
181 let binder = Arc::new(import_binder_from_imports(python, file, facts.imports()));
182 if binder.bindings.values().any(is_importlib_namespace_binding) {
183 literal_importlib_modules_by_file.insert(
184 file.clone(),
185 literal_importlib_modules(facts.source(), &binder.bindings),
186 );
187 }
188 if binder.bindings.values().any(is_sys_namespace_binding)
189 && let Some(replacement) =
190 module_replacement_of(python, file, facts.source(), facts.imports())
191 {
192 replacement_modules.insert(file.clone(), replacement.target_module);
193 }
194 exports_by_file.insert(
195 file.clone(),
196 Arc::new(export_index_from_file_facts(
197 python,
198 file,
199 facts,
200 &module_name,
201 )),
202 );
203 import_bindings_by_file.insert(file.clone(), import_bindings);
204 binders_by_file.insert(file.clone(), binder);
205 } else {
206 exports_by_file.insert(file.clone(), python.export_index_of(file));
207 let binder = python.import_binder_of(file);
208 let imports = python.import_info_of(token, file);
209 if let Ok(source) = python.project().read_source(file) {
210 if binder.bindings.values().any(is_importlib_namespace_binding) {
211 literal_importlib_modules_by_file.insert(
212 file.clone(),
213 literal_importlib_modules(&source, &binder.bindings),
214 );
215 }
216 if binder.bindings.values().any(is_sys_namespace_binding)
217 && let Some(replacement) =
218 module_replacement_of(python, file, &source, &imports)
219 {
220 replacement_modules.insert(file.clone(), replacement.target_module);
221 }
222 }
223 import_bindings_by_file.insert(
224 file.clone(),
225 import_bindings_from_imports(python, file, &imports),
226 );
227 binders_by_file.insert(file.clone(), binder);
228 }
229 });
230 for resolved in module_index.values_mut() {
231 resolved.sort();
232 resolved.dedup();
233 }
234
235 let mut raw_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
236 for (file, target_module) in replacement_modules {
237 let mut targets =
238 resolve_module(&module_index, &module_spellings, &file, &target_module);
239 if targets.len() != 1 {
240 continue;
241 }
242 let target = targets.pop().expect("one module replacement target");
243 if target != file {
244 raw_replacements.insert(file, target);
245 }
246 }
247
248 let mut canonical_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
249 for file in raw_replacements.keys() {
250 if let Some(target) = canonical_module_replacement(file, &raw_replacements) {
251 canonical_replacements.insert(file.clone(), target);
252 }
253 }
254 for resolved in module_index.values_mut() {
255 let mut seen = HashSet::default();
256 resolved.retain_mut(|file| {
257 if let Some(canonical) = canonical_replacements.get(file) {
258 *file = canonical.clone();
259 }
260 seen.insert(file.clone())
261 });
262 }
263
264 let mut reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>> =
265 HashMap::default();
266 let mut star_reexports: HashMap<ProjectFile, Vec<ProjectFile>> = HashMap::default();
267 for (file, exports) in &exports_by_file {
268 for (exported_name, entry) in &exports.exports_by_name {
269 match entry {
270 ExportEntry::Local { local_name } => {
271 let Some(binder) = binders_by_file.get(file) else {
272 continue;
273 };
274 let Some(binding) = binder.bindings.get(local_name) else {
275 continue;
276 };
277 let Some(imported_name) = binding.imported_name.as_ref() else {
278 continue;
279 };
280 for resolved_file in resolve_module(
281 &module_index,
282 &module_spellings,
283 file,
284 &binding.module_specifier,
285 ) {
286 reexport_edges
287 .entry((resolved_file, imported_name.clone()))
288 .or_default()
289 .push((file.clone(), exported_name.clone()));
290 }
291 }
292 ExportEntry::Default { .. } | ExportEntry::ReexportedModule { .. } => {}
295 ExportEntry::ReexportedNamed {
296 module_specifier,
297 imported_name,
298 } => {
299 for resolved_file in
300 resolve_module(&module_index, &module_spellings, file, module_specifier)
301 {
302 reexport_edges
303 .entry((resolved_file, imported_name.clone()))
304 .or_default()
305 .push((file.clone(), exported_name.clone()));
306 }
307 }
308 }
309 }
310 for star in &exports.reexport_stars {
311 for resolved_file in resolve_module(
312 &module_index,
313 &module_spellings,
314 file,
315 &star.module_specifier,
316 ) {
317 star_reexports
318 .entry(resolved_file)
319 .or_default()
320 .push(file.clone());
321 }
322 }
323 }
324
325 let importer_reverse = build_importer_reverse(
326 &module_index,
327 &module_spellings,
328 &files,
329 &import_bindings_by_file,
330 &exports_by_file,
331 );
332 let mut literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>> =
333 HashMap::default();
334 for (importer, modules) in literal_importlib_modules_by_file {
335 for module in modules {
336 for target_file in
337 resolve_module(&module_index, &module_spellings, &importer, &module)
338 {
339 literal_importlib_importers
340 .entry(target_file.clone())
341 .or_default()
342 .push(importer.clone());
343 }
344 }
345 }
346 for importers in literal_importlib_importers.values_mut() {
347 importers.sort();
348 importers.dedup();
349 }
350
351 Self {
352 module_index,
353 module_spellings,
354 exports_by_file,
355 reexport_edges,
356 star_reexports,
357 importer_reverse,
358 literal_importlib_importers,
359 module_binding_timelines: Mutex::new(HashMap::default()),
360 scope_facts_by_file: Mutex::new(HashMap::default()),
361 }
362 }
363
364 pub fn seeds_for_target(
365 &self,
366 target_file: &ProjectFile,
367 target_short: &str,
368 ) -> BTreeSet<(ProjectFile, String)> {
369 let mut seeds: BTreeSet<(ProjectFile, String)> = BTreeSet::new();
370
371 if let Some(exports) = self.exports_by_file.get(target_file) {
372 for (exported_name, entry) in &exports.exports_by_name {
373 let local = match entry {
374 ExportEntry::Local { local_name } => Some(local_name.as_str()),
375 ExportEntry::Default { local_name } => local_name.as_deref(),
376 ExportEntry::ReexportedNamed { .. } | ExportEntry::ReexportedModule { .. } => {
377 None
378 }
379 };
380 if let Some(local_name) = local
381 && local_name == target_short
382 {
383 seeds.insert((target_file.clone(), exported_name.clone()));
384 }
385 }
386 }
387
388 reexport_seeds::expand_reexport_seeds(
389 seeds,
390 &self.reexport_edges,
391 &self.star_reexports,
392 |name| !name.starts_with('_'),
393 )
394 }
395
396 pub fn matching_edges_for_importer(
397 &self,
398 importer: &ProjectFile,
399 seeds: &BTreeSet<(ProjectFile, String)>,
400 ) -> Vec<ImportEdge> {
401 reexport_seeds::matching_edges_for_importer(&self.importer_reverse, importer, seeds)
402 }
403
404 pub fn importer_files_for_seeds(
405 &self,
406 seeds: &BTreeSet<(ProjectFile, String)>,
407 ) -> HashSet<ProjectFile> {
408 let mut importers = HashSet::default();
409 for (target_file, _) in seeds {
410 if let Some(edges) = self.importer_reverse.get(target_file) {
411 importers.extend(
412 edges
413 .iter()
414 .filter(|edge| reexport_seeds::edge_matches_seed(edge, seeds))
415 .map(|edge| edge.importer.clone()),
416 );
417 }
418 if let Some(dynamic_importers) = self.literal_importlib_importers.get(target_file) {
419 importers.extend(dynamic_importers.iter().cloned());
420 }
421 }
422 importers
423 }
424
425 pub fn resolve_module_files(
426 &self,
427 importing_file: &ProjectFile,
428 module_specifier: &str,
429 ) -> Vec<ProjectFile> {
430 resolve_module(
431 &self.module_index,
432 &self.module_spellings,
433 importing_file,
434 module_specifier,
435 )
436 }
437
438 pub fn module_binding_timeline(
439 &self,
440 file: &ProjectFile,
441 build: impl FnOnce() -> ModuleBindingTimeline,
442 ) -> Arc<ModuleBindingTimeline> {
443 if let Some(cached) = self
444 .module_binding_timelines
445 .lock()
446 .expect("Python module-binding timeline cache mutex poisoned")
447 .get(file)
448 .cloned()
449 {
450 return cached;
451 }
452
453 let timeline = Arc::new(build());
454 self.module_binding_timelines
455 .lock()
456 .expect("Python module-binding timeline cache mutex poisoned")
457 .entry(file.clone())
458 .or_insert_with(|| timeline.clone())
459 .clone()
460 }
461
462 pub fn scope_facts(
463 &self,
464 file: &ProjectFile,
465 build: impl FnOnce() -> Option<PythonScopeFacts>,
466 ) -> Option<Arc<PythonScopeFacts>> {
467 if let Some(cached) = self
468 .scope_facts_by_file
469 .lock()
470 .expect("Python scope-facts cache mutex poisoned")
471 .get(file)
472 .cloned()
473 {
474 return Some(cached);
475 }
476
477 let facts = Arc::new(build()?);
478 Some(
479 self.scope_facts_by_file
480 .lock()
481 .expect("Python scope-facts cache mutex poisoned")
482 .entry(file.clone())
483 .or_insert_with(|| facts.clone())
484 .clone(),
485 )
486 }
487}
488
489fn canonical_module_replacement(
490 file: &ProjectFile,
491 replacements: &HashMap<ProjectFile, ProjectFile>,
492) -> Option<ProjectFile> {
493 let mut seen = BTreeSet::new();
494 let mut current = file.clone();
495 while let Some(target) = replacements.get(¤t) {
496 if !seen.insert(current) {
497 return None;
498 }
499 current = target.clone();
500 }
501 Some(current)
502}
503
504fn build_importer_reverse(
505 module_index: &HashMap<String, Vec<ProjectFile>>,
506 module_spellings: &PythonModuleSpellings,
507 files: &[ProjectFile],
508 bindings_by_file: &HashMap<ProjectFile, Vec<(String, ImportBinding)>>,
509 exports_by_file: &HashMap<ProjectFile, Arc<ExportIndex>>,
510) -> HashMap<ProjectFile, Vec<ImportEdge>> {
511 let mut reverse: HashMap<ProjectFile, Vec<ImportEdge>> = HashMap::default();
512 for file in files {
513 let Some(bindings) = bindings_by_file.get(file) else {
514 continue;
515 };
516 for (local_name, binding) in bindings {
517 let imported_module = binding
518 .namespace_imported_module
519 .as_deref()
520 .unwrap_or(&binding.module_specifier);
521 for target_file in resolve_module(module_index, module_spellings, file, imported_module)
522 {
523 if matches!(binding.kind, ImportKind::Glob) {
526 let Some(exports) = exports_by_file.get(&target_file) else {
527 continue;
528 };
529 for export_name in exports.exports_by_name.keys() {
530 if export_name.starts_with('_') {
531 continue;
532 }
533 reverse
534 .entry(target_file.clone())
535 .or_default()
536 .push(ImportEdge {
537 importer: file.clone(),
538 local_name: export_name.clone(),
539 target_file: target_file.clone(),
540 kind: ImportEdgeKind::Named(export_name.clone()),
541 });
542 }
543 continue;
544 }
545 let kind = match (binding.kind, binding.imported_name.as_deref()) {
546 (ImportKind::Default, _) => ImportEdgeKind::Default,
547 (ImportKind::Namespace, _) => ImportEdgeKind::Namespace,
548 (ImportKind::Named, Some(name)) => ImportEdgeKind::Named(name.to_string()),
549 (ImportKind::Named, None) => ImportEdgeKind::Named(local_name.clone()),
550 (ImportKind::CommonJsRequire, _) | (ImportKind::Glob, _) => continue,
552 };
553 reverse
554 .entry(target_file.clone())
555 .or_default()
556 .push(ImportEdge {
557 importer: file.clone(),
558 local_name: local_name.clone(),
559 target_file,
560 kind,
561 });
562 }
563 }
564 }
565 reverse
566}
567
568pub fn usage_seeds(
570 python: &dyn PythonUsageSource,
571 target_file: &ProjectFile,
572 target_short: &str,
573) -> BTreeSet<(ProjectFile, String)> {
574 python
575 .usage_index()
576 .seeds_for_target(target_file, target_short)
577}
578
579pub fn usage_matching_edges(
581 python: &dyn PythonUsageSource,
582 importer: &ProjectFile,
583 seeds: &BTreeSet<(ProjectFile, String)>,
584) -> Vec<ImportEdge> {
585 python
586 .usage_index()
587 .matching_edges_for_importer(importer, seeds)
588}
589
590pub fn usage_importer_files(
591 python: &dyn PythonUsageSource,
592 seeds: &BTreeSet<(ProjectFile, String)>,
593) -> HashSet<ProjectFile> {
594 python.usage_index().importer_files_for_seeds(seeds)
595}
596
597pub fn usage_resolve_module_files(
598 python: &dyn PythonUsageSource,
599 importing_file: &ProjectFile,
600 module_specifier: &str,
601) -> Vec<ProjectFile> {
602 python
603 .usage_index()
604 .resolve_module_files(importing_file, module_specifier)
605}
606
607pub fn usage_module_binding_timeline(
608 python: &dyn PythonUsageSource,
609 file: &ProjectFile,
610 build: impl FnOnce() -> ModuleBindingTimeline,
611) -> Arc<ModuleBindingTimeline> {
612 python.usage_index().module_binding_timeline(file, build)
613}
614
615pub fn usage_scope_facts(
616 python: &dyn PythonUsageSource,
617 file: &ProjectFile,
618 build: impl FnOnce() -> Option<PythonScopeFacts>,
619) -> Option<Arc<PythonScopeFacts>> {
620 python.usage_index().scope_facts(file, build)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use brokk_bifrost_test_macros::hardwon;
627
628 #[test]
629 #[hardwon("59dfb80c3bb8: module replacement chains failed to canonicalize or reject cycles")]
630 fn module_replacement_chains_canonicalize_and_cycles_are_rejected() {
631 let root = tempfile::tempdir().expect("temporary project root");
632 let first = ProjectFile::new(root.path(), "first.py");
633 let second = ProjectFile::new(root.path(), "second.py");
634 let canonical = ProjectFile::new(root.path(), "canonical.py");
635 let chain = HashMap::from_iter([
636 (first.clone(), second.clone()),
637 (second.clone(), canonical.clone()),
638 ]);
639
640 assert_eq!(
641 canonical_module_replacement(&first, &chain),
642 Some(canonical)
643 );
644
645 let cycle = HashMap::from_iter([(first.clone(), second.clone()), (second, first.clone())]);
646 assert_eq!(canonical_module_replacement(&first, &cycle), None);
647 }
648
649 #[test]
650 #[hardwon(
651 "ddf65ec23b34: repeated graph queries rebuilt generation-stable Python binding facts"
652 )]
653 fn module_binding_timeline_is_reused_within_index_generation() {
654 let root = tempfile::tempdir().expect("temporary project root");
655 let file = ProjectFile::new(root.path(), "consumer.py");
656 let index = PythonUsageIndex::default();
657 let first = index.module_binding_timeline(&file, || {
658 ModuleBindingTimeline::from_iter([(
659 "target".to_string(),
660 vec![ModuleBindingEvent {
661 visible_from: 12,
662 conditional: false,
663 kind: ModuleBindingEventKind::Other,
664 }],
665 )])
666 });
667 let second = index.module_binding_timeline(&file, || {
668 panic!("cached timeline should avoid rebuilding the file")
669 });
670
671 assert!(Arc::ptr_eq(&first, &second));
672
673 let first_facts = index
674 .scope_facts(&file, || Some(PythonScopeFacts::default()))
675 .unwrap();
676 let second_facts = index
677 .scope_facts(&file, || {
678 panic!("cached scope facts should avoid rebuilding the file")
679 })
680 .unwrap();
681 assert!(Arc::ptr_eq(&first_facts, &second_facts));
682 }
683}