Skip to main content

brokk_bifrost_python/graph/
resolver.rs

1//! Python's reference resolution: export-name and seed inference, receiver-type
2//! resolution, and annotation candidates.
3//!
4//! `index` arguments below are the *dispatching* analyzer's
5//! [`CodeUnitIndex`] (see [`PythonGraphSource`]); `python` is the Python
6//! analyzer's memoized products.
7
8use crate::graph::PythonGraphSource;
9use crate::graph_support::PythonUsageSource;
10use crate::imports::resolve_fqn_candidates;
11use crate::syntax::{python_deferred_annotation_identifier_ranges, python_node_is_in_annotation};
12use crate::usage_index::usage_seeds;
13use brokk_bifrost_core::analyzer::symbol_path::parse_symbol_path;
14use brokk_bifrost_core::analyzer::usages::model::{ExportEntry, ImportBinder, ImportKind};
15use brokk_bifrost_core::analyzer::usages::{ImportEdge, ImportEdgeKind};
16use brokk_bifrost_core::analyzer::{
17    BoundedDefinitionLookup, CodeUnit, CodeUnitIndex, Language, ProjectFile, Range,
18};
19use std::collections::BTreeSet;
20use tree_sitter::Node;
21
22pub fn infer_export_names(python: &dyn PythonUsageSource, target: &CodeUnit) -> BTreeSet<String> {
23    if target_owner_code_unit(python, target).is_some() {
24        let owner_name = top_level_identifier(python, target);
25        let owner_exports =
26            infer_export_names_for_local(python, target, target.source(), &owner_name);
27        if !owner_exports.is_empty() {
28            return owner_exports;
29        }
30    }
31
32    infer_export_names_for_local(python, target, target.source(), target.identifier())
33}
34
35pub fn infer_usage_seeds(
36    python: &dyn PythonUsageSource,
37    target: &CodeUnit,
38    seed_names: BTreeSet<String>,
39) -> BTreeSet<(ProjectFile, String)> {
40    let mut seeds = BTreeSet::new();
41    for seed_name in &seed_names {
42        seeds.extend(usage_seeds(python, target.source(), seed_name));
43    }
44    if seeds.is_empty()
45        && seed_names.contains(target.identifier())
46        && is_module_level_target_identifier(python, target, target.source(), target.identifier())
47    {
48        seeds.insert((target.source().clone(), target.identifier().to_string()));
49    }
50    seeds
51}
52
53fn infer_export_names_for_local(
54    python: &dyn PythonUsageSource,
55    target: &CodeUnit,
56    file: &ProjectFile,
57    local_name: &str,
58) -> BTreeSet<String> {
59    let index = python.export_index_of(file);
60    let mut export_names = BTreeSet::new();
61    if index.exports_by_name.contains_key(local_name) {
62        export_names.insert(local_name.to_string());
63    }
64    for (export_name, entry) in &index.exports_by_name {
65        if matches!(entry, ExportEntry::Local { local_name: name } if name == local_name) {
66            export_names.insert(export_name.clone());
67        }
68    }
69    if export_names.is_empty()
70        && is_module_level_target_identifier(python, target, file, local_name)
71    {
72        export_names.insert(local_name.to_string());
73    }
74    export_names
75}
76
77fn is_module_level_target_identifier(
78    python: &dyn PythonUsageSource,
79    target: &CodeUnit,
80    file: &ProjectFile,
81    local_name: &str,
82) -> bool {
83    target.source() == file
84        && target.identifier() == local_name
85        && python
86            .parent_of(target)
87            .is_some_and(|parent| parent.is_module() && parent.source() == file)
88}
89
90pub fn top_level_identifier(index: &dyn CodeUnitIndex, target: &CodeUnit) -> String {
91    let mut current = target.clone();
92    while let Some(parent) = index.parent_of(&current) {
93        if parent.is_module() {
94            break;
95        }
96        current = parent;
97    }
98    current.identifier().to_string()
99}
100
101pub fn member_name(index: &dyn CodeUnitIndex, target: &CodeUnit) -> Option<String> {
102    target_owner_code_unit(index, target).map(|_| target.identifier().to_string())
103}
104
105pub fn target_owner_code_unit(index: &dyn CodeUnitIndex, target: &CodeUnit) -> Option<CodeUnit> {
106    index
107        .parent_of(target)
108        .filter(|parent| parent.source() == target.source() && parent.is_class())
109}
110
111pub fn resolve_receiver_type(
112    graph: &PythonGraphSource<'_>,
113    python: &dyn PythonUsageSource,
114    file: &ProjectFile,
115    raw_type: &str,
116    target_self_file: bool,
117) -> Option<CodeUnit> {
118    let raw_type = raw_type.trim();
119    if raw_type.is_empty() || raw_type.contains('.') || raw_type.contains('|') {
120        return None;
121    }
122
123    if let Some(binding) = python.import_binder_of(file).bindings.get(raw_type)
124        && binding.kind == ImportKind::Named
125        && let Some(imported) = binding.imported_name.as_ref()
126    {
127        let fqn = format!("{}.{}", binding.module_specifier, imported);
128        if let Some(class) =
129            resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect())
130                .into_iter()
131                .find(CodeUnit::is_class)
132        {
133            return Some(class);
134        }
135    }
136
137    if let Some(provider) = graph.imports
138        && let Some(imported) = provider
139            .imported_code_units_of(file)
140            .iter()
141            .find(|code_unit| code_unit.identifier() == raw_type && code_unit.is_class())
142    {
143        return Some(imported.clone());
144    }
145
146    graph
147        .index
148        .declarations(file)
149        .into_iter()
150        .find(|code_unit| code_unit.identifier() == raw_type && code_unit.is_class())
151        .or_else(|| {
152            if !target_self_file {
153                return None;
154            }
155            // The only reader of the analyzer's global definition index in this
156            // crate, and the reason `PythonGraphSource::definitions` is a
157            // callback: the index builds on first access, so resolving it
158            // eagerly per scan would pay for a build this branch usually skips.
159            let mut resolved = None;
160            (graph.definitions)(&mut |support| {
161                resolved = resolve_indexed_receiver_type(graph.index, support, file, raw_type);
162            });
163            resolved
164        })
165}
166
167fn resolve_bare_annotation_symbol(
168    graph: &PythonGraphSource<'_>,
169    python: &dyn PythonUsageSource,
170    file: &ProjectFile,
171    source: &str,
172    node: Node<'_>,
173    raw_symbol: &str,
174) -> Vec<CodeUnit> {
175    let raw_symbol = raw_symbol.trim();
176    if raw_symbol.is_empty() {
177        return Vec::new();
178    }
179
180    if let Some(owner) = annotation_scope_owner_class(graph, file, source, node) {
181        let owner_candidates = exact_owner_annotation_members(graph, &owner, raw_symbol);
182        if !owner_candidates.is_empty() {
183            return owner_candidates;
184        }
185    }
186
187    let mut candidates = Vec::new();
188    if let Some(binding) = python.import_binder_of(file).bindings.get(raw_symbol)
189        && binding.kind == ImportKind::Named
190        && let Some(imported) = binding.imported_name.as_ref()
191    {
192        let fqn = format!("{}.{}", binding.module_specifier, imported);
193        candidates.extend(resolve_fqn_candidates(python, &fqn, |name| {
194            graph.index.definitions(name).collect()
195        }));
196    }
197
198    candidates.extend(
199        graph
200            .index
201            .top_level_declarations(file)
202            .into_iter()
203            .filter(|code_unit| !code_unit.is_module() && code_unit.identifier() == raw_symbol),
204    );
205
206    candidates.sort();
207    candidates.dedup();
208    candidates
209}
210
211/// Resolve a structured Python annotation reference.
212///
213/// Only AST nodes that occur inside a function return type, parameter type, or
214/// annotated-assignment type are considered. In particular, string contents are
215/// accepted only in those annotation positions; arbitrary string literals are
216/// never interpreted as type expressions.
217pub fn annotation_reference_candidates(
218    graph: &PythonGraphSource<'_>,
219    python: &dyn PythonUsageSource,
220    file: &ProjectFile,
221    source: &str,
222    node: Node<'_>,
223    target_self_file: bool,
224) -> Option<Vec<CodeUnit>> {
225    if !is_annotation_reference_node(node) {
226        return None;
227    }
228
229    let mut candidates = match node.kind() {
230        "identifier" => {
231            let mut candidates = resolve_bare_annotation_symbol(
232                graph,
233                python,
234                file,
235                source,
236                node,
237                node_text(node, source),
238            );
239            if candidates.is_empty() {
240                candidates.extend(resolve_receiver_type(
241                    graph,
242                    python,
243                    file,
244                    node_text(node, source),
245                    target_self_file,
246                ));
247            }
248            candidates
249        }
250        "string_content" => {
251            let Some(string) = node.parent() else {
252                return Some(Vec::new());
253            };
254            let Some(ranges) = python_deferred_annotation_identifier_ranges(string, source, None)
255            else {
256                return Some(Vec::new());
257            };
258            let mut candidates = Vec::new();
259            for range in ranges {
260                let Some(symbol) = source.get(range.start_byte..range.end_byte) else {
261                    continue;
262                };
263                let mut symbol_candidates =
264                    resolve_bare_annotation_symbol(graph, python, file, source, node, symbol);
265                if symbol_candidates.is_empty() {
266                    symbol_candidates.extend(resolve_receiver_type(
267                        graph,
268                        python,
269                        file,
270                        symbol,
271                        target_self_file,
272                    ));
273                }
274                candidates.extend(symbol_candidates);
275            }
276            candidates
277        }
278        "attribute" => resolve_annotation_attribute_types(graph, python, file, source, node),
279        _ => Vec::new(),
280    };
281    candidates.sort();
282    candidates.dedup();
283    Some(candidates)
284}
285
286fn resolve_annotation_attribute_types(
287    graph: &PythonGraphSource<'_>,
288    python: &dyn PythonUsageSource,
289    file: &ProjectFile,
290    source: &str,
291    node: Node<'_>,
292) -> Vec<CodeUnit> {
293    // Preserve the established namespace-import path (`module.Type`) while
294    // adding owner-qualified nested classes (`Outer.Inner`). The constructor
295    // resolver already understands module/re-export bindings; it simply cannot
296    // interpret a class as the namespace for another class.
297    let mut candidates = resolve_constructor_types(graph, python, file, source, node);
298    let Some((root, attributes)) = annotation_attribute_chain(node) else {
299        return candidates;
300    };
301    let root_text = node_text(root, source);
302    let owners: Vec<_> =
303        resolve_bare_annotation_symbol(graph, python, file, source, root, root_text)
304            .into_iter()
305            .filter(CodeUnit::is_class)
306            .collect();
307    let [owner] = owners.as_slice() else {
308        return candidates;
309    };
310    let mut owner = owner.clone();
311
312    for attribute in attributes {
313        let segment = node_text(attribute, source);
314        let next_candidates = exact_nested_annotation_class(graph, &owner, segment);
315        let [next] = next_candidates.as_slice() else {
316            return candidates;
317        };
318        owner = next.clone();
319    }
320
321    candidates.push(owner);
322    candidates
323}
324
325fn annotation_attribute_chain(node: Node<'_>) -> Option<(Node<'_>, Vec<Node<'_>>)> {
326    let mut attributes = Vec::new();
327    let mut current = node;
328    while current.kind() == "attribute" {
329        attributes.push(current.child_by_field_name("attribute")?);
330        current = current.child_by_field_name("object")?;
331    }
332    if current.kind() != "identifier" || attributes.is_empty() {
333        return None;
334    }
335    attributes.reverse();
336    Some((current, attributes))
337}
338
339fn exact_nested_annotation_class(
340    graph: &PythonGraphSource<'_>,
341    owner: &CodeUnit,
342    segment: &str,
343) -> Vec<CodeUnit> {
344    let mut candidates: Vec<_> = exact_owner_annotation_members(graph, owner, segment)
345        .into_iter()
346        .filter(CodeUnit::is_class)
347        .collect();
348    candidates.sort();
349    candidates.dedup();
350    candidates
351}
352
353fn exact_owner_annotation_members(
354    graph: &PythonGraphSource<'_>,
355    owner: &CodeUnit,
356    segment: &str,
357) -> Vec<CodeUnit> {
358    let mut candidates: Vec<_> = graph
359        .index
360        .declarations(owner.source())
361        .into_iter()
362        .filter(|unit| {
363            unit.identifier() == segment
364                && graph
365                    .index
366                    .parent_of(unit)
367                    .is_some_and(|parent| parent.fq_name() == owner.fq_name())
368        })
369        .collect();
370    candidates.sort();
371    candidates.dedup();
372    candidates
373}
374
375fn annotation_scope_owner_class(
376    graph: &PythonGraphSource<'_>,
377    file: &ProjectFile,
378    source: &str,
379    node: Node<'_>,
380) -> Option<CodeUnit> {
381    if !annotation_expression_is_class_scoped(node) {
382        return None;
383    }
384    let range = Range {
385        start_byte: node.start_byte(),
386        end_byte: node.end_byte(),
387        start_line: 0,
388        end_line: 0,
389    };
390    if let Some(enclosing) = graph.index.enclosing_code_unit(file, &range) {
391        if enclosing.is_class() {
392            return Some(enclosing);
393        }
394        if let Some(owner) = target_owner_code_unit(graph.index, &enclosing) {
395            return Some(owner);
396        }
397    }
398    structural_annotation_owner_class(graph, file, source, node)
399}
400
401fn annotation_expression_is_class_scoped(node: Node<'_>) -> bool {
402    let site_start = node.start_byte();
403    let site_end = node.end_byte();
404    let mut current = node;
405    while let Some(parent) = current.parent() {
406        if matches!(parent.kind(), "function_definition" | "lambda")
407            && parent
408                .child_by_field_name("body")
409                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
410        {
411            return false;
412        }
413        if parent.kind() == "class_definition" {
414            return true;
415        }
416        current = parent;
417    }
418    false
419}
420
421fn structural_annotation_owner_class(
422    graph: &PythonGraphSource<'_>,
423    file: &ProjectFile,
424    source: &str,
425    node: Node<'_>,
426) -> Option<CodeUnit> {
427    let mut current = node;
428    while let Some(parent) = current.parent() {
429        if parent.kind() == "class_definition" {
430            let name = node_text(parent.child_by_field_name("name")?, source).trim();
431            if name.is_empty() {
432                return None;
433            }
434            let class_range = Range {
435                start_byte: parent.start_byte(),
436                end_byte: parent.end_byte(),
437                start_line: 0,
438                end_line: 0,
439            };
440            let mut matches: Vec<_> = graph
441                .index
442                .declarations(file)
443                .into_iter()
444                .filter(|unit| unit.is_class() && unit.identifier() == name)
445                .filter(|unit| {
446                    graph
447                        .index
448                        .ranges(unit)
449                        .into_iter()
450                        .any(|range| range.contains(&class_range))
451                })
452                .collect();
453            matches.sort();
454            matches.dedup();
455            let [owner] = matches.as_slice() else {
456                return None;
457            };
458            return Some(owner.clone());
459        }
460        current = parent;
461    }
462    None
463}
464
465fn is_annotation_reference_node(node: Node<'_>) -> bool {
466    if !matches!(node.kind(), "identifier" | "attribute" | "string_content") {
467        return false;
468    }
469    python_node_is_in_annotation(node)
470}
471
472/// Resolve the class constructed by a Python call callee without interpreting
473/// source text. Bare callees use the import binder or same-file declarations;
474/// qualified callees walk tree-sitter's `attribute` fields back to a namespace
475/// import and append each attribute component structurally.
476pub fn resolve_constructor_types(
477    graph: &PythonGraphSource<'_>,
478    python: &dyn PythonUsageSource,
479    file: &ProjectFile,
480    source: &str,
481    function: Node<'_>,
482) -> Vec<CodeUnit> {
483    let binder = python.import_binder_of(file);
484    let fqn = match function.kind() {
485        "identifier" => {
486            let local = node_text(function, source);
487            if local.is_empty() {
488                return Vec::new();
489            }
490            match binder.bindings.get(local) {
491                Some(binding) if binding.kind == ImportKind::Named => binding
492                    .imported_name
493                    .as_ref()
494                    .map(|imported| format!("{}.{}", binding.module_specifier, imported)),
495                _ => graph
496                    .index
497                    .declarations(file)
498                    .into_iter()
499                    .find(|unit| unit.is_class() && unit.identifier() == local)
500                    .map(|unit| unit.fq_name()),
501            }
502        }
503        "attribute" => namespace_constructor_fqn(&binder, source, function),
504        _ => None,
505    };
506    let Some(fqn) = fqn else {
507        return Vec::new();
508    };
509    let mut classes: Vec<CodeUnit> =
510        resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect())
511            .into_iter()
512            .filter(CodeUnit::is_class)
513            .collect();
514    classes.sort();
515    classes.dedup();
516    classes
517}
518
519fn namespace_constructor_fqn(
520    binder: &ImportBinder,
521    source: &str,
522    function: Node<'_>,
523) -> Option<String> {
524    let mut attributes = Vec::new();
525    let mut current = function;
526    while current.kind() == "attribute" {
527        let attribute = current.child_by_field_name("attribute")?;
528        let text = node_text(attribute, source);
529        if text.is_empty() {
530            return None;
531        }
532        attributes.push(text);
533        current = current.child_by_field_name("object")?;
534    }
535    if current.kind() != "identifier" {
536        return None;
537    }
538    let root = node_text(current, source);
539    let binding = binder.bindings.get(root)?;
540    if binding.kind != ImportKind::Namespace {
541        return None;
542    }
543    let mut fqn = binding.module_specifier.clone();
544    for attribute in attributes.into_iter().rev() {
545        fqn.push('.');
546        fqn.push_str(attribute);
547    }
548    Some(fqn)
549}
550
551fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
552    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
553}
554
555fn resolve_indexed_receiver_type(
556    index: &dyn CodeUnitIndex,
557    lookup: &dyn BoundedDefinitionLookup,
558    file: &ProjectFile,
559    raw_type: &str,
560) -> Option<CodeUnit> {
561    module_fqn_for_file(index, file)
562        .into_iter()
563        .flat_map(|module| lookup.types_in_package(&module, raw_type))
564        .chain(lookup.fqn(raw_type))
565        .chain(lookup.by_normalized_fqn(raw_type))
566        .find(|code_unit| code_unit.identifier() == raw_type && code_unit.is_class())
567}
568
569fn module_fqn_for_file(index: &dyn CodeUnitIndex, file: &ProjectFile) -> Option<String> {
570    index
571        .declarations(file)
572        .into_iter()
573        .find(|code_unit| code_unit.is_module())
574        .map(|code_unit| code_unit.fq_name())
575        .or_else(|| {
576            index
577                .declarations(file)
578                .into_iter()
579                .find(|code_unit| !code_unit.package_name().is_empty())
580                .map(|code_unit| code_unit.package_name().to_string())
581        })
582}
583
584pub fn normalized_receiver_type(annotation: &str) -> Option<String> {
585    let annotation = unwrap_python_string_annotation(annotation.trim());
586    let annotation = unwrap_supported_receiver_wrapper(annotation);
587    if annotation.is_empty()
588        || annotation.contains('|')
589        || annotation.contains('[')
590        || annotation.contains(']')
591        || annotation.contains(',')
592        || annotation.contains('(')
593        || annotation.contains(')')
594        || annotation.contains('{')
595        || annotation.contains('}')
596        || annotation.contains(':')
597    {
598        return None;
599    }
600    Some(annotation.to_string())
601}
602
603fn unwrap_python_string_annotation(annotation: &str) -> &str {
604    if annotation.len() >= 2 {
605        let bytes = annotation.as_bytes();
606        let first = bytes[0];
607        let last = bytes[annotation.len() - 1];
608        if (first == b'\'' || first == b'"') && first == last {
609            return annotation[1..annotation.len() - 1].trim();
610        }
611    }
612    annotation
613}
614
615fn unwrap_supported_receiver_wrapper(annotation: &str) -> &str {
616    let mut current = annotation.trim();
617    loop {
618        let next = current
619            .strip_prefix("Optional[")
620            .or_else(|| current.strip_prefix("typing.Optional["))
621            .and_then(|inner| inner.strip_suffix(']'))
622            .map(str::trim);
623        let Some(unwrapped) = next else {
624            return current;
625        };
626        current = unwrapped;
627    }
628}
629
630pub fn receiver_annotation_matches_target(
631    annotation: &str,
632    edges: &[ImportEdge],
633    target_short: &str,
634    target_self_file: bool,
635) -> bool {
636    let annotation = annotation.trim();
637    if annotation.is_empty() {
638        return false;
639    }
640    if annotation.contains('|')
641        || annotation.contains('[')
642        || annotation.contains(']')
643        || annotation.contains(',')
644        || annotation.contains('(')
645        || annotation.contains(')')
646    {
647        return false;
648    }
649    if annotation == target_short {
650        return target_self_file || edges.iter().any(|edge| edge.local_name == target_short);
651    }
652
653    // `annotation` was already filtered above to exclude generics/unions/calls, so
654    // it is a bare dotted qualifier (Python identifiers never embed a literal
655    // `.`); re-tokenizing with the shared structured splitter and rejoining
656    // every part but the last with `.` reproduces `rsplit_once('.')`'s
657    // (qualifier, member) split exactly.
658    let segments = parse_symbol_path(Language::Python, annotation);
659    let Some((member, qualifier_parts)) = segments.split_last() else {
660        return false;
661    };
662    if qualifier_parts.is_empty() {
663        return false;
664    }
665    let qualifier = qualifier_parts.join(".");
666    let member = member.as_str();
667    if member != target_short {
668        return false;
669    }
670    edges.iter().any(|edge| {
671        matches!(edge.kind, ImportEdgeKind::Namespace)
672            && (edge.local_name == qualifier
673                || qualifier.ends_with(&format!(".{}", edge.local_name)))
674    })
675}
676
677// Python module-name and relative-import resolution were lifted to the analyzer
678// (`PythonAnalyzer::python_module_name` / `resolve_module_files`, see
679// `analyzer::python::usage_index`); both usage paths now resolve through there.