Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ast/src/lang/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub mod php_registry;
pub mod php_resolver;
pub mod py_resolver;
pub mod python;
pub mod ruby_registry;
pub mod ruby_resolver;
pub mod rust_registry;
pub mod rust_resolver;
pub mod swift_registry;
Expand Down Expand Up @@ -49,6 +51,7 @@ pub fn build(
Language::Kotlin => Some(Box::new(kotlin_registry::KotlinRegistry::new(graph, filez))),
Language::Swift => Some(Box::new(swift_registry::SwiftRegistry::new(graph, filez))),
Language::Php => Some(Box::new(php_registry::PhpRegistry::new(graph, filez))),
Language::Ruby => Some(Box::new(ruby_registry::RubyRegistry::new(graph, filez))),
_ => None,
}
}
80 changes: 80 additions & 0 deletions ast/src/lang/registry/ruby_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use super::{ruby_resolver, Registry};
use crate::lang::asg::NodeKeys;
use crate::lang::graphs::{Graph, NodeType};
use std::collections::HashMap;
use std::path::Path;

fn parent_dir(file: &str) -> String {
Path::new(file)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}

pub struct RubyRegistry {
dir_fns: HashMap<String, HashMap<String, NodeKeys>>,
resolved: HashMap<(String, usize, usize), NodeKeys>,
}

impl RubyRegistry {
pub fn new(graph: &impl Graph, filez: &[(String, String)]) -> Self {
let mut reg = RubyRegistry {
dir_fns: HashMap::new(),
resolved: HashMap::new(),
};

// Pass 1: index Function nodes by directory for bare-name fallback.
for (node_type, node_data) in graph.iter_all_nodes() {
if !node_data.file.ends_with(".rb") {
continue;
}
if *node_type != NodeType::Function {
continue;
}
let dir = parent_dir(&node_data.file);
reg.dir_fns
.entry(dir)
.or_default()
.entry(node_data.name.clone())
.or_insert_with(|| NodeKeys::from(node_data));
}

// Pass 2: pre-resolve all call sites per file.
// Skip spec/test files: the default resolver handles those and must
// preserve class_call edges that test annotations rely on.
let all_resolved: Vec<((String, usize, usize), NodeKeys)> = filez
.iter()
.filter(|(f, _)| {
f.ends_with(".rb")
&& !f.contains("/spec/")
&& !f.contains("/test/")
&& !f.ends_with("_spec.rb")
&& !f.ends_with("_test.rb")
})
.flat_map(|(file, source)| {
ruby_resolver::resolve_file_calls(source, file, &reg.dir_fns, graph)
.into_iter()
.map(|((row, col), nk)| ((file.clone(), row, col), nk))
})
.collect();
reg.resolved.extend(all_resolved);

reg
}
}

impl Registry for RubyRegistry {
fn resolve_type(&self, _file: &str, _var_name: &str) -> Option<&str> {
None
}

fn resolve_method(&self, _type_name: &str, _method_name: &str) -> Option<&str> {
None
}

fn resolve_call_at(&self, file: &str, row: usize, col: usize) -> Option<NodeKeys> {
self.resolved
.get(&(file.to_string(), row, col))
.cloned()
}
}
217 changes: 217 additions & 0 deletions ast/src/lang/registry/ruby_resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
use super::scope::{scope_bind, scope_lookup, scope_pop, scope_push, Scope};
use crate::lang::asg::NodeKeys;
use crate::lang::graphs::{Graph, NodeType};
use std::collections::HashMap;
use std::path::Path;
use tree_sitter::{Node, Parser};

fn make_parser() -> Option<Parser> {
let mut parser = Parser::new();
let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
parser.set_language(&lang).ok()?;
Some(parser)
}

fn parent_dir(file: &str) -> String {
Path::new(file)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}

// ── Method lookup ──────────────────────────────────────────────────────────────

fn find_method_in_class<G: Graph>(
graph: &G,
class_name: &str,
method_name: &str,
) -> Option<NodeKeys> {
graph
.find_nodes_by_name(NodeType::Function, method_name)
.into_iter()
.find(|n| n.meta.get("operand").map(|s| s.as_str()) == Some(class_name))
.map(|n| NodeKeys::from(&n))
}

// ── Type evaluator ─────────────────────────────────────────────────────────────

// Ruby has no type annotations, so resolution is limited to:
// - direct constant (ClassName)
// - local variable previously bound via ClassName.new / ClassName.find / etc.
// - ClassName.new(...) call expression itself
fn eval_expr_type(scope: &Scope, node: Node, src: &[u8]) -> Option<String> {
match node.kind() {
"constant" => node.utf8_text(src).ok().map(str::to_string),
"identifier" => scope_lookup(scope, node.utf8_text(src).ok()?).map(str::to_string),
"call" => {
let receiver = node.child_by_field_name("receiver")?;
let method = node.child_by_field_name("method")?;
// ClassName.new(...) → type is ClassName
if receiver.kind() == "constant"
&& method.utf8_text(src).ok()? == "new"
{
receiver.utf8_text(src).ok().map(str::to_string)
} else {
None
}
}
_ => None,
}
}

// ── AST walker ─────────────────────────────────────────────────────────────────

// ActiveRecord class-level finders: when called on a constant, bind the left-hand
// variable to that constant's type so chained calls can be resolved.
const BINDING_METHODS: &[&str] = &["new", "find", "find_by", "find_by!", "create", "first", "last", "build"];

fn walk_node<G: Graph>(
node: Node,
src: &[u8],
scope: &mut Scope,
dir_fns: &HashMap<String, HashMap<String, NodeKeys>>,
graph: &G,
out: &mut HashMap<(usize, usize), NodeKeys>,
file: &str,
) {
match node.kind() {
"class" | "module" => {
let class_name = node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(src).ok().map(str::to_string));
scope_push(scope);
if let Some(ref name) = class_name {
scope_bind(scope, "self", name);
}
// body_statement is a named child (no field name in tree-sitter-ruby)
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i) {
if child.kind() == "body_statement" {
walk_node(child, src, scope, dir_fns, graph, out, file);
}
}
}
scope_pop(scope);
}

"method" | "singleton_method" => {
scope_push(scope);
if let Some(body) = node.child_by_field_name("body").or_else(|| {
(0..node.named_child_count())
.filter_map(|i| node.named_child(i))
.find(|n| n.kind() == "body_statement")
}) {
walk_node(body, src, scope, dir_fns, graph, out, file);
}
scope_pop(scope);
}

"assignment" => {
let left = node.child_by_field_name("left");
let right = node.child_by_field_name("right");
if let (Some(left), Some(right)) = (left, right) {
// Bind local variable when assigned from a typed expression.
if left.kind() == "identifier" {
if let Ok(var_name) = left.utf8_text(src) {
// ClassName.new / ClassName.find / etc. → bind var → ClassName
let type_name = if right.kind() == "call" {
let recv = right.child_by_field_name("receiver");
let meth = right.child_by_field_name("method");
match (recv, meth) {
(Some(r), Some(m))
if r.kind() == "constant"
&& m.utf8_text(src)
.map(|s| BINDING_METHODS.contains(&s))
.unwrap_or(false) =>
{
r.utf8_text(src).ok().map(str::to_string)
}
_ => None,
}
} else {
eval_expr_type(scope, right, src)
};
if let Some(t) = type_name {
scope_bind(scope, var_name, &t);
}
}
}
// Always recurse into right side for nested calls.
walk_node(right, src, scope, dir_fns, graph, out, file);
}
}

"call" => {
let receiver_node = node.child_by_field_name("receiver");
let method_node = node.child_by_field_name("method");

if let Some(method_node) = method_node {
let method_name = method_node.utf8_text(src).ok();

let resolved = if let Some(recv) = receiver_node {
// Typed receiver: constant or scope-tracked variable.
eval_expr_type(scope, recv, src).and_then(|class_name| {
method_name.and_then(|mn| {
let pos = {
let p = method_node.start_position();
(p.row, p.column)
};
find_method_in_class(graph, &class_name, mn).map(|t| (pos, t))
})
})
} else if let Some(mn) = method_name {
// Bare function call → same-directory fallback.
let dir = parent_dir(file);
let pos = {
let p = method_node.start_position();
(p.row, p.column)
};
dir_fns.get(&dir).and_then(|m| m.get(mn)).map(|nk| (pos, nk.clone()))
} else {
None
};

if let Some((pos, target)) = resolved {
out.entry(pos).or_insert(target);
}
}

// Recurse into arguments and block.
if let Some(args) = node.child_by_field_name("arguments") {
walk_node(args, src, scope, dir_fns, graph, out, file);
}
if let Some(block) = node.child_by_field_name("block") {
walk_node(block, src, scope, dir_fns, graph, out, file);
}
}

_ => {
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i) {
walk_node(child, src, scope, dir_fns, graph, out, file);
}
}
}
}
}

// ── Public entry point ─────────────────────────────────────────────────────────

pub fn resolve_file_calls<G: Graph>(
source: &str,
file: &str,
dir_fns: &HashMap<String, HashMap<String, NodeKeys>>,
graph: &G,
) -> HashMap<(usize, usize), NodeKeys> {
let mut out = HashMap::new();
let Some(mut parser) = make_parser() else {
return out;
};
let Some(tree) = parser.parse(source, None) else {
return out;
};
let src = source.as_bytes();
let mut scope: Scope = vec![HashMap::new()];
walk_node(tree.root_node(), src, &mut scope, dir_fns, graph, &mut out, file);
out
}
20 changes: 11 additions & 9 deletions ast/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,11 +691,10 @@ impl Repo {
let fname = path.display().to_string();

let rel = path.strip_prefix(&self.root).unwrap_or(path);
let rel_str = rel.display().to_string();
let in_skipped_dir = conf
.skip_dirs
.iter()
.any(|sd| rel_str == *sd || rel_str.starts_with(&format!("{}/", sd)));
let in_skipped_dir = conf.skip_dirs.iter().any(|sd| {
rel.components()
.any(|c| c.as_os_str().to_str() == Some(sd.as_str()))
});
if in_skipped_dir {
return true;
}
Expand Down Expand Up @@ -875,10 +874,13 @@ fn skip_dir(entry: &DirEntry, skip_dirs: &[String], root: &PathBuf) -> bool {
}
let entry_path = entry.path();
let relative = entry_path.strip_prefix(root).unwrap_or(entry_path);
let relative_str = relative.display().to_string();
let should_skip = skip_dirs
.iter()
.any(|sd| relative_str == *sd || relative_str.starts_with(&format!("{}/", sd)));
// Match any path component by name so that e.g. "migrate" skips "db/migrate" as well as
// top-level "migrate/".
let should_skip = skip_dirs.iter().any(|sd| {
relative
.components()
.any(|c| c.as_os_str().to_str() == Some(sd.as_str()))
});
should_skip
}
fn only_files(path: &std::path::Path, only_include_files: &[String]) -> bool {
Expand Down
4 changes: 2 additions & 2 deletions ast/src/testing/coverage/ruby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@ async fn test_nodes_class_type() -> Result<()> {
)
.await?;

assert_eq!(count, 35, "Should have 35 Class nodes");
assert_eq!(results.len(), 35);
assert_eq!(count, 32, "Should have 32 Class nodes");
assert_eq!(results.len(), 32);

Ok(())
}
Expand Down
3 changes: 3 additions & 0 deletions ast/src/testing/ruby/app/channels/chat_channel.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
class ChatChannel < ApplicationCable::Channel
# @ast node: Function "subscribed"
def subscribed
stream_from "chat_#{params[:room_id]}"
end

# @ast node: Function "unsubscribed"
def unsubscribed
stop_all_streams
end

# @ast node: Function "speak"
def speak(data)
ActionCable.server.broadcast("chat_#{params[:room_id]}", message: data['message'])
end
Expand Down
Loading
Loading