use crate::declarations::{extract_name_path, parse_ruby_tree};
use crate::graph::RubyGraphSource;
use crate::graph::extractor::ruby_type_owner;
use crate::graph::resolver::RubySemanticIndex;
use crate::graph::syntax::is_declaration_constant;
use crate::graph_support::RubySource;
use crate::imports::{parse_ruby_require_call, ruby_symbol_name, ruby_zeitwerk_visible_files_for};
use crate::syntax::single_static_string_content_node;
use brokk_bifrost_core::analyzer::model::{
Range, SemanticAbsenceProof, SemanticDiagnostic, SemanticDiagnosticDomain,
SemanticDiagnosticIncompleteReason, SemanticDiagnosticReport,
};
use brokk_bifrost_core::analyzer::semantic_diagnostics::{node_range, node_text};
use brokk_bifrost_core::analyzer::structural::resolution::BoundaryStatus;
use brokk_bifrost_core::analyzer::tree_walk::collect_parse_errors;
use brokk_bifrost_core::analyzer::{CodeUnit, ProjectFile};
use brokk_bifrost_core::hash::HashSet;
use brokk_bifrost_core::text_utils::compute_line_starts;
use std::borrow::Cow;
use tree_sitter::Node;
pub const RUBY_UNRECOGNIZED_SYMBOL: &str = "ruby_unrecognized_symbol";
pub const RUBY_SEMANTIC_DIAGNOSTIC_SOURCE: &str = "bifrost-ruby";
const MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES: usize = 512 * 1024;
pub const MAX_RUBY_SEMANTIC_DIAGNOSTICS: usize = 200;
pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES: usize = 64;
pub const MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RubyGemBoundary {
Indexed,
Absent(SemanticDiagnosticDomain),
Unpublished(SemanticDiagnosticIncompleteReason),
Incomplete(SemanticDiagnosticIncompleteReason),
}
pub trait RubyGemSurface {
fn constant_boundary(&self, owner_path: &[String], terminal: &str) -> RubyGemBoundary;
fn require_boundary(&self, require_path: &str) -> RubyGemBoundary;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct UnacquiredRubyGems;
impl RubyGemSurface for UnacquiredRubyGems {
fn constant_boundary(&self, _owner_path: &[String], _terminal: &str) -> RubyGemBoundary {
RubyGemBoundary::Unpublished(unknown_dependency_reason())
}
fn require_boundary(&self, _require_path: &str) -> RubyGemBoundary {
RubyGemBoundary::Unpublished(unknown_dependency_reason())
}
}
fn unknown_dependency_reason() -> SemanticDiagnosticIncompleteReason {
SemanticDiagnosticIncompleteReason::MissingDependencyDiscovery {
boundary: BoundaryStatus::ExternalUnknown,
}
}
pub fn collect_ruby_semantic_diagnostics(
graph: RubyGraphSource<'_>,
ruby: &dyn RubySource,
gems: &dyn RubyGemSurface,
file: &ProjectFile,
source: &str,
) -> SemanticDiagnosticReport {
let mut report = SemanticDiagnosticReport::new();
if source.len() > MAX_RUBY_SEMANTIC_DIAGNOSTIC_BYTES {
report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
return report;
}
let Some(tree) = parse_ruby_tree(source) else {
report.push_incomplete(
None,
vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: "Ruby source did not parse".to_string(),
}],
);
return report;
};
let mut parse_errors = Vec::new();
collect_parse_errors(tree.root_node(), &mut parse_errors);
if !parse_errors.is_empty() {
report.push_incomplete(
None,
vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: "Ruby source has parse errors".to_string(),
}],
);
return report;
}
if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), source) {
report.push_incomplete(
None,
vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
);
return report;
}
if let Some(reason) = unresolved_load_directive_reason(ruby, gems, file) {
report.push_incomplete(None, vec![reason]);
return report;
}
let semantic = RubySemanticIndex::build_for_lookup(graph, ruby);
let Some(mut visible_files) =
semantic.visible_files_from_bounded(file, MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES)
else {
report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
return report;
};
let zeitwerk_open = match ruby_zeitwerk_visible_files_for(ruby, file) {
Some(zeitwerk_files) => {
visible_files.extend(zeitwerk_files.iter().cloned());
if visible_files.len() > MAX_RUBY_DIAGNOSTIC_VISIBLE_FILES {
report.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
return report;
}
true
}
None => false,
};
if let Some(reason) = visible_surface_reason(graph, ruby, gems, file, source, &visible_files) {
report.push_incomplete(None, vec![reason]);
return report;
}
let line_starts = compute_line_starts(source);
let mut collector = RubyDiagnosticCollector {
semantic,
ruby,
gems,
file,
source,
line_starts: &line_starts,
visible_files,
zeitwerk_open,
report,
};
collector.scan_tree(tree.root_node());
collector.report
}
struct RubyDiagnosticCollector<'a> {
semantic: RubySemanticIndex<'a>,
ruby: &'a dyn RubySource,
gems: &'a dyn RubyGemSurface,
file: &'a ProjectFile,
source: &'a str,
line_starts: &'a [usize],
visible_files: HashSet<ProjectFile>,
zeitwerk_open: bool,
report: SemanticDiagnosticReport,
}
enum ScanFrame<'tree> {
Node(Node<'tree>),
ExitNamespace(usize),
}
impl RubyDiagnosticCollector<'_> {
fn scan_tree(&mut self, root: Node<'_>) {
let mut lexical_stack = Vec::new();
let mut stack = vec![ScanFrame::Node(root)];
while let Some(frame) = stack.pop() {
if self.report.diagnostics().len() >= MAX_RUBY_SEMANTIC_DIAGNOSTICS {
self.report
.push_incomplete(None, vec![SemanticDiagnosticIncompleteReason::Truncated]);
return;
}
match frame {
ScanFrame::Node(node) => self.scan_node(node, &mut lexical_stack, &mut stack),
ScanFrame::ExitNamespace(len) => lexical_stack.truncate(len),
}
}
}
fn scan_node<'tree>(
&mut self,
node: Node<'tree>,
lexical_stack: &mut Vec<String>,
stack: &mut Vec<ScanFrame<'tree>>,
) {
match node.kind() {
"class" | "module" => {
let Some(owner) = ruby_type_owner(
&self.semantic,
self.file,
&self.visible_files,
lexical_stack,
node,
self.source,
) else {
self.report.push_incomplete(
Some(node_range(node, self.line_starts)),
vec![SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: "declaration namespace did not resolve".to_string(),
}],
);
return;
};
let previous_len = lexical_stack.len();
lexical_stack.push(owner);
stack.push(ScanFrame::ExitNamespace(previous_len));
if let Some(body) = node.child_by_field_name("body") {
stack.push(ScanFrame::Node(body));
}
}
"scope_resolution" => self.check_explicit_path(node, lexical_stack),
"constant" => {}
"assignment" | "operator_assignment" => {
if let Some(right) = node.child_by_field_name("right") {
stack.push(ScanFrame::Node(right));
}
}
"string" | "comment" => {}
_ => push_named_children(stack, node),
}
}
fn check_explicit_path(&mut self, node: Node<'_>, lexical_stack: &[String]) {
if is_declaration_constant(node) {
return;
}
let Some(owner_node) = node.child_by_field_name("scope") else {
return;
};
let Some(terminal_node) = node.child_by_field_name("name") else {
return;
};
let terminal = node_text(terminal_node, self.source);
if terminal.is_empty() {
return;
}
let range = node_range(terminal_node, self.line_starts);
if self
.semantic
.resolve_project_local_constant(
self.file,
&self.visible_files,
lexical_stack,
node,
self.source,
)
.is_some()
{
self.report
.push_resolved(range, BoundaryStatus::WorkspaceLocal);
return;
}
let owner_path = extract_name_path(owner_node, self.source);
let owner_unit = self.semantic.resolve_project_local_constant(
self.file,
&self.visible_files,
lexical_stack,
owner_node,
self.source,
);
match self.gems.constant_boundary(&owner_path.segments, terminal) {
RubyGemBoundary::Indexed => self
.report
.push_resolved(range, BoundaryStatus::ExternalIndexed),
RubyGemBoundary::Absent(domain) => {
if let Some(detail) = self.workspace_reopen_detail(owner_unit.as_ref()) {
self.report.push_incomplete(
Some(range),
vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
);
return;
}
self.push_absent(range, domain, terminal, BoundaryStatus::ExternalIndexed);
}
RubyGemBoundary::Unpublished(reason) => {
match owner_unit {
Some(owner) => match self.owner_escape_detail(&owner) {
Some(detail) => self.report.push_incomplete(
Some(range),
vec![SemanticDiagnosticIncompleteReason::DynamicBehavior { detail }],
),
None => self.push_absent(
range,
SemanticDiagnosticDomain::LexicalScope {
file: self.file.rel_path().to_path_buf(),
range,
},
terminal,
BoundaryStatus::WorkspaceLocal,
),
},
None => self.report.push_incomplete(Some(range), vec![reason]),
}
}
RubyGemBoundary::Incomplete(reason) => {
self.report.push_incomplete(Some(range), vec![reason])
}
}
}
fn push_absent(
&mut self,
range: Range,
domain: SemanticDiagnosticDomain,
terminal: &str,
boundary: BoundaryStatus,
) {
if self.zeitwerk_open {
self.report.push_incomplete(
Some(range),
vec![SemanticDiagnosticIncompleteReason::DynamicBehavior {
detail:
"Zeitwerk autoloading can define this constant from the project file tree"
.to_string(),
}],
);
return;
}
self.report.push_absent(
SemanticAbsenceProof {
range,
domain,
boundary,
},
SemanticDiagnostic {
range,
source: RUBY_SEMANTIC_DIAGNOSTIC_SOURCE,
kind: RUBY_UNRECOGNIZED_SYMBOL,
message: format!("Unrecognized Ruby constant `{terminal}`"),
},
);
}
fn workspace_reopen_detail(&self, owner: Option<&CodeUnit>) -> Option<String> {
let owner = owner?;
Some(self.owner_escape_detail(owner).unwrap_or_else(|| {
format!(
"a workspace file reopens `{}`, which an activated gem pack also declares",
owner.fq_name()
)
}))
}
fn owner_escape_detail(&self, owner: &CodeUnit) -> Option<String> {
let fq_name = owner.fq_name();
if !owner.is_module() {
return Some(format!(
"class `{fq_name}` can inherit constants from ancestors this pass does not enumerate"
));
}
let facts = self.ruby.semantic_facts();
if facts
.ancestors
.get(&fq_name)
.is_some_and(|ancestors| !ancestors.is_empty())
{
return Some(format!(
"`{fq_name}` has ancestors that can supply constants"
));
}
if facts.mixin_included_owners.contains_key(&fq_name) {
return Some(format!(
"`{fq_name}` includes a module that can supply constants"
));
}
if facts.mixin_prepended_owners.contains_key(&fq_name) {
return Some(format!(
"`{fq_name}` prepends a module that can supply constants"
));
}
if facts.mixin_class_owners.contains_key(&fq_name) {
return Some(format!(
"`{fq_name}` extends a module that can supply constants"
));
}
None
}
}
fn push_named_children<'tree>(stack: &mut Vec<ScanFrame<'tree>>, node: Node<'tree>) {
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
for child in children.into_iter().rev() {
stack.push(ScanFrame::Node(child));
}
}
fn open_runtime_boundary_detail(root: Node<'_>, source: &str) -> Option<String> {
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.kind() == "call"
&& let Some(method) = node.child_by_field_name("method")
{
let name = node_text(method, source);
match name {
"const_get" | "const_set" | "remove_const" | "const_missing" | "class_eval"
| "module_eval" | "eval" => {
return Some(format!(
"`{name}` can define or read a constant at run time"
));
}
"autoload" => {
return Some("`autoload` defers a constant to a run-time load".to_string());
}
"require" | "require_relative" | "load"
if parse_ruby_require_call(node, source).is_none() =>
{
return Some(format!(
"`{name}` takes an argument this pass cannot resolve statically"
));
}
_ => {}
}
}
if defines_const_missing_dynamically(node, source) {
return Some("`const_missing` is defined dynamically".to_string());
}
if matches!(node.kind(), "method" | "singleton_method")
&& node
.child_by_field_name("name")
.is_some_and(|name| node_text(name, source) == "const_missing")
{
return Some("`const_missing` is defined in this file".to_string());
}
let mut cursor = node.walk();
stack.extend(node.named_children(&mut cursor));
}
None
}
fn defines_const_missing_dynamically(node: Node<'_>, source: &str) -> bool {
if node.kind() != "call" {
return false;
}
let Some(method) = node.child_by_field_name("method") else {
return false;
};
if !matches!(
node_text(method, source),
"define_method" | "define_singleton_method"
) {
return false;
}
let Some(arguments) = node.child_by_field_name("arguments") else {
return false;
};
let mut cursor = arguments.walk();
let Some(name) = arguments.named_children(&mut cursor).next() else {
return false;
};
ruby_symbol_name(name, source).as_deref() == Some("const_missing")
|| single_static_string_content_node(name)
.is_some_and(|content| node_text(content, source) == "const_missing")
}
fn unresolved_load_directive_reason(
ruby: &dyn RubySource,
gems: &dyn RubyGemSurface,
file: &ProjectFile,
) -> Option<SemanticDiagnosticIncompleteReason> {
for import in ruby.import_info_of(file).iter() {
if crate::imports::resolve_required_file(file, import).is_some() {
continue;
}
let Some(load_path) = import.identifier.as_deref() else {
return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: format!("load directive `{}` names no path", import.raw_snippet),
});
};
if import.raw_snippet.starts_with("require_relative") {
return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: format!("`require_relative \"{load_path}\"` names no project file"),
});
}
match gems.require_boundary(load_path) {
RubyGemBoundary::Indexed => {}
RubyGemBoundary::Unpublished(reason) | RubyGemBoundary::Incomplete(reason) => {
return Some(reason);
}
RubyGemBoundary::Absent(_) => {
unreachable!("require_boundary never proves a load path absent")
}
}
}
None
}
fn visible_surface_reason(
graph: RubyGraphSource<'_>,
ruby: &dyn RubySource,
gems: &dyn RubyGemSurface,
file: &ProjectFile,
source: &str,
visible_files: &HashSet<ProjectFile>,
) -> Option<SemanticDiagnosticIncompleteReason> {
let mut remaining_bytes = MAX_RUBY_DIAGNOSTIC_VISIBLE_SOURCE_BYTES;
for visible_file in visible_files {
if visible_file != file
&& let Some(reason) = unresolved_load_directive_reason(ruby, gems, visible_file)
{
return Some(reason);
}
let visible_source = if visible_file == file {
(source.len() <= remaining_bytes).then_some(Cow::Borrowed(source))
} else {
graph
.index
.project()
.read_source_limited(visible_file, remaining_bytes)
.ok()
.flatten()
.map(Cow::Owned)
};
let Some(visible_source) = visible_source else {
return Some(SemanticDiagnosticIncompleteReason::Truncated);
};
let Some(next_remaining_bytes) = remaining_bytes.checked_sub(visible_source.len()) else {
return Some(SemanticDiagnosticIncompleteReason::Truncated);
};
remaining_bytes = next_remaining_bytes;
let Some(tree) = parse_ruby_tree(&visible_source) else {
return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: format!(
"visible file {} did not parse",
visible_file.rel_path().display()
),
});
};
let mut parse_errors = Vec::new();
collect_parse_errors(tree.root_node(), &mut parse_errors);
if !parse_errors.is_empty() {
return Some(SemanticDiagnosticIncompleteReason::UnsupportedSemantics {
detail: format!(
"visible file {} has parse errors",
visible_file.rel_path().display()
),
});
}
if let Some(detail) = open_runtime_boundary_detail(tree.root_node(), &visible_source) {
return Some(SemanticDiagnosticIncompleteReason::DynamicBehavior {
detail: format!(
"visible file {}: {detail}",
visible_file.rel_path().display()
),
});
}
}
None
}