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