use anyhow::{Context, Result};
use streaming_iterator::StreamingIterator;
use tree_sitter::{Parser, Query, QueryCursor};
use crate::models::{Language, SearchResult, Span, SymbolKind, ImportType};
use crate::parsers::{DependencyExtractor, ImportInfo};
pub fn parse(path: &str, source: &str) -> Result<Vec<SearchResult>> {
let mut parser = Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.context("Failed to set Ruby language")?;
let tree = parser
.parse(source, None)
.context("Failed to parse Ruby source")?;
let root_node = tree.root_node();
let mut symbols = Vec::new();
symbols.extend(extract_modules(source, &root_node, &language.into())?);
symbols.extend(extract_classes(source, &root_node, &language.into())?);
symbols.extend(extract_methods(source, &root_node, &language.into())?);
symbols.extend(extract_singleton_methods(source, &root_node, &language.into())?);
symbols.extend(extract_constants(source, &root_node, &language.into())?);
symbols.extend(extract_instance_variables(source, &root_node, &language.into())?);
symbols.extend(extract_class_variables(source, &root_node, &language.into())?);
symbols.extend(extract_attr_accessors(source, &root_node, &language.into())?);
symbols.extend(extract_local_variables(source, &root_node, &language.into())?);
for symbol in &mut symbols {
symbol.path = path.to_string();
symbol.lang = Language::Ruby;
}
Ok(symbols)
}
fn extract_modules(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(module
name: (constant) @name) @module
"#;
let query = Query::new(language, query_str)
.context("Failed to create module query")?;
extract_symbols(source, root, &query, SymbolKind::Module, None)
}
fn extract_classes(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(class
name: (constant) @name) @class
"#;
let query = Query::new(language, query_str)
.context("Failed to create class query")?;
extract_symbols(source, root, &query, SymbolKind::Class, None)
}
fn extract_methods(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(class
name: (constant) @class_name
(body_statement
(method
name: (_) @method_name))) @class
(module
name: (constant) @module_name
(body_statement
(method
name: (_) @method_name))) @module
"#;
let query = Query::new(language, query_str)
.context("Failed to create method query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
while let Some(match_) = matches.next() {
let mut scope_name = None;
let mut scope_type = None;
let mut method_name = None;
let mut method_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
match capture_name {
"class_name" => {
scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
scope_type = Some("class");
}
"module_name" => {
scope_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
scope_type = Some("module");
}
"method_name" => {
method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
let mut current = capture.node;
while let Some(parent) = current.parent() {
if parent.kind() == "method" {
method_node = Some(parent);
break;
}
current = parent;
}
}
_ => {}
}
}
if let (Some(scope_name), Some(scope_type), Some(method_name), Some(node)) =
(scope_name, scope_type, method_name, method_node) {
let scope = format!("{} {}", scope_type, scope_name);
let span = node_to_span(&node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Method,
Some(method_name),
span,
Some(scope),
preview,
));
}
}
Ok(symbols)
}
fn extract_singleton_methods(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(singleton_method
object: (_) @class_name
name: (_) @method_name) @method
"#;
let query = Query::new(language, query_str)
.context("Failed to create singleton method query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
while let Some(match_) = matches.next() {
let mut class_name = None;
let mut method_name = None;
let mut method_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
match capture_name {
"class_name" => {
class_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
}
"method_name" => {
method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
}
"method" => {
method_node = Some(capture.node);
}
_ => {}
}
}
if let (Some(class_name), Some(method_name), Some(node)) = (class_name, method_name, method_node) {
let scope = format!("class {}", class_name);
let span = node_to_span(&node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Method,
Some(format!("{}.{}", class_name, method_name)),
span,
Some(scope),
preview,
));
}
}
Ok(symbols)
}
fn extract_constants(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(assignment
left: (constant) @name
right: (_)) @const
"#;
let query = Query::new(language, query_str)
.context("Failed to create constant query")?;
extract_symbols(source, root, &query, SymbolKind::Constant, None)
}
fn extract_local_variables(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(assignment
left: (identifier) @name) @assignment
"#;
let query = Query::new(language, query_str)
.context("Failed to create local variable query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
while let Some(match_) = matches.next() {
let mut name = None;
let mut assignment_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
match capture_name {
"name" => {
name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
}
"assignment" => {
assignment_node = Some(capture.node);
}
_ => {}
}
}
if let (Some(name), Some(node)) = (name, assignment_node) {
let mut is_in_method = false;
let mut current = node;
while let Some(parent) = current.parent() {
if parent.kind() == "method" || parent.kind() == "singleton_method" {
is_in_method = true;
break;
}
if parent.kind() == "program" || parent.kind() == "module" || parent.kind() == "class" {
break;
}
current = parent;
}
if is_in_method {
let span = node_to_span(&node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Variable,
Some(name),
span,
None,
preview,
));
}
}
}
Ok(symbols)
}
fn extract_instance_variables(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(instance_variable) @name
"#;
let query = Query::new(language, query_str)
.context("Failed to create instance variable query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(match_) = matches.next() {
for capture in match_.captures {
let name_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
if !seen.contains(name_text) {
seen.insert(name_text.to_string());
let span = node_to_span(&capture.node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Variable,
Some(name_text.to_string()),
span,
None,
preview,
));
}
}
}
Ok(symbols)
}
fn extract_class_variables(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(class_variable) @name
"#;
let query = Query::new(language, query_str)
.context("Failed to create class variable query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(match_) = matches.next() {
for capture in match_.captures {
let name_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
if !seen.contains(name_text) {
seen.insert(name_text.to_string());
let span = node_to_span(&capture.node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Variable,
Some(name_text.to_string()),
span,
None,
preview,
));
}
}
}
Ok(symbols)
}
fn extract_attr_accessors(
source: &str,
root: &tree_sitter::Node,
language: &tree_sitter::Language,
) -> Result<Vec<SearchResult>> {
let query_str = r#"
(call
method: (identifier) @method_type
arguments: (argument_list
(simple_symbol) @name))
(#match? @method_type "^(attr_reader|attr_writer|attr_accessor)$")
"#;
let query = Query::new(language, query_str)
.context("Failed to create attr accessor query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, *root, source.as_bytes());
let mut symbols = Vec::new();
while let Some(match_) = matches.next() {
let mut method_type = None;
let mut name = None;
let mut call_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
match capture_name {
"method_type" => {
method_type = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
}
"name" => {
let symbol_text = capture.node.utf8_text(source.as_bytes()).unwrap_or("");
name = Some(symbol_text.trim_start_matches(':').to_string());
let mut current = capture.node;
while let Some(parent) = current.parent() {
if parent.kind() == "call" {
call_node = Some(parent);
break;
}
current = parent;
}
}
_ => {}
}
}
if let (Some(_method_type), Some(name), Some(node)) = (method_type, name, call_node) {
let span = node_to_span(&node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
SymbolKind::Property,
Some(name),
span,
None,
preview,
));
}
}
Ok(symbols)
}
fn extract_symbols(
source: &str,
root: &tree_sitter::Node,
query: &Query,
kind: SymbolKind,
scope: Option<String>,
) -> Result<Vec<SearchResult>> {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, *root, source.as_bytes());
let mut symbols = Vec::new();
while let Some(match_) = matches.next() {
let mut name = None;
let mut full_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
if capture_name == "name" {
name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
} else {
full_node = Some(capture.node);
}
}
if let (Some(name), Some(node)) = (name, full_node) {
let span = node_to_span(&node);
let preview = extract_preview(source, &span);
symbols.push(SearchResult::new(
String::new(),
Language::Ruby,
kind.clone(),
Some(name),
span,
scope.clone(),
preview,
));
}
}
Ok(symbols)
}
fn node_to_span(node: &tree_sitter::Node) -> Span {
let start = node.start_position();
let end = node.end_position();
Span::new(
start.row + 1, start.column,
end.row + 1,
end.column,
)
}
fn extract_preview(source: &str, span: &Span) -> String {
let lines: Vec<&str> = source.lines().collect();
let start_idx = (span.start_line - 1) as usize; let end_idx = (start_idx + 7).min(lines.len());
lines[start_idx..end_idx].join("\n")
}
pub struct RubyDependencyExtractor;
impl DependencyExtractor for RubyDependencyExtractor {
fn extract_dependencies(source: &str) -> Result<Vec<ImportInfo>> {
let mut parser = Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.context("Failed to set Ruby language")?;
let tree = parser
.parse(source, None)
.context("Failed to parse Ruby source")?;
let root_node = tree.root_node();
let query_str = r#"
(call
method: (identifier) @method_name
arguments: (argument_list) @args) @call
(#match? @method_name "^(require|require_relative|load)$")
"#;
let query = Query::new(&language.into(), query_str)
.context("Failed to create Ruby require query")?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, root_node, source.as_bytes());
let mut imports = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(match_) = matches.next() {
let mut method_name = None;
let mut args_node = None;
for capture in match_.captures {
let capture_name: &str = &query.capture_names()[capture.index as usize];
match capture_name {
"method_name" => {
method_name = Some(capture.node.utf8_text(source.as_bytes()).unwrap_or("").to_string());
}
"args" => {
args_node = Some(capture.node);
}
_ => {}
}
}
if let (Some(method), Some(args)) = (method_name, args_node) {
if !matches!(method.as_str(), "require" | "require_relative" | "load") {
continue;
}
let mut cursor = args.walk();
for child in args.children(&mut cursor) {
match child.kind() {
"string" => {
let mut is_interpolated = false;
let mut child_cursor = child.walk();
for grandchild in child.children(&mut child_cursor) {
if grandchild.kind() == "interpolation" {
is_interpolated = true;
break;
}
}
if is_interpolated {
continue; }
let mut content = None;
let mut child_cursor = child.walk();
for grandchild in child.children(&mut child_cursor) {
if grandchild.kind() == "string_content" {
content = Some(grandchild.utf8_text(source.as_bytes()).unwrap_or("").to_string());
break;
}
}
if let Some(path) = content {
if path.is_empty() {
continue;
}
let line_number = child.start_position().row + 1;
let key = (path.clone(), line_number);
if seen.contains(&key) {
continue;
}
seen.insert(key);
let import_type = classify_ruby_import(&path, &method);
imports.push(ImportInfo {
imported_path: path,
line_number,
import_type,
imported_symbols: None,
});
}
}
"simple_symbol" => {
let mut path = child.utf8_text(source.as_bytes()).unwrap_or("").to_string();
if path.starts_with(':') {
path = path.trim_start_matches(':').to_string();
}
let line_number = child.start_position().row + 1;
let key = (path.clone(), line_number);
if seen.contains(&key) {
continue;
}
seen.insert(key);
let import_type = classify_ruby_import(&path, &method);
imports.push(ImportInfo {
imported_path: path,
line_number,
import_type,
imported_symbols: None,
});
}
_ => {}
}
}
}
}
Ok(imports)
}
}
#[derive(Debug, Clone)]
pub struct RubyProject {
pub gem_name: String, pub project_root: String, pub abs_project_root: String, }
pub fn find_all_gemspec_files(root: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
let mut gemspec_files = Vec::new();
let walker = ignore::WalkBuilder::new(root)
.follow_links(false)
.git_ignore(true)
.build();
for entry in walker {
let entry = entry?;
let path = entry.path();
if path.is_file() {
if path.extension().and_then(|s| s.to_str()) == Some("gemspec") {
gemspec_files.push(path.to_path_buf());
}
}
}
Ok(gemspec_files)
}
pub fn parse_all_ruby_projects(root: &std::path::Path) -> Result<Vec<RubyProject>> {
let gemspec_files = find_all_gemspec_files(root)?;
let mut projects = Vec::new();
let root_abs = root.canonicalize()?;
for gemspec_path in &gemspec_files {
if let Some(project_dir) = gemspec_path.parent() {
if let Some(gem_name) = parse_gemspec_name(gemspec_path) {
let project_abs = project_dir.canonicalize()?;
let project_rel = project_abs.strip_prefix(&root_abs)
.unwrap_or(project_dir)
.to_string_lossy()
.to_string();
projects.push(RubyProject {
gem_name: gem_name.clone(),
project_root: project_rel,
abs_project_root: project_abs.to_string_lossy().to_string(),
});
}
}
}
Ok(projects)
}
pub fn find_ruby_gem_names(root: &std::path::Path) -> Vec<String> {
parse_all_ruby_projects(root)
.unwrap_or_default()
.into_iter()
.map(|p| p.gem_name)
.collect()
}
fn parse_gemspec_name(gemspec_path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(gemspec_path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("s.name") || trimmed.starts_with("spec.name"))
&& trimmed.contains('=')
{
if let Some(equals_pos) = trimmed.find('=') {
let after_equals = &trimmed[equals_pos + 1..].trim();
for quote in ['"', '\''] {
if let Some(start) = after_equals.find(quote) {
if let Some(end) = after_equals[start + 1..].find(quote) {
let name = &after_equals[start + 1..start + 1 + end];
return Some(name.to_string());
}
}
}
}
}
}
None
}
fn gem_name_to_require_paths(gem_name: &str) -> Vec<String> {
let mut paths = Vec::new();
paths.push(gem_name.to_string());
if gem_name.contains('-') {
paths.push(gem_name.replace('-', "_"));
}
if gem_name.contains('_') {
paths.push(gem_name.replace('_', "-"));
}
paths
}
pub fn resolve_ruby_require_to_path(
require_path: &str,
projects: &[RubyProject],
current_file_path: Option<&str>,
) -> Option<String> {
if require_path.starts_with("./") || require_path.starts_with("../") {
if let Some(current_file) = current_file_path {
if let Some(current_dir) = std::path::Path::new(current_file).parent() {
let resolved = current_dir.join(require_path);
let candidates = vec![
format!("{}.rb", resolved.display()),
resolved.display().to_string(),
];
for candidate in candidates {
if let Ok(normalized) = std::path::Path::new(&candidate).canonicalize() {
return Some(normalized.display().to_string());
}
}
}
}
return None;
}
let first_component = require_path.split('/').next().unwrap_or(require_path);
for project in projects {
let gem_variants = gem_name_to_require_paths(&project.gem_name);
for variant in &gem_variants {
if first_component == variant {
let require_file_path = require_path.replace("::", "/");
let candidates = vec![
format!("{}/lib/{}.rb", project.project_root, require_file_path),
format!("{}/{}.rb", project.project_root, require_file_path),
];
for candidate in candidates {
return Some(candidate);
}
}
}
}
None
}
pub fn reclassify_ruby_import(
import_path: &str,
gem_names: &[String],
) -> ImportType {
if import_path.starts_with("./") || import_path.starts_with("../") {
return ImportType::Internal;
}
let first_component = import_path.split('/').next().unwrap_or(import_path);
for gem_name in gem_names {
for variant in gem_name_to_require_paths(gem_name) {
if first_component == variant {
return ImportType::Internal;
}
}
}
if is_ruby_stdlib(import_path) {
return ImportType::Stdlib;
}
ImportType::External
}
fn is_ruby_stdlib(path: &str) -> bool {
let stdlib_prefixes = [
"json", "csv", "yaml", "uri", "net/", "open-uri", "openssl",
"digest", "base64", "securerandom", "time", "date", "set",
"fileutils", "pathname", "tempfile", "logger", "benchmark",
"ostruct", "forwardable", "singleton", "observer", "delegate",
"abbrev", "cgi", "erb", "optparse", "shellwords", "stringio",
"strscan", "socket", "thread", "mutex_m", "monitor", "sync",
"timeout", "weakref", "English", "fiddle", "rbconfig",
];
for prefix in &stdlib_prefixes {
if path == *prefix || path.starts_with(&format!("{}/", prefix)) {
return true;
}
}
false
}
fn classify_ruby_import(path: &str, method: &str) -> ImportType {
if method == "require_relative" {
return ImportType::Internal;
}
if is_ruby_stdlib(path) {
return ImportType::Stdlib;
}
if path.starts_with("./") || path.starts_with("../") {
return ImportType::Internal;
}
ImportType::External
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_class() {
let source = r#"
class User
attr_accessor :name, :email
end
"#;
let symbols = parse("test.rb", source).unwrap();
let class_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Class))
.collect();
assert_eq!(class_symbols.len(), 1);
assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
}
#[test]
fn test_parse_module() {
let source = r#"
module Authentication
def login
# implementation
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let module_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Module))
.collect();
assert_eq!(module_symbols.len(), 1);
assert_eq!(module_symbols[0].symbol.as_deref(), Some("Authentication"));
}
#[test]
fn test_parse_methods() {
let source = r#"
class Calculator
def add(a, b)
a + b
end
def subtract(a, b)
a - b
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let method_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Method))
.collect();
assert_eq!(method_symbols.len(), 2);
assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("add")));
assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("subtract")));
for method in method_symbols {
}
}
#[test]
fn test_parse_singleton_method() {
let source = r#"
class User
def self.create(attributes)
new(attributes).save
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let method_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Method))
.collect();
assert!(method_symbols.len() >= 1);
assert!(method_symbols.iter().any(|s| s.symbol.as_deref().unwrap_or("").contains("create")));
}
#[test]
fn test_parse_constants() {
let source = r#"
MAX_SIZE = 100
DEFAULT_TIMEOUT = 30
API_KEY = "secret123"
"#;
let symbols = parse("test.rb", source).unwrap();
let const_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Constant))
.collect();
assert_eq!(const_symbols.len(), 3);
assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("MAX_SIZE")));
assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("DEFAULT_TIMEOUT")));
assert!(const_symbols.iter().any(|s| s.symbol.as_deref() == Some("API_KEY")));
}
#[test]
fn test_parse_nested_class() {
let source = r#"
module MyApp
class User
def initialize(name)
@name = name
end
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let module_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Module))
.collect();
let class_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Class))
.collect();
assert_eq!(module_symbols.len(), 1);
assert_eq!(class_symbols.len(), 1);
assert_eq!(module_symbols[0].symbol.as_deref(), Some("MyApp"));
assert_eq!(class_symbols[0].symbol.as_deref(), Some("User"));
}
#[test]
fn test_parse_rails_controller() {
let source = r#"
class UsersController < ApplicationController
before_action :authenticate_user!
def index
@users = User.all
end
def show
@user = User.find(params[:id])
end
def create
@user = User.new(user_params)
@user.save
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let class_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Class))
.collect();
let method_symbols: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Method))
.collect();
assert_eq!(class_symbols.len(), 1);
assert_eq!(method_symbols.len(), 3);
assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("index")));
assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("show")));
assert!(method_symbols.iter().any(|s| s.symbol.as_deref() == Some("create")));
}
#[test]
fn test_parse_mixed_symbols() {
let source = r#"
MAX_RETRIES = 3
module Authentication
class Session
def login(username, password)
# implementation
end
def self.destroy_all
# implementation
end
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
assert!(symbols.len() >= 4);
let kinds: Vec<&SymbolKind> = symbols.iter().map(|s| &s.kind).collect();
assert!(kinds.contains(&&SymbolKind::Constant));
assert!(kinds.contains(&&SymbolKind::Module));
assert!(kinds.contains(&&SymbolKind::Class));
assert!(kinds.contains(&&SymbolKind::Method));
}
#[test]
fn test_local_variables_included() {
let source = r#"
GLOBAL_CONSTANT = 100
class Calculator
def calculate(input)
local_var = input * 2
result = local_var + 10
temp = result / 2
temp
end
def self.process(value)
squared = value * value
doubled = squared * 2
doubled
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let variables: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Variable))
.collect();
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("local_var")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("result")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("temp")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("squared")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("doubled")));
for var in variables {
}
let var_names: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Variable))
.filter_map(|s| s.symbol.as_deref())
.collect();
assert!(!var_names.contains(&"GLOBAL_CONSTANT"));
}
#[test]
fn test_instance_and_class_variables() {
let source = r#"
class Counter
@@total_count = 0
def initialize(name)
@name = name
@count = 0
@@total_count += 1
end
def increment
@count += 1
end
def self.get_total
@@total_count
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let variables: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Variable))
.collect();
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@name")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@count")));
assert!(variables.iter().any(|v| v.symbol.as_deref() == Some("@@total_count")));
}
#[test]
fn test_attr_accessors() {
let source = r#"
class Person
attr_reader :name, :age
attr_writer :email
attr_accessor :phone, :address
def initialize(name, age)
@name = name
@age = age
end
end
"#;
let symbols = parse("test.rb", source).unwrap();
let properties: Vec<_> = symbols.iter()
.filter(|s| matches!(s.kind, SymbolKind::Property))
.collect();
assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("name")));
assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("age")));
assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("email")));
assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("phone")));
assert!(properties.iter().any(|p| p.symbol.as_deref() == Some("address")));
assert_eq!(properties.len(), 5);
}
#[test]
fn test_extract_ruby_requires() {
let source = r#"
require 'json'
require 'rails'
require 'activerecord'
require_relative '../models/user'
require_relative './helpers/auth'
class UsersController
def index
# implementation
end
end
"#;
let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
assert_eq!(deps.len(), 5, "Should extract 5 require statements");
assert!(deps.iter().any(|d| d.imported_path == "json"));
assert!(deps.iter().any(|d| d.imported_path == "rails"));
assert!(deps.iter().any(|d| d.imported_path == "activerecord"));
assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
assert!(deps.iter().any(|d| d.imported_path == "./helpers/auth"));
let json_dep = deps.iter().find(|d| d.imported_path == "json").unwrap();
assert!(matches!(json_dep.import_type, ImportType::Stdlib),
"json should be classified as Stdlib");
let rails_dep = deps.iter().find(|d| d.imported_path == "rails").unwrap();
assert!(matches!(rails_dep.import_type, ImportType::External),
"rails should be classified as External");
let user_dep = deps.iter().find(|d| d.imported_path == "../models/user").unwrap();
assert!(matches!(user_dep.import_type, ImportType::Internal),
"require_relative should be classified as Internal");
}
#[test]
fn test_dynamic_requires_filtered() {
let source = r##"
require 'json'
require 'rails'
require_relative '../models/user'
# Dynamic requires - should be filtered out
require variable
require CONSTANT
require File.join('path', 'to', 'file')
require_relative File.dirname(__FILE__) + '/dynamic'
load "#{Rails.root}/lib/dynamic.rb"
"##;
let deps = RubyDependencyExtractor::extract_dependencies(source).unwrap();
assert_eq!(deps.len(), 3, "Should extract 3 static requires only");
assert!(deps.iter().any(|d| d.imported_path == "json"));
assert!(deps.iter().any(|d| d.imported_path == "rails"));
assert!(deps.iter().any(|d| d.imported_path == "../models/user"));
assert!(!deps.iter().any(|d| d.imported_path.contains("variable")));
assert!(!deps.iter().any(|d| d.imported_path.contains("CONSTANT")));
assert!(!deps.iter().any(|d| d.imported_path.contains("File")));
assert!(!deps.iter().any(|d| d.imported_path.contains("Rails")));
}
}
#[cfg(test)]
mod monorepo_tests {
use super::*;
#[test]
fn test_resolve_ruby_require_lib_structure() {
let projects = vec![
RubyProject {
gem_name: "activerecord".to_string(),
project_root: "gems/activerecord".to_string(),
abs_project_root: "/path/to/gems/activerecord".to_string(),
},
];
let result = resolve_ruby_require_to_path(
"activerecord/base",
&projects,
None,
);
assert_eq!(result, Some("gems/activerecord/lib/activerecord/base.rb".to_string()));
}
#[test]
fn test_resolve_ruby_require_root_structure() {
let projects = vec![
RubyProject {
gem_name: "my-gem".to_string(),
project_root: "gems/my-gem".to_string(),
abs_project_root: "/path/to/gems/my-gem".to_string(),
},
];
let result = resolve_ruby_require_to_path(
"my_gem/utils",
&projects,
None,
);
assert_eq!(result, Some("gems/my-gem/lib/my_gem/utils.rb".to_string()));
}
#[test]
fn test_resolve_ruby_require_no_match() {
let projects = vec![
RubyProject {
gem_name: "activerecord".to_string(),
project_root: "gems/activerecord".to_string(),
abs_project_root: "/path/to/gems/activerecord".to_string(),
},
];
let result = resolve_ruby_require_to_path(
"rails/application",
&projects,
None,
);
assert_eq!(result, None);
}
#[test]
fn test_resolve_ruby_require_hyphen_underscore_conversion() {
let projects = vec![
RubyProject {
gem_name: "active-record".to_string(),
project_root: "gems/active-record".to_string(),
abs_project_root: "/path/to/gems/active-record".to_string(),
},
];
let result = resolve_ruby_require_to_path(
"active_record/base",
&projects,
None,
);
assert_eq!(result, Some("gems/active-record/lib/active_record/base.rb".to_string()));
}
#[test]
fn test_resolve_ruby_require_monorepo() {
let projects = vec![
RubyProject {
gem_name: "activerecord".to_string(),
project_root: "gems/activerecord".to_string(),
abs_project_root: "/path/to/gems/activerecord".to_string(),
},
RubyProject {
gem_name: "activesupport".to_string(),
project_root: "gems/activesupport".to_string(),
abs_project_root: "/path/to/gems/activesupport".to_string(),
},
RubyProject {
gem_name: "actionpack".to_string(),
project_root: "gems/actionpack".to_string(),
abs_project_root: "/path/to/gems/actionpack".to_string(),
},
];
let ar_result = resolve_ruby_require_to_path(
"activerecord/base",
&projects,
None,
);
assert_eq!(ar_result, Some("gems/activerecord/lib/activerecord/base.rb".to_string()));
let as_result = resolve_ruby_require_to_path(
"activesupport/core_ext",
&projects,
None,
);
assert_eq!(as_result, Some("gems/activesupport/lib/activesupport/core_ext.rb".to_string()));
let ap_result = resolve_ruby_require_to_path(
"actionpack/controller",
&projects,
None,
);
assert_eq!(ap_result, Some("gems/actionpack/lib/actionpack/controller.rb".to_string()));
}
}