1use crate::declarations::{extract_name_path, parse_ruby_tree};
45use crate::graph::RubyGraphSource;
46use crate::graph::extractor::ruby_type_owner;
47use crate::graph::resolver::RubySemanticIndex;
48use crate::graph::syntax::is_declaration_constant;
49use crate::graph_support::RubySource;
50use crate::imports::{parse_ruby_require_call, ruby_symbol_name, ruby_zeitwerk_visible_files_for};
51use crate::syntax::single_static_string_content_node;
52use brokk_bifrost_core::analyzer::model::{
53 Range, SemanticAbsenceProof, SemanticDiagnostic, SemanticDiagnosticDomain,
54 SemanticDiagnosticIncompleteReason, SemanticDiagnosticReport,
55};
56use brokk_bifrost_core::analyzer::semantic_diagnostics::{node_range, node_text};
57use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
58use brokk_bifrost_core::analyzer::tree_walk::collect_parse_errors;
59use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
60use brokk_bifrost_core::hash::HashSet;
61use brokk_bifrost_core::text_utils::compute_line_starts;
62use std::borrow::Cow;
63use tree_sitter::Node;
64
65pub const RUBY_UNRECOGNIZED_SYMBOL: &str = "ruby_unrecognized_symbol";
66pub const RUBY_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-ruby";
67const MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
68pub const MAX_RUBY_SEMANTIC_DIAGNOSTICS: usize = 200;
69pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES: usize = 64;
70pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES: usize = 2 * 1024 * 1024;
71
72#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum RubyGemBoundary {
76 Indexed,
78 Absent(SemanticDiagnosticDomain),
82 Unpublished(SemanticDiagnosticIncompleteReason),
86 Incomplete(SemanticDiagnosticIncompleteReason),
89}
90
91pub trait RubyGemSurface {
99 fn constant_boundary(&self, owner_path: &[String], terminal: &str) -> RubyGemBoundary;
103
104 fn require_boundary(&self, require_path: &str) -> RubyGemBoundary;
108}
109
110#[derive(Debug, Clone, Copy, Default)]
113pub struct UnacquiredRubyGems;
114
115impl RubyGemSurface for UnacquiredRubyGems {
116 fn constant_boundary(&self, _owner_path: &[String], _terminal: &str) -> RubyGemBoundary {
117 RubyGemBoundary::Unpublished(unknown_dependency_reason())
118 }
119
120 fn require_boundary(&self, _require_path: &str) -> RubyGemBoundary {
121 RubyGemBoundary::Unpublished(unknown_dependency_reason())
122 }
123}
124
125fn unknown_dependency_reason() -> SemanticDiagnosticIncompleteReason {
126 SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
127 boundary: BoundaryStatus::ExternalUnknown,
128 }
129}
130
131pub fn collect_ruby_semantic_diagnostics(
133 graph: RubyGraphSource<'_>,
134 ruby: &dyn RubySource,
135 gems: &dyn RubyGemSurface,
136 file: &ProjectFile,
137 source: &str,
138) -> SemanticDiagnosticReport {
139 let mut report = SemanticDiagnosticReport::new();
140 if source.len() > MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES {
141 report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
142 return report;
143 }
144 let Some(tree) = parse_ruby_tree(source) else {
145 report.push_incomplete(
146 None,
147 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
148 detail: "Ruby source did not parse".to_string(),
149 }],
150 );
151 return report;
152 };
153 let mut parse_errors = Vec::new();
154 collect_parse_errors(tree.root_node(), &mut parse_errors);
155 if !parse_errors.is_empty() {
156 report.push_incomplete(
161 None,
162 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
163 detail: "Ruby source has parse errors".to_string(),
164 }],
165 );
166 return report;
167 }
168 if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), source) {
169 report.push_incomplete(
172 None,
173 vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
174 );
175 return report;
176 }
177 if let Some(reason) = unresolved_load_directive_reason(ruby, gems, file) {
178 report.push_incomplete(None, vec![reason]);
179 return report;
180 }
181
182 let semantic = RubySemanticIndex::build_for_lookup(graph, ruby);
183 let Some(mut visible_files) =
184 semantic.visible_files_from_bounded(file, MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES)
185 else {
186 report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
187 return report;
188 };
189 let zeitwerk_open = match ruby_zeitwerk_visible_files_for(ruby, file) {
195 Some(zeitwerk_files) => {
196 visible_files.extend(zeitwerk_files.iter().cloned());
197 if visible_files.len() > MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES {
198 report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
199 return report;
200 }
201 true
202 }
203 None => false,
204 };
205 if let Some(reason) = visible_surface_reason(graph, ruby, gems, file, source, &visible_files) {
206 report.push_incomplete(None, vec![reason]);
207 return report;
208 }
209
210 let line_starts = compute_line_starts(source);
211 let mut collector = RubyDiagnosticCollector {
212 semantic,
213 ruby,
214 gems,
215 file,
216 source,
217 line_starts: &line_starts,
218 visible_files,
219 zeitwerk_open,
220 report,
221 };
222 collector.scan_tree(tree.root_node());
223 collector.report
224}
225
226struct RubyDiagnosticCollector<'a> {
227 semantic: RubySemanticIndex<'a>,
228 ruby: &'a dyn RubySource,
229 gems: &'a dyn RubyGemSurface,
230 file: &'a ProjectFile,
231 source: &'a str,
232 line_starts: &'a [usize],
233 visible_files: HashSet<ProjectFile>,
234 zeitwerk_open: bool,
237 report: SemanticDiagnosticReport,
238}
239
240enum ScanFrame<'tree> {
241 Node(Node<'tree>),
242 ExitNamespace(usize),
243}
244
245impl RubyDiagnosticCollector<'_> {
246 fn scan_tree(&mut self, root: Node<'_>) {
247 let mut lexical_stack = Vec::new();
248 let mut stack = vec![ScanFrame::Node(root)];
249 while let Some(frame) = stack.pop() {
250 if self.report.diagnostics().len() >= MAX_RUBY_SEMANTIC_DIAGNOSTICS {
251 self.report
252 .push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
253 return;
254 }
255 match frame {
256 ScanFrame::Node(node) => self.scan_node(node, &mut lexical_stack, &mut stack),
257 ScanFrame::ExitNamespace(len) => lexical_stack.truncate(len),
258 }
259 }
260 }
261
262 fn scan_node<'tree>(
263 &mut self,
264 node: Node<'tree>,
265 lexical_stack: &mut Vec<String>,
266 stack: &mut Vec<ScanFrame<'tree>>,
267 ) {
268 match node.kind() {
269 "class" | "module" => {
270 let Some(owner) = ruby_type_owner(
271 &self.semantic,
272 self.file,
273 &self.visible_files,
274 lexical_stack,
275 node,
276 self.source,
277 ) else {
278 self.report.push_incomplete(
281 Some(node_range(node, self.line_starts)),
282 vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
283 detail: "declaration namespace did not resolve".to_string(),
284 }],
285 );
286 return;
287 };
288 let previous_len = lexical_stack.len();
289 lexical_stack.push(owner);
290 stack.push(ScanFrame::ExitNamespace(previous_len));
291 if let Some(body) = node.child_by_field_name("body") {
292 stack.push(ScanFrame::Node(body));
293 }
294 }
295 "scope_resolution" => self.check_explicit_path(node, lexical_stack),
296 "constant" => {}
298 "assignment" | "operator_assignment" => {
299 if let Some(right) = node.child_by_field_name("right") {
300 stack.push(ScanFrame::Node(right));
301 }
302 }
303 "string" | "comment" => {}
304 _ => push_named_children(stack, node),
305 }
306 }
307
308 fn check_explicit_path(&mut self, node: Node<'_>, lexical_stack: &[String]) {
309 if is_declaration_constant(node) {
310 return;
311 }
312 let Some(owner_node) = node.child_by_field_name("scope") else {
313 return;
314 };
315 let Some(terminal_node) = node.child_by_field_name("name") else {
316 return;
317 };
318 let terminal = node_text(terminal_node, self.source);
319 if terminal.is_empty() {
320 return;
321 }
322 let range = node_range(terminal_node, self.line_starts);
323
324 if self
328 .semantic
329 .resolve_project_local_constant(
330 self.file,
331 &self.visible_files,
332 lexical_stack,
333 node,
334 self.source,
335 )
336 .is_some()
337 {
338 self.report
339 .push_resolved(range, BoundaryStatus::WorkspaceLocal);
340 return;
341 }
342
343 let owner_path = extract_name_path(owner_node, self.source);
344 let owner_unit = self.semantic.resolve_project_local_constant(
345 self.file,
346 &self.visible_files,
347 lexical_stack,
348 owner_node,
349 self.source,
350 );
351 match self.gems.constant_boundary(&owner_path.segments, terminal) {
352 RubyGemBoundary::Indexed => self
353 .report
354 .push_resolved(range, BoundaryStatus::ExternalIndexed),
355 RubyGemBoundary::Absent(domain) => {
356 if let Some(detail) = self.workspace_reopen_detail(owner_unit.as_ref()) {
361 self.report.push_incomplete(
362 Some(range),
363 vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
364 );
365 return;
366 }
367 self.push_absent(range, domain, terminal, BoundaryStatus::ExternalIndexed);
368 }
369 RubyGemBoundary::Unpublished(reason) => {
370 match owner_unit {
373 Some(owner) => match self.owner_escape_detail(&owner) {
374 Some(detail) => self.report.push_incomplete(
375 Some(range),
376 vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
377 ),
378 None => self.push_absent(
379 range,
380 SemanticDiagnosticDomain::LexicalScope {
381 file: self.file.rel_path().to_path_buf(),
382 range,
383 },
384 terminal,
385 BoundaryStatus::WorkspaceLocal,
386 ),
387 },
388 None => self.report.push_incomplete(Some(range), vec![reason]),
389 }
390 }
391 RubyGemBoundary::Incomplete(reason) => {
392 self.report.push_incomplete(Some(range), vec![reason])
393 }
394 }
395 }
396
397 fn push_absent(
400 &mut self,
401 range: Range,
402 domain: SemanticDiagnosticDomain,
403 terminal: &str,
404 boundary: BoundaryStatus,
405 ) {
406 if self.zeitwerk_open {
407 self.report.push_incomplete(
408 Some(range),
409 vec![SemanticDiagnosticIncompleteReason::DynamicBehavior {
410 detail:
411 "Zeitwerk autoloading can define this constant from the project file tree"
412 .to_string(),
413 }],
414 );
415 return;
416 }
417 self.report.push_absent(
418 SemanticAbsenceProof {
419 range,
420 domain,
421 boundary,
422 },
423 SemanticDiagnostic {
424 range,
425 source: RUBY_SEMANTIC_DIAGNOSTIC_SOURCE,
426 kind: RUBY_UNRECOGNIZED_SYMBOL,
427 message: format!("Unrecognized Ruby constant `{terminal}`"),
428 },
429 );
430 }
431
432 fn workspace_reopen_detail(&self, owner: Option<&CodeUnit>) -> Option<String> {
439 let owner = owner?;
440 Some(self.owner_escape_detail(owner).unwrap_or_else(|| {
441 format!(
442 "a workspace file reopens `{}`, which an activated gem pack also declares",
443 owner.fq_name()
444 )
445 }))
446 }
447
448 fn owner_escape_detail(&self, owner: &CodeUnit) -> Option<String> {
450 let fq_name = owner.fq_name();
451 if !owner.is_module() {
452 return Some(format!(
453 "class `{fq_name}` can inherit constants from ancestors this pass does not enumerate"
454 ));
455 }
456 let facts = self.ruby.semantic_facts();
457 if facts
458 .ancestors
459 .get(&fq_name)
460 .is_some_and(|ancestors| !ancestors.is_empty())
461 {
462 return Some(format!(
463 "`{fq_name}` has ancestors that can supply constants"
464 ));
465 }
466 if facts.mixin_included_owners.contains_key(&fq_name) {
467 return Some(format!(
468 "`{fq_name}` includes a module that can supply constants"
469 ));
470 }
471 if facts.mixin_prepended_owners.contains_key(&fq_name) {
472 return Some(format!(
473 "`{fq_name}` prepends a module that can supply constants"
474 ));
475 }
476 if facts.mixin_class_owners.contains_key(&fq_name) {
477 return Some(format!(
478 "`{fq_name}` extends a module that can supply constants"
479 ));
480 }
481 None
482 }
483}
484
485fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
486 let mut cursor = node.walk();
487 let children: Vec<_> = node.named_children(&mut cursor).collect();
488 for child in children.into_iter().rev() {
489 stack.push(ScanFrame::Node(child));
490 }
491}
492
493fn open_runtime_boundary_detail(root: Node<'_>, source: &str) -> Option<String> {
495 let mut stack = vec![root];
496 while let Some(node) = stack.pop() {
497 if node.kind() == "call"
498 && let Some(method) = node.child_by_field_name("method")
499 {
500 let name = node_text(method, source);
501 match name {
502 "const_get" | "const_set" | "remove_const" | "const_missing" | "class_eval"
503 | "module_eval" | "eval" => {
504 return Some(format!(
505 "`{name}` can define or read a constant at run time"
506 ));
507 }
508 "autoload" => {
509 return Some("`autoload` defers a constant to a run-time load".to_string());
510 }
511 "require" | "require_relative" | "load"
512 if parse_ruby_require_call(node, source).is_none() =>
513 {
514 return Some(format!(
515 "`{name}` takes an argument this pass cannot resolve statically"
516 ));
517 }
518 _ => {}
519 }
520 }
521 if defines_const_missing_dynamically(node, source) {
522 return Some("`const_missing` is defined dynamically".to_string());
523 }
524 if matches!(node.kind(), "method" | "singleton_method")
525 && node
526 .child_by_field_name("name")
527 .is_some_and(|name| node_text(name, source) == "const_missing")
528 {
529 return Some("`const_missing` is defined in this file".to_string());
530 }
531 let mut cursor = node.walk();
532 stack.extend(node.named_children(&mut cursor));
533 }
534 None
535}
536
537fn defines_const_missing_dynamically(node: Node<'_>, source: &str) -> bool {
538 if node.kind() != "call" {
539 return false;
540 }
541 let Some(method) = node.child_by_field_name("method") else {
542 return false;
543 };
544 if !matches!(
545 node_text(method, source),
546 "define_method" | "define_singleton_method"
547 ) {
548 return false;
549 }
550 let Some(arguments) = node.child_by_field_name("arguments") else {
551 return false;
552 };
553 let mut cursor = arguments.walk();
554 let Some(name) = arguments.named_children(&mut cursor).next() else {
555 return false;
556 };
557 ruby_symbol_name(name, source).as_deref() == Some("const_missing")
558 || single_static_string_content_node(name)
559 .is_some_and(|content| node_text(content, source) == "const_missing")
560}
561
562fn unresolved_load_directive_reason(
569 ruby: &dyn RubySource,
570 gems: &dyn RubyGemSurface,
571 file: &ProjectFile,
572) -> Option<SemanticDiagnosticIncompleteReason> {
573 for import in ruby.import_info_of(file).iter() {
574 if crate::imports::resolve_required_file(file, import).is_some() {
575 continue;
576 }
577 let Some(load_path) = import.identifier.as_deref() else {
578 return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
579 detail: format!("load directive `{}` names no path", import.raw_snippet),
580 });
581 };
582 if import.raw_snippet.starts_with("require_relative") {
583 return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
584 detail: format!("`require_relative \"{load_path}\"` names no project file"),
585 });
586 }
587 match gems.require_boundary(load_path) {
588 RubyGemBoundary::Indexed => {}
589 RubyGemBoundary::Unpublished(reason) | RubyGemBoundary::Incomplete(reason) => {
590 return Some(reason);
591 }
592 RubyGemBoundary::Absent(_) => {
593 unreachable!("require_boundary never proves a load path absent")
594 }
595 }
596 }
597 None
598}
599
600fn visible_surface_reason(
603 graph: RubyGraphSource<'_>,
604 ruby: &dyn RubySource,
605 gems: &dyn RubyGemSurface,
606 file: &ProjectFile,
607 source: &str,
608 visible_files: &HashSet<ProjectFile>,
609) -> Option<SemanticDiagnosticIncompleteReason> {
610 let mut remaining_bytes = MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES;
611 for visible_file in visible_files {
612 if visible_file != file
616 && let Some(reason) = unresolved_load_directive_reason(ruby, gems, visible_file)
617 {
618 return Some(reason);
619 }
620 let visible_source = if visible_file == file {
621 (source.len() <= remaining_bytes).then_some(Cow::Borrowed(source))
622 } else {
623 graph
624 .index
625 .project()
626 .read_source_limited(visible_file, remaining_bytes)
627 .ok()
628 .flatten()
629 .map(Cow::Owned)
630 };
631 let Some(visible_source) = visible_source else {
632 return Some(SemanticDiagnosticIncompleteReason::Truncated);
633 };
634 let Some(next_remaining_bytes) = remaining_bytes.checked_sub(visible_source.len()) else {
635 return Some(SemanticDiagnosticIncompleteReason::Truncated);
636 };
637 remaining_bytes = next_remaining_bytes;
638 let Some(tree) = parse_ruby_tree(&visible_source) else {
639 return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
640 detail: format!(
641 "visible file {} did not parse",
642 visible_file.rel_path().display()
643 ),
644 });
645 };
646 let mut parse_errors = Vec::new();
647 collect_parse_errors(tree.root_node(), &mut parse_errors);
648 if !parse_errors.is_empty() {
649 return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
650 detail: format!(
651 "visible file {} has parse errors",
652 visible_file.rel_path().display()
653 ),
654 });
655 }
656 if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), &visible_source) {
657 return Some(SemanticDiagnosticIncompleteReason::DynamicBehavior {
658 detail: format!(
659 "visible file {}: {detail}",
660 visible_file.rel_path().display()
661 ),
662 });
663 }
664 }
665 None
666}