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            exits: Vec::new(),
83        };
84        walk_tree_iterative(
85            root,
86            &mut state,
87            |node, state| state.enter(node),
88            |state| state.exit(),
89        );
90    }
91
92    fn record(&mut self, callee: String, node: Node<'_>) {
93        let range = ruby_semantic_identifier_range(node, self.source);
94        self.edges.record_kind(
95            self.input,
96            callee,
97            classify_reference_node(node),
98            range.start_byte,
99            range.end_byte,
100        );
101    }
102
103    fn record_unproven_name(&mut self, name: &str, node: Node<'_>) {
104        let range = ruby_semantic_identifier_range(node, self.source);
105        self.edges
106            .record_unproven_name(self.input, name, range.start_byte, range.end_byte);
107    }
108}
109
110enum RubyEdgeExit {
111    Lexical,
112    Method,
113    LocalScope,
114}
115
116struct RubyEdgeWalkState<'scan, 'ctx> {
117    scan: &'scan mut RubyEdgeScan<'ctx>,
118    locals: LocalInferenceEngine<String>,
119    lexical_stack: Vec<String>,
120    method_stack: Vec<ReceiverMode>,
121    exits: Vec<RubyEdgeExit>,
122}
123
124impl RubyEdgeWalkState<'_, '_> {
125    fn enter(&mut self, node: Node<'_>) -> TreeWalkAction {
126        match node.kind() {
127            "class" | "module" => {
128                self.record_superclass_reference(node);
129                if let Some(owner) = self.type_owner(node) {
130                    self.lexical_stack.push(owner);
131                    self.exits.push(RubyEdgeExit::Lexical);
132                    self.record_reference(node);
133                    return TreeWalkAction::DescendWithExit;
134                }
135            }
136            "method" | "singleton_method" => {
137                self.locals.enter_scope();
138                self.seed_parameter_shadows(node);
139                self.method_stack.push(method_receiver_mode(node));
140                self.exits.push(RubyEdgeExit::Method);
141                return TreeWalkAction::DescendWithExit;
142            }
143            "singleton_class" => {
144                self.locals.enter_scope();
145                self.method_stack.push(ReceiverMode::Class);
146                self.exits.push(RubyEdgeExit::Method);
147                return TreeWalkAction::DescendWithExit;
148            }
149            "block" | "do_block" => {
150                self.locals.enter_scope();
151                self.exits.push(RubyEdgeExit::LocalScope);
152                return TreeWalkAction::DescendWithExit;
153            }
154            "assignment" => self.seed_assignment(node),
155            _ => {}
156        }
157        self.record_reference(node);
158        TreeWalkAction::Descend
159    }
160
161    fn exit(&mut self) {
162        match self.exits.pop() {
163            Some(RubyEdgeExit::Lexical) => {
164                self.lexical_stack.pop();
165            }
166            Some(RubyEdgeExit::Method) => {
167                self.method_stack.pop();
168                self.locals.exit_scope();
169            }
170            Some(RubyEdgeExit::LocalScope) => {
171                self.locals.exit_scope();
172            }
173            None => {}
174        }
175    }
176
177    fn type_owner(&self, node: Node<'_>) -> Option<String> {
178        ruby_type_owner(
179            self.scan.semantic,
180            self.scan.file,
181            &self.scan.visible_files,
182            &self.lexical_stack,
183            node,
184            self.scan.source,
185        )
186    }
187
188    fn record_reference(&mut self, node: Node<'_>) {
189        self.record_constant_reference(node);
190        self.record_method_reference(node);
191    }
192
193    fn record_superclass_reference(&mut self, node: Node<'_>) {
194        let Some(superclass) = node.child_by_field_name("superclass") else {
195            return;
196        };
197        let mut stack = vec![superclass];
198        while let Some(current) = stack.pop() {
199            self.record_constant_reference(current);
200            for index in (0..current.named_child_count()).rev() {
201                if let Some(child) = current.named_child(index) {
202                    stack.push(child);
203                }
204            }
205        }
206    }
207
208    fn record_constant_reference(&mut self, node: Node<'_>) {
209        if crate::imports::is_ruby_autoload_symbol_argument(node, self.scan.source) {
210            self.record_autoload_symbol_constant_reference(node);
211            return;
212        }
213        if !matches!(node.kind(), "constant" | "scope_resolution") || is_declaration_constant(node)
214        {
215            return;
216        }
217        if let Some(unit) = self.scan.semantic.resolve_constant(
218            self.scan.file,
219            &self.scan.visible_files,
220            &self.lexical_stack,
221            node,
222            self.scan.source,
223        ) && (unit.is_class() || unit.is_module())
224        {
225            self.scan.record(unit.fq_name(), node);
226        }
227    }
228
229    fn record_autoload_symbol_constant_reference(&mut self, node: Node<'_>) {
230        let Some(name) = crate::imports::ruby_symbol_name(node, self.scan.source) else {
231            return;
232        };
233        if let Some(unit) = self.scan.semantic.resolve_constant_name(
234            self.scan.file,
235            &self.scan.visible_files,
236            &self.lexical_stack,
237            &name,
238        ) && (unit.is_class() || unit.is_module())
239        {
240            self.scan.record(unit.fq_name(), node);
241        }
242    }
243
244    fn record_method_reference(&mut self, node: Node<'_>) {
245        if node.kind() == "identifier" {
246            self.record_bare_identifier_method_reference(node);
247            return;
248        }
249        if node.kind() != "call" {
250            return;
251        }
252        let Some(method) = node.child_by_field_name("method") else {
253            return;
254        };
255        let member = node_text(method, self.scan.source);
256        if member.is_empty() {
257            return;
258        }
259        if let Some((dispatched_member, dispatched_node)) =
260            dynamic_dispatch_target_argument(node, self.scan.source)
261        {
262            self.record_call_method_reference(
263                node,
264                &dispatched_member,
265                dispatched_node,
266                MethodLookup::Explicit,
267            );
268            return;
269        }
270        self.record_call_method_reference(
271            node,
272            member,
273            method,
274            if node.child_by_field_name("receiver").is_some() {
275                MethodLookup::Explicit
276            } else {
277                MethodLookup::Bare
278            },
279        );
280    }
281
282    fn record_call_method_reference(
283        &mut self,
284        node: Node<'_>,
285        member: &str,
286        hit_node: Node<'_>,
287        lookup: MethodLookup,
288    ) {
289        let receiver_node = node.child_by_field_name("receiver");
290        // A `self.method` or implicit-self (bare) call is on the current instance
291        // / own class — a same-owner site (#1138). An explicit variable/constant
292        // receiver stays external.
293        let same_owner = match receiver_node {
294            None => true,
295            Some(receiver) => receiver.kind() == "self",
296        };
297        let receiver = match receiver_node {
298            Some(receiver) => self.receiver_type(receiver),
299            None => self.enclosing_receiver(node.start_byte()),
300        };
301        let Some(receiver) = receiver else {
302            self.scan.record_unproven_name(member, hit_node);
303            return;
304        };
305        if member == "new" && receiver.mode == ReceiverMode::Class {
306            self.record_unique_method_candidate(
307                self.initialize_receiver(&receiver),
308                "initialize",
309                hit_node,
310                MethodLookup::Explicit,
311                same_owner,
312            );
313            return;
314        }
315        self.record_unique_method_candidate(receiver, member, hit_node, lookup, same_owner);
316    }
317
318    fn record_bare_identifier_method_reference(&mut self, node: Node<'_>) {
319        let name = node_text(node, self.scan.source);
320        if name.is_empty()
321            || self.locals.is_shadowed(name)
322            || is_declaration_identifier(node)
323            || is_call_method_identifier(node)
324        {
325            return;
326        }
327        let Some(receiver) = self.enclosing_receiver(node.start_byte()) else {
328            return;
329        };
330        // A bare method-name reference resolves against the enclosing (implicit
331        // self) receiver — a same-owner site (#1138).
332        self.record_unique_method_candidate(receiver, name, node, MethodLookup::Bare, true);
333    }
334
335    fn record_unique_method_candidate(
336        &mut self,
337        receiver: ReceiverType,
338        member: &str,
339        node: Node<'_>,
340        lookup: MethodLookup,
341        same_owner: bool,
342    ) {
343        // A `self.`/implicit-self call is a same-owner reference (#1138): record
344        // it as unproven inbound rather than a proven edge, so a method reachable
345        // only through same-owner calls reads INCONCLUSIVE, never confidently
346        // dead. An explicit variable/constant receiver — even of the same type —
347        // is a different instance and stays external.
348        if same_owner {
349            self.scan.record_unproven_name(member, node);
350            return;
351        }
352        let candidates = match lookup {
353            MethodLookup::Bare => self.scan.semantic.resolve_bare_method_candidates(
354                self.scan.support,
355                &self.scan.visible_files,
356                &receiver,
357                member,
358            ),
359            MethodLookup::Explicit => self.scan.semantic.resolve_method_candidates(
360                self.scan.support,
361                &self.scan.visible_files,
362                &receiver,
363                member,
364            ),
365        };
366        if let Some(fqn) = unique_candidate_fqn(candidates) {
367            self.scan.record(fqn, node);
368        } else {
369            self.scan.record_unproven_name(member, node);
370        }
371    }
372
373    fn receiver_type(&self, node: Node<'_>) -> Option<ReceiverType> {
374        ruby_receiver_type(
375            self.scan.semantic,
376            self.scan.file,
377            &self.scan.visible_files,
378            &self.lexical_stack,
379            &self.locals,
380            &self.method_stack,
381            node,
382            self.scan.source,
383        )
384    }
385
386    fn enclosing_receiver(&self, byte: usize) -> Option<ReceiverType> {
387        ruby_enclosing_receiver(&self.lexical_stack, &self.method_stack).or_else(|| {
388            self.scan
389                .class_ranges
390                .enclosing(byte)
391                .map(|owner_fq_name| ReceiverType {
392                    owner_fq_name: owner_fq_name.to_string(),
393                    mode: ReceiverMode::Instance,
394                })
395        })
396    }
397
398    fn initialize_receiver(&self, receiver: &ReceiverType) -> ReceiverType {
399        ReceiverType {
400            owner_fq_name: receiver.owner_fq_name.clone(),
401            mode: ReceiverMode::Instance,
402        }
403    }
404
405    fn seed_assignment(&mut self, node: Node<'_>) {
406        ruby_seed_assignment(
407            self.scan.semantic,
408            self.scan.file,
409            &self.scan.visible_files,
410            &self.lexical_stack,
411            &self.method_stack,
412            &mut self.locals,
413            node,
414            self.scan.source,
415        );
416    }
417
418    fn seed_parameter_shadows(&mut self, node: Node<'_>) {
419        ruby_seed_parameter_shadows(&mut self.locals, node, self.scan.source);
420    }
421}
422
423#[derive(Clone, Copy)]
424enum MethodLookup {
425    Bare,
426    Explicit,
427}
428
429fn unique_candidate_fqn(candidates: Vec<CodeUnit>) -> Option<String> {
430    if candidates.len() == 1 {
431        candidates
432            .into_iter()
433            .next()
434            .map(|candidate| candidate.fq_name())
435    } else {
436        None
437    }
438}