Skip to main content

brokk_bifrost_ruby/graph/
inverted.rs

1//! Whole-workspace inverted edge builder for Ruby.
2//!
3//! Ruby's query path is intentionally conservative: it emits a hit only when
4//! parser and analyzer facts prove the target. The inverted path follows the same
5//! rule. It records constants resolved by `RubySemanticIndex`, class-side method
6//! calls such as `Klass.call`, constructor calls as `Klass.initialize`, and calls
7//! through `self`, lexical receivers, factory-return inference, or locally typed
8//! receivers. Unknown receivers and candidate sets with anything other than one
9//! resolved declaration record unproven inbound evidence for bulk dead-code
10//! analysis rather than a proven edge.
11
12use crate::graph::RubyGraphSource;
13use crate::graph::extractor::{
14    ruby_enclosing_receiver, ruby_receiver_type, ruby_seed_assignment, ruby_seed_parameter_shadows,
15    ruby_type_owner,
16};
17use crate::graph::resolver::{ReceiverMode, ReceiverType, RubySemanticIndex};
18use crate::graph::syntax::{
19    dynamic_dispatch_target_argument, is_call_method_identifier, is_declaration_constant,
20    is_declaration_identifier, method_receiver_mode, node_text,
21};
22use crate::graph_support::RubySource;
23use crate::syntax::ruby_semantic_identifier_range;
24use brokk_bifrost_core::analyzer::tree_walk::{TreeWalkAction, walk_tree_iterative};
25use brokk_bifrost_core::analyzer::usages::inverted_edges::{
26    ClassRangeIndex, FileEdgeScanInput, PerFileEdges, classify_reference_node,
27};
28use brokk_bifrost_core::analyzer::usages::local_inference::{
29    LocalInferenceConfig, LocalInferenceEngine,
30};
31use brokk_bifrost_core::analyzer::{BoundedDefinitionLookup, CodeUnit, ProjectFile};
32use brokk_bifrost_core::hash::HashSet;
33use tree_sitter::Node;
34
35/// One file's `caller -> callee` edges, for the whole-workspace inverted pass.
36///
37/// The pass's fan-out -- `build_edge_output` plus `parse_and_collect`, the
38/// shared cross-language driver -- stays in `brokk-bifrost-analysis` and calls
39/// this per file, so the Ruby half is a pure function of a parsed file and the
40/// two sources.
41pub fn scan_file(
42    graph: RubyGraphSource<'_>,
43    ruby: &dyn RubySource,
44    support: &dyn BoundedDefinitionLookup,
45    file: &ProjectFile,
46    input: &FileEdgeScanInput<'_>,
47) -> PerFileEdges {
48    let semantic = RubySemanticIndex::build_for_lookup(graph, ruby);
49    let visible_files = semantic.visible_files_from(file);
50    let mut scan = RubyEdgeScan {
51        semantic: &semantic,
52        support,
53        file,
54        source: input.source,
55        visible_files,
56        class_ranges: ClassRangeIndex::build(graph.index, file),
57        input,
58        edges: PerFileEdges::default(),
59    };
60    scan.scan(input.root());
61    scan.edges
62}
63
64struct RubyEdgeScan<'a> {
65    semantic: &'a RubySemanticIndex<'a>,
66    support: &'a dyn BoundedDefinitionLookup,
67    file: &'a ProjectFile,
68    source: &'a str,
69    visible_files: HashSet<ProjectFile>,
70    class_ranges: ClassRangeIndex,
71    input: &'a FileEdgeScanInput<'a>,
72    edges: PerFileEdges,
73}
74
75impl RubyEdgeScan<'_> {
76    fn scan(&mut self, root: Node<'_>) {
77        let mut state = RubyEdgeWalkState {
78            scan: self,
79            locals: LocalInferenceEngine::new(LocalInferenceConfig::default()),
80            lexical_stack: Vec::new(),
81            method_stack: Vec::new(),
82            receiver_context: Vec::new(),
83            exits: Vec::new(),
84        };
85        walk_tree_iterative(
86            root,
87            &mut state,
88            |node, state| state.enter(node),
89            |state| state.exit(),
90        );
91    }
92
93    fn record(&mut self, callee: String, node: Node<'_>) {
94        let range = ruby_semantic_identifier_range(node, self.source);
95        self.edges.record_kind(
96            self.input,
97            callee,
98            classify_reference_node(node),
99            range.start_byte,
100            range.end_byte,
101        );
102    }
103
104    fn record_unproven_name(&mut self, name: &str, node: Node<'_>) {
105        let range = ruby_semantic_identifier_range(node, self.source);
106        self.edges
107            .record_unproven_name(self.input, name, range.start_byte, range.end_byte);
108    }
109}
110
111enum RubyEdgeExit {
112    Type { has_lexical_owner: bool },
113    Method,
114    SingletonClass,
115    LocalScope,
116}
117
118struct RubyEdgeWalkState<'scan, 'ctx> {
119    scan: &'scan mut RubyEdgeScan<'ctx>,
120    locals: LocalInferenceEngine<String>,
121    lexical_stack: Vec<String>,
122    method_stack: Vec<ReceiverMode>,
123    receiver_context: Vec<ReceiverMode>,
124    exits: Vec<RubyEdgeExit>,
125}
126
127impl RubyEdgeWalkState<'_, '_> {
128    fn enter(&mut self, node: Node<'_>) -> TreeWalkAction {
129        match node.kind() {
130            "class" | "module" => {
131                self.record_superclass_reference(node);
132                let owner = self.type_owner(node);
133                if let Some(owner) = owner.as_ref() {
134                    self.lexical_stack.push(owner.clone());
135                }
136                self.receiver_context.push(ReceiverMode::Instance);
137                self.exits.push(RubyEdgeExit::Type {
138                    has_lexical_owner: owner.is_some(),
139                });
140                self.record_reference(node);
141                return TreeWalkAction::DescendWithExit;
142            }
143            "method" | "singleton_method" => {
144                self.locals.enter_scope();
145                self.seed_parameter_shadows(node);
146                self.method_stack.push(method_receiver_mode(
147                    node,
148                    self.receiver_context.last().copied(),
149                ));
150                self.exits.push(RubyEdgeExit::Method);
151                return TreeWalkAction::DescendWithExit;
152            }
153            "singleton_class" => {
154                self.locals.enter_scope();
155                self.method_stack.push(ReceiverMode::Class);
156                self.receiver_context.push(ReceiverMode::Class);
157                self.exits.push(RubyEdgeExit::SingletonClass);
158                return TreeWalkAction::DescendWithExit;
159            }
160            "block" | "do_block" => {
161                self.locals.enter_scope();
162                self.exits.push(RubyEdgeExit::LocalScope);
163                return TreeWalkAction::DescendWithExit;
164            }
165            "assignment" => self.seed_assignment(node),
166            _ => {}
167        }
168        self.record_reference(node);
169        TreeWalkAction::Descend
170    }
171
172    fn exit(&mut self) {
173        match self.exits.pop() {
174            Some(RubyEdgeExit::Type { has_lexical_owner }) => {
175                assert!(
176                    matches!(self.receiver_context.pop(), Some(ReceiverMode::Instance)),
177                    "type receiver context must match its exit"
178                );
179                if has_lexical_owner {
180                    self.lexical_stack.pop();
181                }
182            }
183            Some(RubyEdgeExit::Method) => {
184                self.method_stack.pop();
185                self.locals.exit_scope();
186            }
187            Some(RubyEdgeExit::SingletonClass) => {
188                assert!(
189                    matches!(self.receiver_context.pop(), Some(ReceiverMode::Class)),
190                    "singleton-class receiver context must match its exit"
191                );
192                self.method_stack.pop();
193                self.locals.exit_scope();
194            }
195            Some(RubyEdgeExit::LocalScope) => {
196                self.locals.exit_scope();
197            }
198            None => {}
199        }
200    }
201
202    fn type_owner(&self, node: Node<'_>) -> Option<String> {
203        ruby_type_owner(
204            self.scan.semantic,
205            self.scan.file,
206            &self.scan.visible_files,
207            &self.lexical_stack,
208            node,
209            self.scan.source,
210        )
211    }
212
213    fn record_reference(&mut self, node: Node<'_>) {
214        self.record_constant_reference(node);
215        self.record_method_reference(node);
216    }
217
218    fn record_superclass_reference(&mut self, node: Node<'_>) {
219        let Some(superclass) = node.child_by_field_name("superclass") else {
220            return;
221        };
222        let mut stack = vec![superclass];
223        while let Some(current) = stack.pop() {
224            self.record_constant_reference(current);
225            for index in (0..current.named_child_count()).rev() {
226                if let Some(child) = current.named_child(index) {
227                    stack.push(child);
228                }
229            }
230        }
231    }
232
233    fn record_constant_reference(&mut self, node: Node<'_>) {
234        if crate::imports::is_ruby_autoload_symbol_argument(node, self.scan.source) {
235            self.record_autoload_symbol_constant_reference(node);
236            return;
237        }
238        if !matches!(node.kind(), "constant" | "scope_resolution") || is_declaration_constant(node)
239        {
240            return;
241        }
242        if let Some(unit) = self.scan.semantic.resolve_constant(
243            self.scan.file,
244            &self.scan.visible_files,
245            &self.lexical_stack,
246            node,
247            self.scan.source,
248        ) && (unit.is_class() || unit.is_module())
249        {
250            self.scan.record(unit.fq_name(), node);
251        }
252    }
253
254    fn record_autoload_symbol_constant_reference(&mut self, node: Node<'_>) {
255        let Some(name) = crate::imports::ruby_symbol_name(node, self.scan.source) else {
256            return;
257        };
258        if let Some(unit) = self.scan.semantic.resolve_constant_name(
259            self.scan.file,
260            &self.scan.visible_files,
261            &self.lexical_stack,
262            &name,
263        ) && (unit.is_class() || unit.is_module())
264        {
265            self.scan.record(unit.fq_name(), node);
266        }
267    }
268
269    fn record_method_reference(&mut self, node: Node<'_>) {
270        if node.kind() == "identifier" {
271            self.record_bare_identifier_method_reference(node);
272            return;
273        }
274        if node.kind() != "call" {
275            return;
276        }
277        let Some(method) = node.child_by_field_name("method") else {
278            return;
279        };
280        let member = node_text(method, self.scan.source);
281        if member.is_empty() {
282            return;
283        }
284        if let Some((dispatched_member, dispatched_node)) =
285            dynamic_dispatch_target_argument(node, self.scan.source)
286        {
287            self.record_call_method_reference(
288                node,
289                &dispatched_member,
290                dispatched_node,
291                MethodLookup::Explicit,
292            );
293            return;
294        }
295        self.record_call_method_reference(
296            node,
297            member,
298            method,
299            if node.child_by_field_name("receiver").is_some() {
300                MethodLookup::Explicit
301            } else {
302                MethodLookup::Bare
303            },
304        );
305    }
306
307    fn record_call_method_reference(
308        &mut self,
309        node: Node<'_>,
310        member: &str,
311        hit_node: Node<'_>,
312        lookup: MethodLookup,
313    ) {
314        let receiver_node = node.child_by_field_name("receiver");
315        // A `self.method` or implicit-self (bare) call is on the current instance
316        // / own class — a same-owner site (#1138). An explicit variable/constant
317        // receiver stays external.
318        let same_owner = match receiver_node {
319            None => true,
320            Some(receiver) => receiver.kind() == "self",
321        };
322        let receiver = match receiver_node {
323            Some(receiver) => self.receiver_type(receiver),
324            None => self.enclosing_receiver(node.start_byte()),
325        };
326        let Some(receiver) = receiver else {
327            self.scan.record_unproven_name(member, hit_node);
328            return;
329        };
330        if member == "new" && receiver.mode == ReceiverMode::Class {
331            self.record_unique_method_candidate(
332                self.initialize_receiver(&receiver),
333                "initialize",
334                hit_node,
335                MethodLookup::Explicit,
336                same_owner,
337            );
338            return;
339        }
340        self.record_unique_method_candidate(receiver, member, hit_node, lookup, same_owner);
341    }
342
343    fn record_bare_identifier_method_reference(&mut self, node: Node<'_>) {
344        let name = node_text(node, self.scan.source);
345        if name.is_empty()
346            || self.locals.is_shadowed(name)
347            || is_declaration_identifier(node)
348            || is_call_method_identifier(node)
349        {
350            return;
351        }
352        let Some(receiver) = self.enclosing_receiver(node.start_byte()) else {
353            return;
354        };
355        // A bare method-name reference resolves against the enclosing (implicit
356        // self) receiver — a same-owner site (#1138).
357        self.record_unique_method_candidate(receiver, name, node, MethodLookup::Bare, true);
358    }
359
360    fn record_unique_method_candidate(
361        &mut self,
362        receiver: ReceiverType,
363        member: &str,
364        node: Node<'_>,
365        lookup: MethodLookup,
366        same_owner: bool,
367    ) {
368        // A `self.`/implicit-self call is a same-owner reference (#1138): record
369        // it as unproven inbound rather than a proven edge, so a method reachable
370        // only through same-owner calls reads INCONCLUSIVE, never confidently
371        // dead. An explicit variable/constant receiver — even of the same type —
372        // is a different instance and stays external.
373        if same_owner {
374            self.scan.record_unproven_name(member, node);
375            return;
376        }
377        let candidates = match lookup {
378            MethodLookup::Bare => self.scan.semantic.resolve_bare_method_candidates(
379                self.scan.support,
380                &self.scan.visible_files,
381                &receiver,
382                member,
383            ),
384            MethodLookup::Explicit => self.scan.semantic.resolve_method_candidates(
385                self.scan.support,
386                &self.scan.visible_files,
387                &receiver,
388                member,
389            ),
390        };
391        if let Some(fqn) = unique_candidate_fqn(candidates) {
392            self.scan.record(fqn, node);
393        } else {
394            self.scan.record_unproven_name(member, node);
395        }
396    }
397
398    fn receiver_type(&self, node: Node<'_>) -> Option<ReceiverType> {
399        ruby_receiver_type(
400            self.scan.semantic,
401            self.scan.file,
402            &self.scan.visible_files,
403            &self.lexical_stack,
404            &self.locals,
405            &self.method_stack,
406            node,
407            self.scan.source,
408        )
409    }
410
411    fn enclosing_receiver(&self, byte: usize) -> Option<ReceiverType> {
412        ruby_enclosing_receiver(&self.lexical_stack, &self.method_stack).or_else(|| {
413            self.scan
414                .class_ranges
415                .enclosing(byte)
416                .map(|owner_fq_name| ReceiverType {
417                    owner_fq_name: owner_fq_name.to_string(),
418                    mode: ReceiverMode::Instance,
419                })
420        })
421    }
422
423    fn initialize_receiver(&self, receiver: &ReceiverType) -> ReceiverType {
424        ReceiverType {
425            owner_fq_name: receiver.owner_fq_name.clone(),
426            mode: ReceiverMode::Instance,
427        }
428    }
429
430    fn seed_assignment(&mut self, node: Node<'_>) {
431        ruby_seed_assignment(
432            self.scan.semantic,
433            self.scan.file,
434            &self.scan.visible_files,
435            &self.lexical_stack,
436            &self.method_stack,
437            &mut self.locals,
438            node,
439            self.scan.source,
440        );
441    }
442
443    fn seed_parameter_shadows(&mut self, node: Node<'_>) {
444        ruby_seed_parameter_shadows(&mut self.locals, node, self.scan.source);
445    }
446}
447
448#[derive(Clone, Copy)]
449enum MethodLookup {
450    Bare,
451    Explicit,
452}
453
454fn unique_candidate_fqn(candidates: Vec<CodeUnit>) -> Option<String> {
455    if candidates.len() == 1 {
456        candidates
457            .into_iter()
458            .next()
459            .map(|candidate| candidate.fq_name())
460    } else {
461        None
462    }
463}