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                    continue;
432                }
433                // Non-wildcard from-imports always populate `identifier`
434                // as `alias ?? name` (see `python_import_details`), so
435                // `local_name()` reproduces the same alias-first fallback
436                // without re-deriving it here.
437                let local_name = import
438                    .local_name()
439                    .map(str::to_string)
440                    .unwrap_or_else(|| name.clone());
441                let module_candidate = format!("{resolved_module}.{name}");
442                if resolve_module_code_unit(python, &module_candidate).is_some() {
443                    bindings.push((
444                        local_name,
445                        ImportBinding {
446                            module_specifier: module_candidate,
447                            namespace_imported_module: None,
448                            kind: ImportKind::Namespace,
449                            imported_name: None,
450                        },
451                    ));
452                    continue;
453                }
454                bindings.push((
455                    local_name,
456                    ImportBinding {
457                        module_specifier: resolved_module,
458                        namespace_imported_module: None,
459                        kind: ImportKind::Named,
460                        imported_name: Some(name),
461                    },
462                ));
463            }
464        }
465    }
466
467    bindings
468}
469
470pub fn public_declarations_in_module(python: &dyn PythonSource, module_fq: &str) -> Vec<CodeUnit> {
471    let Some(module_code_unit) = resolve_module_code_unit(python, module_fq) else {
472        return Vec::new();
473    };
474    python
475        .direct_children(&module_code_unit)
476        .into_iter()
477        .filter(|code_unit| !code_unit.identifier().starts_with('_'))
478        .collect()
479}
480
481pub fn resolve_base_class(
482    python: &dyn PythonSource,
483    code_unit: &CodeUnit,
484    raw: &str,
485) -> Option<CodeUnit> {
486    let trimmed = raw.trim();
487    if trimmed.is_empty() {
488        return None;
489    }
490
491    let binder = python.import_binder_of(code_unit.source());
492    if let Some((head, tail)) = trimmed.split_once('.') {
493        if let Some(binding) = binder.bindings.get(head)
494            && binding.kind == ImportKind::Namespace
495        {
496            let fq_name = format!("{}.{}", binding.module_specifier, tail);
497            return python.definitions(&fq_name).next();
498        }
499        return python.definitions(trimmed).next();
500    }
501
502    if let Some(binding) = binder.bindings.get(trimmed) {
503        match binding.kind {
504            ImportKind::Namespace => {
505                return resolve_module_code_unit(python, &binding.module_specifier);
506            }
507            ImportKind::Named => {
508                let imported_name = binding.imported_name.as_ref()?;
509                let fqn = format!("{}.{}", binding.module_specifier, imported_name);
510                return resolve_exported_fqn(python, &fqn)
511                    .into_iter()
512                    .next()
513                    .or_else(|| python.definitions(&fqn).next());
514            }
515            _ => {}
516        }
517    }
518
519    if python
520        .import_info_of(code_unit.source())
521        .iter()
522        .any(|import| import.is_wildcard)
523        && let Some(imported) = resolve_import_bindings(python, code_unit.source()).get(trimmed)
524    {
525        return Some(imported.clone());
526    }
527
528    let local_fq_name = format!("{}.{}", code_unit.package_name(), trimmed);
529    python
530        .definitions(&local_fq_name)
531        .next()
532        .or_else(|| python.definitions(trimmed).next())
533}
534
535pub fn render_skeleton_recursive(
536    index: &dyn CodeUnitIndex,
537    code_unit: &CodeUnit,
538    indent: &str,
539    header_only: bool,
540    out: &mut String,
541) {
542    if let Some(signature) = python_signature(index, code_unit, header_only) {
543        for line in signature.lines() {
544            out.push_str(indent);
545            out.push_str(line);
546            out.push('\n');
547        }
548    }
549
550    let all_children = index.direct_children(code_unit);
551    let field_children: Vec<_> = all_children
552        .iter()
553        .filter(|child| child.is_field())
554        .cloned()
555        .collect();
556    let children = if header_only {
557        field_children.clone()
558    } else {
559        all_children.clone()
560    };
561    if !children.is_empty() || code_unit.is_class() || code_unit.is_module() {
562        let child_indent = format!("{indent}  ");
563        for child in children {
564            render_skeleton_recursive(index, &child, &child_indent, header_only, out);
565        }
566        if header_only && all_children.len() > field_children.len() {
567            out.push_str(&child_indent);
568            out.push_str("[...]\n");
569        }
570    }
571}
572
573fn python_signature(
574    index: &dyn CodeUnitIndex,
575    code_unit: &CodeUnit,
576    _header_only: bool,
577) -> Option<String> {
578    if code_unit.is_module() {
579        return None;
580    }
581
582    let source = index.get_source(code_unit, false)?;
583    let lines: Vec<_> = source
584        .lines()
585        .map(str::trim_end)
586        .filter(|line| !line.trim().is_empty())
587        .collect();
588    if lines.is_empty() {
589        return None;
590    }
591
592    let mut decorators = Vec::new();
593    let mut header = None;
594    for line in lines {
595        let trimmed = line.trim_start();
596        if trimmed.starts_with('@') {
597            decorators.push(trimmed.to_string());
598            continue;
599        }
600        header = Some(trimmed.to_string());
601        break;
602    }
603    let mut rendered = String::new();
604    for decorator in decorators {
605        rendered.push_str(&decorator);
606        rendered.push('\n');
607    }
608
609    let header = header?;
610    match code_unit.kind() {
611        CodeUnitType::Class => rendered.push_str(&header),
612        CodeUnitType::Function => {
613            rendered.push_str(header.trim_end_matches(':'));
614            rendered.push_str(": ...");
615        }
616        CodeUnitType::Field | CodeUnitType::Macro => rendered.push_str(header.as_str()),
617        CodeUnitType::Module | CodeUnitType::FileScope => return None,
618    }
619    Some(rendered)
620}
621
622fn import_order_requires_source(binder: &ImportBinder, local_names: &HashSet<String>) -> bool {
623    binder
624        .bindings
625        .keys()
626        .any(|bound_name| local_names.contains(bound_name))
627}