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