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