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