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