Skip to main content

brokk_bifrost_python/graph/
extractor.rs

1//! Python's forward, per-target usage scan: the scoped import closure, the
2//! per-file walk that proves a reference, and the receiver-type facts both this
3//! and the inverted walk resolve through.
4
5use crate::graph::PythonGraphSource;
6use crate::graph::hits::{
7    record_hit, record_import_hit, record_self_receiver_hit, record_unproven_hit,
8};
9use crate::graph::resolver::{
10    annotation_reference_candidates, member_name, normalized_receiver_type,
11    receiver_annotation_matches_target, resolve_constructor_types, resolve_receiver_type,
12    target_owner_code_unit, top_level_identifier,
13};
14use crate::graph_support::{PythonSource, PythonUsageSource};
15use crate::imports::resolve_fqn_candidates;
16use crate::usage_index::{
17    ModuleBindingEvent, ModuleBindingEventKind, ModuleBindingTimeline, PythonScopeFacts,
18    usage_matching_edges, usage_module_binding_timeline, usage_resolve_module_files,
19    usage_scope_facts,
20};
21use brokk_bifrost_core::analyzer::usages::local_inference::{
22    LocalBindingsSnapshot, LocalInferenceConfig, LocalInferenceEngine, SymbolResolution,
23};
24use brokk_bifrost_core::analyzer::usages::model::{ImportKind, UsageHit};
25use brokk_bifrost_core::analyzer::usages::{ImportEdge, ImportEdgeKind};
26use brokk_bifrost_core::analyzer::{CodeUnit, CodeUnitIndex, ProjectFile, Range};
27use brokk_bifrost_core::cancellation::CancellationToken;
28use brokk_bifrost_core::hash::{HashMap, HashSet};
29use brokk_bifrost_core::text_utils::compute_line_starts;
30use rayon::prelude::*;
31use std::collections::BTreeSet;
32use std::sync::{Arc, Mutex};
33use tree_sitter::{Node, Parser, Tree};
34
35pub struct ParsedFile {
36    pub source: Arc<String>,
37    pub tree: Tree,
38}
39
40pub struct PythonProjectGraph {
41    parsed: HashMap<ProjectFile, ParsedFile>,
42}
43
44impl PythonProjectGraph {
45    pub fn scan_files(
46        &self,
47        candidate_files: &HashSet<ProjectFile>,
48        target_file: &ProjectFile,
49    ) -> HashSet<ProjectFile> {
50        candidate_files
51            .iter()
52            .cloned()
53            .chain(std::iter::once(target_file.clone()))
54            .collect()
55    }
56}
57
58pub fn build_python_graph(
59    candidate_files: &HashSet<ProjectFile>,
60    target_file: &ProjectFile,
61    cancellation: Option<&CancellationToken>,
62) -> PythonProjectGraph {
63    let parser_language = tree_sitter_python::LANGUAGE.into();
64    let files: HashSet<ProjectFile> = candidate_files
65        .iter()
66        .cloned()
67        .chain(std::iter::once(target_file.clone()))
68        .collect();
69    let mut parsed = HashMap::default();
70
71    for file in files {
72        if cancellation.is_some_and(CancellationToken::is_cancelled) {
73            break;
74        }
75        let Ok(source) = file.read_to_string() else {
76            continue;
77        };
78        if cancellation.is_some_and(CancellationToken::is_cancelled) {
79            break;
80        }
81        if source.is_empty() {
82            continue;
83        }
84        let mut parser = Parser::new();
85        if parser.set_language(&parser_language).is_err() {
86            continue;
87        }
88        let Some(tree) = parser.parse(source.as_str(), None) else {
89            continue;
90        };
91        if cancellation.is_some_and(CancellationToken::is_cancelled) {
92            break;
93        }
94        parsed.insert(
95            file,
96            ParsedFile {
97                source: Arc::new(source),
98                tree,
99            },
100        );
101    }
102
103    PythonProjectGraph { parsed }
104}
105
106pub fn scan_files_for_seeds(
107    graph: &PythonGraphSource<'_>,
108    python: &dyn PythonUsageSource,
109    project_graph: &PythonProjectGraph,
110    files: &HashSet<ProjectFile>,
111    target: &CodeUnit,
112    seeds: &BTreeSet<(ProjectFile, String)>,
113    cancellation: Option<&CancellationToken>,
114) -> ScanResult {
115    let collected: Mutex<BTreeSet<UsageHit>> = Mutex::new(BTreeSet::new());
116    let unproven_collected: Mutex<BTreeSet<UsageHit>> = Mutex::new(BTreeSet::new());
117    let target_short = top_level_identifier(graph.index, target);
118    let target_member = member_name(graph.index, target);
119    let target_owner = target_owner_code_unit(graph.index, target);
120    // A same-file best-effort for unresolvable receivers is only safe when the
121    // member name is unambiguous in the target's file (exactly one class there
122    // declares it), so `recv.member` can only mean the target.
123    let member_unique_in_target_file = target_member.as_deref().is_some_and(|member| {
124        let owners: HashSet<CodeUnit> = graph
125            .index
126            .declarations(target.source())
127            .into_iter()
128            .filter(|decl| {
129                decl.identifier() == member && target_owner_code_unit(graph.index, decl).is_some()
130            })
131            .filter_map(|decl| target_owner_code_unit(graph.index, &decl))
132            .collect();
133        owners.len() == 1
134    });
135    let files_vec: Vec<&ProjectFile> = files.iter().collect();
136    let parser_language = tree_sitter_python::LANGUAGE.into();
137
138    files_vec.par_iter().for_each(|file| {
139        if cancellation.is_some_and(CancellationToken::is_cancelled) {
140            return;
141        }
142        let owned_source: Option<Arc<String>>;
143        let owned_tree: Option<Tree>;
144        let (source_str, tree_ref) = if let Some(parsed) = project_graph.parsed.get(*file) {
145            (parsed.source.as_str(), &parsed.tree)
146        } else {
147            let Ok(source) = file.read_to_string() else {
148                return;
149            };
150            if source.is_empty() {
151                return;
152            }
153            let mut parser = Parser::new();
154            if parser.set_language(&parser_language).is_err() {
155                return;
156            }
157            let Some(tree) = parser.parse(source.as_str(), None) else {
158                return;
159            };
160            owned_source = Some(Arc::new(source));
161            owned_tree = Some(tree);
162            (
163                owned_source.as_deref().unwrap().as_str(),
164                owned_tree.as_ref().unwrap(),
165            )
166        };
167        if cancellation.is_some_and(CancellationToken::is_cancelled) {
168            return;
169        }
170
171        let edges = {
172            let _scope = brokk_bifrost_core::profiling::scope("python_graph::matching_edges");
173            usage_matching_edges(python, file, seeds)
174        };
175        // This is an AST-name gate, not a source-text resolver. Every usage
176        // accepted by the later structural walk has a matching identifier or
177        // can occur inside an annotation string. Skip files that lack both
178        // before building their scope facts and performing that expensive walk.
179        if !file_may_reference_target(
180            tree_ref.root_node(),
181            source_str,
182            target,
183            target_short.as_str(),
184            target_member.as_deref(),
185            &edges,
186        ) {
187            return;
188        }
189        let module_bindings = {
190            let _scope =
191                brokk_bifrost_core::profiling::scope("python_graph::module_binding_timeline");
192            let raw_module_bindings = usage_module_binding_timeline(python, file, || {
193                collect_module_binding_timeline(tree_ref.root_node(), source_str)
194            });
195            classify_module_binding_timeline(
196                python,
197                file,
198                raw_module_bindings.as_ref(),
199                seeds,
200                &edges,
201            )
202        };
203        let target_self_file = *file == target.source();
204        let scope_facts = {
205            let _scope = brokk_bifrost_core::profiling::scope("python_graph::scope_facts");
206            usage_scope_facts(python, file, || {
207                collect_scope_facts_from_parsed_source(
208                    graph,
209                    python,
210                    file,
211                    source_str,
212                    tree_ref.root_node(),
213                )
214            })
215        };
216        let scope_range_index = build_scope_range_index(graph, scope_facts.as_ref());
217
218        let mut local_hits = BTreeSet::new();
219        let mut local_unproven_hits = BTreeSet::new();
220        let line_starts = compute_line_starts(source_str);
221
222        let mut scan_ctx = ScanCtx {
223            python,
224            file,
225            source: source_str,
226            line_starts: &line_starts,
227            graph,
228            target,
229            target_short: &target_short,
230            target_member: target_member.as_deref(),
231            target_owner: target_owner.clone(),
232            target_is_module: target.is_module(),
233            target_source: target.source(),
234            seeds,
235            edges: &edges,
236            target_self_file,
237            member_best_effort_unique: target_self_file && member_unique_in_target_file,
238            module_bindings: &module_bindings,
239            scope_facts: scope_facts.as_ref(),
240            scope_range_index: &scope_range_index,
241            hits: &mut local_hits,
242            unproven_hits: &mut local_unproven_hits,
243        };
244
245        {
246            let _scope = brokk_bifrost_core::profiling::scope("python_graph::scan_tree");
247            scan_node(tree_ref.root_node(), &mut scan_ctx);
248        }
249
250        if !local_hits.is_empty() {
251            let mut sink = collected
252                .lock()
253                .expect("usage hit collector mutex poisoned");
254            sink.extend(local_hits);
255        }
256        if !local_unproven_hits.is_empty() {
257            let mut sink = unproven_collected
258                .lock()
259                .expect("usage unproven hit collector mutex poisoned");
260            sink.extend(local_unproven_hits);
261        }
262    });
263
264    ScanResult {
265        hits: collected
266            .into_inner()
267            .expect("usage hit collector mutex poisoned"),
268        unproven_hits: unproven_collected
269            .into_inner()
270            .expect("usage unproven hit collector mutex poisoned"),
271    }
272}
273
274fn file_may_reference_target(
275    root: Node<'_>,
276    source: &str,
277    target: &CodeUnit,
278    target_short: &str,
279    target_member: Option<&str>,
280    edges: &[ImportEdge],
281) -> bool {
282    if target.is_module() {
283        return true;
284    }
285
286    let mut stack = vec![root];
287    while let Some(node) = stack.pop() {
288        if node.kind() == "string" {
289            // Annotation strings can use an FQN or a re-export alias that is
290            // not an exact identifier node. Keep them for the structured
291            // annotation resolver below.
292            return true;
293        }
294        if node.kind() == "identifier" {
295            let name = slice(node, source);
296            if name == target_short
297                || target_member.is_some_and(|member| name == member)
298                || edges.iter().any(|edge| edge.local_name == name)
299            {
300                return true;
301            }
302        }
303
304        let mut cursor = node.walk();
305        stack.extend(node.named_children(&mut cursor));
306    }
307    false
308}
309
310pub struct ScanResult {
311    pub hits: BTreeSet<UsageHit>,
312    pub unproven_hits: BTreeSet<UsageHit>,
313}
314
315pub struct ScanCtx<'a> {
316    python: &'a dyn PythonUsageSource,
317    pub file: &'a ProjectFile,
318    pub source: &'a str,
319    pub line_starts: &'a [usize],
320    pub graph: &'a PythonGraphSource<'a>,
321    target: &'a CodeUnit,
322    target_short: &'a str,
323    target_member: Option<&'a str>,
324    target_owner: Option<CodeUnit>,
325    target_is_module: bool,
326    target_source: &'a ProjectFile,
327    seeds: &'a BTreeSet<(ProjectFile, String)>,
328    edges: &'a [ImportEdge],
329    target_self_file: bool,
330    /// True when a same-file best-effort is justified for an unresolvable
331    /// receiver: the target is a member, its owner is in this file, and exactly
332    /// one class in this file declares that member name (so `recv.member` with
333    /// an un-inferrable `recv` unambiguously means the target). Cross-file
334    /// untyped receivers stay conservative.
335    member_best_effort_unique: bool,
336    module_bindings: &'a HashMap<String, Vec<ClassifiedModuleBindingEvent>>,
337    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
338    scope_range_index: &'a [ScopeRangeEntry],
339    pub hits: &'a mut BTreeSet<UsageHit>,
340    pub unproven_hits: &'a mut BTreeSet<UsageHit>,
341}
342
343struct ScopeRangeEntry {
344    range: Range,
345    scope: CodeUnit,
346    prefix_max_end: usize,
347}
348
349fn build_scope_range_index(
350    graph: &PythonGraphSource<'_>,
351    scope_facts: &HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
352) -> Vec<ScopeRangeEntry> {
353    let mut entries = scope_facts
354        .keys()
355        .flat_map(|scope| {
356            graph
357                .index
358                .ranges(scope)
359                .into_iter()
360                .map(|range| ScopeRangeEntry {
361                    range,
362                    scope: scope.clone(),
363                    prefix_max_end: 0,
364                })
365        })
366        .collect::<Vec<_>>();
367    entries.sort_by(|left, right| {
368        left.range
369            .start_byte
370            .cmp(&right.range.start_byte)
371            .then_with(|| right.range.end_byte.cmp(&left.range.end_byte))
372            .then_with(|| left.scope.cmp(&right.scope))
373    });
374    let mut max_end = 0;
375    for entry in &mut entries {
376        max_end = max_end.max(entry.range.end_byte);
377        entry.prefix_max_end = max_end;
378    }
379    entries
380}
381
382fn indexed_scope_entry<'entry, 'facts>(
383    scope_range_index: &'entry [ScopeRangeEntry],
384    scope_facts: &'facts HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
385    node: Node<'_>,
386) -> Option<(&'entry CodeUnit, &'facts LocalBindingsSnapshot<String>)> {
387    let mut cursor =
388        scope_range_index.partition_point(|entry| entry.range.start_byte <= node.start_byte());
389    while cursor > 0 {
390        cursor -= 1;
391        let entry = &scope_range_index[cursor];
392        if entry.prefix_max_end < node.end_byte() {
393            return None;
394        }
395        if entry.range.end_byte >= node.end_byte() {
396            return scope_facts
397                .get(&entry.scope)
398                .map(|facts| (&entry.scope, facts));
399        }
400    }
401    None
402}
403
404/// The per-function receiver-type facts enclosing `node`, if any. Shared by the
405/// forward scan ([`ScanCtx`]) and the inverted builder (`PyScan`) so the two
406/// paths resolve a receiver's scope through one place.
407pub fn enclosing_scope_facts<'a>(
408    index: &dyn CodeUnitIndex,
409    file: &ProjectFile,
410    scope_facts: &'a HashMap<CodeUnit, LocalBindingsSnapshot<String>>,
411    node: Node<'_>,
412) -> Option<&'a LocalBindingsSnapshot<String>> {
413    let range = Range {
414        start_byte: node.start_byte(),
415        end_byte: node.end_byte(),
416        start_line: 0,
417        end_line: 0,
418    };
419    let enclosing = index.enclosing_code_unit(file, &range)?;
420    scope_facts.get(&enclosing)
421}
422
423impl ScanCtx<'_> {
424    fn scope_entry_for_node(
425        &self,
426        node: Node<'_>,
427    ) -> Option<(&CodeUnit, &LocalBindingsSnapshot<String>)> {
428        indexed_scope_entry(self.scope_range_index, self.scope_facts, node)
429    }
430
431    fn scope_facts_for_node(&self, node: Node<'_>) -> Option<&LocalBindingsSnapshot<String>> {
432        self.scope_entry_for_node(node).map(|(_, facts)| facts)
433    }
434
435    fn binds_target(&self, ident: &str, node: Node<'_>) -> bool {
436        let scope_entry = self.scope_entry_for_node(node);
437        if self.target_self_file
438            && ident == self.target_short
439            && scope_entry
440                .is_none_or(|(scope, facts)| scope.is_module() || !facts.is_shadowed(ident))
441        {
442            return true;
443        }
444        if scope_entry.is_some_and(|(scope, facts)| !scope.is_module() && facts.is_shadowed(ident))
445        {
446            return false;
447        }
448        self.module_binding_targets_query(ident, node)
449    }
450
451    fn receiver_binds_target(&self, expr: &str, node: Node<'_>) -> bool {
452        if self.binds_target(expr, node) {
453            return true;
454        }
455
456        if self.target_member.is_some() && self.import_edge_visible_for(expr, node) {
457            return true;
458        }
459
460        // `self`/`cls` is implicitly typed as the enclosing class, so a same-file
461        // `self.member` access is a usage of that class's member even though the
462        // receiver is never assigned a type the way a local or parameter is.
463        if matches!(expr, "self" | "cls") && self.self_receiver_matches_target(node) {
464            return true;
465        }
466
467        match enclosing_runtime_parameter_type(expr, node, self.source) {
468            EnclosingParameterType::Typed(raw_type) => {
469                return self.receiver_type_matches_target(&raw_type);
470            }
471            EnclosingParameterType::Untyped => return false,
472            EnclosingParameterType::NotDeclared => {}
473        }
474
475        let Some(scope_facts) = self.scope_facts_for_node(node) else {
476            return false;
477        };
478        let resolution = scope_facts.resolution_for(expr);
479        let Some(raw_type) = resolution
480            .as_precise()
481            .and_then(|targets| targets.iter().next())
482        else {
483            return false;
484        };
485        self.receiver_type_matches_target(raw_type)
486    }
487
488    /// Whether `node` is evaluated in the target member owner's class namespace.
489    /// This includes class-level field initializers and the decorators,
490    /// annotations, and defaults of a method declaration. The method body itself
491    /// executes later with ordinary function scoping, where a bare member name
492    /// does not reach the class namespace.
493    fn node_directly_in_owner_class_body(&self, node: Node<'_>) -> bool {
494        let Some(target_owner) = self.target_owner.as_ref() else {
495            return false;
496        };
497        let range = Range {
498            start_byte: node.start_byte(),
499            end_byte: node.end_byte(),
500            start_line: 0,
501            end_line: 0,
502        };
503        let Some(enclosing) = self.graph.index.enclosing_code_unit(self.file, &range) else {
504            return false;
505        };
506        if &enclosing == target_owner {
507            return true;
508        }
509        if enclosing.is_function() {
510            return target_owner_code_unit(self.graph.index, &enclosing).as_ref()
511                == Some(target_owner)
512                && function_declaration_expression_is_class_scoped(node);
513        }
514        target_owner_code_unit(self.graph.index, &enclosing).as_ref() == Some(target_owner)
515    }
516
517    /// Whether `expr`'s type is genuinely un-inferrable in `node`'s scope (an
518    /// unseeded receiver such as an unannotated parameter), as opposed to a
519    /// receiver we resolved to some specific — possibly different — type.
520    fn receiver_type_is_unknown(&self, expr: &str, node: Node<'_>) -> bool {
521        match enclosing_runtime_parameter_type(expr, node, self.source) {
522            EnclosingParameterType::Typed(_) => return false,
523            EnclosingParameterType::Untyped => return true,
524            EnclosingParameterType::NotDeclared => {}
525        }
526        match self.scope_facts_for_node(node) {
527            Some(facts) => facts.resolution_for(expr).is_unknown(),
528            None => true,
529        }
530    }
531
532    fn import_edge_visible_for(&self, ident: &str, node: Node<'_>) -> bool {
533        if let Some(scope_facts) = self.scope_facts_for_node(node)
534            && scope_facts.is_shadowed(ident)
535        {
536            return false;
537        }
538        self.module_binding_targets_query(ident, node)
539    }
540
541    fn module_binding_targets_query(&self, ident: &str, node: Node<'_>) -> bool {
542        self.module_binding_matches_query(ident, node, true, |kind| {
543            kind != ModuleBindingKind::Other
544        })
545    }
546
547    fn module_binding_targets_symbol(&self, ident: &str, node: Node<'_>) -> bool {
548        let unclassified_named_import = self.edges.iter().any(|edge| {
549            edge.local_name == ident && !matches!(edge.kind, ImportEdgeKind::Namespace)
550        });
551        self.module_binding_matches_query(ident, node, unclassified_named_import, |kind| {
552            kind == ModuleBindingKind::TargetSymbolImport
553        })
554    }
555
556    fn module_binding_matches_query(
557        &self,
558        ident: &str,
559        node: Node<'_>,
560        unclassified: bool,
561        matches: impl Fn(ModuleBindingKind) -> bool,
562    ) -> bool {
563        if !self.edges.iter().any(|edge| edge.local_name == ident) {
564            return false;
565        }
566        let Some(events) = self.module_bindings.get(ident) else {
567            return unclassified;
568        };
569        let cutoff = if reference_is_deferred_function_body(node) {
570            usize::MAX
571        } else {
572            node.start_byte()
573        };
574        let visible: Vec<_> = events
575            .iter()
576            .filter(|event| event.visible_from <= cutoff)
577            .collect();
578        let start = visible
579            .iter()
580            .rposition(|event| !event.conditional)
581            .unwrap_or(0);
582        visible[start..].iter().any(|event| matches(event.kind))
583    }
584
585    /// Whether the class enclosing `node` is the target member's owner (or a
586    /// subclass of it, for inherited members). Used to resolve `self`/`cls`
587    /// receivers, whose type is the lexically enclosing class.
588    fn self_receiver_matches_target(&self, node: Node<'_>) -> bool {
589        let Some(target_owner) = self.target_owner.as_ref() else {
590            return false;
591        };
592        let range = Range {
593            start_byte: node.start_byte(),
594            end_byte: node.end_byte(),
595            start_line: 0,
596            end_line: 0,
597        };
598        let Some(enclosing) = self.graph.index.enclosing_code_unit(self.file, &range) else {
599            return false;
600        };
601        let enclosing_class = if enclosing.is_class() {
602            enclosing
603        } else {
604            match target_owner_code_unit(self.graph.index, &enclosing) {
605                Some(class) => class,
606                None => return false,
607            }
608        };
609        if &enclosing_class == target_owner {
610            return true;
611        }
612        self.graph
613            .hierarchy
614            .map(|provider| provider.get_ancestors(&enclosing_class))
615            .unwrap_or_default()
616            .into_iter()
617            .any(|ancestor| ancestor == *target_owner)
618    }
619
620    fn receiver_type_matches_target(&self, raw_type: &str) -> bool {
621        if receiver_annotation_matches_target(
622            raw_type,
623            self.edges,
624            self.target_short,
625            self.target_self_file,
626        ) {
627            return true;
628        }
629
630        let Some(target_owner) = self.target_owner.as_ref() else {
631            return false;
632        };
633        let Some(receiver_type) = resolve_receiver_type(
634            self.graph,
635            self.python,
636            self.file,
637            raw_type,
638            self.target_self_file,
639        ) else {
640            return false;
641        };
642        if &receiver_type == target_owner {
643            return true;
644        }
645        self.graph
646            .hierarchy
647            .map(|provider| provider.get_ancestors(&receiver_type))
648            .unwrap_or_default()
649            .into_iter()
650            .any(|ancestor| ancestor == *target_owner)
651    }
652}
653
654fn function_declaration_expression_is_class_scoped(node: Node<'_>) -> bool {
655    let site_start = node.start_byte();
656    let site_end = node.end_byte();
657    let mut current = node;
658    while let Some(parent) = current.parent() {
659        if parent.kind() == "function_definition" {
660            return parent.child_by_field_name("body").is_none_or(|body| {
661                !(body.start_byte() <= site_start && site_end <= body.end_byte())
662            });
663        }
664        if parent.kind() == "decorated_definition" {
665            return current.kind() == "decorator";
666        }
667        if parent.kind() == "class_definition" {
668            break;
669        }
670        current = parent;
671    }
672    false
673}
674
675fn scan_node(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
676    let mut stack = vec![node];
677    while let Some(node) = stack.pop() {
678        match node.kind() {
679            "import_statement" | "import_from_statement" => {
680                handle_import_candidate(node, ctx);
681                continue;
682            }
683            "identifier" => {
684                if handle_annotation_reference_candidate(node, ctx) {
685                    continue;
686                }
687                handle_identifier_candidate(node, ctx);
688            }
689            "attribute" => {
690                if handle_annotation_reference_candidate(node, ctx) {
691                    continue;
692                }
693                handle_attribute_candidate(node, ctx);
694            }
695            "string_content" => {
696                handle_annotation_reference_candidate(node, ctx);
697            }
698            "keyword_argument" => {
699                handle_keyword_argument_candidate(node, ctx);
700                if let Some(value) = node.child_by_field_name("value") {
701                    stack.push(value);
702                }
703                continue;
704            }
705            _ => {}
706        }
707
708        let mut cursor = node.walk();
709        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
710        children.reverse();
711        stack.extend(children);
712    }
713}
714
715fn handle_annotation_reference_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) -> bool {
716    let Some(candidates) = annotation_reference_candidates(
717        ctx.graph,
718        ctx.python,
719        ctx.file,
720        ctx.source,
721        node,
722        ctx.target_self_file,
723    ) else {
724        return false;
725    };
726
727    if (ctx.target.is_class() || ctx.target.is_field() || ctx.target_member.is_none())
728        && candidates.iter().all(|candidate| *candidate == *ctx.target)
729        && candidates.iter().any(|candidate| *candidate == *ctx.target)
730    {
731        let site = if node.kind() == "attribute" {
732            node.child_by_field_name("attribute").unwrap_or(node)
733        } else {
734            node
735        };
736        record_hit(site, ctx);
737    }
738
739    if ctx.target_member.is_some() && node.kind() == "attribute" && candidates.is_empty() {
740        return false;
741    }
742
743    true
744}
745
746fn handle_keyword_argument_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
747    let (Some(target_member), Some(name), Some(arguments)) = (
748        ctx.target_member,
749        node.child_by_field_name("name"),
750        node.parent(),
751    ) else {
752        return;
753    };
754    if name.kind() != "identifier"
755        || slice(name, ctx.source) != target_member
756        || arguments.kind() != "argument_list"
757    {
758        return;
759    }
760    let Some(call) = arguments.parent().filter(|parent| parent.kind() == "call") else {
761        return;
762    };
763    let Some(function) = call.child_by_field_name("function") else {
764        return;
765    };
766    if function.kind() == "identifier" && slice(function, ctx.source) == "cls" {
767        if ctx.self_receiver_matches_target(function) {
768            record_hit(name, ctx);
769        }
770        return;
771    }
772    let Some(target_owner) = ctx.target_owner.as_ref() else {
773        return;
774    };
775    if let Some(root) = leftmost_identifier(function)
776        && ctx
777            .scope_facts_for_node(function)
778            .is_some_and(|facts| facts.is_shadowed(slice(root, ctx.source)))
779    {
780        return;
781    }
782    let matches = resolve_constructor_types(ctx.graph, ctx.python, ctx.file, ctx.source, function)
783        .into_iter()
784        .any(|class| {
785            &class == target_owner
786                || ctx
787                    .graph
788                    .hierarchy
789                    .map(|provider| provider.get_ancestors(&class))
790                    .unwrap_or_default()
791                    .into_iter()
792                    .any(|ancestor| &ancestor == target_owner)
793        });
794    if matches {
795        record_hit(name, ctx);
796    }
797}
798
799fn leftmost_identifier(mut node: Node<'_>) -> Option<Node<'_>> {
800    loop {
801        match node.kind() {
802            "identifier" => return Some(node),
803            "attribute" => node = node.child_by_field_name("object")?,
804            _ => return None,
805        }
806    }
807}
808
809/// Emit an `Import`-binding hit for `from <mod> import <target>` (the token that
810/// brings the target into this file). Gated on there being an import edge whose
811/// local name is the target — so a same-named symbol imported from a different
812/// module is not falsely counted. Only top-level symbols (not members) are
813/// imported by their own name.
814fn handle_import_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
815    if ctx.target_member.is_some() {
816        return;
817    }
818    if !ctx
819        .edges
820        .iter()
821        .any(|edge| edge.local_name == ctx.target_short)
822    {
823        return;
824    }
825    let mut stack = vec![node];
826    while let Some(node) = stack.pop() {
827        if node.kind() == "identifier" && slice(node, ctx.source) == ctx.target_short {
828            record_import_hit(node, ctx);
829            return;
830        }
831        let mut cursor = node.walk();
832        for child in node.named_children(&mut cursor) {
833            stack.push(child);
834        }
835    }
836}
837
838fn handle_identifier_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
839    if node
840        .parent()
841        .is_some_and(|parent| parent.kind() == "attribute")
842    {
843        return;
844    }
845    let text = slice(node, ctx.source);
846    if text.is_empty() || is_declaration_identifier(node) {
847        return;
848    }
849    if let Some(member) = ctx.target_member {
850        // A constructor call `Owner(...)` invokes the class's `__init__`, so it
851        // is a usage of `__init__` even though `__init__` never appears.
852        if member == "__init__" && is_call_callee(node) && ctx.binds_target(text, node) {
853            record_hit(node, ctx);
854            return;
855        }
856        // For a member target, a bare identifier is a usage only when it names
857        // the member directly in the owner class body (the class namespace).
858        if text == member && ctx.node_directly_in_owner_class_body(node) {
859            record_hit(node, ctx);
860        }
861        return;
862    }
863    if !ctx.binds_target(text, node) {
864        return;
865    }
866    if !ctx.target_is_module
867        && ctx.edges.iter().any(|edge| edge.local_name == text)
868        && !ctx.module_binding_targets_symbol(text, node)
869    {
870        return;
871    }
872    record_hit(node, ctx);
873}
874
875fn handle_attribute_candidate(node: Node<'_>, ctx: &mut ScanCtx<'_>) {
876    let Some(object) = node.child_by_field_name("object") else {
877        return;
878    };
879    let Some(attribute) = node.child_by_field_name("attribute") else {
880        return;
881    };
882    let object_text = slice(object, ctx.source);
883    let attribute_text = slice(attribute, ctx.source);
884    if let Some(member) = ctx.target_member
885        && attribute_text == member
886    {
887        // A `self.member` / `cls.member` receiver is the current instance / own
888        // class — a same-owner site, excluded from external usage counts (#1014
889        // facet B). A typed local of the same type is a different instance and
890        // stays an external hit.
891        let is_same_owner_receiver =
892            matches!(object_text, "self" | "cls") && ctx.self_receiver_matches_target(node);
893        if is_same_owner_receiver {
894            record_self_receiver_hit(attribute, ctx);
895        } else if ctx.receiver_binds_target(object_text, node)
896            || (object.kind() == "call" && call_result_matches_target(object, ctx))
897        {
898            record_hit(attribute, ctx);
899        } else if member_receiver_match_is_unproven(object, object_text, node, ctx) {
900            record_unproven_hit(attribute, ctx);
901        }
902    }
903
904    let object_binds_target = if ctx.target_is_module {
905        imported_root_targets_module(ctx, object, node)
906    } else {
907        ctx.binds_target(object_text, node)
908    };
909    if object.kind() == "identifier"
910        && object_binds_target
911        && (ctx.target_is_module
912            || (ctx.target_member.is_none()
913                && !ctx.edges.iter().any(|edge| {
914                    matches!(edge.kind, ImportEdgeKind::Namespace) && edge.local_name == object_text
915                })))
916    {
917        record_hit(object, ctx);
918    }
919
920    if ctx.target_is_module
921        && let Some(module_qualifier) = module_attribute_target_hit(node, ctx)
922    {
923        record_hit(module_qualifier, ctx);
924    }
925
926    // A bare member name used as the *object* of an attribute access in the
927    // owner class body — e.g. the `x` in `@x.setter`/`@x.deleter` decorating a
928    // property `x` — is a usage of that member.
929    if let Some(member) = ctx.target_member
930        && object.kind() == "identifier"
931        && object_text == member
932        && ctx.node_directly_in_owner_class_body(object)
933    {
934        record_hit(object, ctx);
935    }
936
937    // Best-effort for an un-inferrable receiver: `recv.member` where `recv`'s
938    // type cannot be resolved is attributed to the target when the target's
939    // owner is in this file and the member name is unique among local classes
940    // (so `recv.member` can only mean the target). `self`/`cls` are handled
941    // structurally above; cross-file untyped receivers stay conservative.
942    if ctx.member_best_effort_unique
943        && let Some(member) = ctx.target_member
944        && attribute_text == member
945        && object.kind() == "identifier"
946        && !matches!(object_text, "self" | "cls")
947        && !ctx.receiver_binds_target(object_text, node)
948        && ctx.receiver_type_is_unknown(object_text, node)
949    {
950        record_hit(attribute, ctx);
951    }
952
953    if let Some(namespace_target) = namespace_attribute_target_hit(node, ctx) {
954        record_hit(namespace_target, ctx);
955    }
956}
957
958/// Resolve a top-level symbol written through a namespace import and any number
959/// of intermediate modules (`K.feature.DISK`, `pkg.core.ops.eye_like`).
960///
961/// The written path is assembled only from tree-sitter's `attribute` fields.
962/// The analyzer's canonical export resolver then proves that the whole path
963/// names the exact physical target, so re-export aliases remain supported
964/// without comparing rendered source paths or broadening same-name candidates.
965fn namespace_attribute_target_hit<'a>(node: Node<'a>, ctx: &ScanCtx<'_>) -> Option<Node<'a>> {
966    if ctx.target_member.is_some() {
967        return None;
968    }
969    let (root, attributes) = attribute_chain(node)?;
970    let terminal = *attributes.last()?;
971    let terminal_name = slice(terminal, ctx.source);
972    if terminal_name.is_empty()
973        || !ctx
974            .seeds
975            .iter()
976            .any(|(_, seed_name)| seed_name == terminal_name)
977    {
978        return None;
979    }
980
981    let root_name = slice(root, ctx.source);
982    if root_name.is_empty() || import_root_shadowed(ctx, root_name, root, node) {
983        return None;
984    }
985    let binder = ctx.python.import_binder_of(ctx.file);
986    let binding = binder.bindings.get(root_name)?;
987    if binding.kind != ImportKind::Namespace {
988        return None;
989    }
990    let mut written_module = binding.module_specifier.clone();
991    for attribute in &attributes[..attributes.len() - 1] {
992        let segment = slice(*attribute, ctx.source);
993        if segment.is_empty() {
994            return None;
995        }
996        written_module.push('.');
997        written_module.push_str(segment);
998    }
999    if usage_resolve_module_files(ctx.python, ctx.file, &written_module)
1000        .iter()
1001        .any(|resolved| {
1002            ctx.seeds
1003                .contains(&(resolved.clone(), terminal_name.to_string()))
1004        })
1005    {
1006        return Some(terminal);
1007    }
1008
1009    let mut written_fqn = binding.module_specifier.clone();
1010    for attribute in attributes {
1011        let segment = slice(attribute, ctx.source);
1012        if segment.is_empty() {
1013            return None;
1014        }
1015        written_fqn.push('.');
1016        written_fqn.push_str(segment);
1017    }
1018    resolve_fqn_candidates(ctx.python, &written_fqn, |name| {
1019        ctx.graph.index.definitions(name).collect()
1020    })
1021    .into_iter()
1022    .any(|candidate| &candidate == ctx.target)
1023    .then_some(terminal)
1024}
1025
1026fn imported_root_targets_module(ctx: &ScanCtx<'_>, root: Node<'_>, reference: Node<'_>) -> bool {
1027    let Some(module_fqn) = imported_module_binding_fqn(ctx, root, reference) else {
1028        return false;
1029    };
1030    usage_resolve_module_files(ctx.python, ctx.file, &module_fqn)
1031        .into_iter()
1032        .any(|resolved_file| &resolved_file == ctx.target_source)
1033}
1034
1035fn module_attribute_target_hit<'a>(node: Node<'a>, ctx: &ScanCtx<'_>) -> Option<Node<'a>> {
1036    let (root, attributes) = attribute_chain(node)?;
1037    if attributes.is_empty() {
1038        return None;
1039    }
1040    let mut module_fqn = imported_module_binding_fqn(ctx, root, node)?;
1041    for attribute in attributes {
1042        let segment = slice(attribute, ctx.source);
1043        if segment.is_empty() {
1044            return None;
1045        }
1046        if module_fqn.ends_with('.') {
1047            module_fqn.push_str(segment);
1048        } else {
1049            module_fqn.push('.');
1050            module_fqn.push_str(segment);
1051        }
1052        let resolved = usage_resolve_module_files(ctx.python, ctx.file, &module_fqn);
1053        if resolved.is_empty() {
1054            return None;
1055        }
1056        if resolved
1057            .iter()
1058            .any(|resolved_file| resolved_file == ctx.target_source)
1059        {
1060            return Some(attribute);
1061        }
1062    }
1063    None
1064}
1065
1066fn call_result_matches_target(call: Node<'_>, ctx: &ScanCtx<'_>) -> bool {
1067    let Some(target_owner) = ctx.target_owner.as_ref() else {
1068        return false;
1069    };
1070    let scope_facts = ctx.scope_facts_for_node(call);
1071    call_result_types(
1072        ctx.graph,
1073        ctx.python,
1074        ctx.file,
1075        ctx.source,
1076        call,
1077        scope_facts,
1078    )
1079    .into_iter()
1080    .any(|class| {
1081        &class == target_owner
1082            || ctx
1083                .graph
1084                .hierarchy
1085                .map(|provider| provider.get_ancestors(&class))
1086                .unwrap_or_default()
1087                .into_iter()
1088                .any(|ancestor| &ancestor == target_owner)
1089    })
1090}
1091
1092pub fn call_result_types(
1093    graph: &PythonGraphSource<'_>,
1094    python: &dyn PythonUsageSource,
1095    file: &ProjectFile,
1096    source: &str,
1097    call: Node<'_>,
1098    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1099) -> Vec<CodeUnit> {
1100    let Some(function) = call.child_by_field_name("function") else {
1101        return Vec::new();
1102    };
1103    let constructed = resolve_constructor_types(graph, python, file, source, function);
1104    if !constructed.is_empty() {
1105        return constructed;
1106    }
1107    let callable_fqns = resolve_callable_fqns(graph, python, file, source, function, scope_facts);
1108    if callable_fqns.is_empty() {
1109        return Vec::new();
1110    }
1111    let callables = callable_fqns
1112        .into_iter()
1113        .flat_map(|callable_fqn| {
1114            resolve_fqn_candidates(python, &callable_fqn, |name| {
1115                graph.index.definitions(name).collect()
1116            })
1117        })
1118        .collect::<Vec<_>>();
1119    let mut classes = Vec::new();
1120    for callable in callables.into_iter().filter(CodeUnit::is_function) {
1121        let Some(raw_type) = callable_return_type_name(graph, python, &callable) else {
1122            continue;
1123        };
1124        if let Some(class) =
1125            resolve_receiver_type(graph, python, callable.source(), &raw_type, true)
1126        {
1127            classes.push(class);
1128        }
1129    }
1130    classes.sort();
1131    classes.dedup();
1132    classes
1133}
1134
1135fn resolve_callable_fqns(
1136    graph: &PythonGraphSource<'_>,
1137    python: &dyn PythonUsageSource,
1138    file: &ProjectFile,
1139    source: &str,
1140    function: Node<'_>,
1141    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1142) -> Vec<String> {
1143    match function.kind() {
1144        "identifier" => {
1145            resolve_identifier_callable_fqns(graph, python, file, source, function, scope_facts)
1146        }
1147        "attribute" => {
1148            resolve_attribute_callable_fqns(graph, python, file, source, function, scope_facts)
1149        }
1150        _ => Vec::new(),
1151    }
1152}
1153
1154fn resolve_identifier_callable_fqns(
1155    graph: &PythonGraphSource<'_>,
1156    python: &dyn PythonUsageSource,
1157    file: &ProjectFile,
1158    source: &str,
1159    function: Node<'_>,
1160    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1161) -> Vec<String> {
1162    let local = slice(function, source);
1163    if local.is_empty() || scope_facts.is_some_and(|facts| facts.is_shadowed(local)) {
1164        return Vec::new();
1165    }
1166    let binder = python.import_binder_of(file);
1167    match binder.bindings.get(local) {
1168        Some(binding) if binding.kind == ImportKind::Named => binding
1169            .imported_name
1170            .as_ref()
1171            .map(|imported| vec![format!("{}.{}", binding.module_specifier, imported)])
1172            .unwrap_or_default(),
1173        _ => graph
1174            .index
1175            .declarations(file)
1176            .into_iter()
1177            .find(|unit| unit.is_function() && unit.identifier() == local)
1178            .map(|unit| vec![unit.fq_name()])
1179            .unwrap_or_default(),
1180    }
1181}
1182
1183fn resolve_attribute_callable_fqns(
1184    graph: &PythonGraphSource<'_>,
1185    python: &dyn PythonUsageSource,
1186    file: &ProjectFile,
1187    source: &str,
1188    function: Node<'_>,
1189    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1190) -> Vec<String> {
1191    let Some(receiver) = function.child_by_field_name("object") else {
1192        return Vec::new();
1193    };
1194    let Some(method) = function.child_by_field_name("attribute") else {
1195        return Vec::new();
1196    };
1197    let method = slice(method, source);
1198    if method.is_empty() {
1199        return Vec::new();
1200    }
1201    let mut fqns = attribute_receiver_classes(graph, python, file, source, receiver, scope_facts)
1202        .into_iter()
1203        .map(|class| format!("{}.{}", class.fq_name(), method))
1204        .collect::<Vec<_>>();
1205    fqns.sort();
1206    fqns.dedup();
1207    fqns
1208}
1209
1210fn attribute_receiver_classes(
1211    graph: &PythonGraphSource<'_>,
1212    python: &dyn PythonUsageSource,
1213    file: &ProjectFile,
1214    source: &str,
1215    receiver: Node<'_>,
1216    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1217) -> Vec<CodeUnit> {
1218    let mut classes = match receiver.kind() {
1219        "identifier" => {
1220            identifier_receiver_classes(graph, python, file, source, receiver, scope_facts)
1221        }
1222        "attribute" => {
1223            if let Some(root) = leftmost_identifier(receiver)
1224                && scope_facts.is_some_and(|facts| facts.is_shadowed(slice(root, source)))
1225            {
1226                Vec::new()
1227            } else {
1228                resolve_constructor_types(graph, python, file, source, receiver)
1229            }
1230        }
1231        _ => Vec::new(),
1232    };
1233    classes.sort();
1234    classes.dedup();
1235    classes
1236}
1237
1238fn identifier_receiver_classes(
1239    graph: &PythonGraphSource<'_>,
1240    python: &dyn PythonUsageSource,
1241    file: &ProjectFile,
1242    source: &str,
1243    receiver: Node<'_>,
1244    scope_facts: Option<&LocalBindingsSnapshot<String>>,
1245) -> Vec<CodeUnit> {
1246    let ident = slice(receiver, source);
1247    if ident.is_empty() {
1248        return Vec::new();
1249    }
1250    if matches!(ident, "self" | "cls")
1251        && let Some(class) = enclosing_class_for_node(graph, file, receiver)
1252    {
1253        return vec![class];
1254    }
1255    if let Some(facts) = scope_facts {
1256        if let Some(raw_type) = facts
1257            .resolution_for(ident)
1258            .as_precise()
1259            .and_then(|targets| targets.iter().next())
1260            && let Some(class) = resolve_receiver_type(graph, python, file, raw_type, false)
1261        {
1262            return vec![class];
1263        }
1264        if facts.is_shadowed(ident) {
1265            return Vec::new();
1266        }
1267    }
1268    resolve_receiver_type(graph, python, file, ident, false)
1269        .into_iter()
1270        .collect()
1271}
1272
1273fn enclosing_class_for_node(
1274    graph: &PythonGraphSource<'_>,
1275    file: &ProjectFile,
1276    node: Node<'_>,
1277) -> Option<CodeUnit> {
1278    let range = Range {
1279        start_byte: node.start_byte(),
1280        end_byte: node.end_byte(),
1281        start_line: 0,
1282        end_line: 0,
1283    };
1284    let enclosing = graph.index.enclosing_code_unit(file, &range)?;
1285    // Python's structural model never separately indexes nested
1286    // function/lambda scopes as their own CodeUnit, so `self`/`cls` can only
1287    // ever need the enclosing unit itself or (a method's owner) exactly one
1288    // `parent_of` hop up — `.take(2)` keeps that bound explicit rather than
1289    // an unbounded walk that could climb past the same-file owner class.
1290    brokk_bifrost_core::analyzer::usages::common::enclosing_owner_chain(enclosing, |unit| {
1291        graph.index.parent_of(unit)
1292    })
1293    .take(2)
1294    .find(|unit| unit.is_class() && unit.source() == file)
1295}
1296
1297fn attribute_chain<'a>(node: Node<'a>) -> Option<(Node<'a>, Vec<Node<'a>>)> {
1298    let mut attributes = Vec::new();
1299    let mut current = node;
1300    loop {
1301        if current.kind() != "attribute" {
1302            return None;
1303        }
1304        attributes.push(current.child_by_field_name("attribute")?);
1305        current = current.child_by_field_name("object")?;
1306        if current.kind() == "identifier" {
1307            attributes.reverse();
1308            return Some((current, attributes));
1309        }
1310    }
1311}
1312
1313fn imported_module_binding_fqn(
1314    ctx: &ScanCtx<'_>,
1315    root: Node<'_>,
1316    reference: Node<'_>,
1317) -> Option<String> {
1318    let root_text = slice(root, ctx.source);
1319    if root_text.is_empty() {
1320        return None;
1321    }
1322    if import_root_shadowed(ctx, root_text, root, reference) {
1323        return None;
1324    }
1325    let binder = ctx.python.import_binder_of(ctx.file);
1326    let binding = binder.bindings.get(root_text)?;
1327    match binding.kind {
1328        ImportKind::Namespace => Some(binding.module_specifier.clone()),
1329        ImportKind::Named => {
1330            let imported = binding.imported_name.as_ref()?;
1331            let candidate = if binding.module_specifier.ends_with('.') {
1332                format!("{}{}", binding.module_specifier, imported)
1333            } else {
1334                format!("{}.{}", binding.module_specifier, imported)
1335            };
1336            (!usage_resolve_module_files(ctx.python, ctx.file, &candidate).is_empty())
1337                .then_some(candidate)
1338        }
1339        _ => None,
1340    }
1341}
1342
1343fn import_root_shadowed(
1344    ctx: &ScanCtx<'_>,
1345    root_text: &str,
1346    root: Node<'_>,
1347    reference: Node<'_>,
1348) -> bool {
1349    ctx.scope_facts_for_node(root)
1350        .or_else(|| ctx.scope_facts_for_node(reference))
1351        .is_some_and(|facts| facts.is_shadowed(root_text))
1352        || enclosing_parameters_shadow(root_text, reference, ctx.source)
1353}
1354
1355fn enclosing_parameters_shadow(root_text: &str, reference: Node<'_>, source: &str) -> bool {
1356    let mut current = reference;
1357    while let Some(parent) = current.parent() {
1358        if matches!(parent.kind(), "function_definition" | "lambda") {
1359            let Some(parameters) = parent.child_by_field_name("parameters") else {
1360                return false;
1361            };
1362            let mut cursor = parameters.walk();
1363            return parameters.named_children(&mut cursor).any(|parameter| {
1364                parameter_symbol(parameter, source).as_deref() == Some(root_text)
1365            });
1366        }
1367        current = parent;
1368    }
1369    false
1370}
1371
1372enum EnclosingParameterType {
1373    NotDeclared,
1374    Untyped,
1375    Typed(String),
1376}
1377
1378fn enclosing_runtime_parameter_type(
1379    name: &str,
1380    reference: Node<'_>,
1381    source: &str,
1382) -> EnclosingParameterType {
1383    let site_start = reference.start_byte();
1384    let site_end = reference.end_byte();
1385    let mut current = reference;
1386    while let Some(parent) = current.parent() {
1387        if matches!(parent.kind(), "function_definition" | "lambda")
1388            && parent
1389                .child_by_field_name("body")
1390                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
1391            && let Some(parameters) = parent.child_by_field_name("parameters")
1392        {
1393            let mut cursor = parameters.walk();
1394            for parameter in parameters.named_children(&mut cursor) {
1395                if parameter_symbol(parameter, source).as_deref() != Some(name) {
1396                    continue;
1397                }
1398                return parameter
1399                    .child_by_field_name("type")
1400                    .and_then(|annotation| normalized_receiver_type(slice(annotation, source)))
1401                    .map_or(EnclosingParameterType::Untyped, |raw_type| {
1402                        EnclosingParameterType::Typed(raw_type)
1403                    });
1404            }
1405        }
1406        current = parent;
1407    }
1408    EnclosingParameterType::NotDeclared
1409}
1410
1411fn member_receiver_match_is_unproven(
1412    object: Node<'_>,
1413    object_text: &str,
1414    node: Node<'_>,
1415    ctx: &ScanCtx<'_>,
1416) -> bool {
1417    if matches!(object_text, "self" | "cls") {
1418        return false;
1419    }
1420    match object.kind() {
1421        "identifier" => {
1422            ctx.receiver_type_is_unknown(object_text, node) && !ctx.member_best_effort_unique
1423        }
1424        "attribute" => true,
1425        _ => false,
1426    }
1427}
1428
1429pub fn slice<'a>(node: Node<'_>, source: &'a str) -> &'a str {
1430    brokk_bifrost_core::analyzer::common::node_source_text(node, source)
1431}
1432
1433/// Whether `node` is the `function` callee of a call expression (`node(...)`).
1434fn is_call_callee(node: Node<'_>) -> bool {
1435    node.parent().is_some_and(|parent| {
1436        parent.kind() == "call"
1437            && parent
1438                .child_by_field_name("function")
1439                .is_some_and(|function| function.id() == node.id())
1440    })
1441}
1442
1443pub fn is_declaration_identifier(node: Node<'_>) -> bool {
1444    let Some(parent) = node.parent() else {
1445        return false;
1446    };
1447    let parent_kind = parent.kind();
1448    if matches!(
1449        parent_kind,
1450        "class_definition" | "function_definition" | "parameters"
1451    ) && parent
1452        .child_by_field_name("name")
1453        .map(|name| name.id() == node.id())
1454        .unwrap_or(false)
1455    {
1456        return true;
1457    }
1458
1459    if matches!(
1460        parent_kind,
1461        "aliased_import" | "import_from_statement" | "import_statement"
1462    ) {
1463        return true;
1464    }
1465
1466    parent_kind == "assignment"
1467        && parent
1468            .child_by_field_name("left")
1469            .map(|left| {
1470                left.start_byte() <= node.start_byte() && node.end_byte() <= left.end_byte()
1471            })
1472            .unwrap_or(false)
1473}
1474
1475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1476enum ModuleBindingKind {
1477    TargetSymbolImport,
1478    TargetModuleImport,
1479    Other,
1480}
1481
1482#[derive(Clone, Copy, Debug)]
1483struct ClassifiedModuleBindingEvent {
1484    visible_from: usize,
1485    conditional: bool,
1486    kind: ModuleBindingKind,
1487}
1488
1489pub fn collect_module_binding_timeline(root: Node<'_>, source: &str) -> ModuleBindingTimeline {
1490    let mut timeline = ModuleBindingTimeline::default();
1491    let mut stack = vec![root];
1492    while let Some(node) = stack.pop() {
1493        match node.kind() {
1494            "function_definition" | "class_definition" => {
1495                if let Some(name) = node.child_by_field_name("name") {
1496                    record_module_binding(
1497                        &mut timeline,
1498                        slice(name, source),
1499                        node.end_byte(),
1500                        binding_is_conditional(node),
1501                        ModuleBindingEventKind::Other,
1502                    );
1503                }
1504                continue;
1505            }
1506            "import_statement" | "import_from_statement" => {
1507                collect_import_binding_events(node, source, &mut timeline);
1508                continue;
1509            }
1510            "assignment" | "augmented_assignment" | "named_expression" => {
1511                if let Some(left) = node.child_by_field_name("left") {
1512                    record_local_binding_targets(
1513                        left,
1514                        source,
1515                        node.end_byte(),
1516                        binding_is_conditional(node),
1517                        &mut timeline,
1518                    );
1519                }
1520                continue;
1521            }
1522            "for_statement" => {
1523                if let Some(left) = node.child_by_field_name("left") {
1524                    record_local_binding_targets(
1525                        left,
1526                        source,
1527                        left.end_byte(),
1528                        true,
1529                        &mut timeline,
1530                    );
1531                }
1532            }
1533            _ => {}
1534        }
1535
1536        let mut cursor = node.walk();
1537        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
1538        children.reverse();
1539        stack.extend(children);
1540    }
1541    for events in timeline.values_mut() {
1542        events.sort_by_key(|event| event.visible_from);
1543    }
1544    timeline
1545}
1546
1547fn collect_import_binding_events(
1548    node: Node<'_>,
1549    source: &str,
1550    timeline: &mut ModuleBindingTimeline,
1551) {
1552    if node.kind() == "import_statement" {
1553        let mut cursor = node.walk();
1554        for imported in node.children_by_field_name("name", &mut cursor) {
1555            let name = imported.child_by_field_name("name").unwrap_or(imported);
1556            let Some(local) = imported
1557                .child_by_field_name("alias")
1558                .or_else(|| first_identifier(name))
1559            else {
1560                continue;
1561            };
1562            let module = slice(name, source).trim();
1563            record_module_binding(
1564                timeline,
1565                slice(local, source),
1566                node.end_byte(),
1567                binding_is_conditional(node),
1568                ModuleBindingEventKind::ImportModule(module.to_string()),
1569            );
1570        }
1571        return;
1572    }
1573
1574    let Some(module_node) = node.child_by_field_name("module_name") else {
1575        return;
1576    };
1577    let module = slice(module_node, source).trim();
1578    let mut cursor = node.walk();
1579    for imported in node.children_by_field_name("name", &mut cursor) {
1580        if imported.kind() == "wildcard_import" {
1581            continue;
1582        }
1583        let name = imported.child_by_field_name("name").unwrap_or(imported);
1584        let Some(imported_identifier) = last_identifier(name) else {
1585            continue;
1586        };
1587        let imported_name = slice(imported_identifier, source).trim();
1588        let Some(local) = imported
1589            .child_by_field_name("alias")
1590            .or_else(|| last_identifier(name))
1591        else {
1592            continue;
1593        };
1594        record_module_binding(
1595            timeline,
1596            slice(local, source),
1597            node.end_byte(),
1598            binding_is_conditional(node),
1599            ModuleBindingEventKind::FromImport {
1600                module: module.to_string(),
1601                imported_name: imported_name.to_string(),
1602            },
1603        );
1604    }
1605}
1606
1607fn classify_module_binding_timeline(
1608    python: &dyn PythonUsageSource,
1609    file: &ProjectFile,
1610    timeline: &ModuleBindingTimeline,
1611    seeds: &BTreeSet<(ProjectFile, String)>,
1612    edges: &[ImportEdge],
1613) -> HashMap<String, Vec<ClassifiedModuleBindingEvent>> {
1614    let mut classified = HashMap::default();
1615    let mut module_targets: HashMap<String, bool> = HashMap::default();
1616    let relevant_locals: HashSet<&str> =
1617        edges.iter().map(|edge| edge.local_name.as_str()).collect();
1618    for (local, events) in timeline {
1619        if !relevant_locals.contains(local.as_str()) {
1620            continue;
1621        }
1622        let classified_events = events
1623            .iter()
1624            .map(|event| {
1625                let kind = match &event.kind {
1626                    ModuleBindingEventKind::ImportModule(module) => {
1627                        if *module_targets
1628                            .entry(module.clone())
1629                            .or_insert_with(|| module_contains_seed(python, file, module, seeds))
1630                        {
1631                            ModuleBindingKind::TargetModuleImport
1632                        } else {
1633                            ModuleBindingKind::Other
1634                        }
1635                    }
1636                    ModuleBindingEventKind::FromImport {
1637                        module,
1638                        imported_name,
1639                    } => {
1640                        let direct = usage_resolve_module_files(python, file, module).iter().any(
1641                            |resolved| seeds.contains(&(resolved.clone(), imported_name.clone())),
1642                        );
1643                        let submodule = if module.ends_with('.') {
1644                            format!("{module}{imported_name}")
1645                        } else {
1646                            format!("{module}.{imported_name}")
1647                        };
1648                        let imports_target_module =
1649                            *module_targets.entry(submodule.clone()).or_insert_with(|| {
1650                                module_contains_seed(python, file, &submodule, seeds)
1651                            });
1652                        if direct {
1653                            ModuleBindingKind::TargetSymbolImport
1654                        } else if imports_target_module {
1655                            ModuleBindingKind::TargetModuleImport
1656                        } else {
1657                            ModuleBindingKind::Other
1658                        }
1659                    }
1660                    ModuleBindingEventKind::Other => ModuleBindingKind::Other,
1661                };
1662                ClassifiedModuleBindingEvent {
1663                    visible_from: event.visible_from,
1664                    conditional: event.conditional,
1665                    kind,
1666                }
1667            })
1668            .collect();
1669        classified.insert(local.clone(), classified_events);
1670    }
1671    classified
1672}
1673
1674fn module_contains_seed(
1675    python: &dyn PythonUsageSource,
1676    file: &ProjectFile,
1677    module: &str,
1678    seeds: &BTreeSet<(ProjectFile, String)>,
1679) -> bool {
1680    usage_resolve_module_files(python, file, module)
1681        .iter()
1682        .any(|resolved| seeds.iter().any(|(seed_file, _)| seed_file == resolved))
1683}
1684
1685fn record_module_binding(
1686    timeline: &mut ModuleBindingTimeline,
1687    name: &str,
1688    visible_from: usize,
1689    conditional: bool,
1690    kind: ModuleBindingEventKind,
1691) {
1692    let name = name.trim();
1693    if name.is_empty() {
1694        return;
1695    }
1696    timeline
1697        .entry(name.to_string())
1698        .or_default()
1699        .push(ModuleBindingEvent {
1700            visible_from,
1701            conditional,
1702            kind,
1703        });
1704}
1705
1706fn record_local_binding_targets(
1707    target: Node<'_>,
1708    source: &str,
1709    visible_from: usize,
1710    conditional: bool,
1711    timeline: &mut ModuleBindingTimeline,
1712) {
1713    let mut stack = vec![target];
1714    while let Some(node) = stack.pop() {
1715        if node.kind() == "identifier" {
1716            record_module_binding(
1717                timeline,
1718                slice(node, source),
1719                visible_from,
1720                conditional,
1721                ModuleBindingEventKind::Other,
1722            );
1723            continue;
1724        }
1725        if matches!(node.kind(), "attribute" | "subscript") {
1726            continue;
1727        }
1728        let mut cursor = node.walk();
1729        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
1730        children.reverse();
1731        stack.extend(children);
1732    }
1733}
1734
1735fn binding_is_conditional(mut node: Node<'_>) -> bool {
1736    while let Some(parent) = node.parent() {
1737        if matches!(
1738            parent.kind(),
1739            "if_statement"
1740                | "try_statement"
1741                | "except_clause"
1742                | "match_statement"
1743                | "case_clause"
1744                | "for_statement"
1745                | "while_statement"
1746        ) {
1747            return true;
1748        }
1749        if matches!(
1750            parent.kind(),
1751            "module" | "function_definition" | "class_definition"
1752        ) {
1753            return false;
1754        }
1755        node = parent;
1756    }
1757    false
1758}
1759
1760fn first_identifier(node: Node<'_>) -> Option<Node<'_>> {
1761    identifier_extreme(node, false)
1762}
1763
1764fn last_identifier(node: Node<'_>) -> Option<Node<'_>> {
1765    identifier_extreme(node, true)
1766}
1767
1768fn identifier_extreme(node: Node<'_>, last: bool) -> Option<Node<'_>> {
1769    let mut best = None;
1770    let mut stack = vec![node];
1771    while let Some(node) = stack.pop() {
1772        if node.kind() == "identifier" {
1773            if best.is_none_or(|current: Node<'_>| {
1774                if last {
1775                    node.start_byte() > current.start_byte()
1776                } else {
1777                    node.start_byte() < current.start_byte()
1778                }
1779            }) {
1780                best = Some(node);
1781            }
1782            continue;
1783        }
1784        let mut cursor = node.walk();
1785        stack.extend(node.named_children(&mut cursor));
1786    }
1787    best
1788}
1789
1790fn reference_is_deferred_function_body(node: Node<'_>) -> bool {
1791    let site_start = node.start_byte();
1792    let site_end = node.end_byte();
1793    let mut current = node;
1794    while let Some(parent) = current.parent() {
1795        if matches!(parent.kind(), "function_definition" | "lambda")
1796            && parent
1797                .child_by_field_name("body")
1798                .is_some_and(|body| body.start_byte() <= site_start && site_end <= body.end_byte())
1799        {
1800            return true;
1801        }
1802        current = parent;
1803    }
1804    false
1805}
1806
1807pub fn collect_assigned_identifiers(node: Node<'_>, source: &str, out: &mut HashSet<String>) {
1808    let mut stack = vec![node];
1809    while let Some(node) = stack.pop() {
1810        if node.kind() == "identifier" {
1811            let text = slice(node, source).trim();
1812            if !text.is_empty() {
1813                out.insert(text.to_string());
1814            }
1815            continue;
1816        }
1817
1818        let mut cursor = node.walk();
1819        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
1820        children.reverse();
1821        stack.extend(children);
1822    }
1823}
1824
1825pub fn collect_scope_facts_from_parsed_source(
1826    graph: &PythonGraphSource<'_>,
1827    python: &dyn PythonUsageSource,
1828    file: &ProjectFile,
1829    source: &str,
1830    root: Node<'_>,
1831) -> PythonScopeFacts {
1832    let mut factory_return_types = collect_factory_return_types_from_root(root, source);
1833    collect_imported_factory_return_types(graph, python, file, &mut factory_return_types);
1834    collect_scope_facts_with_factory_returns(graph, file, source, &factory_return_types)
1835}
1836
1837fn collect_imported_factory_return_types(
1838    graph: &PythonGraphSource<'_>,
1839    python: &dyn PythonUsageSource,
1840    file: &ProjectFile,
1841    factory_return_types: &mut HashMap<String, String>,
1842) {
1843    let binder = python.import_binder_of(file);
1844    for (local, binding) in &binder.bindings {
1845        if !matches!(binding.kind, ImportKind::Named) {
1846            continue;
1847        }
1848        let Some(imported) = binding.imported_name.as_deref() else {
1849            continue;
1850        };
1851        let fqn = format!("{}.{}", binding.module_specifier, imported);
1852        let units =
1853            resolve_fqn_candidates(python, &fqn, |name| graph.index.definitions(name).collect());
1854        for unit in units {
1855            if unit.is_function() {
1856                if let Some(return_type) = callable_return_type_name(graph, python, &unit) {
1857                    factory_return_types
1858                        .entry(local.clone())
1859                        .or_insert(return_type);
1860                }
1861                continue;
1862            }
1863            if !unit.is_class() {
1864                continue;
1865            }
1866            factory_return_types
1867                .entry(local.clone())
1868                .or_insert_with(|| unit.identifier().to_string());
1869            collect_imported_class_method_return_types(
1870                graph,
1871                python,
1872                local,
1873                &unit,
1874                factory_return_types,
1875            );
1876        }
1877    }
1878}
1879
1880fn collect_imported_class_method_return_types(
1881    graph: &PythonGraphSource<'_>,
1882    python: &dyn PythonSource,
1883    local_class_name: &str,
1884    class_unit: &CodeUnit,
1885    factory_return_types: &mut HashMap<String, String>,
1886) {
1887    for member in graph.index.direct_children(class_unit) {
1888        if !member.is_function() {
1889            continue;
1890        }
1891        let Some(return_type) = callable_return_type_name(graph, python, &member) else {
1892            continue;
1893        };
1894        factory_return_types
1895            .entry(format!("{}.{}", local_class_name, member.identifier()))
1896            .or_insert(return_type);
1897    }
1898}
1899
1900fn callable_return_type_name(
1901    graph: &PythonGraphSource<'_>,
1902    python: &dyn PythonSource,
1903    callable: &CodeUnit,
1904) -> Option<String> {
1905    // The analyzer's already-parsed whole-file tree when it has one. This runs
1906    // once per imported class member, and the fallback below clones the entire
1907    // file source and builds a fresh `Parser` per declaration range. Same
1908    // prepared-syntax fast path C++ resolution uses for the same reason.
1909    if let Some(prepared) = python.prepared_syntax(callable.source()) {
1910        #[cfg(any(test, feature = "test-support"))]
1911        note_callable_return_type_lookup_for_test(true);
1912        return callable_return_type_name_in_tree(
1913            graph,
1914            callable,
1915            prepared.source(),
1916            prepared.tree().root_node(),
1917        );
1918    }
1919    #[cfg(any(test, feature = "test-support"))]
1920    note_callable_return_type_lookup_for_test(false);
1921    let source = graph.index.indexed_source(callable.source())?;
1922    declaration_source_slices(graph, callable, &source)
1923        .into_iter()
1924        .find_map(|declaration_source| {
1925            let mut parser = Parser::new();
1926            parser
1927                .set_language(&tree_sitter_python::LANGUAGE.into())
1928                .ok()?;
1929            let tree = parser.parse(declaration_source, None)?;
1930            let function = first_function_definition(tree.root_node())?;
1931            factory_return_type(function, declaration_source)
1932        })
1933}
1934
1935/// How `callable_return_type_name` answered, counted per arm.
1936///
1937/// Both arms are counted, not just the slow one: a test that asserted only
1938/// "no reparses" would pass vacuously on a fixture that never reaches this
1939/// function at all.
1940#[cfg(any(test, feature = "test-support"))]
1941#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1942pub struct CallableReturnTypeLookupCounts {
1943    /// Answered from the analyzer's already-parsed whole-file tree.
1944    pub prepared: usize,
1945    /// Fell back to cloning the file source and building a fresh parser.
1946    pub reparsed: usize,
1947}
1948
1949#[cfg(any(test, feature = "test-support"))]
1950thread_local! {
1951    static CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST: std::cell::Cell<CallableReturnTypeLookupCounts> =
1952        const { std::cell::Cell::new(CallableReturnTypeLookupCounts { prepared: 0, reparsed: 0 }) };
1953}
1954
1955#[cfg(any(test, feature = "test-support"))]
1956fn note_callable_return_type_lookup_for_test(from_prepared_syntax: bool) {
1957    CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST.with(|counts| {
1958        let mut observed = counts.get();
1959        if from_prepared_syntax {
1960            observed.prepared += 1;
1961        } else {
1962            observed.reparsed += 1;
1963        }
1964        counts.set(observed);
1965    });
1966}
1967
1968/// Runs `body` and reports which arm each `callable_return_type_name` call
1969/// took. An analyzer that holds prepared syntax must report zero reparses.
1970#[cfg(any(test, feature = "test-support"))]
1971pub fn with_callable_return_type_lookup_counter_for_test<T>(
1972    body: impl FnOnce() -> T,
1973) -> (T, CallableReturnTypeLookupCounts) {
1974    CALLABLE_RETURN_TYPE_LOOKUPS_FOR_TEST.with(|counts| {
1975        counts.set(CallableReturnTypeLookupCounts::default());
1976        let result = body();
1977        let observed = counts.get();
1978        counts.set(CallableReturnTypeLookupCounts::default());
1979        (result, observed)
1980    })
1981}
1982
1983/// The declaration walk both arms of [`callable_return_type_name`] share.
1984///
1985/// `source` and `root` must be the same snapshot: `factory_return_type` slices
1986/// `source` at the node's byte offsets, so a whole-file tree needs whole-file
1987/// source and a per-range reparse needs that range's slice.
1988fn callable_return_type_name_in_tree(
1989    graph: &PythonGraphSource<'_>,
1990    callable: &CodeUnit,
1991    source: &str,
1992    root: Node<'_>,
1993) -> Option<String> {
1994    let mut ranges = graph.index.ranges(callable);
1995    ranges.sort_by_key(|range| range.start_byte);
1996    ranges.into_iter().find_map(|range| {
1997        let declaration = root.descendant_for_byte_range(range.start_byte, range.end_byte)?;
1998        let function = first_function_definition(declaration)?;
1999        factory_return_type(function, source)
2000    })
2001}
2002
2003fn first_function_definition(root: Node<'_>) -> Option<Node<'_>> {
2004    let mut stack = vec![root];
2005    while let Some(node) = stack.pop() {
2006        if node.kind() == "function_definition" {
2007            return Some(node);
2008        }
2009        let mut cursor = node.walk();
2010        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2011        children.reverse();
2012        stack.extend(children);
2013    }
2014    None
2015}
2016
2017fn collect_scope_facts_with_factory_returns(
2018    graph: &PythonGraphSource<'_>,
2019    file: &ProjectFile,
2020    source: &str,
2021    factory_return_types: &HashMap<String, String>,
2022) -> PythonScopeFacts {
2023    let declarations = graph.index.declarations(file);
2024    let mut class_facts_by_name: HashMap<String, LocalBindingsSnapshot<String>> =
2025        HashMap::default();
2026    for declaration in declarations
2027        .iter()
2028        .filter(|declaration| declaration.is_class())
2029    {
2030        let Some(declaration_source) = declaration_source(graph, declaration, source) else {
2031            continue;
2032        };
2033        let facts = collect_scope_facts_from_source(
2034            &declaration_source,
2035            ScopeFactTraversal::Class,
2036            true,
2037            Some(declaration.short_name()),
2038            factory_return_types,
2039        );
2040        class_facts_by_name.insert(
2041            declaration.short_name().to_string(),
2042            facts.filtered_visible_bindings(|symbol, _| symbol.starts_with("self.")),
2043        );
2044    }
2045
2046    let mut scope_facts = HashMap::default();
2047    for declaration in declarations
2048        .iter()
2049        .filter(|declaration| declaration.is_function())
2050    {
2051        let Some(declaration_source) = declaration_source(graph, declaration, source) else {
2052            continue;
2053        };
2054        // fqname-M4: package-less short_name owner, matched below against
2055        // `class_facts_by_name` keys built from `short_name()`; `fq.parent()`
2056        // (`default_parent_fq_name`) would render the package-qualified owner,
2057        // a different string that would never hit in that map.
2058        let owner = declaration
2059            .short_name()
2060            .rsplit_once('.')
2061            .map(|(owner, _)| owner);
2062        let mut facts = collect_scope_facts_from_source(
2063            &declaration_source,
2064            ScopeFactTraversal::Function,
2065            false,
2066            owner,
2067            factory_return_types,
2068        );
2069        if let Some(owner) = owner
2070            && let Some(class_facts) = class_facts_by_name.get(owner)
2071        {
2072            facts = facts.merged_with_visible(class_facts);
2073        }
2074        scope_facts.insert(declaration.clone(), facts);
2075    }
2076
2077    // Module-level statements (e.g. a top-level `f = Foo()`) form their own scope.
2078    // `enclosing_code_unit` resolves a top-level usage to the module CodeUnit, so
2079    // its bindings must be recorded too, otherwise constructed-local receivers
2080    // used at module scope resolve to no type.
2081    for declaration in declarations.iter().filter(|d| d.is_module()) {
2082        let Some(declaration_source) = declaration_source(graph, declaration, source) else {
2083            continue;
2084        };
2085        let facts = collect_scope_facts_from_source(
2086            &declaration_source,
2087            ScopeFactTraversal::Module,
2088            false,
2089            None,
2090            factory_return_types,
2091        );
2092        scope_facts.insert(declaration.clone(), facts);
2093    }
2094    scope_facts
2095}
2096
2097fn declaration_source(
2098    graph: &PythonGraphSource<'_>,
2099    declaration: &CodeUnit,
2100    file_source: &str,
2101) -> Option<String> {
2102    let slices = declaration_source_slices(graph, declaration, file_source);
2103    (!slices.is_empty()).then(|| slices.join("\n\n"))
2104}
2105
2106fn declaration_source_slices<'a>(
2107    graph: &PythonGraphSource<'_>,
2108    declaration: &CodeUnit,
2109    file_source: &'a str,
2110) -> Vec<&'a str> {
2111    let mut ranges = graph.index.ranges(declaration);
2112    ranges.sort_by_key(|range| range.start_byte);
2113    ranges
2114        .into_iter()
2115        .filter_map(|range| file_source.get(range.start_byte..range.end_byte))
2116        .collect()
2117}
2118
2119fn collect_scope_facts_from_source(
2120    source: &str,
2121    traversal: ScopeFactTraversal,
2122    allow_self_receivers: bool,
2123    current_class: Option<&str>,
2124    factory_return_types: &HashMap<String, String>,
2125) -> LocalBindingsSnapshot<String> {
2126    let events = collect_scope_fact_events(source, traversal);
2127    collect_scope_facts_from_events(
2128        &events,
2129        allow_self_receivers,
2130        current_class,
2131        factory_return_types,
2132    )
2133}
2134
2135pub fn collect_function_scope_facts_from_node(
2136    function: Node<'_>,
2137    source: &str,
2138) -> LocalBindingsSnapshot<String> {
2139    let mut events = Vec::new();
2140    if function.kind() == "lambda" {
2141        if let Some(parameters) = function.child_by_field_name("parameters") {
2142            collect_parameter_events(parameters, source, &mut events);
2143        }
2144    } else {
2145        collect_scope_fact_events_from_node(
2146            function,
2147            source,
2148            ScopeFactTraversal::Function,
2149            &mut events,
2150        );
2151    }
2152    collect_scope_facts_from_events(&events, false, None, &HashMap::default())
2153}
2154
2155fn collect_scope_facts_from_events(
2156    events: &[ScopeFactEvent],
2157    allow_self_receivers: bool,
2158    current_class: Option<&str>,
2159    factory_return_types: &HashMap<String, String>,
2160) -> LocalBindingsSnapshot<String> {
2161    let mut engine = LocalInferenceEngine::new(LocalInferenceConfig::default());
2162    for event in events {
2163        if let ScopeFactEvent::Parameter { symbol, .. } = event
2164            && !engine.is_shadowed(symbol)
2165        {
2166            engine.declare_shadow(symbol.clone());
2167        }
2168    }
2169
2170    let mut changed = true;
2171    while changed {
2172        changed = false;
2173        let mut aliases = Vec::new();
2174        for event in events {
2175            match event {
2176                ScopeFactEvent::Parameter {
2177                    symbol,
2178                    annotation: Some(annotation),
2179                }
2180                | ScopeFactEvent::Annotation { symbol, annotation } => {
2181                    apply_annotation_event(
2182                        symbol,
2183                        annotation,
2184                        allow_self_receivers,
2185                        &mut engine,
2186                        &mut changed,
2187                    );
2188                }
2189                ScopeFactEvent::Parameter {
2190                    annotation: None, ..
2191                } => {}
2192                ScopeFactEvent::Assignment { lhs, rhs } => {
2193                    if !engine.is_shadowed(lhs) {
2194                        engine.declare_shadow(lhs.clone());
2195                    }
2196                    if lhs.starts_with("self.") && !allow_self_receivers {
2197                        continue;
2198                    }
2199
2200                    match rhs {
2201                        AssignmentRhs::Call(callee) => {
2202                            if !engine.is_shadowed(callee) {
2203                                if let Some(receiver_type) = factory_return_type_for_callee(
2204                                    callee,
2205                                    current_class,
2206                                    factory_return_types,
2207                                ) && engine.resolve_symbol(lhs).is_unknown()
2208                                {
2209                                    engine.seed_symbol(lhs.clone(), receiver_type.clone());
2210                                    changed = true;
2211                                    continue;
2212                                }
2213
2214                                if let Some(receiver_type) = normalized_receiver_type(callee)
2215                                    && engine.resolve_symbol(lhs).is_unknown()
2216                                {
2217                                    engine.seed_symbol(lhs.clone(), receiver_type);
2218                                    changed = true;
2219                                    continue;
2220                                }
2221                            }
2222                        }
2223                        AssignmentRhs::Symbol(rhs_symbol) => {
2224                            if !engine.is_shadowed(rhs_symbol)
2225                                && let Some(receiver_type) = normalized_receiver_type(rhs_symbol)
2226                                && engine.resolve_symbol(lhs).is_unknown()
2227                            {
2228                                engine.seed_symbol(lhs.clone(), receiver_type);
2229                                changed = true;
2230                                continue;
2231                            }
2232
2233                            if let SymbolResolution::Precise(targets) =
2234                                engine.resolve_symbol(rhs_symbol)
2235                                && !targets.is_empty()
2236                            {
2237                                aliases.push((lhs.clone(), rhs_symbol.clone()));
2238                            }
2239                        }
2240                        AssignmentRhs::Unknown => {}
2241                    }
2242                }
2243            }
2244        }
2245        let before = engine.snapshot();
2246        engine.apply_aliases_until_stable(aliases);
2247        if engine.snapshot() != before {
2248            changed = true;
2249        }
2250    }
2251
2252    engine.snapshot()
2253}
2254
2255fn factory_return_type_for_callee<'a>(
2256    callee: &str,
2257    current_class: Option<&str>,
2258    factory_return_types: &'a HashMap<String, String>,
2259) -> Option<&'a String> {
2260    if let Some(receiver_type) = factory_return_types.get(callee) {
2261        return Some(receiver_type);
2262    }
2263    let class_name = current_class?;
2264    let method = callee
2265        .strip_prefix("self.")
2266        .or_else(|| callee.strip_prefix("cls."))?;
2267    factory_return_types.get(&format!("{class_name}.{method}"))
2268}
2269
2270fn apply_annotation_event(
2271    symbol: &str,
2272    annotation: &str,
2273    allow_self_receivers: bool,
2274    engine: &mut LocalInferenceEngine<String>,
2275    changed: &mut bool,
2276) {
2277    if symbol.starts_with("self.") && !allow_self_receivers {
2278        return;
2279    }
2280    if let Some(receiver_type) = normalized_receiver_type(annotation)
2281        && engine.resolve_symbol(symbol).is_unknown()
2282    {
2283        engine.seed_symbol(symbol.to_string(), receiver_type);
2284        *changed = true;
2285    }
2286}
2287
2288enum ScopeFactEvent {
2289    Parameter {
2290        symbol: String,
2291        annotation: Option<String>,
2292    },
2293    Annotation {
2294        symbol: String,
2295        annotation: String,
2296    },
2297    Assignment {
2298        lhs: String,
2299        rhs: AssignmentRhs,
2300    },
2301}
2302
2303enum AssignmentRhs {
2304    Symbol(String),
2305    Call(String),
2306    Unknown,
2307}
2308
2309#[derive(Clone, Copy)]
2310enum ScopeFactTraversal {
2311    Module,
2312    Function,
2313    Class,
2314}
2315
2316fn collect_scope_fact_events(source: &str, traversal: ScopeFactTraversal) -> Vec<ScopeFactEvent> {
2317    if source.trim().is_empty() {
2318        return Vec::new();
2319    }
2320
2321    let mut parser = Parser::new();
2322    if parser
2323        .set_language(&tree_sitter_python::LANGUAGE.into())
2324        .is_err()
2325    {
2326        return Vec::new();
2327    }
2328    let Some(tree) = parser.parse(source, None) else {
2329        return Vec::new();
2330    };
2331
2332    let mut events = Vec::new();
2333    collect_scope_fact_events_from_node(tree.root_node(), source, traversal, &mut events);
2334    events
2335}
2336
2337fn collect_scope_fact_events_from_node(
2338    root: Node<'_>,
2339    source: &str,
2340    traversal: ScopeFactTraversal,
2341    events: &mut Vec<ScopeFactEvent>,
2342) {
2343    let mut stack = vec![(root, false)];
2344    while let Some((node, inside_function)) = stack.pop() {
2345        let next_inside_function = match traversal {
2346            ScopeFactTraversal::Module => {
2347                if matches!(
2348                    node.kind(),
2349                    "function_definition" | "class_definition" | "lambda"
2350                ) {
2351                    continue;
2352                }
2353                false
2354            }
2355            ScopeFactTraversal::Function => match node.kind() {
2356                "function_definition" if inside_function => continue,
2357                "function_definition" => true,
2358                "class_definition" | "lambda" => continue,
2359                _ => inside_function,
2360            },
2361            ScopeFactTraversal::Class => inside_function,
2362        };
2363        if matches!(traversal, ScopeFactTraversal::Function) && !next_inside_function {
2364            let mut cursor = node.walk();
2365            let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2366            children.reverse();
2367            stack.extend(children.into_iter().map(|child| (child, false)));
2368            continue;
2369        }
2370        match node.kind() {
2371            "parameters" | "lambda_parameters" => collect_parameter_events(node, source, events),
2372            "assignment" => collect_assignment_events(node, source, events),
2373            _ => {}
2374        }
2375
2376        let mut cursor = node.walk();
2377        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2378        children.reverse();
2379        stack.extend(
2380            children
2381                .into_iter()
2382                .map(|child| (child, next_inside_function)),
2383        );
2384    }
2385}
2386
2387fn collect_parameter_events(node: Node<'_>, source: &str, events: &mut Vec<ScopeFactEvent>) {
2388    let mut cursor = node.walk();
2389    for child in node.named_children(&mut cursor) {
2390        if child.kind() == "type_parameter" {
2391            continue;
2392        }
2393        let Some(symbol) = parameter_symbol(child, source) else {
2394            continue;
2395        };
2396        if matches!(symbol.as_str(), "self" | "cls" | "/") {
2397            continue;
2398        }
2399        let annotation = child
2400            .child_by_field_name("type")
2401            .map(|annotation| slice(annotation, source).trim().to_string())
2402            .filter(|annotation| !annotation.is_empty());
2403        events.push(ScopeFactEvent::Parameter { symbol, annotation });
2404    }
2405}
2406
2407fn parameter_symbol(node: Node<'_>, source: &str) -> Option<String> {
2408    if node.kind() == "identifier" {
2409        return non_empty_node_text(node, source);
2410    }
2411    if let Some(name) = node.child_by_field_name("name") {
2412        return non_empty_node_text(name, source);
2413    }
2414    let mut cursor = node.walk();
2415    node.named_children(&mut cursor)
2416        .find(|child| child.kind() == "identifier")
2417        .and_then(|identifier| non_empty_node_text(identifier, source))
2418}
2419
2420fn collect_assignment_events(node: Node<'_>, source: &str, events: &mut Vec<ScopeFactEvent>) {
2421    let Some(left) = node.child_by_field_name("left") else {
2422        return;
2423    };
2424    let Some(lhs) = receiver_symbol(left, source) else {
2425        return;
2426    };
2427
2428    if let Some(annotation) = node
2429        .child_by_field_name("type")
2430        .map(|annotation| slice(annotation, source).trim().to_string())
2431        .filter(|annotation| !annotation.is_empty())
2432    {
2433        events.push(ScopeFactEvent::Annotation {
2434            symbol: lhs,
2435            annotation,
2436        });
2437        return;
2438    }
2439
2440    let rhs = node
2441        .child_by_field_name("right")
2442        .and_then(|right| rhs_symbol(right, source))
2443        .unwrap_or(AssignmentRhs::Unknown);
2444    events.push(ScopeFactEvent::Assignment { lhs, rhs });
2445}
2446
2447fn receiver_symbol(node: Node<'_>, source: &str) -> Option<String> {
2448    match node.kind() {
2449        "identifier" | "attribute" => non_empty_node_text(node, source),
2450        _ => None,
2451    }
2452}
2453
2454fn rhs_symbol(node: Node<'_>, source: &str) -> Option<AssignmentRhs> {
2455    match node.kind() {
2456        "identifier" | "attribute" => non_empty_node_text(node, source).map(AssignmentRhs::Symbol),
2457        "call" => node
2458            .child_by_field_name("function")
2459            .or_else(|| node.named_child(0))
2460            .and_then(|callee| receiver_symbol(callee, source))
2461            .map(AssignmentRhs::Call),
2462        _ => None,
2463    }
2464}
2465
2466fn non_empty_node_text(node: Node<'_>, source: &str) -> Option<String> {
2467    let text = slice(node, source).trim();
2468    (!text.is_empty()).then(|| text.to_string())
2469}
2470
2471fn collect_factory_return_types_from_root(root: Node<'_>, source: &str) -> HashMap<String, String> {
2472    let mut returns = HashMap::default();
2473    let mut stack = vec![(root, None::<String>)];
2474    while let Some((node, class_name)) = stack.pop() {
2475        match node.kind() {
2476            "class_definition" => {
2477                let next_class = node
2478                    .child_by_field_name("name")
2479                    .and_then(|name| non_empty_node_text(name, source))
2480                    .or(class_name);
2481                push_factory_index_children(node, next_class, &mut stack);
2482            }
2483            "function_definition" => {
2484                if let Some(name) = node
2485                    .child_by_field_name("name")
2486                    .and_then(|name| non_empty_node_text(name, source))
2487                    && let Some(return_type) = factory_return_type(node, source)
2488                {
2489                    let key = class_name
2490                        .as_ref()
2491                        .map(|class| format!("{class}.{name}"))
2492                        .unwrap_or(name);
2493                    returns.insert(key, return_type);
2494                }
2495            }
2496            _ => push_factory_index_children(node, class_name, &mut stack),
2497        }
2498    }
2499    returns
2500}
2501
2502fn push_factory_index_children<'tree>(
2503    node: Node<'tree>,
2504    class_name: Option<String>,
2505    stack: &mut Vec<(Node<'tree>, Option<String>)>,
2506) {
2507    let mut cursor = node.walk();
2508    let mut children: Vec<Node<'tree>> = node.named_children(&mut cursor).collect();
2509    children.reverse();
2510    stack.extend(
2511        children
2512            .into_iter()
2513            .map(|child| (child, class_name.clone())),
2514    );
2515}
2516
2517fn factory_return_type(function: Node<'_>, source: &str) -> Option<String> {
2518    if let Some(return_type) = function.child_by_field_name("return_type") {
2519        let raw = slice(return_type, source).trim();
2520        return normalized_receiver_type(raw);
2521    }
2522
2523    let body = function.child_by_field_name("body")?;
2524    let mut candidates = HashSet::default();
2525    let mut saw_return = false;
2526    let mut saw_unknown_return = false;
2527    let mut stack = vec![body];
2528    while let Some(node) = stack.pop() {
2529        if node != body && matches!(node.kind(), "function_definition" | "class_definition") {
2530            continue;
2531        }
2532        if node.kind() == "return_statement" {
2533            saw_return = true;
2534            match node
2535                .named_child(0)
2536                .and_then(|value| returned_receiver_type(value, source))
2537            {
2538                Some(returned_type) => {
2539                    candidates.insert(returned_type);
2540                }
2541                None => saw_unknown_return = true,
2542            }
2543        }
2544        let mut cursor = node.walk();
2545        let mut children: Vec<Node<'_>> = node.named_children(&mut cursor).collect();
2546        children.reverse();
2547        stack.extend(children);
2548    }
2549    if !saw_return || saw_unknown_return {
2550        return None;
2551    }
2552    (candidates.len() == 1)
2553        .then(|| candidates.into_iter().next())
2554        .flatten()
2555}
2556
2557fn returned_receiver_type(node: Node<'_>, source: &str) -> Option<String> {
2558    let raw = match node.kind() {
2559        "identifier" => non_empty_node_text(node, source),
2560        "call" => node
2561            .child_by_field_name("function")
2562            .or_else(|| node.named_child(0))
2563            .filter(|callee| callee.kind() == "identifier")
2564            .and_then(|callee| non_empty_node_text(callee, source)),
2565        _ => None,
2566    }?;
2567    normalized_receiver_type(&raw)
2568}
2569
2570#[cfg(test)]
2571mod tests {
2572    use super::*;
2573    use std::path::PathBuf;
2574
2575    #[test]
2576    fn lambda_scope_facts_preserve_untyped_parameter_shadowing() {
2577        let source = "shadowed = lambda method: method.signature\n";
2578        let mut parser = Parser::new();
2579        parser
2580            .set_language(&tree_sitter_python::LANGUAGE.into())
2581            .unwrap();
2582        let tree = parser.parse(source, None).unwrap();
2583        let mut nodes = vec![tree.root_node()];
2584        let lambda = loop {
2585            let node = nodes.pop().unwrap();
2586            if node.kind() == "lambda" {
2587                break node;
2588            }
2589            let mut cursor = node.walk();
2590            nodes.extend(node.named_children(&mut cursor));
2591        };
2592
2593        let facts = collect_function_scope_facts_from_node(lambda, source);
2594
2595        assert!(facts.is_shadowed("method"));
2596        assert!(facts.resolution_for("method").is_unknown());
2597    }
2598
2599    #[test]
2600    fn pre_cancelled_graph_build_skips_python_file_parsing() {
2601        let temp = tempfile::tempdir().unwrap();
2602        let root = temp.path().canonicalize().unwrap();
2603        std::fs::write(root.join("target.py"), "def target():\n    pass\n").unwrap();
2604        let file = ProjectFile::new(root.clone(), PathBuf::from("target.py"));
2605        let files = [file.clone()].into_iter().collect();
2606        let cancellation = CancellationToken::default();
2607        cancellation.cancel();
2608
2609        let graph = build_python_graph(&files, &file, Some(&cancellation));
2610
2611        assert!(graph.parsed.is_empty());
2612    }
2613
2614    #[test]
2615    fn graph_build_parses_only_candidates_and_target_not_transitive_imports() {
2616        let temp = tempfile::tempdir().unwrap();
2617        let root = temp.path().canonicalize().unwrap();
2618        std::fs::write(root.join("target.py"), "from dependency import value\n").unwrap();
2619        std::fs::write(
2620            root.join("candidate.py"),
2621            "from transitively_imported import value\n",
2622        )
2623        .unwrap();
2624        std::fs::write(root.join("dependency.py"), "value = 1\n").unwrap();
2625        std::fs::write(root.join("transitively_imported.py"), "value = 2\n").unwrap();
2626        let target = ProjectFile::new(root.clone(), PathBuf::from("target.py"));
2627        let candidate = ProjectFile::new(root.clone(), PathBuf::from("candidate.py"));
2628        let dependency = ProjectFile::new(root.clone(), PathBuf::from("dependency.py"));
2629        let transitive = ProjectFile::new(root.clone(), PathBuf::from("transitively_imported.py"));
2630        let candidates = [candidate.clone()].into_iter().collect();
2631
2632        let graph = build_python_graph(&candidates, &target, None);
2633
2634        assert_eq!(graph.parsed.len(), 2);
2635        assert!(graph.parsed.contains_key(&target));
2636        assert!(graph.parsed.contains_key(&candidate));
2637        assert!(!graph.parsed.contains_key(&dependency));
2638        assert!(!graph.parsed.contains_key(&transitive));
2639    }
2640}