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