use crate::ir::reference::{RefKind, UnresolvedRef};
use crate::ir::{Symbol, SymbolId, SymbolKind, TextRange};
use crate::languages::LanguageAnalyzer;
use crate::languages::path::normalize_path_like;
use crate::languages::util::{byte_to_line, line_offsets};
use crate::ts_core::{QueryRunner, compile_queries_ruby, load_ruby_spec};
pub struct SpecRubyAnalyzer {
queries: crate::ts_core::CompiledQueries,
runner: QueryRunner,
}
impl SpecRubyAnalyzer {
pub fn new() -> Self {
let spec = load_ruby_spec();
let queries = compile_queries_ruby(&spec).expect("compile ruby queries");
let runner = QueryRunner::new_ruby();
Self { queries, runner }
}
}
impl Default for SpecRubyAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl LanguageAnalyzer for SpecRubyAnalyzer {
fn language(&self) -> &'static str {
"ruby"
}
fn symbols_in_file(&self, path: &str, source: &str) -> Vec<Symbol> {
let offs = line_offsets(source);
let lines: Vec<&str> = source.lines().collect();
let mut out = Vec::new();
for caps in self.runner.run_captures(source, &self.queries.decl) {
if let Some(nc) = caps.iter().find(|c| c.name == "name") {
let name = &source[nc.start..nc.end];
if name.is_empty() {
continue;
}
let mut kind = SymbolKind::Function;
if let Some(decl_node) = caps.iter().find(|c| c.name == "decl") {
kind = match decl_node.kind.as_str() {
"class" => SymbolKind::Struct,
"module" => SymbolKind::Module,
"method" | "singleton_method" => SymbolKind::Method,
_ => SymbolKind::Function,
};
} else {
for c in &caps {
match c.kind.as_str() {
"class" => {
kind = SymbolKind::Struct;
break;
}
"module" => {
kind = SymbolKind::Module;
break;
}
"method" | "singleton_method" => {
kind = SymbolKind::Method;
break;
}
_ => {}
}
}
}
let sl = byte_to_line(&offs, nc.start);
let mut el = byte_to_line(&offs, nc.end.saturating_sub(1)).max(sl);
let start_idx = (sl.saturating_sub(1)) as usize;
if start_idx < lines.len() {
let end_idx = find_ruby_block_end(&lines, start_idx);
el = ((end_idx as u32) + 1).max(sl);
}
out.push(Symbol {
id: SymbolId::new("ruby", path, &kind, name, sl),
name: name.to_string(),
kind,
file: path.to_string(),
range: TextRange {
start_line: sl,
end_line: el,
},
language: "ruby".to_string(),
});
}
}
out
}
fn unresolved_refs(&self, path: &str, source: &str) -> Vec<UnresolvedRef> {
let mut out = Vec::new();
let offs = line_offsets(source);
let re_sym_call = regex::Regex::new(r":([A-Za-z_][A-Za-z0-9_?!]*)").unwrap();
for caps in self.runner.run_captures(source, &self.queries.calls) {
let name_cap = caps.iter().find(|c| c.name == "name");
if let Some(n) = name_cap {
let mut name = source[n.start..n.end].to_string();
if name.is_empty() {
continue;
}
if (name == "send" || name == "public_send")
&& let Some(callnode) = caps.iter().find(|c| c.name == "call")
{
let text = &source[callnode.start..callnode.end];
if let Some(mat) = re_sym_call.captures(text) {
name = mat.get(1).unwrap().as_str().to_string();
}
}
let ln = if let Some(callnode) = caps.iter().find(|c| c.name == "call") {
byte_to_line(&offs, callnode.start)
} else {
byte_to_line(&offs, n.start)
};
out.push(UnresolvedRef {
name,
kind: RefKind::Call,
file: path.to_string(),
line: ln,
qualifier: None,
is_method: true,
});
}
}
use regex::Regex;
let re_bare = Regex::new(r"^\s*([a-zA-Z_][A-Za-z0-9_?!]*)").unwrap();
let seen: std::collections::HashSet<(u32, String)> =
out.iter().map(|r| (r.line, r.name.clone())).collect();
for (i, line) in source.lines().enumerate() {
if let Some(cap) = re_bare.captures(line) {
let name = cap.get(1).unwrap().as_str();
let rest = &line[cap.get(0).unwrap().end()..];
let rest_trim = rest.trim_start();
if rest_trim.starts_with('=')
|| rest_trim.starts_with('.')
|| rest_trim.starts_with("::")
|| rest_trim.starts_with('(')
{
} else {
let ln = (i as u32) + 1;
if !seen.contains(&(ln, name.to_string())) {
out.push(UnresolvedRef {
name: name.to_string(),
kind: RefKind::Call,
file: path.to_string(),
line: ln,
qualifier: None,
is_method: true,
});
}
}
}
}
out
}
fn imports_in_file(
&self,
path: &str,
source: &str,
) -> std::collections::HashMap<String, String> {
use regex::Regex;
let mut map = std::collections::HashMap::new();
let re_req = Regex::new(r#"^\s*(require|require_relative)\s+['\"]([^'\"]+)['\"]"#).unwrap();
for line in source.lines() {
if let Some(cap) = re_req.captures(line) {
let kind = cap.get(1).unwrap().as_str();
let raw = cap.get(2).unwrap().as_str();
let mut pfx = raw.trim();
if pfx.ends_with(".rb") {
pfx = &pfx[..pfx.len() - 3];
}
let pfx = pfx.replace('\\', "/");
let normalized = if kind == "require_relative" {
let base_dir = std::path::Path::new(path)
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let joined = base_dir.join(pfx);
let canon = normalize_path_like(&joined);
canon
.trim_start_matches("./")
.trim_start_matches('.')
.trim_start_matches('/')
.to_string()
} else {
pfx.trim_start_matches("./")
.trim_start_matches('.')
.trim_start_matches('/')
.to_string()
};
if normalized.is_empty() {
continue;
}
map.insert(format!("__glob__{}", normalized), normalized);
}
}
map
}
}
fn find_ruby_block_end(lines: &[&str], start: usize) -> usize {
let mut depth = 0i32;
let re_begin = regex::Regex::new(r"\b(def|class|module)\b").unwrap();
let re_end = regex::Regex::new(r"\bend\b").unwrap();
for (idx, line) in lines.iter().enumerate().skip(start) {
if re_begin.is_match(line) {
depth += 1;
}
if re_end.is_match(line) {
depth -= 1;
if depth == 0 {
return idx;
}
}
}
lines.len().saturating_sub(1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::languages::LanguageAnalyzer;
#[test]
fn extract_calls_including_safe_nav_and_send() {
let src = r#"def m; end
def foo
a=nil
a&.m
m
self.send(:m)
end
"#;
let ana = SpecRubyAnalyzer::new();
let refs = ana.unresolved_refs("a.rb", src);
let names: Vec<_> = refs.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"m"));
assert!(names.iter().filter(|&&n| n == "m").count() >= 2);
}
}