Skip to main content

brokk_bifrost_python/
graph_support.rs

1//! The language half of Python's resolution logic: module lookup, the export
2//! index, the import binder, base-class resolution and the skeleton renderer,
3//! written as free functions over a source trait instead of as methods on
4//! `PythonAnalyzer`.
5//!
6//! `PythonAnalyzer` (in `brokk-bifrost-analysis`) owns the lazy cells (seven
7//! moka caches, one `OnceLock` and two `PoolSafeMemo`s) and implements
8//! [`PythonSource`] out of its own accessors, so the functions below
9//! reach back for the memoized products they need without naming the analyzer
10//! type.
11
12use brokk_bifrost_core::analyzer::capabilities::ImportAnalysisProvider;
13use brokk_bifrost_core::analyzer::model::{CodeUnitType, ImportInfo};
14use brokk_bifrost_core::analyzer::prepared_syntax::{IndexedFileFacts, PreparedSyntaxTree};
15use brokk_bifrost_core::analyzer::usages::model::{
16    ExportEntry, ExportIndex, ImportBinder, ImportBinding, ImportKind, ReexportStar,
17};
18use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, ProjectFile};
19use brokk_bifrost_core::hash::HashSet;
20use std::collections::BTreeSet;
21use std::sync::Arc;
22
23use crate::declarations::{collect_python_identifiers, parse_python_tree};
24use crate::imports::{
25    PythonImportDetails, python_import_details, python_import_infos_from_node,
26    python_namespace_binding_module, python_namespace_binding_name, resolve_exported_fqn,
27    resolve_import_bindings, resolve_python_relative_module,
28};
29use crate::usage_index::PythonUsageIndex;
30
31/// The analyzer-resident products Python's language logic resolves through, on
32/// top of the two core capability traits it reads declarations and imports
33/// with. The analyzer is the only implementor and every method forwards to one
34/// of its own accessors, so the cells stay where they are and no free function
35/// can reach past this surface.
36///
37/// The usage index is deliberately absent: [`PythonUsageIndex::build`] and
38/// everything it calls take this trait, so the build cannot re-enter the memo
39/// it is filling. Code that runs once the index exists takes
40/// [`PythonUsageSource`].
41pub trait PythonSource: CodeUnitIndex + ImportAnalysisProvider {
42    /// Path-derived module units for `module_fq`; `None` when the store could
43    /// not answer the path-symbol query at all.
44    fn path_module_fqn(&self, module_fq: &str) -> Option<Vec<CodeUnit>>;
45
46    /// [`Self::path_module_fqn`] for a whole batch, resolved in one store
47    /// transaction.
48    fn path_module_fqns_batch(&self, module_fqs: &[String]) -> Vec<Option<Vec<CodeUnit>>>;
49
50    fn definition_fqn(&self, fqn: &str) -> Vec<CodeUnit>;
51
52    /// Shared by handle: both products are immutable for the analyzer
53    /// generation that cached them, and callers ask for them once per receiver
54    /// type, annotation or export name, so deep-cloning the whole map out of
55    /// the cache on every hit was pure waste.
56    fn import_binder_of(&self, file: &ProjectFile) -> Arc<ImportBinder>;
57
58    fn export_index_of(&self, file: &ProjectFile) -> Arc<ExportIndex>;
59
60    /// The parsed tree and its source backing for `file`, from the analyzer's
61    /// query read cache.
62    ///
63    /// A caller that needs a syntax node for an already-indexed declaration
64    /// must reach it through here rather than re-parsing: `indexed_source`
65    /// hands out an owned copy of the whole file, and building a `Parser` per
66    /// declaration reparses text the analyzer has already parsed. `None` when
67    /// the analyzer holds no prepared tree, which is what keeps the re-parsing
68    /// path alive as a fallback.
69    fn prepared_syntax(&self, file: &ProjectFile) -> Option<Arc<PreparedSyntaxTree>>;
70
71    /// Every file's indexed facts, visited in the analyzer's own bulk-read
72    /// batches. `None` marks a file the index carries no record for.
73    fn visit_file_facts(
74        &self,
75        files: &[ProjectFile],
76        visit: &mut dyn FnMut(&ProjectFile, Option<&dyn IndexedFileFacts>),
77    );
78}
79
80/// [`PythonSource`] plus the built usage index. Everything reached from
81/// the export/importer walks needs it; the index build itself must not.
82pub trait PythonUsageSource: PythonSource {
83    fn usage_index(&self) -> Arc<PythonUsageIndex>;
84}
85
86pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
87    let Some(tree) = parse_python_tree(source) else {
88        return BTreeSet::new();
89    };
90    let mut identifiers = HashSet::default();
91    collect_python_identifiers(tree.root_node(), source, &mut identifiers);
92    identifiers.into_iter().collect()
93}
94
95pub fn resolve_module_code_unit(python: &dyn PythonSource, module_fq: &str) -> Option<CodeUnit> {
96    if let Some(units) = python.path_module_fqn(module_fq) {
97        return units.into_iter().find(|code_unit| code_unit.is_module());
98    }
99    python
100        .definition_fqn(module_fq)
101        .into_iter()
102        .find(CodeUnit::is_module)
103}
104
105/// Batched sibling of `resolve_module_code_unit`: resolves every FQN's path-symbol lookup in one
106/// store transaction instead of one per FQN, then falls back to the (unbatched, rarer)
107/// definition-lookup path per FQN exactly as the single-FQN version does. Preserves its per-item
108/// semantics precisely, including that a path lookup which succeeds but finds no module unit does
109/// *not* fall through to the definition lookup.
110pub fn resolve_module_code_units_batch(
111    python: &dyn PythonSource,
112    module_fqs: &[String],
113) -> Vec<Option<CodeUnit>> {
114    let path_results = python.path_module_fqns_batch(module_fqs);
115    let mut results: Vec<Option<CodeUnit>> = vec![None; module_fqs.len()];
116    let mut needs_definition_fallback = Vec::new();
117    for (i, units) in path_results.into_iter().enumerate() {
118        match units {
119            Some(units) => results[i] = units.into_iter().find(CodeUnit::is_module),
120            None => needs_definition_fallback.push(i),
121        }
122    }
123    for i in needs_definition_fallback {
124        results[i] = python
125            .definition_fqn(&module_fqs[i])
126            .into_iter()
127            .find(CodeUnit::is_module);
128    }
129    results
130}
131
132pub fn compute_export_index_of(python: &dyn PythonSource, file: &ProjectFile) -> ExportIndex {
133    let mut index = ExportIndex::empty();
134    let mut events = Vec::new();
135    let declarations = python.top_level_declarations(file);
136    collect_local_export_events(
137        declarations.iter(),
138        |code_unit| {
139            python
140                .ranges(code_unit)
141                .iter()
142                .map(|range| range.start_byte)
143                .min()
144                .unwrap_or(usize::MAX)
145        },
146        &mut events,
147    );
148
149    if let Ok(source) = file.read_to_string()
150        && let Some(tree) = parse_python_tree(&source)
151    {
152        collect_reexport_events(
153            python,
154            file,
155            tree.root_node(),
156            &source,
157            &mut events,
158            &mut index,
159        );
160    } else {
161        let imports = python.import_info_of(file);
162        collect_reexport_events_from_imports(python, file, &imports, &mut events, &mut index);
163    }
164
165    finish_export_index(events, index)
166}
167
168pub fn export_index_from_file_facts(
169    python: &dyn PythonSource,
170    file: &ProjectFile,
171    facts: &dyn IndexedFileFacts,
172    module_name: &str,
173    binder: &ImportBinder,
174) -> ExportIndex {
175    let mut index = ExportIndex::empty();
176    let mut events = Vec::new();
177    let mut local_names = collect_local_export_events(
178        facts.top_level_declarations().iter(),
179        |code_unit| {
180            facts
181                .declaration_ranges(code_unit)
182                .into_iter()
183                .flatten()
184                .map(|range| range.start_byte)
185                .min()
186                .unwrap_or(usize::MAX)
187        },
188        &mut events,
189    );
190
191    if !facts
192        .top_level_declarations()
193        .iter()
194        .any(CodeUnit::is_module)
195        && let Some(identifier) = module_name.rsplit('.').next()
196        && !identifier.is_empty()
197        && !identifier.starts_with('_')
198    {
199        local_names.insert(identifier.to_string());
200        events.push((
201            0,
202            identifier.to_string(),
203            ExportEntry::Local {
204                local_name: identifier.to_string(),
205            },
206        ));
207    }
208
209    if import_order_requires_source(binder, &local_names)
210        && let Ok(source) = file.read_to_string()
211        && let Some(tree) = parse_python_tree(&source)
212    {
213        collect_reexport_events(
214            python,
215            file,
216            tree.root_node(),
217            &source,
218            &mut events,
219            &mut index,
220        );
221    } else {
222        collect_reexport_events_from_imports(
223            python,
224            file,
225            facts.imports(),
226            &mut events,
227            &mut index,
228        );
229    }
230
231    finish_export_index(events, index)
232}
233
234fn collect_local_export_events<'a>(
235    declarations: impl IntoIterator<Item = &'a CodeUnit>,
236    mut start_byte: impl FnMut(&CodeUnit) -> usize,
237    events: &mut Vec<(usize, String, ExportEntry)>,
238) -> HashSet<String> {
239    let mut local_names = HashSet::default();
240    for code_unit in declarations {
241        let identifier = code_unit.identifier().trim();
242        if identifier.is_empty() {
243            continue;
244        }
245        local_names.insert(identifier.to_string());
246        events.push((
247            start_byte(code_unit),
248            identifier.to_string(),
249            ExportEntry::Local {
250                local_name: identifier.to_string(),
251            },
252        ));
253    }
254    local_names
255}
256
257fn finish_export_index(
258    mut events: Vec<(usize, String, ExportEntry)>,
259    mut index: ExportIndex,
260) -> ExportIndex {
261    events.sort_by_key(|(start_byte, _, _)| *start_byte);
262    for (_, exported_name, entry) in events {
263        index.exports_by_name.insert(exported_name, entry);
264    }
265    index
266}
267
268fn collect_reexport_events(
269    python: &dyn PythonSource,
270    file: &ProjectFile,
271    root: tree_sitter::Node<'_>,
272    source: &str,
273    events: &mut Vec<(usize, String, ExportEntry)>,
274    index: &mut ExportIndex,
275) {
276    // Module scope is not depth one. A `from ... import` inside an if/else,
277    // try/except, with or match block still binds a module-level name, so it
278    // re-exports like any other (issue #1764). Only a function or class body
279    // opens a scope whose bindings are not module exports.
280    let mut stack = vec![root];
281    while let Some(node) = stack.pop() {
282        let mut cursor = node.walk();
283        for child in node.named_children(&mut cursor) {
284            match child.kind() {
285                "import_from_statement" => {
286                    for info in python_import_infos_from_node(child, source) {
287                        record_single_reexport_event(python, file, &info, events, index);
288                    }
289                }
290                "function_definition" | "class_definition" => {}
291                _ => stack.push(child),
292            }
293        }
294    }
295}
296
297fn collect_reexport_events_from_imports(
298    python: &dyn PythonSource,
299    file: &ProjectFile,
300    imports: &[ImportInfo],
301    events: &mut Vec<(usize, String, ExportEntry)>,
302    index: &mut ExportIndex,
303) {
304    for import in imports {
305        record_single_reexport_event(python, file, import, events, index);
306    }
307}
308
309fn record_single_reexport_event(
310    python: &dyn PythonSource,
311    file: &ProjectFile,
312    import: &ImportInfo,
313    events: &mut Vec<(usize, String, ExportEntry)>,
314    index: &mut ExportIndex,
315) {
316    let Some(PythonImportDetails::FromImport {
317        module,
318        name,
319        alias,
320        wildcard,
321    }) = python_import_details(import)
322    else {
323        return;
324    };
325    let start_byte = import
326        .path
327        .as_ref()
328        .map(|path| path.declaration_start_byte)
329        .unwrap_or(usize::MAX);
330    let resolved_module = if module.starts_with('.') {
331        resolve_python_relative_module(file, &module)
332    } else {
333        Some(module.clone())
334    };
335    let Some(resolved_module) = resolved_module else {
336        return;
337    };
338
339    if wildcard {
340        index.reexport_stars.push(ReexportStar {
341            module_specifier: resolved_module,
342        });
343        return;
344    }
345    let exported_name = alias.unwrap_or(name.clone());
346    // `from P import S` binds the submodule `P.S` itself when that module
347    // exists, exactly as the import binder reads it below. Recording it as
348    // "the name S inside module P.S" would follow the subpackage's own
349    // exports, which silently mis-resolves whenever the subpackage re-exports
350    // a member named after itself (issue #1762).
351    let module_candidate = format!("{resolved_module}.{name}");
352    if resolve_module_code_unit(python, &module_candidate).is_some() {
353        events.push((
354            start_byte,
355            exported_name,
356            ExportEntry::ReexportedModule {
357                module_specifier: module_candidate,
358            },
359        ));
360        return;
361    }
362    events.push((
363        start_byte,
364        exported_name,
365        ExportEntry::ReexportedNamed {
366            module_specifier: resolved_module,
367            imported_name: name,
368        },
369    ));
370}
371
372pub fn import_binder_from_imports(
373    python: &dyn PythonSource,
374    file: &ProjectFile,
375    imports: &[ImportInfo],
376) -> ImportBinder {
377    let mut binder = ImportBinder::empty();
378
379    for (local_name, binding) in import_bindings_from_imports(python, file, imports) {
380        binder.bindings.insert(local_name, binding);
381    }
382
383    binder
384}
385
386/// Resolve each structured import without collapsing repeated local names.
387///
388/// Candidate discovery needs every lexical binding. The ordinary binder keeps
389/// one effective binding for simple lookups.
390pub fn import_bindings_from_imports(
391    python: &dyn PythonSource,
392    file: &ProjectFile,
393    imports: &[ImportInfo],
394) -> Vec<(String, ImportBinding)> {
395    let mut bindings = Vec::new();
396
397    for import in imports {
398        let Some(details) = python_import_details(import) else {
399            continue;
400        };
401        match details {
402            PythonImportDetails::Import { module, alias } => {
403                let local_name = python_namespace_binding_name(import, alias.as_deref(), &module);
404                let module_specifier =
405                    python_namespace_binding_module(import, alias.as_deref(), &module);
406                bindings.push((
407                    local_name,
408                    ImportBinding {
409                        module_specifier,
410                        namespace_imported_module: Some(module),
411                        kind: ImportKind::Namespace,
412                        imported_name: None,
413                    },
414                ));
415            }
416            PythonImportDetails::FromImport {
417                module,
418                name,
419                wildcard,
420                ..
421            } => {
422                let resolved_module = if module.starts_with('.') {
423                    resolve_python_relative_module(file, &module)
424                } else {
425                    Some(module.clone())
426                };
427                let Some(resolved_module) = resolved_module else {
428                    continue;
429                };
430                if wildcard {
431                    // A glob import introduces each public declaration as a
432                    // real local binding. Expand it from the structured module
433                    // declarations so constructor and receiver inference can
434                    // resolve the same names Python places in the namespace.
435                    bindings.extend(
436                        public_declarations_in_module(python, &resolved_module)
437                            .into_iter()
438                            .map(|declaration| {
439                                let name = declaration.identifier().to_string();
440                                (
441                                    name.clone(),
442                                    ImportBinding {
443                                        module_specifier: resolved_module.clone(),
444                                        namespace_imported_module: None,
445                                        kind: ImportKind::Named,
446                                        imported_name: Some(name),
447                                    },
448                                )
449                            }),
450                    );
451                    continue;
452                }
453                // Non-wildcard from-imports always populate `identifier`
454                // as `alias ?? name` (see `python_import_details`), so
455                // `local_name()` reproduces the same alias-first fallback
456                // without re-deriving it here.
457                let local_name = import
458                    .local_name()
459                    .map(str::to_string)
460                    .unwrap_or_else(|| name.clone());
461                let module_candidate = format!("{resolved_module}.{name}");
462                if resolve_module_code_unit(python, &module_candidate).is_some() {
463                    bindings.push((
464                        local_name,
465                        ImportBinding {
466                            module_specifier: module_candidate,
467                            namespace_imported_module: None,
468                            kind: ImportKind::Namespace,
469                            imported_name: None,
470                        },
471                    ));
472                    continue;
473                }
474                bindings.push((
475                    local_name,
476                    ImportBinding {
477                        module_specifier: resolved_module,
478                        namespace_imported_module: None,
479                        kind: ImportKind::Named,
480                        imported_name: Some(name),
481                    },
482                ));
483            }
484        }
485    }
486
487    bindings
488}
489
490pub fn public_declarations_in_module(python: &dyn PythonSource, module_fq: &str) -> Vec<CodeUnit> {
491    let Some(module_code_unit) = resolve_module_code_unit(python, module_fq) else {
492        return Vec::new();
493    };
494    python
495        .direct_children(&module_code_unit)
496        .into_iter()
497        .filter(|code_unit| !code_unit.identifier().starts_with('_'))
498        .collect()
499}
500
501pub fn resolve_base_class(
502    python: &dyn PythonSource,
503    code_unit: &CodeUnit,
504    raw: &str,
505) -> Option<CodeUnit> {
506    let trimmed = raw.trim();
507    if trimmed.is_empty() {
508        return None;
509    }
510
511    let binder = python.import_binder_of(code_unit.source());
512    if let Some((head, tail)) = trimmed.split_once('.') {
513        if let Some(binding) = binder.bindings.get(head)
514            && binding.kind == ImportKind::Namespace
515        {
516            let fq_name = format!("{}.{}", binding.module_specifier, tail);
517            return python.definitions(&fq_name).next();
518        }
519        return python.definitions(trimmed).next();
520    }
521
522    if let Some(binding) = binder.bindings.get(trimmed) {
523        match binding.kind {
524            ImportKind::Namespace => {
525                return resolve_module_code_unit(python, &binding.module_specifier);
526            }
527            ImportKind::Named => {
528                let imported_name = binding.imported_name.as_ref()?;
529                let fqn = format!("{}.{}", binding.module_specifier, imported_name);
530                return resolve_exported_fqn(python, &fqn)
531                    .into_iter()
532                    .next()
533                    .or_else(|| python.definitions(&fqn).next());
534            }
535            _ => {}
536        }
537    }
538
539    if python
540        .import_info_of(code_unit.source())
541        .iter()
542        .any(|import| import.is_wildcard)
543        && let Some(imported) = resolve_import_bindings(python, code_unit.source()).get(trimmed)
544    {
545        return Some(imported.clone());
546    }
547
548    let local_fq_name = format!("{}.{}", code_unit.package_name(), trimmed);
549    python
550        .definitions(&local_fq_name)
551        .next()
552        .or_else(|| python.definitions(trimmed).next())
553}
554
555pub fn render_skeleton_recursive(
556    index: &dyn CodeUnitIndex,
557    code_unit: &CodeUnit,
558    indent: &str,
559    header_only: bool,
560    out: &mut String,
561) {
562    if let Some(signature) = python_signature(index, code_unit, header_only) {
563        for line in signature.lines() {
564            out.push_str(indent);
565            out.push_str(line);
566            out.push('\n');
567        }
568    }
569
570    let all_children = index.direct_children(code_unit);
571    let field_children: Vec<_> = all_children
572        .iter()
573        .filter(|child| child.is_field())
574        .cloned()
575        .collect();
576    let children = if header_only {
577        field_children.clone()
578    } else {
579        all_children.clone()
580    };
581    if !children.is_empty() || code_unit.is_class() || code_unit.is_module() {
582        let child_indent = format!("{indent}  ");
583        for child in children {
584            render_skeleton_recursive(index, &child, &child_indent, header_only, out);
585        }
586        if header_only && all_children.len() > field_children.len() {
587            out.push_str(&child_indent);
588            out.push_str("[...]\n");
589        }
590    }
591}
592
593fn python_signature(
594    index: &dyn CodeUnitIndex,
595    code_unit: &CodeUnit,
596    _header_only: bool,
597) -> Option<String> {
598    if code_unit.is_module() {
599        return None;
600    }
601
602    let source = index.get_source(code_unit, false)?;
603    let lines: Vec<_> = source
604        .lines()
605        .map(str::trim_end)
606        .filter(|line| !line.trim().is_empty())
607        .collect();
608    if lines.is_empty() {
609        return None;
610    }
611
612    let mut decorators = Vec::new();
613    let mut header = None;
614    for line in lines {
615        let trimmed = line.trim_start();
616        if trimmed.starts_with('@') {
617            decorators.push(trimmed.to_string());
618            continue;
619        }
620        header = Some(trimmed.to_string());
621        break;
622    }
623    let mut rendered = String::new();
624    for decorator in decorators {
625        rendered.push_str(&decorator);
626        rendered.push('\n');
627    }
628
629    let header = header?;
630    match code_unit.kind() {
631        CodeUnitType::Class => rendered.push_str(&header),
632        CodeUnitType::Function => {
633            rendered.push_str(header.trim_end_matches(':'));
634            rendered.push_str(": ...");
635        }
636        CodeUnitType::Field | CodeUnitType::Macro => rendered.push_str(header.as_str()),
637        CodeUnitType::Module | CodeUnitType::FileScope => return None,
638    }
639    Some(rendered)
640}
641
642fn import_order_requires_source(binder: &ImportBinder, local_names: &HashSet<String>) -> bool {
643    binder
644        .bindings
645        .keys()
646        .any(|bound_name| local_names.contains(bound_name))
647}