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        let mut imported_candidates =
194            resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect());
195        imported_candidates.retain(|candidate| {
196            !candidate.is_module() || candidate.fq_name() != binding.module_specifier
197        });
198        candidates.extend(imported_candidates);
199    }
200
201    candidates.extend(
202        graph
203            .index
204            .top_level_declarations(file)
205            .into_iter()
206            .filter(|code_unit| {
207                !code_unit.is_module()
208                    && code_unit.identifier() == raw_symbol
209                    && graph
210                        .index
211                        .parent_of(code_unit)
212                        .is_some_and(|parent| parent.is_module())
213            }),
214    );
215
216    candidates.sort();
217    candidates.dedup();
218    candidates
219}
220
221/// Resolve a structured Python annotation reference.
222///
223/// Only AST nodes that occur inside a function return type, parameter type, or
224/// annotated-assignment type are considered. In particular, string contents are
225/// accepted only in those annotation positions; arbitrary string literals are
226/// never interpreted as type expressions.
227pub fn annotation_reference_candidates(
228    graph: &PythonGraphSource<'_>,
229    python: &dyn PythonUsageSource,
230    file: &ProjectFile,
231    source: &str,
232    node: Node<'_>,
233    target_self_file: bool,
234) -> Option<Vec<CodeUnit>> {
235    if !is_annotation_reference_node(node) {
236        return None;
237    }
238
239    let mut candidates = match node.kind() {
240        "identifier" => {
241            let mut candidates = resolve_bare_annotation_symbol(
242                graph,
243                python,
244                file,
245                source,
246                node,
247                node_text(node, source),
248            );
249            if candidates.is_empty() {
250                candidates.extend(resolve_receiver_type(
251                    graph,
252                    python,
253                    file,
254                    node_text(node, source),
255                    target_self_file,
256                ));
257            }
258            candidates
259        }
260        "string_content" => {
261            let Some(string) = node.parent() else {
262                return Some(Vec::new());
263            };
264            let Some(ranges) = python_deferred_annotation_identifier_ranges(string, source, None)
265            else {
266                return Some(Vec::new());
267            };
268            let mut candidates = Vec::new();
269            for range in ranges {
270                let Some(symbol) = source.get(range.start_byte..range.end_byte) else {
271                    continue;
272                };
273                let mut symbol_candidates =
274                    resolve_bare_annotation_symbol(graph, python, file, source, node, symbol);
275                if symbol_candidates.is_empty() {
276                    symbol_candidates.extend(resolve_receiver_type(
277                        graph,
278                        python,
279                        file,
280                        symbol,
281                        target_self_file,
282                    ));
283                }
284                candidates.extend(symbol_candidates);
285            }
286            candidates
287        }
288        "attribute" => resolve_annotation_attribute_types(graph, python, file, source, node),
289        _ => Vec::new(),
290    };
291    candidates.sort();
292    candidates.dedup();
293    Some(candidates)
294}
295
296/// Return the exact qualifier token when a class target owns part of a
297/// structured annotation attribute chain.
298pub fn annotation_class_qualifier_site<'tree>(
299    graph: &PythonGraphSource<'_>,
300    python: &dyn PythonUsageSource,
301    file: &ProjectFile,
302    source: &str,
303    node: Node<'tree>,
304    target: &CodeUnit,
305) -> Option<Node<'tree>> {
306    if node.kind() != "attribute" || !target.is_class() || !is_annotation_reference_node(node) {
307        return None;
308    }
309
310    let (root, attributes) = annotation_attribute_chain(node)?;
311    let owners: Vec<_> =
312        resolve_bare_annotation_symbol(graph, python, file, source, root, node_text(root, source))
313            .into_iter()
314            .filter(CodeUnit::is_class)
315            .collect();
316    let [owner] = owners.as_slice() else {
317        return None;
318    };
319    let mut owner = owner.clone();
320    if &owner == target {
321        return Some(root);
322    }
323
324    // The final attribute is the annotation declaration itself. Only the
325    // preceding segments are class qualifiers.
326    let qualifier_count = attributes.len().saturating_sub(1);
327    for attribute in attributes.into_iter().take(qualifier_count) {
328        let next_candidates =
329            exact_nested_annotation_class(graph, &owner, node_text(attribute, source));
330        let [next] = next_candidates.as_slice() else {
331            return None;
332        };
333        owner = next.clone();
334        if &owner == target {
335            return Some(attribute);
336        }
337    }
338
339    None
340}
341
342fn resolve_annotation_attribute_types(
343    graph: &PythonGraphSource<'_>,
344    python: &dyn PythonUsageSource,
345    file: &ProjectFile,
346    source: &str,
347    node: Node<'_>,
348) -> Vec<CodeUnit> {
349    // Preserve the established namespace-import path (`module.Type`) while
350    // adding owner-qualified nested classes (`Outer.Inner`). The namespace walk
351    // already understands module/re-export bindings; it simply cannot interpret
352    // a class as the namespace for another class.
353    let mut candidates = namespace_qualified_declarations(graph, python, file, source, node);
354    let Some((root, attributes)) = annotation_attribute_chain(node) else {
355        return candidates;
356    };
357    let root_text = node_text(root, source);
358    let owners: Vec<_> =
359        resolve_bare_annotation_symbol(graph, python, file, source, root, root_text)
360            .into_iter()
361            .filter(CodeUnit::is_class)
362            .collect();
363    let [owner] = owners.as_slice() else {
364        return candidates;
365    };
366    let mut owner = owner.clone();
367
368    for attribute in attributes {
369        let segment = node_text(attribute, source);
370        let next_candidates = exact_nested_annotation_class(graph, &owner, segment);
371        let [next] = next_candidates.as_slice() else {
372            return candidates;
373        };
374        owner = next.clone();
375    }
376
377    candidates.push(owner);
378    candidates
379}
380
381fn annotation_attribute_chain(node: Node<'_>) -> Option<(Node<'_>, Vec<Node<'_>>)> {
382    let mut attributes = Vec::new();
383    let mut current = node;
384    while current.kind() == "attribute" {
385        attributes.push(current.child_by_field_name("attribute")?);
386        current = current.child_by_field_name("object")?;
387    }
388    if current.kind() != "identifier" || attributes.is_empty() {
389        return None;
390    }
391    attributes.reverse();
392    Some((current, attributes))
393}
394
395fn exact_nested_annotation_class(
396    graph: &PythonGraphSource<'_>,
397    owner: &CodeUnit,
398    segment: &str,
399) -> Vec<CodeUnit> {
400    let mut candidates: Vec<_> = exact_owner_annotation_members(graph, owner, segment)
401        .into_iter()
402        .filter(CodeUnit::is_class)
403        .collect();
404    candidates.sort();
405    candidates.dedup();
406    candidates
407}
408
409fn exact_owner_annotation_members(
410    graph: &PythonGraphSource<'_>,
411    owner: &CodeUnit,
412    segment: &str,
413) -> Vec<CodeUnit> {
414    let mut candidates: Vec<_> = graph
415        .index
416        .declarations(owner.source())
417        .into_iter()
418        .filter(|unit| {
419            unit.identifier() == segment
420                && graph
421                    .index
422                    .parent_of(unit)
423                    .is_some_and(|parent| parent.fq_name() == owner.fq_name())
424        })
425        .collect();
426    candidates.sort();
427    candidates.dedup();
428    candidates
429}
430
431fn annotation_scope_owner_class(
432    graph: &PythonGraphSource<'_>,
433    file: &ProjectFile,
434    source: &str,
435    node: Node<'_>,
436) -> Option<CodeUnit> {
437    if !annotation_expression_is_class_scoped(node) {
438        return None;
439    }
440    let range = Range {
441        start_byte: node.start_byte(),
442        end_byte: node.end_byte(),
443        start_line: 0,
444        end_line: 0,
445    };
446    if let Some(enclosing) = graph.index.enclosing_code_unit(file, &range) {
447        if enclosing.is_class() {
448            return Some(enclosing);
449        }
450        if let Some(owner) = target_owner_code_unit(graph.index, &enclosing) {
451            return Some(owner);
452        }
453    }
454    structural_annotation_owner_class(graph, file, source, node)
455}
456
457fn annotation_expression_is_class_scoped(node: Node<'_>) -> bool {
458    let site_start = node.start_byte();
459    let site_end = node.end_byte();
460    let mut current = node;
461    while let Some(parent) = current.parent() {
462        if matches!(parent.kind(), "function_definition" | "lambda")
463            && parent
464                .child_by_field_name("body")
465                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
466        {
467            return false;
468        }
469        if parent.kind() == "class_definition" {
470            return true;
471        }
472        current = parent;
473    }
474    false
475}
476
477fn structural_annotation_owner_class(
478    graph: &PythonGraphSource<'_>,
479    file: &ProjectFile,
480    source: &str,
481    node: Node<'_>,
482) -> Option<CodeUnit> {
483    let mut current = node;
484    while let Some(parent) = current.parent() {
485        if parent.kind() == "class_definition" {
486            let name = node_text(parent.child_by_field_name("name")?, source).trim();
487            if name.is_empty() {
488                return None;
489            }
490            let class_range = Range {
491                start_byte: parent.start_byte(),
492                end_byte: parent.end_byte(),
493                start_line: 0,
494                end_line: 0,
495            };
496            let mut matches: Vec<_> = graph
497                .index
498                .declarations(file)
499                .into_iter()
500                .filter(|unit| unit.is_class() && unit.identifier() == name)
501                .filter(|unit| {
502                    graph
503                        .index
504                        .ranges(unit)
505                        .into_iter()
506                        .any(|range| range.contains(&class_range))
507                })
508                .collect();
509            matches.sort();
510            matches.dedup();
511            let [owner] = matches.as_slice() else {
512                return None;
513            };
514            return Some(owner.clone());
515        }
516        current = parent;
517    }
518    None
519}
520
521fn is_annotation_reference_node(node: Node<'_>) -> bool {
522    if !matches!(node.kind(), "identifier" | "attribute" | "string_content") {
523        return false;
524    }
525    python_node_is_in_annotation(node)
526}
527
528/// Resolve the class constructed by a Python call callee without interpreting
529/// source text. Bare callees use the import binder or same-file declarations;
530/// qualified callees walk tree-sitter's `attribute` fields back to a namespace
531/// import and append each attribute component structurally.
532pub fn resolve_constructor_types(
533    graph: &PythonGraphSource<'_>,
534    python: &dyn PythonUsageSource,
535    file: &ProjectFile,
536    source: &str,
537    function: Node<'_>,
538) -> Vec<CodeUnit> {
539    let candidates = match function.kind() {
540        "identifier" => {
541            let local = node_text(function, source);
542            if local.is_empty() {
543                return Vec::new();
544            }
545            let binder = python.import_binder_of(file);
546            let fqn = match binder.bindings.get(local) {
547                Some(binding) if binding.kind == ImportKind::Named => binding
548                    .imported_name
549                    .as_ref()
550                    .map(|imported| format!("{}.{}", binding.module_specifier, imported)),
551                _ => graph
552                    .index
553                    .declarations(file)
554                    .into_iter()
555                    .find(|unit| unit.is_class() && unit.identifier() == local)
556                    .map(|unit| unit.fq_name()),
557            };
558            let Some(fqn) = fqn else {
559                return Vec::new();
560            };
561            resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect())
562        }
563        "attribute" => namespace_qualified_declarations(graph, python, file, source, function),
564        _ => Vec::new(),
565    };
566    // A call callee names something constructible; an annotation does not, so
567    // the kind filter belongs here rather than in the shared namespace walk.
568    let mut classes: Vec<CodeUnit> = candidates.into_iter().filter(CodeUnit::is_class).collect();
569    classes.sort();
570    classes.dedup();
571    classes
572}
573
574/// Resolve the class in the nearest enclosing callable parameter default.
575///
576/// For `def run(Foo: type = Foo): Foo(bar=1)`, the body binding shadows the
577/// imported class name. The structured default still proves which class the
578/// keyword belongs to when the default callable is used.
579pub fn resolve_callable_parameter_default_types(
580    graph: &PythonGraphSource<'_>,
581    python: &dyn PythonUsageSource,
582    file: &ProjectFile,
583    source: &str,
584    reference: Node<'_>,
585    local_name: &str,
586) -> Vec<CodeUnit> {
587    let site_start = reference.start_byte();
588    let site_end = reference.end_byte();
589    let mut current = reference;
590    while let Some(parent) = current.parent() {
591        current = parent;
592        if !matches!(current.kind(), "function_definition" | "lambda") {
593            continue;
594        }
595        if current
596            .child_by_field_name("body")
597            .is_none_or(|body| !(body.start_byte() <= site_start && site_end <= body.end_byte()))
598        {
599            continue;
600        }
601        let Some(parameters) = current.child_by_field_name("parameters") else {
602            return Vec::new();
603        };
604        let mut cursor = parameters.walk();
605        for parameter in parameters.named_children(&mut cursor) {
606            let name = if parameter.kind() == "identifier" {
607                Some(parameter)
608            } else {
609                parameter.child_by_field_name("name")
610            };
611            if name.is_none_or(|name| node_text(name, source) != local_name) {
612                continue;
613            }
614            let Some(value) = parameter.child_by_field_name("value") else {
615                return Vec::new();
616            };
617            return resolve_constructor_types(graph, python, file, source, value);
618        }
619        return Vec::new();
620    }
621    Vec::new()
622}
623
624/// The declarations a namespace-qualified attribute path names (`module.Name`,
625/// `pkg.module.Name`), walked structurally through the import binder.
626///
627/// No kind filter: a Python annotation legitimately names a module-level type
628/// alias, `TypeAlias`, `NewType` or `TypeVar` value, all of which the analyzer
629/// models as fields (issue #1763).
630fn namespace_qualified_declarations(
631    graph: &PythonGraphSource<'_>,
632    python: &dyn PythonUsageSource,
633    file: &ProjectFile,
634    source: &str,
635    node: Node<'_>,
636) -> Vec<CodeUnit> {
637    let binder = python.import_binder_of(file);
638    let Some(fqn) = namespace_constructor_fqn(&binder, source, node) else {
639        return Vec::new();
640    };
641    let mut candidates =
642        resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect());
643    candidates.sort();
644    candidates.dedup();
645    candidates
646}
647
648fn namespace_constructor_fqn(
649    binder: &ImportBinder,
650    source: &str,
651    function: Node<'_>,
652) -> Option<String> {
653    let mut attributes = Vec::new();
654    let mut current = function;
655    while current.kind() == "attribute" {
656        let attribute = current.child_by_field_name("attribute")?;
657        let text = node_text(attribute, source);
658        if text.is_empty() {
659            return None;
660        }
661        attributes.push(text);
662        current = current.child_by_field_name("object")?;
663    }
664    if current.kind() != "identifier" {
665        return None;
666    }
667    let root = node_text(current, source);
668    let binding = binder.bindings.get(root)?;
669    if binding.kind != ImportKind::Namespace {
670        return None;
671    }
672    let mut fqn = binding.module_specifier.clone();
673    for attribute in attributes.into_iter().rev() {
674        fqn.push('.');
675        fqn.push_str(attribute);
676    }
677    Some(fqn)
678}
679
680fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
681    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
682}
683
684fn resolve_indexed_receiver_type(
685    index: &dyn CodeUnitIndex,
686    lookup: &dyn BoundedDefinitionLookup,
687    file: &ProjectFile,
688    raw_type: &str,
689) -> Option<CodeUnit> {
690    module_fqn_for_file(index, file)
691        .into_iter()
692        .flat_map(|module| lookup.types_in_package(&module, raw_type))
693        .chain(lookup.fqn(raw_type))
694        .chain(lookup.by_normalized_fqn(raw_type))
695        .find(|code_unit| code_unit.identifier() == raw_type && code_unit.is_class())
696}
697
698fn module_fqn_for_file(index: &dyn CodeUnitIndex, file: &ProjectFile) -> Option<String> {
699    index
700        .declarations(file)
701        .into_iter()
702        .find(|code_unit| code_unit.is_module())
703        .map(|code_unit| code_unit.fq_name())
704        .or_else(|| {
705            index
706                .declarations(file)
707                .into_iter()
708                .find(|code_unit| !code_unit.package_name().is_empty())
709                .map(|code_unit| code_unit.package_name().to_string())
710        })
711}
712
713pub fn normalized_receiver_type(annotation: &str) -> Option<String> {
714    let annotation = unwrap_python_string_annotation(annotation.trim());
715    let annotation = unwrap_supported_receiver_wrapper(annotation);
716    if annotation.is_empty()
717        || annotation.contains('|')
718        || annotation.contains('[')
719        || annotation.contains(']')
720        || annotation.contains(',')
721        || annotation.contains('(')
722        || annotation.contains(')')
723        || annotation.contains('{')
724        || annotation.contains('}')
725        || annotation.contains(':')
726    {
727        return None;
728    }
729    Some(annotation.to_string())
730}
731
732fn unwrap_python_string_annotation(annotation: &str) -> &str {
733    if annotation.len() >= 2 {
734        let bytes = annotation.as_bytes();
735        let first = bytes[0];
736        let last = bytes[annotation.len() - 1];
737        if (first == b'\'' || first == b'"') && first == last {
738            return annotation[1..annotation.len() - 1].trim();
739        }
740    }
741    annotation
742}
743
744fn unwrap_supported_receiver_wrapper(annotation: &str) -> &str {
745    let mut current = annotation.trim();
746    loop {
747        let next = current
748            .strip_prefix("Optional[")
749            .or_else(|| current.strip_prefix("typing.Optional["))
750            .and_then(|inner| inner.strip_suffix(']'))
751            .map(str::trim);
752        let Some(unwrapped) = next else {
753            return current;
754        };
755        current = unwrapped;
756    }
757}
758
759pub fn receiver_annotation_matches_target(
760    annotation: &str,
761    edges: &[ImportEdge],
762    target_short: &str,
763    target_self_file: bool,
764) -> bool {
765    let annotation = annotation.trim();
766    if annotation.is_empty() {
767        return false;
768    }
769    if annotation.contains('|')
770        || annotation.contains('[')
771        || annotation.contains(']')
772        || annotation.contains(',')
773        || annotation.contains('(')
774        || annotation.contains(')')
775    {
776        return false;
777    }
778    if annotation == target_short {
779        return target_self_file || edges.iter().any(|edge| edge.local_name == target_short);
780    }
781
782    // `annotation` was already filtered above to exclude generics/unions/calls, so
783    // it is a bare dotted qualifier (Python identifiers never embed a literal
784    // `.`); re-tokenizing with the shared structured splitter and rejoining
785    // every part but the last with `.` reproduces `rsplit_once('.')`'s
786    // (qualifier, member) split exactly.
787    let segments = parse_symbol_path(Language::Python, annotation);
788    let Some((member, qualifier_parts)) = segments.split_last() else {
789        return false;
790    };
791    if qualifier_parts.is_empty() {
792        return false;
793    }
794    let qualifier = qualifier_parts.join(".");
795    let member = member.as_str();
796    if member != target_short {
797        return false;
798    }
799    edges.iter().any(|edge| {
800        matches!(edge.kind, ImportEdgeKind::Namespace)
801            && (edge.local_name == qualifier
802                || qualifier.ends_with(&format!(".{}", edge.local_name)))
803    })
804}
805
806// Python module-name and relative-import resolution were lifted to the analyzer
807// (`PythonAnalyzer::python_module_name` / `resolve_module_files`, see
808// `analyzer::python::usage_index`); both usage paths now resolve through there.