1use 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(¤t) {
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 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
221pub 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
296pub 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 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 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
528pub 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 let mut classes: Vec<CodeUnit> = candidates.into_iter().filter(CodeUnit::is_class).collect();
569 classes.sort();
570 classes.dedup();
571 classes
572}
573
574pub 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
624fn 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 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