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