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 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
211pub 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 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
472pub 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 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