1use 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
55pub 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}