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    let mut cursor = root.walk();
277    for node in root.named_children(&mut cursor) {
278        if node.kind() != "import_from_statement" {
279            continue;
280        }
281        for info in python_import_infos_from_node(node, source) {
282            record_single_reexport_event(python, file, &info, events, index);
283        }
284    }
285}
286
287fn collect_reexport_events_from_imports(
288    python: &dyn PythonSource,
289    file: &ProjectFile,
290    imports: &[ImportInfo],
291    events: &mut Vec<(usize, String, ExportEntry)>,
292    index: &mut ExportIndex,
293) {
294    for import in imports {
295        record_single_reexport_event(python, file, import, events, index);
296    }
297}
298
299fn record_single_reexport_event(
300    python: &dyn PythonSource,
301    file: &ProjectFile,
302    import: &ImportInfo,
303    events: &mut Vec<(usize, String, ExportEntry)>,
304    index: &mut ExportIndex,
305) {
306    let Some(PythonImportDetails::FromImport {
307        module,
308        name,
309        alias,
310        wildcard,
311    }) = python_import_details(import)
312    else {
313        return;
314    };
315    let start_byte = import
316        .path
317        .as_ref()
318        .map(|path| path.declaration_start_byte)
319        .unwrap_or(usize::MAX);
320    let resolved_module = if module.starts_with('.') {
321        resolve_python_relative_module(file, &module)
322    } else {
323        Some(module.clone())
324    };
325    let Some(resolved_module) = resolved_module else {
326        return;
327    };
328
329    if wildcard {
330        index.reexport_stars.push(ReexportStar {
331            module_specifier: resolved_module,
332        });
333        return;
334    }
335    let exported_name = alias.unwrap_or(name.clone());
336    let imported_name = format!("{resolved_module}.{name}");
337    if resolve_module_code_unit(python, &imported_name).is_some() {
338        events.push((
339            start_byte,
340            exported_name,
341            ExportEntry::ReexportedNamed {
342                module_specifier: imported_name,
343                imported_name: name,
344            },
345        ));
346        return;
347    }
348    events.push((
349        start_byte,
350        exported_name,
351        ExportEntry::ReexportedNamed {
352            module_specifier: resolved_module,
353            imported_name: name,
354        },
355    ));
356}
357
358pub fn import_binder_from_imports(
359    python: &dyn PythonSource,
360    file: &ProjectFile,
361    imports: &[ImportInfo],
362) -> ImportBinder {
363    let mut binder = ImportBinder::empty();
364
365    for import in imports {
366        let Some(details) = python_import_details(import) else {
367            continue;
368        };
369        match details {
370            PythonImportDetails::Import { module, alias } => {
371                let local_name = python_namespace_binding_name(import, alias.as_deref(), &module);
372                let module_specifier =
373                    python_namespace_binding_module(import, alias.as_deref(), &module);
374                binder.bindings.insert(
375                    local_name,
376                    ImportBinding {
377                        module_specifier,
378                        namespace_imported_module: Some(module),
379                        kind: ImportKind::Namespace,
380                        imported_name: None,
381                    },
382                );
383            }
384            PythonImportDetails::FromImport {
385                module,
386                name,
387                wildcard,
388                ..
389            } => {
390                let resolved_module = if module.starts_with('.') {
391                    resolve_python_relative_module(file, &module)
392                } else {
393                    Some(module.clone())
394                };
395                let Some(resolved_module) = resolved_module else {
396                    continue;
397                };
398                if wildcard {
399                    continue;
400                }
401                // Non-wildcard from-imports always populate `identifier`
402                // as `alias ?? name` (see `python_import_details`), so
403                // `local_name()` reproduces the same alias-first fallback
404                // without re-deriving it here.
405                let local_name = import
406                    .local_name()
407                    .map(str::to_string)
408                    .unwrap_or_else(|| name.clone());
409                let module_candidate = format!("{resolved_module}.{name}");
410                if resolve_module_code_unit(python, &module_candidate).is_some() {
411                    binder.bindings.insert(
412                        local_name,
413                        ImportBinding {
414                            module_specifier: module_candidate,
415                            namespace_imported_module: None,
416                            kind: ImportKind::Namespace,
417                            imported_name: None,
418                        },
419                    );
420                    continue;
421                }
422                binder.bindings.insert(
423                    local_name,
424                    ImportBinding {
425                        module_specifier: resolved_module,
426                        namespace_imported_module: None,
427                        kind: ImportKind::Named,
428                        imported_name: Some(name),
429                    },
430                );
431            }
432        }
433    }
434
435    binder
436}
437
438pub fn public_declarations_in_module(python: &dyn PythonSource, module_fq: &str) -> Vec<CodeUnit> {
439    let Some(module_code_unit) = resolve_module_code_unit(python, module_fq) else {
440        return Vec::new();
441    };
442    python
443        .direct_children(&module_code_unit)
444        .into_iter()
445        .filter(|code_unit| !code_unit.identifier().starts_with('_'))
446        .collect()
447}
448
449pub fn resolve_base_class(
450    python: &dyn PythonSource,
451    code_unit: &CodeUnit,
452    raw: &str,
453) -> Option<CodeUnit> {
454    let trimmed = raw.trim();
455    if trimmed.is_empty() {
456        return None;
457    }
458
459    let binder = python.import_binder_of(code_unit.source());
460    if let Some((head, tail)) = trimmed.split_once('.') {
461        if let Some(binding) = binder.bindings.get(head)
462            && binding.kind == ImportKind::Namespace
463        {
464            let fq_name = format!("{}.{}", binding.module_specifier, tail);
465            return python.definitions(&fq_name).next();
466        }
467        return python.definitions(trimmed).next();
468    }
469
470    if let Some(binding) = binder.bindings.get(trimmed) {
471        match binding.kind {
472            ImportKind::Namespace => {
473                return resolve_module_code_unit(python, &binding.module_specifier);
474            }
475            ImportKind::Named => {
476                let imported_name = binding.imported_name.as_ref()?;
477                let fqn = format!("{}.{}", binding.module_specifier, imported_name);
478                return resolve_exported_fqn(python, &fqn)
479                    .into_iter()
480                    .next()
481                    .or_else(|| python.definitions(&fqn).next());
482            }
483            _ => {}
484        }
485    }
486
487    if python
488        .import_info_of(code_unit.source())
489        .iter()
490        .any(|import| import.is_wildcard)
491        && let Some(imported) = resolve_import_bindings(python, code_unit.source()).get(trimmed)
492    {
493        return Some(imported.clone());
494    }
495
496    let local_fq_name = format!("{}.{}", code_unit.package_name(), trimmed);
497    python
498        .definitions(&local_fq_name)
499        .next()
500        .or_else(|| python.definitions(trimmed).next())
501}
502
503pub fn render_skeleton_recursive(
504    index: &dyn CodeUnitIndex,
505    code_unit: &CodeUnit,
506    indent: &str,
507    header_only: bool,
508    out: &mut String,
509) {
510    if let Some(signature) = python_signature(index, code_unit, header_only) {
511        for line in signature.lines() {
512            out.push_str(indent);
513            out.push_str(line);
514            out.push('\n');
515        }
516    }
517
518    let all_children = index.direct_children(code_unit);
519    let field_children: Vec<_> = all_children
520        .iter()
521        .filter(|child| child.is_field())
522        .cloned()
523        .collect();
524    let children = if header_only {
525        field_children.clone()
526    } else {
527        all_children.clone()
528    };
529    if !children.is_empty() || code_unit.is_class() || code_unit.is_module() {
530        let child_indent = format!("{indent}  ");
531        for child in children {
532            render_skeleton_recursive(index, &child, &child_indent, header_only, out);
533        }
534        if header_only && all_children.len() > field_children.len() {
535            out.push_str(&child_indent);
536            out.push_str("[...]\n");
537        }
538    }
539}
540
541fn python_signature(
542    index: &dyn CodeUnitIndex,
543    code_unit: &CodeUnit,
544    _header_only: bool,
545) -> Option<String> {
546    if code_unit.is_module() {
547        return None;
548    }
549
550    let source = index.get_source(code_unit, false)?;
551    let lines: Vec<_> = source
552        .lines()
553        .map(str::trim_end)
554        .filter(|line| !line.trim().is_empty())
555        .collect();
556    if lines.is_empty() {
557        return None;
558    }
559
560    let mut decorators = Vec::new();
561    let mut header = None;
562    for line in lines {
563        let trimmed = line.trim_start();
564        if trimmed.starts_with('@') {
565            decorators.push(trimmed.to_string());
566            continue;
567        }
568        header = Some(trimmed.to_string());
569        break;
570    }
571    let mut rendered = String::new();
572    for decorator in decorators {
573        rendered.push_str(&decorator);
574        rendered.push('\n');
575    }
576
577    let header = header?;
578    match code_unit.kind() {
579        CodeUnitType::Class => rendered.push_str(&header),
580        CodeUnitType::Function => {
581            rendered.push_str(header.trim_end_matches(':'));
582            rendered.push_str(": ...");
583        }
584        CodeUnitType::Field | CodeUnitType::Macro => rendered.push_str(header.as_str()),
585        CodeUnitType::Module | CodeUnitType::FileScope => return None,
586    }
587    Some(rendered)
588}
589
590fn import_order_requires_source(binder: &ImportBinder, local_names: &HashSet<String>) -> bool {
591    binder
592        .bindings
593        .keys()
594        .any(|bound_name| local_names.contains(bound_name))
595}