Skip to main content

brokk_bifrost_python/
usage_index.rs

1//! Analyzer-level re-export + importer index for Python, so both usage paths
2//! resolve references through analyzer state. Built once from the analyzer's own
3//! module index + `export_index_of` / `import_binder_of` and cached on
4//! [`PythonAnalyzer`] (dropped on `update`/`update_all` like the other caches).
5//!
6//! Forward export seeds follow re-export chains
7//! ([`PythonUsageIndex::seeds_for_target`]), and the reverse importer index
8//! resolves which local names in an importer bind a seed
9//! ([`PythonUsageIndex::matching_edges_for_importer`]). Candidate-file narrowing
10//! stays in the forward path's scoped import closure (`PythonProjectGraph`), not
11//! here. Module resolution reuses the analyzer's existing [`python_module_name`]
12//! + [`resolve_python_relative_module`].
13
14use 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    PythonModuleSpellings, PythonSource, PythonUsageSource, export_index_from_file_facts,
28    import_binder_from_imports, import_bindings_from_imports, python_module_file_under_source_root,
29    retain_modules_for_importer,
30};
31use crate::imports::{
32    literal_importlib_modules, module_replacement_of, resolve_python_relative_module,
33};
34
35/// Re-export and reverse-import indices over the Python workspace.
36#[derive(Debug, Default)]
37pub struct PythonUsageIndex {
38    module_index: HashMap<String, Vec<ProjectFile>>,
39    module_spellings: Arc<PythonModuleSpellings>,
40    exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>>,
41    reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>>,
42    star_reexports: HashMap<ProjectFile, Vec<ProjectFile>>,
43    importer_reverse: HashMap<ProjectFile, Vec<ImportEdge>>,
44    literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>>,
45    module_binding_timelines: Mutex<HashMap<ProjectFile, Arc<ModuleBindingTimeline>>>,
46    scope_facts_by_file: Mutex<HashMap<ProjectFile, Arc<PythonScopeFacts>>>,
47}
48
49pub type ModuleBindingTimeline = HashMap<String, Vec<ModuleBindingEvent>>;
50pub type PythonScopeFacts = HashMap<CodeUnit, LocalBindingsSnapshot<String>>;
51
52#[derive(Clone, Debug)]
53pub struct ModuleBindingEvent {
54    pub visible_from: usize,
55    pub conditional: bool,
56    pub kind: ModuleBindingEventKind,
57}
58
59#[derive(Clone, Debug)]
60pub enum ModuleBindingEventKind {
61    ImportModule {
62        module: String,
63        consumed_attributes: usize,
64    },
65    FromImport {
66        module: String,
67        imported_name: String,
68    },
69    Other,
70}
71
72/// Resolve a module specifier to the files defining it: a leading-dot specifier
73/// is made absolute against the importing file's package, then looked up in the
74/// module index.
75fn resolve_module(
76    module_index: &HashMap<String, Vec<ProjectFile>>,
77    module_spellings: &PythonModuleSpellings,
78    importing_file: &ProjectFile,
79    module_specifier: &str,
80) -> Vec<ProjectFile> {
81    let resolved_module = if module_specifier.starts_with('.') {
82        resolve_python_relative_module(importing_file, module_specifier)
83    } else {
84        Some(module_specifier.to_string())
85    };
86    let Some(resolved_module) = resolved_module else {
87        return Vec::new();
88    };
89    let mut files = module_index
90        .get(&resolved_module)
91        .cloned()
92        .unwrap_or_default();
93    // Two sibling source roots can index the same module name, so the root
94    // that owns the importing file decides which of them its import means.
95    retain_modules_for_importer(importing_file, &mut files, |file| file);
96    if files.is_empty() {
97        // No module name in the index spells the specifier, because it is
98        // written against a source root the workspace does not state (#3506).
99        files.extend(python_module_file_under_source_root(
100            module_spellings,
101            importing_file,
102            &resolved_module,
103        ));
104    }
105    files
106}
107
108fn is_sys_namespace_binding(binding: &ImportBinding) -> bool {
109    binding.kind == ImportKind::Namespace
110        && binding
111            .namespace_imported_module
112            .as_deref()
113            .unwrap_or(&binding.module_specifier)
114            == "sys"
115}
116
117fn is_importlib_namespace_binding(binding: &ImportBinding) -> bool {
118    binding.kind == ImportKind::Namespace
119        && binding
120            .namespace_imported_module
121            .as_deref()
122            .unwrap_or(&binding.module_specifier)
123            == "importlib"
124}
125
126impl PythonUsageIndex {
127    /// Takes [`PythonSource`], not [`PythonUsageSource`]: the cell this
128    /// build fills is only reachable through the latter, so the narrower
129    /// parameter is what stops the build from re-entering it.
130    pub fn build(python: &dyn PythonSource, token: QueryToken<'_>) -> Self {
131        let _scope = brokk_bifrost_core::profiling::scope("PythonUsageIndex::build");
132        let mut files: Vec<ProjectFile> = python
133            .project()
134            .analyzable_files(Language::Python)
135            .map(|set| set.into_iter().collect())
136            .unwrap_or_default();
137        files.sort();
138        files.dedup();
139
140        let module_spellings = python.module_spellings();
141        let mut module_index: HashMap<String, Vec<ProjectFile>> = HashMap::default();
142        let mut exports_by_file: HashMap<ProjectFile, Arc<ExportIndex>> = HashMap::default();
143        let mut binders_by_file: HashMap<ProjectFile, Arc<ImportBinder>> = HashMap::default();
144        let mut import_bindings_by_file: HashMap<ProjectFile, Vec<(String, ImportBinding)>> =
145            HashMap::default();
146        let mut literal_importlib_modules_by_file: HashMap<ProjectFile, HashSet<String>> =
147            HashMap::default();
148        let mut replacement_modules: HashMap<ProjectFile, String> = HashMap::default();
149        python.visit_file_facts(&files, &mut |file, facts| {
150            let module_name = facts
151                .and_then(|facts| {
152                    facts
153                        .top_level_declarations()
154                        .iter()
155                        .find(|unit| unit.is_module())
156                })
157                .map(|unit| unit.fq_name().to_string())
158                .unwrap_or_else(|| python_module_name(file));
159            module_index
160                .entry(module_name.clone())
161                .or_default()
162                .push(file.clone());
163            if let Some(facts) = facts {
164                let import_bindings = import_bindings_from_imports(python, file, facts.imports());
165                let binder = Arc::new(import_binder_from_imports(python, file, facts.imports()));
166                if binder.bindings.values().any(is_importlib_namespace_binding) {
167                    literal_importlib_modules_by_file.insert(
168                        file.clone(),
169                        literal_importlib_modules(facts.source(), &binder.bindings),
170                    );
171                }
172                if binder.bindings.values().any(is_sys_namespace_binding)
173                    && let Some(replacement) =
174                        module_replacement_of(python, file, facts.source(), facts.imports())
175                {
176                    replacement_modules.insert(file.clone(), replacement.target_module);
177                }
178                exports_by_file.insert(
179                    file.clone(),
180                    Arc::new(export_index_from_file_facts(
181                        python,
182                        file,
183                        facts,
184                        &module_name,
185                    )),
186                );
187                import_bindings_by_file.insert(file.clone(), import_bindings);
188                binders_by_file.insert(file.clone(), binder);
189            } else {
190                exports_by_file.insert(file.clone(), python.export_index_of(file));
191                let binder = python.import_binder_of(file);
192                let imports = python.import_info_of(token, file);
193                if let Ok(source) = python.project().read_source(file) {
194                    if binder.bindings.values().any(is_importlib_namespace_binding) {
195                        literal_importlib_modules_by_file.insert(
196                            file.clone(),
197                            literal_importlib_modules(&source, &binder.bindings),
198                        );
199                    }
200                    if binder.bindings.values().any(is_sys_namespace_binding)
201                        && let Some(replacement) =
202                            module_replacement_of(python, file, &source, &imports)
203                    {
204                        replacement_modules.insert(file.clone(), replacement.target_module);
205                    }
206                }
207                import_bindings_by_file.insert(
208                    file.clone(),
209                    import_bindings_from_imports(python, file, &imports),
210                );
211                binders_by_file.insert(file.clone(), binder);
212            }
213        });
214        for resolved in module_index.values_mut() {
215            resolved.sort();
216            resolved.dedup();
217        }
218
219        let mut raw_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
220        for (file, target_module) in replacement_modules {
221            let mut targets =
222                resolve_module(&module_index, &module_spellings, &file, &target_module);
223            if targets.len() != 1 {
224                continue;
225            }
226            let target = targets.pop().expect("one module replacement target");
227            if target != file {
228                raw_replacements.insert(file, target);
229            }
230        }
231
232        let mut canonical_replacements: HashMap<ProjectFile, ProjectFile> = HashMap::default();
233        for file in raw_replacements.keys() {
234            if let Some(target) = canonical_module_replacement(file, &raw_replacements) {
235                canonical_replacements.insert(file.clone(), target);
236            }
237        }
238        for resolved in module_index.values_mut() {
239            let mut seen = HashSet::default();
240            resolved.retain_mut(|file| {
241                if let Some(canonical) = canonical_replacements.get(file) {
242                    *file = canonical.clone();
243                }
244                seen.insert(file.clone())
245            });
246        }
247
248        let mut reexport_edges: HashMap<(ProjectFile, String), Vec<(ProjectFile, String)>> =
249            HashMap::default();
250        let mut star_reexports: HashMap<ProjectFile, Vec<ProjectFile>> = HashMap::default();
251        for (file, exports) in &exports_by_file {
252            for (exported_name, entry) in &exports.exports_by_name {
253                match entry {
254                    ExportEntry::Local { local_name } => {
255                        let Some(binder) = binders_by_file.get(file) else {
256                            continue;
257                        };
258                        let Some(binding) = binder.bindings.get(local_name) else {
259                            continue;
260                        };
261                        let Some(imported_name) = binding.imported_name.as_ref() else {
262                            continue;
263                        };
264                        for resolved_file in resolve_module(
265                            &module_index,
266                            &module_spellings,
267                            file,
268                            &binding.module_specifier,
269                        ) {
270                            reexport_edges
271                                .entry((resolved_file, imported_name.clone()))
272                                .or_default()
273                                .push((file.clone(), exported_name.clone()));
274                        }
275                    }
276                    // A re-exported module is a terminal binding: it carries
277                    // no member name, so it opens no name-to-name edge.
278                    ExportEntry::Default { .. } | ExportEntry::ReexportedModule { .. } => {}
279                    ExportEntry::ReexportedNamed {
280                        module_specifier,
281                        imported_name,
282                    } => {
283                        for resolved_file in
284                            resolve_module(&module_index, &module_spellings, file, module_specifier)
285                        {
286                            reexport_edges
287                                .entry((resolved_file, imported_name.clone()))
288                                .or_default()
289                                .push((file.clone(), exported_name.clone()));
290                        }
291                    }
292                }
293            }
294            for star in &exports.reexport_stars {
295                for resolved_file in resolve_module(
296                    &module_index,
297                    &module_spellings,
298                    file,
299                    &star.module_specifier,
300                ) {
301                    star_reexports
302                        .entry(resolved_file)
303                        .or_default()
304                        .push(file.clone());
305                }
306            }
307        }
308
309        let importer_reverse = build_importer_reverse(
310            &module_index,
311            &module_spellings,
312            &files,
313            &import_bindings_by_file,
314            &exports_by_file,
315        );
316        let mut literal_importlib_importers: HashMap<ProjectFile, Vec<ProjectFile>> =
317            HashMap::default();
318        for (importer, modules) in literal_importlib_modules_by_file {
319            for module in modules {
320                for target_file in
321                    resolve_module(&module_index, &module_spellings, &importer, &module)
322                {
323                    literal_importlib_importers
324                        .entry(target_file.clone())
325                        .or_default()
326                        .push(importer.clone());
327                }
328            }
329        }
330        for importers in literal_importlib_importers.values_mut() {
331            importers.sort();
332            importers.dedup();
333        }
334
335        Self {
336            module_index,
337            module_spellings,
338            exports_by_file,
339            reexport_edges,
340            star_reexports,
341            importer_reverse,
342            literal_importlib_importers,
343            module_binding_timelines: Mutex::new(HashMap::default()),
344            scope_facts_by_file: Mutex::new(HashMap::default()),
345        }
346    }
347
348    pub fn seeds_for_target(
349        &self,
350        target_file: &ProjectFile,
351        target_short: &str,
352    ) -> BTreeSet<(ProjectFile, String)> {
353        let mut seeds: BTreeSet<(ProjectFile, String)> = BTreeSet::new();
354
355        if let Some(exports) = self.exports_by_file.get(target_file) {
356            for (exported_name, entry) in &exports.exports_by_name {
357                let local = match entry {
358                    ExportEntry::Local { local_name } => Some(local_name.as_str()),
359                    ExportEntry::Default { local_name } => local_name.as_deref(),
360                    ExportEntry::ReexportedNamed { .. } | ExportEntry::ReexportedModule { .. } => {
361                        None
362                    }
363                };
364                if let Some(local_name) = local
365                    && local_name == target_short
366                {
367                    seeds.insert((target_file.clone(), exported_name.clone()));
368                }
369            }
370        }
371
372        let mut frontier: VecDeque<(ProjectFile, String)> = seeds.iter().cloned().collect();
373        while let Some(seed) = frontier.pop_front() {
374            if let Some(reexports) = self.reexport_edges.get(&seed) {
375                for next in reexports {
376                    if seeds.insert(next.clone()) {
377                        frontier.push_back(next.clone());
378                    }
379                }
380            }
381            if !seed.1.starts_with('_')
382                && let Some(star_files) = self.star_reexports.get(&seed.0)
383            {
384                for star_file in star_files {
385                    let next = (star_file.clone(), seed.1.clone());
386                    if seeds.insert(next.clone()) {
387                        frontier.push_back(next);
388                    }
389                }
390            }
391        }
392
393        seeds
394    }
395
396    pub fn matching_edges_for_importer(
397        &self,
398        importer: &ProjectFile,
399        seeds: &BTreeSet<(ProjectFile, String)>,
400    ) -> Vec<ImportEdge> {
401        let mut matches = Vec::new();
402        for (target_file, _) in seeds {
403            let Some(edges) = self.importer_reverse.get(target_file) else {
404                continue;
405            };
406            matches.extend(
407                edges
408                    .iter()
409                    .filter(|edge| &edge.importer == importer && edge_matches_seed(edge, seeds))
410                    .cloned(),
411            );
412        }
413        matches
414    }
415
416    pub fn importer_files_for_seeds(
417        &self,
418        seeds: &BTreeSet<(ProjectFile, String)>,
419    ) -> HashSet<ProjectFile> {
420        let mut importers = HashSet::default();
421        for (target_file, _) in seeds {
422            if let Some(edges) = self.importer_reverse.get(target_file) {
423                importers.extend(
424                    edges
425                        .iter()
426                        .filter(|edge| edge_matches_seed(edge, seeds))
427                        .map(|edge| edge.importer.clone()),
428                );
429            }
430            if let Some(dynamic_importers) = self.literal_importlib_importers.get(target_file) {
431                importers.extend(dynamic_importers.iter().cloned());
432            }
433        }
434        importers
435    }
436
437    pub fn resolve_module_files(
438        &self,
439        importing_file: &ProjectFile,
440        module_specifier: &str,
441    ) -> Vec<ProjectFile> {
442        resolve_module(
443            &self.module_index,
444            &self.module_spellings,
445            importing_file,
446            module_specifier,
447        )
448    }
449
450    pub fn module_binding_timeline(
451        &self,
452        file: &ProjectFile,
453        build: impl FnOnce() -> ModuleBindingTimeline,
454    ) -> Arc<ModuleBindingTimeline> {
455        if let Some(cached) = self
456            .module_binding_timelines
457            .lock()
458            .expect("Python module-binding timeline cache mutex poisoned")
459            .get(file)
460            .cloned()
461        {
462            return cached;
463        }
464
465        let timeline = Arc::new(build());
466        self.module_binding_timelines
467            .lock()
468            .expect("Python module-binding timeline cache mutex poisoned")
469            .entry(file.clone())
470            .or_insert_with(|| timeline.clone())
471            .clone()
472    }
473
474    pub fn scope_facts(
475        &self,
476        file: &ProjectFile,
477        build: impl FnOnce() -> Option<PythonScopeFacts>,
478    ) -> Option<Arc<PythonScopeFacts>> {
479        if let Some(cached) = self
480            .scope_facts_by_file
481            .lock()
482            .expect("Python scope-facts cache mutex poisoned")
483            .get(file)
484            .cloned()
485        {
486            return Some(cached);
487        }
488
489        let facts = Arc::new(build()?);
490        Some(
491            self.scope_facts_by_file
492                .lock()
493                .expect("Python scope-facts cache mutex poisoned")
494                .entry(file.clone())
495                .or_insert_with(|| facts.clone())
496                .clone(),
497        )
498    }
499}
500
501fn edge_matches_seed(edge: &ImportEdge, seeds: &BTreeSet<(ProjectFile, String)>) -> bool {
502    match &edge.kind {
503        ImportEdgeKind::Named(name) => seeds.contains(&(edge.target_file.clone(), name.clone())),
504        ImportEdgeKind::Default => {
505            seeds.contains(&(edge.target_file.clone(), "default".to_string()))
506        }
507        ImportEdgeKind::Namespace => seeds.iter().any(|(file, _)| file == &edge.target_file),
508        ImportEdgeKind::CommonJsRequire(export_name) => {
509            seeds.contains(&(edge.target_file.clone(), export_name.clone()))
510        }
511    }
512}
513
514fn canonical_module_replacement(
515    file: &ProjectFile,
516    replacements: &HashMap<ProjectFile, ProjectFile>,
517) -> Option<ProjectFile> {
518    let mut seen = BTreeSet::new();
519    let mut current = file.clone();
520    while let Some(target) = replacements.get(&current) {
521        if !seen.insert(current) {
522            return None;
523        }
524        current = target.clone();
525    }
526    Some(current)
527}
528
529fn build_importer_reverse(
530    module_index: &HashMap<String, Vec<ProjectFile>>,
531    module_spellings: &PythonModuleSpellings,
532    files: &[ProjectFile],
533    bindings_by_file: &HashMap<ProjectFile, Vec<(String, ImportBinding)>>,
534    exports_by_file: &HashMap<ProjectFile, Arc<ExportIndex>>,
535) -> HashMap<ProjectFile, Vec<ImportEdge>> {
536    let mut reverse: HashMap<ProjectFile, Vec<ImportEdge>> = HashMap::default();
537    for file in files {
538        let Some(bindings) = bindings_by_file.get(file) else {
539            continue;
540        };
541        for (local_name, binding) in bindings {
542            let imported_module = binding
543                .namespace_imported_module
544                .as_deref()
545                .unwrap_or(&binding.module_specifier);
546            for target_file in resolve_module(module_index, module_spellings, file, imported_module)
547            {
548                // A glob `from m import *` binds every export of the target file
549                // as a named edge, mirroring the graph it replaces.
550                if matches!(binding.kind, ImportKind::Glob) {
551                    let Some(exports) = exports_by_file.get(&target_file) else {
552                        continue;
553                    };
554                    for export_name in exports.exports_by_name.keys() {
555                        if export_name.starts_with('_') {
556                            continue;
557                        }
558                        reverse
559                            .entry(target_file.clone())
560                            .or_default()
561                            .push(ImportEdge {
562                                importer: file.clone(),
563                                local_name: export_name.clone(),
564                                target_file: target_file.clone(),
565                                kind: ImportEdgeKind::Named(export_name.clone()),
566                            });
567                    }
568                    continue;
569                }
570                let kind = match (binding.kind, binding.imported_name.as_deref()) {
571                    (ImportKind::Default, _) => ImportEdgeKind::Default,
572                    (ImportKind::Namespace, _) => ImportEdgeKind::Namespace,
573                    (ImportKind::Named, Some(name)) => ImportEdgeKind::Named(name.to_string()),
574                    (ImportKind::Named, None) => ImportEdgeKind::Named(local_name.clone()),
575                    // Python binders never emit CommonJsRequire; glob handled above.
576                    (ImportKind::CommonJsRequire, _) | (ImportKind::Glob, _) => continue,
577                };
578                reverse
579                    .entry(target_file.clone())
580                    .or_default()
581                    .push(ImportEdge {
582                        importer: file.clone(),
583                        local_name: local_name.clone(),
584                        target_file,
585                        kind,
586                    });
587            }
588        }
589    }
590    reverse
591}
592
593/// Export seeds for the target, following re-export chains.
594pub fn usage_seeds(
595    python: &dyn PythonUsageSource,
596    target_file: &ProjectFile,
597    target_short: &str,
598) -> BTreeSet<(ProjectFile, String)> {
599    python
600        .usage_index()
601        .seeds_for_target(target_file, target_short)
602}
603
604/// The import edges in `importer` that bind one of the `seeds`.
605pub fn usage_matching_edges(
606    python: &dyn PythonUsageSource,
607    importer: &ProjectFile,
608    seeds: &BTreeSet<(ProjectFile, String)>,
609) -> Vec<ImportEdge> {
610    python
611        .usage_index()
612        .matching_edges_for_importer(importer, seeds)
613}
614
615pub fn usage_importer_files(
616    python: &dyn PythonUsageSource,
617    seeds: &BTreeSet<(ProjectFile, String)>,
618) -> HashSet<ProjectFile> {
619    python.usage_index().importer_files_for_seeds(seeds)
620}
621
622pub fn usage_resolve_module_files(
623    python: &dyn PythonUsageSource,
624    importing_file: &ProjectFile,
625    module_specifier: &str,
626) -> Vec<ProjectFile> {
627    python
628        .usage_index()
629        .resolve_module_files(importing_file, module_specifier)
630}
631
632pub fn usage_module_binding_timeline(
633    python: &dyn PythonUsageSource,
634    file: &ProjectFile,
635    build: impl FnOnce() -> ModuleBindingTimeline,
636) -> Arc<ModuleBindingTimeline> {
637    python.usage_index().module_binding_timeline(file, build)
638}
639
640pub fn usage_scope_facts(
641    python: &dyn PythonUsageSource,
642    file: &ProjectFile,
643    build: impl FnOnce() -> Option<PythonScopeFacts>,
644) -> Option<Arc<PythonScopeFacts>> {
645    python.usage_index().scope_facts(file, build)
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn module_replacement_chains_canonicalize_and_cycles_are_rejected() {
654        let root = tempfile::tempdir().expect("temporary project root");
655        let first = ProjectFile::new(root.path(), "first.py");
656        let second = ProjectFile::new(root.path(), "second.py");
657        let canonical = ProjectFile::new(root.path(), "canonical.py");
658        let chain = HashMap::from_iter([
659            (first.clone(), second.clone()),
660            (second.clone(), canonical.clone()),
661        ]);
662
663        assert_eq!(
664            canonical_module_replacement(&first, &chain),
665            Some(canonical)
666        );
667
668        let cycle = HashMap::from_iter([(first.clone(), second.clone()), (second, first.clone())]);
669        assert_eq!(canonical_module_replacement(&first, &cycle), None);
670    }
671
672    #[test]
673    fn module_binding_timeline_is_reused_within_index_generation() {
674        let root = tempfile::tempdir().expect("temporary project root");
675        let file = ProjectFile::new(root.path(), "consumer.py");
676        let index = PythonUsageIndex::default();
677        let first = index.module_binding_timeline(&file, || {
678            ModuleBindingTimeline::from_iter([(
679                "target".to_string(),
680                vec![ModuleBindingEvent {
681                    visible_from: 12,
682                    conditional: false,
683                    kind: ModuleBindingEventKind::Other,
684                }],
685            )])
686        });
687        let second = index.module_binding_timeline(&file, || {
688            panic!("cached timeline should avoid rebuilding the file")
689        });
690
691        assert!(Arc::ptr_eq(&first, &second));
692
693        let first_facts = index
694            .scope_facts(&file, || Some(PythonScopeFacts::default()))
695            .unwrap();
696        let second_facts = index
697            .scope_facts(&file, || {
698                panic!("cached scope facts should avoid rebuilding the file")
699            })
700            .unwrap();
701        assert!(Arc::ptr_eq(&first_facts, &second_facts));
702    }
703}