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