Skip to main content

brokk_bifrost_ruby/
diagnostics.rs

1//! Ruby's semantic diagnostics: conservative unresolved-constant reporting.
2//!
3//! Unlike Go's, Python's and PHP's, Ruby's pass routes through the *graph*
4//! semantic index rather than a `BoundedDefinitionLookup`, so it follows
5//! `graph::resolver` across the crate line rather than being independently
6//! movable. `analyzer/ruby/diagnostics.rs` in `brokk-bifrost-analysis` keeps the
7//! downcast that produces the arguments and the `SemanticDiagnosticReport`
8//! wrapper `IAnalyzer::semantic_diagnostics` returns.
9
10use crate::declarations::parse_ruby_tree;
11use crate::graph::RubyGraphSource;
12use crate::graph::extractor::ruby_type_owner;
13use crate::graph::resolver::RubySemanticIndex;
14use crate::graph::syntax::is_declaration_constant;
15use crate::graph_support::RubySource;
16use crate::imports::{
17    parse_ruby_require_call, ruby_has_unresolved_load_directive, ruby_symbol_name,
18    ruby_zeitwerk_visible_files_for,
19};
20use crate::syntax::single_static_string_content_node;
21use brokk_bifrost_core::analyzer::model::{Range, SemanticDiagnostic};
22use brokk_bifrost_core::analyzer::semantic_diagnostics::{node_range, node_text};
23use brokk_bifrost_core::analyzer::tree_walk::collect_parse_errors;
24use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
25use brokk_bifrost_core::hash::HashSet;
26use brokk_bifrost_core::text_utils::compute_line_starts;
27use std::borrow::Cow;
28use tree_sitter::Node;
29
30pub const RUBY_UNRECOGNIZED_SYMBOL: &str = "ruby_unrecognized_symbol";
31pub const RUBY_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-ruby";
32const MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
33const MAX_RUBY_SEMANTIC_DIAGNOSTICS: usize = 200;
34const MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES: usize = 64;
35const MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES: usize = 2 * 1024 * 1024;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct RubySemanticDiagnostic {
39    pub range: Range,
40    pub kind: &'static str,
41    pub message: String,
42}
43
44impl From<RubySemanticDiagnostic> for SemanticDiagnostic {
45    fn from(diagnostic: RubySemanticDiagnostic) -> Self {
46        Self {
47            range: diagnostic.range,
48            source: RUBY_SEMANTIC_DIAGNOSTIC_SOURCE,
49            kind: diagnostic.kind,
50            message: diagnostic.message,
51        }
52    }
53}
54
55/// Collect high-confidence Ruby unresolved-constant diagnostics.
56///
57/// The pass deliberately does not diagnose methods or members. Ruby can add
58/// those dynamically through `method_missing`, runtime patching, gems, and
59/// framework conventions. It only reports a terminal constant when a clean,
60/// convention-free file names a known project-local namespace and the existing
61/// structured resolver proves that the terminal is not indexed or visible.
62pub fn collect_ruby_semantic_diagnostics(
63    graph: RubyGraphSource<'_>,
64    ruby: &dyn RubySource,
65    file: &ProjectFile,
66    source: &str,
67) -> Vec<RubySemanticDiagnostic> {
68    if source.len() > MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES
69        || ruby_zeitwerk_visible_files_for(ruby, file).is_some()
70        || ruby_has_unresolved_load_directive(ruby, file)
71    {
72        return Vec::new();
73    }
74    let Some(tree) = parse_ruby_tree(source) else {
75        return Vec::new();
76    };
77    let mut parse_errors = Vec::new();
78    collect_parse_errors(tree.root_node(), &mut parse_errors);
79    if !parse_errors.is_empty() || file_has_open_runtime_boundary(tree.root_node(), source) {
80        return Vec::new();
81    }
82
83    let line_starts = compute_line_starts(source);
84    let semantic = RubySemanticIndex::build_for_lookup(graph, ruby);
85    let Some(visible_files) =
86        semantic.visible_files_from_bounded(file, MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES)
87    else {
88        return Vec::new();
89    };
90    if visible_files_have_open_runtime_boundary(graph, ruby, file, source, &visible_files) {
91        return Vec::new();
92    }
93    let mut collector = RubyDiagnosticCollector {
94        semantic,
95        ruby,
96        file,
97        source,
98        line_starts: &line_starts,
99        visible_files,
100        diagnostics: Vec::new(),
101    };
102    collector.scan_tree(tree.root_node());
103    collector.diagnostics
104}
105
106struct RubyDiagnosticCollector<'a> {
107    semantic: RubySemanticIndex<'a>,
108    ruby: &'a dyn RubySource,
109    file: &'a ProjectFile,
110    source: &'a str,
111    line_starts: &'a [usize],
112    visible_files: HashSet<ProjectFile>,
113    diagnostics: Vec<RubySemanticDiagnostic>,
114}
115
116enum ScanFrame<'tree> {
117    Node(Node<'tree>),
118    ExitNamespace(usize),
119}
120
121impl RubyDiagnosticCollector<'_> {
122    fn scan_tree(&mut self, root: Node<'_>) {
123        let mut lexical_stack = Vec::new();
124        let mut stack = vec![ScanFrame::Node(root)];
125        while let Some(frame) = stack.pop() {
126            if self.diagnostics.len() >= MAX_RUBY_SEMANTIC_DIAGNOSTICS {
127                break;
128            }
129            match frame {
130                ScanFrame::Node(node) => self.scan_node(node, &mut lexical_stack, &mut stack),
131                ScanFrame::ExitNamespace(len) => lexical_stack.truncate(len),
132            }
133        }
134    }
135
136    fn scan_node<'tree>(
137        &mut self,
138        node: Node<'tree>,
139        lexical_stack: &mut Vec<String>,
140        stack: &mut Vec<ScanFrame<'tree>>,
141    ) {
142        match node.kind() {
143            "class" | "module" => {
144                let Some(owner) = ruby_type_owner(
145                    &self.semantic,
146                    self.file,
147                    &self.visible_files,
148                    lexical_stack,
149                    node,
150                    self.source,
151                ) else {
152                    return;
153                };
154                let previous_len = lexical_stack.len();
155                lexical_stack.push(owner);
156                stack.push(ScanFrame::ExitNamespace(previous_len));
157                if let Some(body) = node.child_by_field_name("body") {
158                    stack.push(ScanFrame::Node(body));
159                }
160            }
161            "scope_resolution" => self.check_explicit_path(node, lexical_stack),
162            "constant" => {}
163            "assignment" | "operator_assignment" => {
164                if let Some(right) = node.child_by_field_name("right") {
165                    stack.push(ScanFrame::Node(right));
166                }
167            }
168            "string" | "comment" => {}
169            _ => push_named_children(stack, node),
170        }
171    }
172
173    fn check_explicit_path(&mut self, node: Node<'_>, lexical_stack: &[String]) {
174        if is_declaration_constant(node) {
175            return;
176        }
177        let Some(owner) = node.child_by_field_name("scope") else {
178            return;
179        };
180        let Some(owner_unit) = self.semantic.resolve_project_local_constant(
181            self.file,
182            &self.visible_files,
183            lexical_stack,
184            owner,
185            self.source,
186        ) else {
187            return;
188        };
189        if !owner_unit.is_module() || self.owner_has_constant_lookup_escape(&owner_unit) {
190            return;
191        }
192        if self
193            .semantic
194            .resolve_project_local_constant(
195                self.file,
196                &self.visible_files,
197                lexical_stack,
198                node,
199                self.source,
200            )
201            .is_some()
202        {
203            return;
204        }
205        let Some(terminal) = node.child_by_field_name("name") else {
206            return;
207        };
208        self.push_unrecognized(terminal);
209    }
210
211    fn owner_has_constant_lookup_escape(&self, owner: &CodeUnit) -> bool {
212        let facts = self.ruby.semantic_facts();
213        let owner = owner.fq_name();
214        facts
215            .ancestors
216            .get(&owner)
217            .is_some_and(|ancestors| !ancestors.is_empty())
218            || facts.mixin_included_owners.contains_key(&owner)
219            || facts.mixin_prepended_owners.contains_key(&owner)
220            || facts.mixin_class_owners.contains_key(&owner)
221    }
222
223    fn push_unrecognized(&mut self, node: Node<'_>) {
224        let name = node_text(node, self.source);
225        if name.is_empty() {
226            return;
227        }
228        self.diagnostics.push(RubySemanticDiagnostic {
229            range: node_range(node, self.line_starts),
230            kind: RUBY_UNRECOGNIZED_SYMBOL,
231            message: format!("Unrecognized Ruby constant `{name}`"),
232        });
233    }
234}
235
236fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
237    let mut cursor = node.walk();
238    let children: Vec<_> = node.named_children(&mut cursor).collect();
239    for child in children.into_iter().rev() {
240        stack.push(ScanFrame::Node(child));
241    }
242}
243
244fn file_has_open_runtime_boundary(root: Node<'_>, source: &str) -> bool {
245    let mut stack = vec![root];
246    while let Some(node) = stack.pop() {
247        if node.kind() == "call"
248            && node.child_by_field_name("method").is_some_and(|method| {
249                matches!(
250                    node_text(method, source),
251                    "const_get"
252                        | "const_set"
253                        | "remove_const"
254                        | "const_missing"
255                        | "class_eval"
256                        | "module_eval"
257                        | "eval"
258                )
259            })
260        {
261            return true;
262        }
263        if defines_const_missing_dynamically(node, source) {
264            return true;
265        }
266        if node.kind() == "call"
267            && let Some(method) = node.child_by_field_name("method")
268        {
269            match node_text(method, source) {
270                "autoload" => return true,
271                "require" | "require_relative" | "load"
272                    if parse_ruby_require_call(node, source).is_none() =>
273                {
274                    return true;
275                }
276                _ => {}
277            }
278        }
279        if matches!(node.kind(), "method" | "singleton_method")
280            && node
281                .child_by_field_name("name")
282                .is_some_and(|name| node_text(name, source) == "const_missing")
283        {
284            return true;
285        }
286        let mut cursor = node.walk();
287        stack.extend(node.named_children(&mut cursor));
288    }
289    false
290}
291
292fn defines_const_missing_dynamically(node: Node<'_>, source: &str) -> bool {
293    if node.kind() != "call" {
294        return false;
295    }
296    let Some(method) = node.child_by_field_name("method") else {
297        return false;
298    };
299    if !matches!(
300        node_text(method, source),
301        "define_method" | "define_singleton_method"
302    ) {
303        return false;
304    }
305    let Some(arguments) = node.child_by_field_name("arguments") else {
306        return false;
307    };
308    let mut cursor = arguments.walk();
309    let Some(name) = arguments.named_children(&mut cursor).next() else {
310        return false;
311    };
312    ruby_symbol_name(name, source).as_deref() == Some("const_missing")
313        || single_static_string_content_node(name)
314            .is_some_and(|content| node_text(content, source) == "const_missing")
315}
316
317fn visible_files_have_open_runtime_boundary(
318    graph: RubyGraphSource<'_>,
319    ruby: &dyn RubySource,
320    file: &ProjectFile,
321    source: &str,
322    visible_files: &HashSet<ProjectFile>,
323) -> bool {
324    let mut remaining_bytes = MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES;
325    for visible_file in visible_files {
326        if ruby_has_unresolved_load_directive(ruby, visible_file) {
327            return true;
328        }
329        let visible_source = if visible_file == file {
330            (source.len() <= remaining_bytes).then_some(Cow::Borrowed(source))
331        } else {
332            graph
333                .index
334                .project()
335                .read_source_limited(visible_file, remaining_bytes)
336                .ok()
337                .flatten()
338                .map(Cow::Owned)
339        };
340        let Some(visible_source) = visible_source else {
341            return true;
342        };
343        let Some(next_remaining_bytes) = remaining_bytes.checked_sub(visible_source.len()) else {
344            return true;
345        };
346        remaining_bytes = next_remaining_bytes;
347        let Some(tree) = parse_ruby_tree(&visible_source) else {
348            return true;
349        };
350        let mut parse_errors = Vec::new();
351        collect_parse_errors(tree.root_node(), &mut parse_errors);
352        if !parse_errors.is_empty()
353            || file_has_open_runtime_boundary(tree.root_node(), &visible_source)
354        {
355            return true;
356        }
357    }
358    false
359}