use std::{
collections::{HashMap, HashSet},
path::Path,
};
use sqry_core::graph::unified::build::helper::CalleeKindHint;
use sqry_core::graph::unified::edge::FfiConvention;
use sqry_core::graph::unified::edge::kind::TypeOfContext;
use sqry_core::graph::unified::{GraphBuildHelper, StagingGraph};
use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
use tree_sitter::{Node, Point, Tree};
use super::type_extractor::{canonical_type_string, extract_type_names};
use super::yard_parser::{extract_yard_comment, parse_yard_tags};
const DEFAULT_SCOPE_DEPTH: usize = 4;
const FILE_MODULE_NAME: &str = "<file_module>";
type CallEdgeData = (String, String, usize, Span, bool);
#[derive(Debug, Clone, Copy)]
pub struct RubyGraphBuilder {
max_scope_depth: usize,
}
impl Default for RubyGraphBuilder {
fn default() -> Self {
Self {
max_scope_depth: DEFAULT_SCOPE_DEPTH,
}
}
}
impl RubyGraphBuilder {
#[must_use]
pub fn new(max_scope_depth: usize) -> Self {
Self { max_scope_depth }
}
}
impl GraphBuilder for RubyGraphBuilder {
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let mut helper = GraphBuildHelper::new(staging, file, Language::Ruby);
let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
}
})?;
walk_tree_for_graph(
tree.root_node(),
content,
&ast_graph,
&mut helper,
&ast_graph.ffi_enabled_scopes,
)?;
apply_controller_dsl_hooks(&ast_graph, &mut helper);
process_yard_annotations(tree.root_node(), content, &ast_graph, &mut helper)?;
Ok(())
}
fn language(&self) -> Language {
Language::Ruby
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Visibility {
Public,
Protected,
Private,
}
impl Visibility {
#[allow(dead_code)] fn as_str(self) -> &'static str {
match self {
Visibility::Public => "public",
Visibility::Protected => "protected",
Visibility::Private => "private",
}
}
fn from_keyword(keyword: &str) -> Option<Self> {
match keyword {
"public" => Some(Visibility::Public),
"protected" => Some(Visibility::Protected),
"private" => Some(Visibility::Private),
_ => None,
}
}
}
#[derive(Debug, Clone)]
enum RubyContextKind {
Method,
SingletonMethod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ControllerDslKind {
Before,
After,
Around,
}
#[allow(dead_code)] #[derive(Debug, Clone)]
struct ControllerDslHook {
container: String,
kind: ControllerDslKind,
callbacks: Vec<String>,
only: Option<Vec<String>>, except: Option<Vec<String>>, }
#[derive(Debug, Clone)]
struct RubyContext {
qualified_name: String,
container: Option<String>,
kind: RubyContextKind,
visibility: Visibility,
start_position: Point,
end_position: Point,
}
impl RubyContext {
#[allow(dead_code)] fn is_method(&self) -> bool {
matches!(
self.kind,
RubyContextKind::Method | RubyContextKind::SingletonMethod
)
}
fn is_singleton(&self) -> bool {
matches!(self.kind, RubyContextKind::SingletonMethod)
}
fn qualified_name(&self) -> &str {
&self.qualified_name
}
fn container(&self) -> Option<&str> {
self.container.as_deref()
}
fn visibility(&self) -> Visibility {
self.visibility
}
}
struct ASTGraph {
contexts: Vec<RubyContext>,
node_to_context: HashMap<usize, usize>,
attr_visibility: HashMap<usize, Visibility>,
ffi_enabled_scopes: HashSet<Vec<String>>,
#[allow(dead_code)] controller_dsl_hooks: Vec<ControllerDslHook>,
}
impl ASTGraph {
fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
let mut builder = ContextBuilder::new(content, max_depth)?;
builder.walk(tree.root_node())?;
Ok(Self {
contexts: builder.contexts,
node_to_context: builder.node_to_context,
attr_visibility: builder.attr_visibility,
ffi_enabled_scopes: builder.ffi_enabled_scopes,
controller_dsl_hooks: builder.controller_dsl_hooks,
})
}
#[allow(dead_code)] fn contexts(&self) -> &[RubyContext] {
&self.contexts
}
fn context_for_node(&self, node: &Node<'_>) -> Option<&RubyContext> {
self.node_to_context
.get(&node.id())
.and_then(|idx| self.contexts.get(*idx))
}
fn attr_visibility_for_node(&self, node: &Node<'_>) -> Visibility {
self.attr_visibility
.get(&node.id())
.copied()
.unwrap_or(Visibility::Public)
}
}
fn walk_tree_for_graph(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut sqry_core::graph::unified::GraphBuildHelper,
ffi_enabled_scopes: &HashSet<Vec<String>>,
) -> GraphResult<()> {
let mut current_namespace: Vec<String> = Vec::new();
walk_tree_for_graph_impl(
node,
content,
ast_graph,
helper,
ffi_enabled_scopes,
&mut current_namespace,
)
}
fn apply_controller_dsl_hooks(ast_graph: &ASTGraph, helper: &mut GraphBuildHelper) {
if ast_graph.controller_dsl_hooks.is_empty() {
return;
}
let mut actions_by_container: HashMap<String, Vec<String>> = HashMap::new();
for context in &ast_graph.contexts {
if !matches!(context.kind, RubyContextKind::Method) {
continue;
}
let Some(container) = context.container() else {
continue;
};
let Some(action_name) = context.qualified_name.rsplit('#').next() else {
continue;
};
actions_by_container
.entry(container.to_string())
.or_default()
.push(action_name.to_string());
}
let mut emitted: HashSet<(String, String)> = HashSet::new();
for hook in &ast_graph.controller_dsl_hooks {
let Some(actions) = actions_by_container.get(&hook.container) else {
continue;
};
for action in actions {
let included = if let Some(only) = &hook.only {
only.iter().any(|name| name == action)
} else if let Some(except) = &hook.except {
!except.iter().any(|name| name == action)
} else {
true
};
if !included {
continue;
}
for callback in &hook.callbacks {
if callback.trim().is_empty() {
continue;
}
let action_qname = format!("{}#{}", hook.container, action);
let callback_qname = format!("{}#{}", hook.container, callback);
if !emitted.insert((action_qname.clone(), callback_qname.clone())) {
continue;
}
let action_id = helper.ensure_method(&action_qname, None, false, false);
let callback_id = helper.ensure_method(&callback_qname, None, false, false);
helper.add_call_edge_full_with_span(action_id, callback_id, 255, false, vec![]);
}
}
}
}
#[allow(
clippy::too_many_lines,
reason = "Ruby graph extraction handles DSLs and FFI patterns in one traversal."
)]
fn walk_tree_for_graph_impl(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut sqry_core::graph::unified::GraphBuildHelper,
ffi_enabled_scopes: &HashSet<Vec<String>>,
current_namespace: &mut Vec<String>,
) -> GraphResult<()> {
match node.kind() {
"class" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(class_name) = name_node.utf8_text(content)
{
let span = span_from_points(node.start_position(), node.end_position());
let qualified_name = class_name.to_string();
let class_id = helper.add_class(&qualified_name, Some(span));
let module_id = helper.add_module(FILE_MODULE_NAME, None);
helper.add_export_edge(module_id, class_id);
if let Some(superclass_node) = node.child_by_field_name("superclass")
&& let Ok(superclass_name) = superclass_node.utf8_text(content)
{
let superclass_name = superclass_name.trim();
if !superclass_name.is_empty() {
let parent_id = helper.add_class(superclass_name, None);
helper.add_inherits_edge(class_id, parent_id);
}
}
current_namespace.push(class_name.trim().to_string());
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_tree_for_graph_impl(
child,
content,
ast_graph,
helper,
ffi_enabled_scopes,
current_namespace,
)?;
}
current_namespace.pop();
return Ok(());
}
}
"module" => {
if let Some(name_node) = node.child_by_field_name("name")
&& let Ok(module_name) = name_node.utf8_text(content)
{
let span = span_from_points(node.start_position(), node.end_position());
let qualified_name = module_name.to_string();
let mod_id = helper.add_module(&qualified_name, Some(span));
let file_module_id = helper.add_module(FILE_MODULE_NAME, None);
helper.add_export_edge(file_module_id, mod_id);
current_namespace.push(module_name.trim().to_string());
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_tree_for_graph_impl(
child,
content,
ast_graph,
helper,
ffi_enabled_scopes,
current_namespace,
)?;
}
current_namespace.pop();
return Ok(());
}
}
"method" | "singleton_method" => {
if let Some(context) = ast_graph.context_for_node(&node) {
let span = span_from_points(context.start_position, context.end_position);
let is_async = detect_async_method(node, content);
let params = node
.child_by_field_name("parameters")
.and_then(|params_node| extract_method_parameters(params_node, content));
let return_type = extract_return_type(node, content);
let signature = match (params.as_ref(), return_type.as_ref()) {
(Some(p), Some(r)) => Some(format!("{p} -> {r}")),
(Some(p), None) => Some(p.clone()),
(None, Some(r)) => Some(format!("-> {r}")),
(None, None) => None,
};
let visibility = context.visibility().as_str();
let method_id = helper.add_method_with_signature(
context.qualified_name(),
Some(span),
is_async,
context.is_singleton(),
Some(visibility),
signature.as_deref(),
);
if context.visibility() == Visibility::Public {
let module_id = helper.add_module(FILE_MODULE_NAME, None);
helper.add_export_edge(module_id, method_id);
}
}
}
"assignment" => {
if let Some(left_node) = node.child_by_field_name("left")
&& left_node.kind() == "constant"
&& let Ok(const_name) = left_node.utf8_text(content)
{
let qualified_name = if current_namespace.is_empty() {
const_name.to_string()
} else {
format!("{}::{}", current_namespace.join("::"), const_name)
};
let span = span_from_points(node.start_position(), node.end_position());
let const_id = helper.add_constant(&qualified_name, Some(span));
let module_id = helper.add_module(FILE_MODULE_NAME, None);
helper.add_export_edge(module_id, const_id);
}
}
"call" | "command" | "command_call" | "identifier" | "super" => {
if is_include_or_extend_statement(node, content) {
handle_include_extend(node, content, helper, current_namespace);
}
else if node.kind() == "identifier" && !is_statement_identifier_call_candidate(node) {
} else if is_require_statement(node, content) {
if let Some((from_qname, to_qname)) =
build_import_for_staging(node, content, helper.file_path())
{
let from_id = helper.add_import(&from_qname, None);
let to_id = helper.add_import(
&to_qname,
Some(span_from_points(node.start_position(), node.end_position())),
);
helper.add_import_edge(from_id, to_id);
}
} else if is_ffi_attach_function(node, content, ffi_enabled_scopes, current_namespace) {
build_ffi_edge_for_attach_function(node, content, helper, current_namespace);
} else {
if let Ok(Some((source_qname, target_qname, argument_count, span, is_singleton))) =
build_call_for_staging(ast_graph, node, content)
{
let source_id = helper.ensure_method(&source_qname, None, false, is_singleton);
let target_id =
helper.ensure_callee(&target_qname, span, CalleeKindHint::Function);
let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
helper.add_call_edge_full_with_span(
source_id,
target_id,
argument_count,
false,
vec![span],
);
}
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_tree_for_graph_impl(
child,
content,
ast_graph,
helper,
ffi_enabled_scopes,
current_namespace,
)?;
}
Ok(())
}
fn is_ffi_attach_function(
node: Node,
content: &[u8],
ffi_enabled_scopes: &HashSet<Vec<String>>,
current_namespace: &[String],
) -> bool {
let method_name = match node.kind() {
"command" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok()),
"call" | "command_call" => node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
};
let Some(method_name) = method_name else {
return false;
};
let method_name = method_name.trim();
if !matches!(
method_name,
"attach_function" | "attach_variable" | "ffi_lib" | "callback"
) {
return false;
}
let receiver = match node.kind() {
"call" | "command_call" | "method_call" => node
.child_by_field_name("receiver")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
};
if let Some(receiver) = receiver {
let trimmed = receiver.trim();
if trimmed == "FFI" || trimmed.contains("FFI::Library") || trimmed.starts_with("FFI::") {
return true;
}
}
ffi_enabled_scopes.contains(current_namespace)
}
fn build_ffi_edge_for_attach_function(
node: Node,
content: &[u8],
helper: &mut sqry_core::graph::unified::GraphBuildHelper,
current_namespace: &[String],
) {
let arguments = node.child_by_field_name("arguments");
let func_name = if let Some(args) = arguments {
extract_first_symbol_from_arguments(args, content)
} else {
let mut cursor = node.walk();
let mut found_name = false;
let mut result = None;
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if !found_name {
found_name = true;
continue;
}
if matches!(child.kind(), "symbol" | "simple_symbol")
&& let Ok(text) = child.utf8_text(content)
{
result = Some(text.trim().trim_start_matches(':').to_string());
break;
}
}
result
};
let Some(func_name) = func_name else {
return;
};
let caller_name = if current_namespace.is_empty() {
"<module>".to_string()
} else {
current_namespace.join("::")
};
let caller_id = helper.add_module(&caller_name, None);
let ffi_func_name = format!("ffi::{func_name}");
let span = span_from_points(node.start_position(), node.end_position());
let ffi_func_id = helper.add_function(&ffi_func_name, Some(span), false, false);
helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
}
fn extract_first_symbol_from_arguments(arguments: Node, content: &[u8]) -> Option<String> {
let mut cursor = arguments.walk();
for child in arguments.children(&mut cursor) {
if matches!(child.kind(), "symbol" | "simple_symbol")
&& let Ok(text) = child.utf8_text(content)
{
return Some(text.trim().trim_start_matches(':').to_string());
}
if child.kind() == "bare_symbol"
&& let Ok(text) = child.utf8_text(content)
{
return Some(text.trim().to_string());
}
}
None
}
fn build_call_for_staging(
ast_graph: &ASTGraph,
call_node: Node<'_>,
content: &[u8],
) -> GraphResult<Option<CallEdgeData>> {
let Some(call_context) = ast_graph.context_for_node(&call_node) else {
return Ok(None);
};
let Some(method_call) = extract_method_call(call_node, content)? else {
return Ok(None);
};
if is_visibility_command(&method_call) {
return Ok(None);
}
let source_qualified = call_context.qualified_name().to_string();
let target_name = resolve_callee(&method_call, call_context);
if target_name.is_empty() {
return Ok(None);
}
let span = span_from_node(call_node);
let argument_count = count_arguments(method_call.arguments, content);
let is_singleton = call_context.is_singleton();
Ok(Some((
source_qualified,
target_name,
argument_count,
span,
is_singleton,
)))
}
fn build_import_for_staging(
require_node: Node<'_>,
content: &[u8],
file_path: &str,
) -> Option<(String, String)> {
let method_name = match require_node.kind() {
"command" => require_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string()),
"call" | "method_call" => require_node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok())
.map(|s| s.trim().to_string()),
_ => None,
};
let method_name = method_name?;
if !matches!(method_name.as_str(), "require" | "require_relative") {
return None;
}
let arguments = require_node.child_by_field_name("arguments");
let module_name = if let Some(args) = arguments {
extract_require_module_name(args, content)
} else {
let mut cursor = require_node.walk();
let mut found_name = false;
let mut result = None;
for child in require_node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if !found_name {
found_name = true;
continue;
}
result = extract_string_content(child, content);
break;
}
result
};
let module_name = module_name?;
if module_name.is_empty() {
return None;
}
let is_relative = method_name == "require_relative";
let resolved_path = resolve_ruby_require(&module_name, is_relative, file_path);
Some(("<module>".to_string(), resolved_path))
}
fn is_statement_identifier_call_candidate(node: Node<'_>) -> bool {
node.kind() == "identifier"
&& node
.parent()
.is_some_and(|p| matches!(p.kind(), "body_statement" | "program"))
}
fn detect_async_method(method_node: Node<'_>, content: &[u8]) -> bool {
let body_node = method_node.child_by_field_name("body");
if body_node.is_none() {
return false;
}
let body_node = body_node.unwrap();
if let Ok(body_text) = body_node.utf8_text(content) {
let body_lower = body_text.to_lowercase();
if body_lower.contains("fiber.")
|| body_lower.contains("fiber.new")
|| body_lower.contains("fiber.yield")
|| body_lower.contains("fiber.resume")
|| body_lower.contains("thread.new")
|| body_lower.contains("thread.start")
|| body_lower.contains("async do")
|| body_lower.contains("async {")
|| body_lower.contains("async.reactor")
|| body_lower.contains("concurrent::")
{
return true;
}
}
false
}
fn is_include_or_extend_statement(node: Node<'_>, content: &[u8]) -> bool {
let method_name = match node.kind() {
"command" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok()),
"call" | "method_call" => node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
};
method_name.is_some_and(|name| matches!(name.trim(), "include" | "extend"))
}
fn handle_include_extend(
node: Node<'_>,
content: &[u8],
helper: &mut sqry_core::graph::unified::GraphBuildHelper,
current_namespace: &[String],
) {
let module_name = if let Some(args) = node.child_by_field_name("arguments") {
extract_first_constant_from_arguments(args, content)
} else if node.kind() == "command" {
let mut cursor = node.walk();
let mut found_method = false;
let mut result = None;
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if !found_method {
found_method = true;
continue;
}
if child.kind() == "constant"
&& let Ok(text) = child.utf8_text(content)
{
result = Some(text.trim().to_string());
break;
}
}
result
} else {
None
};
let Some(module_name) = module_name else {
return;
};
let class_name = if current_namespace.is_empty() {
return; } else {
current_namespace.join("::")
};
let class_id = helper.add_class(&class_name, None);
let module_id = helper.add_module(&module_name, None);
helper.add_implements_edge(class_id, module_id);
}
fn extract_first_constant_from_arguments(args_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = args_node.walk();
for child in args_node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if child.kind() == "constant"
&& let Ok(text) = child.utf8_text(content)
{
return Some(text.trim().to_string());
}
}
None
}
fn is_require_statement(node: Node<'_>, content: &[u8]) -> bool {
let method_name = match node.kind() {
"command" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok()),
"call" | "method_call" => node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
};
method_name.is_some_and(|name| matches!(name.trim(), "require" | "require_relative"))
}
struct ContextBuilder<'a> {
contexts: Vec<RubyContext>,
node_to_context: HashMap<usize, usize>,
attr_visibility: HashMap<usize, Visibility>,
namespace: Vec<String>,
visibility_stack: Vec<Visibility>,
ffi_enabled_scopes: HashSet<Vec<String>>,
controller_dsl_hooks: Vec<ControllerDslHook>,
max_depth: usize,
content: &'a [u8],
guard: sqry_core::query::security::RecursionGuard,
}
impl<'a> ContextBuilder<'a> {
fn new(content: &'a [u8], max_depth: usize) -> Result<Self, String> {
let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
.map_err(|e| format!("Failed to load recursion limits: {e}"))?;
let file_ops_depth = recursion_limits
.effective_file_ops_depth()
.map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
let guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
.map_err(|e| format!("Failed to create recursion guard: {e}"))?;
Ok(Self {
contexts: Vec::new(),
node_to_context: HashMap::new(),
attr_visibility: HashMap::new(),
namespace: Vec::new(),
visibility_stack: vec![Visibility::Public],
ffi_enabled_scopes: HashSet::new(),
controller_dsl_hooks: Vec::new(),
max_depth,
content,
guard,
})
}
fn walk(&mut self, node: Node<'a>) -> Result<(), String> {
self.guard
.enter()
.map_err(|e| format!("Recursion limit exceeded: {e}"))?;
match node.kind() {
"class" => self.visit_class(node)?,
"module" => self.visit_module(node)?,
"singleton_class" => self.visit_singleton_class(node)?,
"method" => self.visit_method(node)?,
"singleton_method" => self.visit_singleton_method(node)?,
"command" | "command_call" | "call" => {
self.detect_ffi_extend(node)?;
self.detect_controller_dsl(node)?;
self.record_attr_visibility(node);
self.adjust_visibility(node)?;
self.walk_children(node)?;
}
"identifier" => {
self.adjust_visibility_from_identifier(node)?;
self.walk_children(node)?;
}
_ => self.walk_children(node)?,
}
self.guard.exit();
Ok(())
}
fn visit_class(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "class node missing name".to_string())?;
let class_name = self.node_text(name_node)?;
if self.namespace.len() > self.max_depth {
return Ok(());
}
self.namespace.push(class_name);
self.visibility_stack.push(Visibility::Public);
self.walk_children(node)?;
self.visibility_stack.pop();
self.namespace.pop();
Ok(())
}
fn visit_module(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "module node missing name".to_string())?;
let module_name = self.node_text(name_node)?;
if self.namespace.len() > self.max_depth {
return Ok(());
}
self.namespace.push(module_name);
self.visibility_stack.push(Visibility::Public);
self.walk_children(node)?;
self.visibility_stack.pop();
self.namespace.pop();
Ok(())
}
fn visit_method(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "method node missing name".to_string())?;
let method_name = self.node_text(name_node)?;
let (qualified_name, container) =
method_qualified_name(&self.namespace, &method_name, false);
let visibility = inline_visibility_for_method(node, self.content)
.unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
let context = RubyContext {
qualified_name,
container,
kind: RubyContextKind::Method,
visibility,
start_position: node.start_position(),
end_position: node.end_position(),
};
let idx = self.contexts.len();
self.contexts.push(context);
associate_descendants(node, idx, &mut self.node_to_context);
self.walk_children(node)?;
Ok(())
}
fn visit_singleton_class(&mut self, node: Node<'a>) -> Result<(), String> {
let value_node = node
.child_by_field_name("value")
.ok_or_else(|| "singleton_class missing value".to_string())?;
let object_text = self.node_text(value_node)?;
let scope_name = if object_text == "self" {
if let Some(current_class) = self.namespace.last() {
format!("<<{current_class}>>")
} else {
"<<main>>".to_string()
}
} else {
format!("<<{object_text}>>")
};
if self.namespace.len() > self.max_depth {
return Ok(());
}
self.namespace.push(scope_name);
self.visibility_stack.push(Visibility::Public);
self.visit_singleton_class_body(node)?;
self.visibility_stack.pop();
self.namespace.pop();
Ok(())
}
fn visit_singleton_class_body(&mut self, node: Node<'a>) -> Result<(), String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if child.kind() == "method" {
self.visit_method_as_singleton(child)?;
} else {
self.walk(child)?;
}
}
Ok(())
}
fn visit_method_as_singleton(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "method node missing name".to_string())?;
let method_name = self.node_text(name_node)?;
let actual_namespace: Vec<String> = self
.namespace
.iter()
.map(|s| {
if s.starts_with("<<") && s.ends_with(">>") {
s[2..s.len() - 2].to_string()
} else {
s.clone()
}
})
.collect();
let (qualified_name, container) =
method_qualified_name(&actual_namespace, &method_name, true);
let visibility = inline_visibility_for_method(node, self.content)
.unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
let context = RubyContext {
qualified_name,
container,
kind: RubyContextKind::SingletonMethod,
visibility,
start_position: node.start_position(),
end_position: node.end_position(),
};
let idx = self.contexts.len();
self.contexts.push(context);
associate_descendants(node, idx, &mut self.node_to_context);
self.walk_children(node)?;
Ok(())
}
fn visit_singleton_method(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| "singleton_method missing name".to_string())?;
let method_name = self.node_text(name_node)?;
let object_node = node
.child_by_field_name("object")
.ok_or_else(|| "singleton_method missing object".to_string())?;
let object_text = self.node_text(object_node)?;
let (qualified_name, container) =
singleton_qualified_name(&self.namespace, object_text.trim(), &method_name);
let visibility = inline_visibility_for_method(node, self.content)
.unwrap_or_else(|| *self.visibility_stack.last().unwrap_or(&Visibility::Public));
let context = RubyContext {
qualified_name,
container,
kind: RubyContextKind::SingletonMethod,
visibility,
start_position: node.start_position(),
end_position: node.end_position(),
};
let idx = self.contexts.len();
self.contexts.push(context);
associate_descendants(node, idx, &mut self.node_to_context);
self.walk_children(node)?;
Ok(())
}
fn detect_ffi_extend(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node.child_by_field_name("name");
let Some(name_node) = name_node else {
return Ok(());
};
let keyword = self.node_text(name_node)?;
if keyword.trim() != "extend" {
return Ok(());
}
let arg_text = if let Some(arguments) = node.child_by_field_name("arguments") {
node_text_raw(arguments, self.content).unwrap_or_default()
} else {
let mut cursor = node.walk();
let mut found_name = false;
let mut result = String::new();
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if !found_name {
found_name = true;
continue;
}
if let Some(text) = node_text_raw(child, self.content) {
result = text;
break;
}
}
result
};
if arg_text.contains("FFI::Library") {
self.ffi_enabled_scopes.insert(self.namespace.clone());
}
Ok(())
}
fn detect_controller_dsl(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node
.child_by_field_name("name")
.or_else(|| node.child_by_field_name("method"));
let Some(name_node) = name_node else {
return Ok(());
};
let dsl = self.node_text(name_node)?;
let kind = match dsl.as_str() {
"before_action" => Some(ControllerDslKind::Before),
"after_action" => Some(ControllerDslKind::After),
"around_action" => Some(ControllerDslKind::Around),
_ => None,
};
let Some(kind) = kind else {
return Ok(());
};
if self.namespace.is_empty() {
return Ok(());
}
let container = self.namespace.join("::");
let mut callbacks: Vec<String> = Vec::new();
let mut only: Option<Vec<String>> = None;
let mut except: Option<Vec<String>> = None;
if let Some(arguments) = node.child_by_field_name("arguments") {
let mut cursor = arguments.walk();
for child in arguments.children(&mut cursor) {
if !child.is_named() {
continue;
}
let kind = child.kind();
match kind {
"symbol" | "simple_symbol" | "array" if callbacks.is_empty() => {
let mut v = extract_symbols_from_node(child, self.content);
callbacks.append(&mut v);
}
"pair" => {
let key = child.child_by_field_name("key");
let val = child.child_by_field_name("value");
if key.is_none() || val.is_none() {
continue;
}
let key_text = self.node_text(key.unwrap()).unwrap_or_default();
let symbols = extract_symbols_from_node(val.unwrap(), self.content);
if key_text.contains("only") && !symbols.is_empty() {
only = Some(symbols);
} else if key_text.contains("except") && !symbols.is_empty() {
except = Some(symbols);
}
}
"hash" => {
let mut hcur = child.walk();
for pair in child.children(&mut hcur) {
if !pair.is_named() {
continue;
}
if pair.kind() != "pair" {
continue;
}
let key = pair.child_by_field_name("key");
let val = pair.child_by_field_name("value");
if key.is_none() || val.is_none() {
continue;
}
let key_text = self.node_text(key.unwrap()).unwrap_or_default();
let symbols = extract_symbols_from_node(val.unwrap(), self.content);
if key_text.contains("only") && !symbols.is_empty() {
only = Some(symbols);
} else if key_text.contains("except") && !symbols.is_empty() {
except = Some(symbols);
}
}
}
_ => {}
}
}
} else {
if let Some(raw) = node_text_raw(node, self.content) {
let (cbs, o, e) = parse_controller_dsl_args(&raw);
callbacks = cbs;
only = o;
except = e;
}
}
if callbacks.is_empty() {
return Ok(());
}
self.controller_dsl_hooks.push(ControllerDslHook {
container,
kind,
callbacks,
only,
except,
});
Ok(())
}
fn adjust_visibility(&mut self, node: Node<'a>) -> Result<(), String> {
let name_node = node.child_by_field_name("name");
let Some(name_node) = name_node else {
return Ok(());
};
let keyword = self.node_text(name_node)?;
let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
return Ok(());
};
if !has_call_arguments(node)
&& let Some(last) = self.visibility_stack.last_mut()
{
*last = new_visibility;
}
Ok(())
}
fn adjust_visibility_from_identifier(&mut self, node: Node<'a>) -> Result<(), String> {
let keyword = self.node_text(node)?;
let Some(new_visibility) = Visibility::from_keyword(keyword.trim()) else {
return Ok(());
};
if let Some(last) = self.visibility_stack.last_mut() {
*last = new_visibility;
}
Ok(())
}
fn record_attr_visibility(&mut self, node: Node<'a>) {
if !is_attr_call(node, self.content) {
return;
}
let visibility = self
.visibility_stack
.last()
.copied()
.unwrap_or(Visibility::Public);
self.attr_visibility.insert(node.id(), visibility);
}
fn walk_children(&mut self, node: Node<'a>) -> Result<(), String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.is_named() {
self.walk(child)?;
}
}
Ok(())
}
fn node_text(&self, node: Node<'a>) -> Result<String, String> {
node.utf8_text(self.content)
.map(|s| s.trim().to_string())
.map_err(|err| err.to_string())
}
}
#[derive(Clone)]
struct MethodCall<'a> {
name: String,
receiver: Option<String>,
arguments: Option<Node<'a>>,
node: Node<'a>,
}
fn extract_method_call<'a>(node: Node<'a>, content: &[u8]) -> GraphResult<Option<MethodCall<'a>>> {
let method_name = match node.kind() {
"call" | "command_call" | "method_call" => {
let method_node = node
.child_by_field_name("method")
.ok_or_else(|| builder_parse_error(node, "call node missing method name"))?;
node_text(method_node, content)?
}
"command" => {
let name_node = node
.child_by_field_name("name")
.ok_or_else(|| builder_parse_error(node, "command node missing name"))?;
node_text(name_node, content)?
}
"super" => "super".to_string(),
"identifier" => {
if !should_treat_identifier_as_call(node) {
return Ok(None);
}
node_text(node, content)?
}
_ => return Ok(None),
};
let receiver = match node.kind() {
"call" | "command_call" | "method_call" => node
.child_by_field_name("receiver")
.and_then(|r| node_text(r, content).ok()),
_ => None,
};
let arguments = node.child_by_field_name("arguments");
Ok(Some(MethodCall {
name: method_name,
receiver,
arguments,
node,
}))
}
fn should_treat_identifier_as_call(node: Node<'_>) -> bool {
if let Some(parent) = node.parent() {
let kind = parent.kind();
if matches!(
kind,
"call"
| "command"
| "command_call"
| "method_call"
| "method"
| "singleton_method"
| "alias"
| "symbol"
) {
return false;
}
if kind.contains("assignment")
|| matches!(
kind,
"parameters"
| "method_parameters"
| "block_parameters"
| "lambda_parameters"
| "constant_path"
| "module"
| "class"
| "hash"
| "pair"
| "array"
| "argument_list"
)
{
return false;
}
}
true
}
fn resolve_callee(method_call: &MethodCall<'_>, context: &RubyContext) -> String {
let name = method_call.name.trim();
if name.is_empty() {
return String::new();
}
if name == "super" {
return format!("super::{}", context.qualified_name());
}
if let Some(receiver) = method_call.receiver.as_deref() {
let receiver = receiver.trim();
if receiver == "self" {
if let Some(container) = context.container() {
return format!("{container}.{name}");
}
return format!("self.{name}");
}
if receiver.contains("::") || receiver.starts_with("::") || is_constant(receiver) {
let cleaned = receiver.trim_start_matches("::");
if let Some(class_name) = cleaned.strip_suffix(".new") {
return format!("{class_name}#{name}");
}
return format!("{cleaned}.{name}");
}
return name.to_string();
}
if context.is_singleton() {
if let Some(container) = context.container() {
return format!("{container}.{name}");
}
return name.to_string();
}
if let Some(container) = context.container() {
return format!("{container}#{name}");
}
name.to_string()
}
fn count_arguments(arguments: Option<Node<'_>>, content: &[u8]) -> usize {
let Some(arguments) = arguments else {
return 0;
};
let mut count = 0;
let mut cursor = arguments.walk();
for child in arguments.children(&mut cursor) {
if child.is_named()
&& !is_literal_delimiter(child.kind())
&& node_text(child, content)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
count += 1;
}
}
count
}
fn associate_descendants(node: Node<'_>, idx: usize, map: &mut HashMap<usize, usize>) {
let mut stack = vec![node];
while let Some(current) = stack.pop() {
map.insert(current.id(), idx);
let mut cursor = current.walk();
for child in current.children(&mut cursor) {
stack.push(child);
}
}
}
fn method_qualified_name(
namespace: &[String],
method_name: &str,
singleton: bool,
) -> (String, Option<String>) {
if namespace.is_empty() {
return (method_name.to_string(), None);
}
let container = namespace.join("::");
let qualified = if singleton {
format!("{container}.{method_name}")
} else {
format!("{container}#{method_name}")
};
(qualified, Some(container))
}
fn singleton_qualified_name(
current_namespace: &[String],
object_text: &str,
method_name: &str,
) -> (String, Option<String>) {
if object_text == "self" {
if current_namespace.is_empty() {
(method_name.to_string(), None)
} else {
let container = current_namespace.join("::");
(format!("{container}.{method_name}"), Some(container))
}
} else {
let parts = split_constant_path(object_text);
if parts.is_empty() {
(method_name.to_string(), None)
} else {
let container = parts.join("::");
(format!("{container}.{method_name}"), Some(container))
}
}
}
fn split_constant_path(path: &str) -> Vec<String> {
path.trim()
.trim_start_matches("::")
.split("::")
.filter_map(|seg| {
let trimmed = seg.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect()
}
fn is_constant(text: &str) -> bool {
text.chars().next().is_some_and(|c| c.is_ascii_uppercase())
}
fn is_visibility_command(method_call: &MethodCall<'_>) -> bool {
matches!(
method_call.name.as_str(),
"public" | "private" | "protected"
) && method_call.receiver.is_none()
&& !has_call_arguments(method_call.node)
}
fn has_call_arguments(node: Node<'_>) -> bool {
if let Some(arguments) = node.child_by_field_name("arguments") {
let mut cursor = arguments.walk();
for child in arguments.children(&mut cursor) {
if child.is_named() {
return true;
}
}
}
false
}
fn inline_visibility_for_method(node: Node<'_>, content: &[u8]) -> Option<Visibility> {
let parent = node.parent()?;
let visibility_node = match parent.kind() {
"call" | "command" | "command_call" => parent,
"argument_list" => parent.parent()?,
_ => return None,
};
if !matches!(visibility_node.kind(), "call" | "command" | "command_call") {
return None;
}
let keyword_node = visibility_node
.child_by_field_name("name")
.or_else(|| visibility_node.child_by_field_name("method"))?;
let keyword = node_text_raw(keyword_node, content)?;
Visibility::from_keyword(keyword.trim())
}
fn node_text(node: Node<'_>, content: &[u8]) -> Result<String, GraphBuilderError> {
node.utf8_text(content)
.map(|s| s.trim().to_string())
.map_err(|err| builder_parse_error(node, &format!("utf8 error: {err}")))
}
fn node_text_raw(node: Node<'_>, content: &[u8]) -> Option<String> {
node.utf8_text(content)
.ok()
.map(std::string::ToString::to_string)
}
fn builder_parse_error(node: Node<'_>, reason: &str) -> GraphBuilderError {
GraphBuilderError::ParseError {
span: span_from_node(node),
reason: reason.to_string(),
}
}
#[allow(clippy::match_same_arms)]
fn extract_method_parameters(params_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut params = Vec::new();
let mut cursor = params_node.walk();
for child in params_node.named_children(&mut cursor) {
match child.kind() {
"identifier" | "optional_parameter" => {
if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"splat_parameter" => {
if let Some(name_node) = child.child_by_field_name("name") {
if let Ok(name) = name_node.utf8_text(content) {
params.push(format!("*{name}"));
}
} else if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"hash_splat_parameter" => {
if let Some(name_node) = child.child_by_field_name("name") {
if let Ok(name) = name_node.utf8_text(content) {
params.push(format!("**{name}"));
}
} else if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"block_parameter" => {
if let Some(name_node) = child.child_by_field_name("name") {
if let Ok(name) = name_node.utf8_text(content) {
params.push(format!("&{name}"));
}
} else if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"keyword_parameter" => {
if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"destructured_parameter" => {
if let Ok(text) = child.utf8_text(content) {
params.push(text.to_string());
}
}
"forward_parameter" => {
params.push("...".to_string());
}
"hash_splat_nil" => {
params.push("**nil".to_string());
}
_ => {
}
}
}
if params.is_empty() {
None
} else {
Some(params.join(", "))
}
}
fn extract_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
if let Some(return_type) = extract_sorbet_return_type(method_node, content) {
return Some(return_type);
}
if let Some(return_type) = extract_rbs_return_type(method_node, content) {
return Some(return_type);
}
if let Some(return_type) = extract_yard_return_type(method_node, content) {
return Some(return_type);
}
None
}
fn extract_sorbet_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut sibling = method_node.prev_sibling()?;
while sibling.kind() == "comment" {
sibling = sibling.prev_sibling()?;
}
if sibling.kind() == "call"
&& let Some(method_name) = sibling.child_by_field_name("method")
&& let Ok(name_text) = method_name.utf8_text(content)
&& name_text == "sig"
{
if let Some(block_node) = sibling.child_by_field_name("block") {
return extract_returns_from_sig_block(block_node, content);
}
}
None
}
fn extract_returns_from_sig_block(block_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = block_node.walk();
for child in block_node.named_children(&mut cursor) {
if child.kind() == "call"
&& let Some(method_name) = child.child_by_field_name("method")
&& let Ok(name_text) = method_name.utf8_text(content)
&& name_text == "returns"
{
if let Some(args) = child.child_by_field_name("arguments") {
let mut args_cursor = args.walk();
for arg in args.named_children(&mut args_cursor) {
if arg.kind() != ","
&& let Ok(type_text) = arg.utf8_text(content)
{
return Some(type_text.to_string());
}
}
}
}
if let Some(nested_type) = extract_returns_from_sig_block(child, content) {
return Some(nested_type);
}
}
None
}
fn extract_rbs_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = method_node.walk();
for child in method_node.children(&mut cursor) {
if child.kind() == "comment"
&& let Ok(comment_text) = child.utf8_text(content)
{
if comment_text.trim_start().starts_with("#:") {
if let Some(arrow_pos) = find_top_level_arrow(comment_text) {
let return_part = &comment_text[arrow_pos + 2..];
let return_type = return_part.trim().to_string();
if !return_type.is_empty() {
return Some(return_type);
}
}
}
}
}
None
}
fn find_top_level_arrow(text: &str) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
let mut depth: i32 = 0;
let mut i = 0;
while i < chars.len() {
match chars[i] {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth = depth.saturating_sub(1),
'-' if i + 1 < chars.len() && chars[i + 1] == '>' && depth == 0 => {
return Some(i);
}
_ => {}
}
i += 1;
}
None
}
fn extract_yard_return_type(method_node: Node<'_>, content: &[u8]) -> Option<String> {
let mut sibling_opt = method_node.prev_sibling();
let method_start_row = method_node.start_position().row;
let mut comments = Vec::new();
let mut expected_row = method_start_row;
while let Some(sibling) = sibling_opt {
if sibling.kind() == "comment" {
let comment_end_row = sibling.end_position().row;
if comment_end_row + 1 >= expected_row {
if let Ok(comment_text) = sibling.utf8_text(content) {
comments.push(comment_text);
}
expected_row = sibling.start_position().row;
sibling_opt = sibling.prev_sibling();
} else {
break;
}
} else {
break;
}
}
for comment in comments.iter().rev() {
if let Some(return_pos) = comment.find("@return") {
let after_return = &comment[return_pos + 7..];
if let Some(start_bracket) = after_return.find('[')
&& let Some(end_bracket) = after_return.find(']')
&& end_bracket > start_bracket
{
let return_type = &after_return[start_bracket + 1..end_bracket];
return Some(return_type.trim().to_string());
}
}
}
None
}
fn span_from_node(node: Node<'_>) -> Span {
span_from_points(node.start_position(), node.end_position())
}
fn span_from_points(start: Point, end: Point) -> Span {
Span::new(
Position::new(start.row, start.column),
Position::new(end.row, end.column),
)
}
fn is_literal_delimiter(kind: &str) -> bool {
matches!(kind, "," | "(" | ")" | "[" | "]")
}
fn parse_controller_dsl_args(
text: &str,
) -> (Vec<String>, Option<Vec<String>>, Option<Vec<String>>) {
let mut head = text;
let mut tail = "";
if let Some(idx) = text.find("only:") {
head = &text[..idx];
tail = &text[idx..];
} else if let Some(idx) = text.find("except:") {
head = &text[..idx];
tail = &text[idx..];
}
let callbacks = extract_symbol_list_from_args(head);
let only = extract_kw_symbol_list(tail, "only:");
let except = extract_kw_symbol_list(tail, "except:");
(callbacks, only, except)
}
fn extract_symbol_list_from_args(text: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' {
let start = i + 1;
let mut j = start;
while j < bytes.len() {
let c = bytes[j] as char;
if c.is_ascii_alphanumeric() || c == '_' {
j += 1;
} else {
break;
}
}
if j > start {
out.push(text[start..j].to_string());
i = j;
continue;
}
}
i += 1;
}
out
}
fn extract_kw_symbol_list(text: &str, kw: &str) -> Option<Vec<String>> {
let pos = text.find(kw)?;
let mut after = &text[pos + kw.len()..];
after = after.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
if after.starts_with('[')
&& let Some(end) = after.find(']')
{
return Some(extract_symbol_list_from_args(&after[..=end]));
}
if let Some(colon) = after.find(':') {
let mut j = colon + 1;
while j < after.len() {
let ch = after.as_bytes()[j] as char;
if ch.is_ascii_alphanumeric() || ch == '_' {
j += 1;
} else {
break;
}
}
if j > colon + 1 {
return Some(vec![after[colon + 1..j].to_string()]);
}
}
None
}
fn extract_symbols_from_node(node: Node<'_>, content: &[u8]) -> Vec<String> {
let mut out = Vec::new();
match node.kind() {
"symbol" | "simple_symbol" => {
if let Ok(t) = node_text(node, content) {
out.push(t.trim_start_matches(':').to_string());
}
}
"array" => {
let mut c = node.walk();
for ch in node.children(&mut c) {
if matches!(ch.kind(), "symbol" | "simple_symbol")
&& let Ok(t) = node_text(ch, content)
{
out.push(t.trim_start_matches(':').to_string());
}
}
}
_ => {
if let Some(txt) = node_text_raw(node, content) {
out = extract_symbol_list_from_args(&txt);
}
}
}
out
}
fn extract_require_module_name(arguments: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = arguments.walk();
for child in arguments.children(&mut cursor) {
if !child.is_named() {
continue;
}
if let Some(s) = extract_string_content(child, content) {
return Some(s);
}
}
None
}
fn extract_string_content(node: Node<'_>, content: &[u8]) -> Option<String> {
let text = node.utf8_text(content).ok()?;
let trimmed = text.trim();
if ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
&& trimmed.len() >= 2
{
return Some(trimmed[1..trimmed.len() - 1].to_string());
}
if matches!(node.kind(), "string" | "chained_string") {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "string_content"
&& let Ok(s) = child.utf8_text(content)
{
return Some(s.to_string());
}
}
}
None
}
pub(crate) fn resolve_ruby_require(
module_name: &str,
is_relative: bool,
source_file: &str,
) -> String {
if is_relative {
let source_path = std::path::Path::new(source_file);
let source_dir = source_path.parent().unwrap_or(std::path::Path::new(""));
let relative_path = std::path::Path::new(module_name);
let resolved = source_dir.join(relative_path);
let normalized = normalize_path(&resolved);
let path_str = normalized.to_string_lossy();
let separators: &[char] = &['/', '\\'];
path_str
.split(separators)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("::")
} else {
module_name.replace('/', "::")
}
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {
}
std::path::Component::ParentDir => {
if components
.last()
.is_some_and(|c| *c != std::path::Component::ParentDir)
{
components.pop();
} else {
components.push(component);
}
}
_ => {
components.push(component);
}
}
}
components.iter().collect()
}
fn process_yard_annotations(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
match node.kind() {
"method" => {
process_method_yard(node, content, helper)?;
}
"singleton_method" => {
process_singleton_method_yard(node, content, helper)?;
}
"call" | "command" | "command_call" => {
if is_attr_call(node, content) {
process_attr_yard(node, content, ast_graph, helper)?;
}
}
"assignment" => {
if is_instance_variable_assignment(node, content) {
process_assignment_yard(node, content, helper)?;
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
process_yard_annotations(child, content, ast_graph, helper)?;
}
Ok(())
}
fn process_method_yard(
method_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(yard_text) = extract_yard_comment(method_node, content) else {
return Ok(());
};
let tags = parse_yard_tags(&yard_text);
let Some(name_node) = method_node.child_by_field_name("name") else {
return Ok(());
};
let method_name = name_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(method_node),
reason: "failed to read method name".to_string(),
})?
.trim()
.to_string();
if method_name.is_empty() {
return Ok(());
}
let class_name = get_enclosing_class_name(method_node, content);
let qualified_name = if let Some(class_name) = class_name {
format!("{class_name}#{method_name}")
} else {
method_name.clone()
};
let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
for (param_idx, param_tag) in tags.params.iter().enumerate() {
let canonical_type = canonical_type_string(¶m_tag.type_str);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Parameter),
param_idx.try_into().ok(),
Some(¶m_tag.name),
);
let type_names = extract_type_names(¶m_tag.type_str);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
if let Some(return_type) = &tags.returns {
let canonical_type = canonical_type_string(return_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
let type_names = extract_type_names(return_type);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
Ok(())
}
fn process_singleton_method_yard(
method_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(yard_text) = extract_yard_comment(method_node, content) else {
return Ok(());
};
let tags = parse_yard_tags(&yard_text);
let Some(name_node) = method_node.child_by_field_name("name") else {
return Ok(());
};
let method_name = name_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(method_node),
reason: "failed to read method name".to_string(),
})?
.trim()
.to_string();
if method_name.is_empty() {
return Ok(());
}
let class_name = get_enclosing_class_name(method_node, content);
let qualified_name = if let Some(class_name) = class_name {
format!("{class_name}.{method_name}")
} else {
method_name.clone()
};
let method_node_id = helper.ensure_method(&qualified_name, None, false, true);
for (param_idx, param_tag) in tags.params.iter().enumerate() {
let canonical_type = canonical_type_string(¶m_tag.type_str);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Parameter),
param_idx.try_into().ok(),
Some(¶m_tag.name),
);
let type_names = extract_type_names(¶m_tag.type_str);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
if let Some(return_type) = &tags.returns {
let canonical_type = canonical_type_string(return_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
method_node_id,
type_node_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
let type_names = extract_type_names(return_type);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(method_node_id, ref_type_id);
}
}
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
fn process_attr_yard(
attr_node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(method_name) = attr_method_name(attr_node, content) else {
return Ok(());
};
let is_reader = method_name == "attr_reader";
let attr_names = extract_attr_names(attr_node, content);
if attr_names.is_empty() {
return Ok(());
}
let class_name = get_enclosing_class_name(attr_node, content);
let yard_return = extract_yard_comment(attr_node, content)
.map(|yard_text| parse_yard_tags(&yard_text))
.and_then(|tags| tags.returns);
let span = span_from_node(attr_node);
let visibility = ast_graph.attr_visibility_for_node(&attr_node).as_str();
for attr_name in attr_names {
let qualified_name = if let Some(ref class) = class_name {
format!("{class}#{attr_name}")
} else {
attr_name.clone()
};
let attr_node_id = if is_reader {
helper.add_constant_with_static_and_visibility(
&qualified_name,
Some(span),
false,
Some(visibility),
)
} else {
helper.add_property_with_static_and_visibility(
&qualified_name,
Some(span),
false,
Some(visibility),
)
};
if let Some(var_type) = &yard_return {
let canonical_type = canonical_type_string(var_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
attr_node_id,
type_node_id,
Some(TypeOfContext::Field),
None,
Some(&attr_name),
);
for type_name in extract_type_names(var_type) {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(attr_node_id, ref_type_id);
}
}
}
Ok(())
}
fn attr_method_name(node: Node, content: &[u8]) -> Option<String> {
let raw = match node.kind() {
"command" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok()),
"call" | "command_call" => node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
}?;
Some(raw.trim().to_string())
}
fn process_assignment_yard(
assignment_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(yard_text) = extract_yard_comment(assignment_node, content) else {
return Ok(());
};
let tags = parse_yard_tags(&yard_text);
let Some(var_type) = &tags.type_annotation else {
return Ok(());
};
let Some(left_node) = assignment_node.child_by_field_name("left") else {
return Ok(());
};
if left_node.kind() != "instance_variable" {
return Ok(());
}
let var_name = left_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(assignment_node),
reason: "failed to read variable name".to_string(),
})?
.trim()
.to_string();
if var_name.is_empty() {
return Ok(());
}
let class_name = get_enclosing_class_name(assignment_node, content);
let qualified_name = if let Some(class) = class_name {
format!("{class}#{var_name}")
} else {
var_name.clone()
};
let var_node_id = helper.add_variable(&qualified_name, None);
let canonical_type = canonical_type_string(var_type);
let type_node_id = helper.add_type(&canonical_type, None);
helper.add_typeof_edge_with_context(
var_node_id,
type_node_id,
Some(TypeOfContext::Variable),
None,
Some(&var_name),
);
let type_names = extract_type_names(var_type);
for type_name in type_names {
let ref_type_id = helper.add_type(&type_name, None);
helper.add_reference_edge(var_node_id, ref_type_id);
}
Ok(())
}
fn is_attr_call(node: Node, content: &[u8]) -> bool {
let method_name = match node.kind() {
"command" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok()),
"call" | "command_call" => node
.child_by_field_name("method")
.and_then(|n| n.utf8_text(content).ok()),
_ => None,
};
method_name
.is_some_and(|name| matches!(name.trim(), "attr_reader" | "attr_writer" | "attr_accessor"))
}
fn is_instance_variable_assignment(node: Node, _content: &[u8]) -> bool {
if let Some(left_node) = node.child_by_field_name("left") {
left_node.kind() == "instance_variable"
} else {
false
}
}
fn extract_attr_names(attr_node: Node, content: &[u8]) -> Vec<String> {
let mut names = Vec::new();
let arguments = attr_node.child_by_field_name("arguments");
if let Some(args) = arguments {
let mut cursor = args.walk();
for child in args.children(&mut cursor) {
if matches!(child.kind(), "symbol" | "simple_symbol")
&& let Ok(text) = child.utf8_text(content)
{
let cleaned = text.trim().trim_start_matches(':');
if !cleaned.is_empty() {
names.push(cleaned.to_string());
}
} else if child.kind() == "string"
&& let Ok(text) = child.utf8_text(content)
{
let cleaned = text
.trim()
.trim_start_matches(['\'', '"'])
.trim_end_matches(['\'', '"']);
if !cleaned.is_empty() {
names.push(cleaned.to_string());
}
}
}
} else if matches!(attr_node.kind(), "command" | "command_call") {
let mut cursor = attr_node.walk();
let mut found_method = false;
for child in attr_node.children(&mut cursor) {
if !child.is_named() {
continue;
}
if !found_method {
found_method = true;
continue;
}
if matches!(child.kind(), "symbol" | "simple_symbol")
&& let Ok(text) = child.utf8_text(content)
{
let cleaned = text.trim().trim_start_matches(':');
if !cleaned.is_empty() {
names.push(cleaned.to_string());
}
} else if child.kind() == "string"
&& let Ok(text) = child.utf8_text(content)
{
let cleaned = text
.trim()
.trim_start_matches(['\'', '"'])
.trim_end_matches(['\'', '"']);
if !cleaned.is_empty() {
names.push(cleaned.to_string());
}
}
}
}
names
}
fn get_enclosing_class_name(node: Node, content: &[u8]) -> Option<String> {
let mut current = node;
let mut namespace_parts = Vec::new();
while let Some(parent) = current.parent() {
if matches!(parent.kind(), "class" | "module") {
if let Some(name_node) = parent.child_by_field_name("name")
&& let Ok(name_text) = name_node.utf8_text(content)
{
let trimmed = name_text.trim();
if trimmed.starts_with("::") {
namespace_parts.clear();
namespace_parts.push(trimmed.trim_start_matches("::").to_string());
break;
}
namespace_parts.insert(0, trimmed.to_string());
}
}
current = parent;
}
if namespace_parts.is_empty() {
None
} else {
Some(namespace_parts.join("::"))
}
}
#[cfg(test)]
mod field_emission_tests {
use sqry_core::graph::GraphBuilder;
use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
use sqry_core::graph::unified::build::test_helpers::{
build_node_name_lookup, build_string_lookup,
};
use sqry_core::graph::unified::edge::EdgeKind;
use sqry_core::graph::unified::edge::kind::TypeOfContext;
use sqry_core::graph::unified::node::NodeKind;
use std::path::Path;
use tree_sitter::Parser;
use super::RubyGraphBuilder;
fn parse(source: &str) -> tree_sitter::Tree {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_ruby::LANGUAGE.into())
.expect("load Ruby grammar");
parser.parse(source, None).expect("parse Ruby source")
}
fn build(source: &str) -> StagingGraph {
let tree = parse(source);
let mut staging = StagingGraph::new();
let builder = RubyGraphBuilder::default();
builder
.build_graph(&tree, source.as_bytes(), Path::new("test.rb"), &mut staging)
.expect("build graph");
staging
}
fn find_node<'a>(
staging: &'a StagingGraph,
name: &str,
kind: Option<NodeKind>,
) -> Option<&'a sqry_core::graph::unified::storage::NodeEntry> {
let strings = build_string_lookup(staging);
for op in staging.operations() {
if let StagingOp::AddNode { entry, .. } = op {
if let Some(k) = kind
&& entry.kind != k
{
continue;
}
let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
if let Some(s) = strings.get(&name_idx)
&& s == name
{
return Some(entry);
}
}
}
None
}
fn count_nodes_named(staging: &StagingGraph, name: &str) -> usize {
let strings = build_string_lookup(staging);
staging
.operations()
.iter()
.filter(|op| {
if let StagingOp::AddNode { entry, .. } = op {
let name_idx = entry.qualified_name.unwrap_or(entry.name).index();
strings.get(&name_idx).is_some_and(|s| s == name)
} else {
false
}
})
.count()
}
fn visibility(
staging: &StagingGraph,
entry: &sqry_core::graph::unified::storage::NodeEntry,
) -> Option<String> {
let strings = build_string_lookup(staging);
entry
.visibility
.and_then(|visibility_id| strings.get(&visibility_id.index()).cloned())
}
fn typeof_edges_for_node(
staging: &StagingGraph,
source_name: &str,
) -> Vec<(Option<TypeOfContext>, Option<String>, String)> {
let names = build_node_name_lookup(staging);
let strings = build_string_lookup(staging);
let mut out = Vec::new();
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind: EdgeKind::TypeOf { context, name, .. },
..
} = op
{
let src = names.get(source).cloned().unwrap_or_default();
if src != source_name {
continue;
}
let edge_name = name.and_then(|sid| strings.get(&sid.index()).cloned());
let target_name = names.get(target).cloned().unwrap_or_default();
out.push((*context, edge_name, target_name));
}
}
out
}
#[test]
fn req_r0001_attr_accessor_without_yard_emits_property_node() {
let src = "class Foo\n attr_accessor :x\nend\n";
let staging = build(src);
find_node(&staging, "Foo::x", Some(NodeKind::Property))
.expect("Foo#x Property must be emitted without YARD");
}
#[test]
fn req_r0001_attr_reader_without_yard_emits_constant_node() {
let src = "class Foo\n attr_reader :y\nend\n";
let staging = build(src);
find_node(&staging, "Foo::y", Some(NodeKind::Constant))
.expect("Foo#y Constant must be emitted without YARD");
}
#[test]
fn req_r0001_attr_writer_without_yard_emits_property_node() {
let src = "class Foo\n attr_writer :z\nend\n";
let staging = build(src);
find_node(&staging, "Foo::z", Some(NodeKind::Property))
.expect("Foo#z Property must be emitted without YARD");
}
#[test]
fn req_r0001_attr_with_yard_still_emits() {
let src = "class Foo\n # @return [String]\n attr_reader :y\nend\n";
let staging = build(src);
find_node(&staging, "Foo::y", Some(NodeKind::Constant))
.expect("Foo#y Constant must be emitted when YARD is present too");
}
#[test]
fn req_r0023_attr_reader_branches_to_constant() {
let src = "class Bar\n attr_reader :name\nend\n";
let staging = build(src);
let entry = find_node(&staging, "Bar::name", Some(NodeKind::Constant))
.expect("attr_reader must produce Constant");
assert_eq!(entry.kind, NodeKind::Constant);
assert!(
find_node(&staging, "Bar::name", Some(NodeKind::Property)).is_none(),
"attr_reader must NOT also produce a Property"
);
}
#[test]
fn req_r0023_attr_writer_branches_to_property() {
let src = "class Bar\n attr_writer :name\nend\n";
let staging = build(src);
let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
.expect("attr_writer must produce Property");
assert_eq!(entry.kind, NodeKind::Property);
assert!(
find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
"attr_writer must NOT also produce a Constant"
);
}
#[test]
fn req_r0023_attr_accessor_branches_to_property() {
let src = "class Bar\n attr_accessor :name\nend\n";
let staging = build(src);
let entry = find_node(&staging, "Bar::name", Some(NodeKind::Property))
.expect("attr_accessor must produce Property");
assert_eq!(entry.kind, NodeKind::Property);
assert!(
find_node(&staging, "Bar::name", Some(NodeKind::Constant)).is_none(),
"attr_accessor must NOT also produce a Constant"
);
}
#[test]
fn req_r0023_attr_accessor_emits_one_per_argument() {
let src = "class Multi\n attr_accessor :a, :b, :c\nend\n";
let staging = build(src);
find_node(&staging, "Multi::a", Some(NodeKind::Property))
.expect("Multi#a Property must exist");
find_node(&staging, "Multi::b", Some(NodeKind::Property))
.expect("Multi#b Property must exist");
find_node(&staging, "Multi::c", Some(NodeKind::Property))
.expect("Multi#c Property must exist");
assert_eq!(count_nodes_named(&staging, "Multi::a"), 1);
assert_eq!(count_nodes_named(&staging, "Multi::b"), 1);
assert_eq!(count_nodes_named(&staging, "Multi::c"), 1);
}
#[test]
fn req_r0017_qualified_name_uses_ruby_hash_idiom() {
let src = "class Foo\n attr_accessor :x\nend\n";
let staging = build(src);
find_node(&staging, "Foo::x", Some(NodeKind::Property))
.expect("canonical Foo::x must exist");
assert!(
find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
"bare 'x' must not be the qualified name (would collide across classes)"
);
}
#[test]
fn req_r0006_yard_type_tag_drives_typeof_field_edge() {
let src = "class User\n # @return [String]\n attr_reader :name\nend\n";
let staging = build(src);
let edges = typeof_edges_for_node(&staging, "User::name");
assert!(
!edges.is_empty(),
"User#name should have a TypeOf edge from YARD @return"
);
let has_string = edges.iter().any(|(_, _, t)| t == "String");
assert!(
has_string,
"YARD @return [String] should produce a TypeOf target 'String', got {edges:?}"
);
}
#[test]
fn req_r0006_typeof_uses_field_context_and_bare_name() {
let src = "class C\n # @return [String]\n attr_accessor :title\nend\n";
let staging = build(src);
let edges = typeof_edges_for_node(&staging, "C::title");
assert!(!edges.is_empty(), "C#title should have a TypeOf edge");
for (ctx, name, _) in &edges {
assert_eq!(*ctx, Some(TypeOfContext::Field), "context must be Field");
assert_eq!(
name.as_deref(),
Some("title"),
"edge name must be the bare attr name"
);
}
}
#[test]
fn req_r0006_no_yard_means_no_typeof_edge_but_node_emitted() {
let src = "class C\n attr_accessor :untyped\nend\n";
let staging = build(src);
find_node(&staging, "C::untyped", Some(NodeKind::Property))
.expect("Property must emit even without YARD type tag");
let edges = typeof_edges_for_node(&staging, "C::untyped");
assert!(
edges.is_empty(),
"no YARD => no TypeOf{{Field}} enrichment edge, got {edges:?}"
);
}
#[test]
fn req_r0023_attr_node_visibility_defaults_to_public() {
let src = "class V\n attr_accessor :x\nend\n";
let staging = build(src);
let entry =
find_node(&staging, "V::x", Some(NodeKind::Property)).expect("V#x Property must exist");
assert_eq!(
visibility(&staging, entry).as_deref(),
Some("public"),
"Ruby attr_* nodes default to public visibility"
);
}
#[test]
fn req_r0023_attr_node_visibility_tracks_private_and_protected_scope() {
let src = "class V\n private\n attr_accessor :hidden\n protected\n attr_reader :guarded\nend\n";
let staging = build(src);
let hidden = find_node(&staging, "V::hidden", Some(NodeKind::Property))
.expect("V#hidden Property must exist");
let guarded = find_node(&staging, "V::guarded", Some(NodeKind::Constant))
.expect("V#guarded Constant must exist");
assert_eq!(
visibility(&staging, hidden).as_deref(),
Some("private"),
"Ruby attr_* nodes must inherit private visibility scope"
);
assert_eq!(
visibility(&staging, guarded).as_deref(),
Some("protected"),
"Ruby attr_reader nodes must inherit protected visibility scope"
);
}
#[test]
fn req_r0023_attr_node_is_not_static() {
let src = "class S\n attr_reader :y\nend\n";
let staging = build(src);
let entry =
find_node(&staging, "S::y", Some(NodeKind::Constant)).expect("S#y Constant must exist");
assert!(
!entry.is_static,
"attr_* nodes must have is_static=false (always instance per design §4.5)"
);
}
#[test]
fn req_r0017_same_attr_name_across_classes_distinct_nodes() {
let src = "class A\n attr_accessor :x\nend\nclass B\n attr_accessor :x\nend\n";
let staging = build(src);
find_node(&staging, "A::x", Some(NodeKind::Property)).expect("A#x Property must exist");
find_node(&staging, "B::x", Some(NodeKind::Property)).expect("B#x Property must exist");
assert!(
find_node(&staging, "x", Some(NodeKind::Property)).is_none(),
"bare 'x' must not exist; qualified names disambiguate cross-class"
);
}
#[test]
fn req_r0017_nested_module_class_qualifies_attr() {
let src = "module M\n class Inner\n attr_accessor :n\n end\nend\n";
let staging = build(src);
find_node(&staging, "M::Inner::n", Some(NodeKind::Property))
.expect("M::Inner#n Property must exist with full namespace");
}
#[test]
fn req_r0001_attr_reader_string_argument_emits_constant() {
let src = "class User\n attr_reader \"username\"\nend\n";
let staging = build(src);
find_node(&staging, "User::username", Some(NodeKind::Constant))
.expect("attr_reader with string arg must emit Constant");
}
#[test]
fn req_r0001_attr_accessor_command_call_form_emits_property() {
let src = "class Service\n self.attr_accessor :logger\nend\n";
let staging = build(src);
find_node(&staging, "Service::logger", Some(NodeKind::Property))
.expect("self.attr_accessor command_call must emit Property");
}
}