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