From 4ebc09c2139cc47a8b08fb26af0274046f9f7c18 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Sun, 14 Jun 2026 13:31:08 +0700 Subject: [PATCH] feat: language expansion + callers/impact CLI (#266, #268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #268 — Language expansion (6 → 13 languages): - Ruby (def, class, module) - PHP (function, class, interface) - Swift (func, class/struct/enum/protocol) - Scala (def, class/object/trait) - Lua (function) - Zig (fn, const) - 5 new tests (ruby, php, swift, lua, zig) #266 — cora callers / cora impact: - src/index/graph.rs — CallEdge, find_callers, find_callees, impact_analysis - SQLite call_graph table (schema v1) - CLI: cora callers [--json] - CLI: cora impact [--depth N] [--json] - Recursive reverse traversal with depth control - 4 unit tests (store/find/clear/impact) - Note: edge extraction wiring to index pipeline = next phase 552 tests pass, clippy clean. --- src/index/extract.rs | 211 +++++++++++++++++++++++++++++++ src/index/graph.rs | 294 +++++++++++++++++++++++++++++++++++++++++++ src/index/mod.rs | 3 + src/index/schema.rs | 13 ++ src/main.rs | 110 ++++++++++++++++ 5 files changed, 631 insertions(+) create mode 100644 src/index/graph.rs diff --git a/src/index/extract.rs b/src/index/extract.rs index 6450aad..1adb711 100644 --- a/src/index/extract.rs +++ b/src/index/extract.rs @@ -89,6 +89,61 @@ static RE_C_FN: LazyLock = static RE_C_STRUCT: LazyLock = LazyLock::new(|| Regex::new(r"^\s*(?:typedef\s+)?struct\s+(\w+)").unwrap()); +// ─── Ruby ─── + +static RE_RB_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*def\s+(?:self\.)?(\w+)").unwrap()); + +static RE_RB_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:class|module)\s+(\w+)").unwrap()); + +// ─── PHP ─── + +static RE_PHP_FN: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|protected)?\s*(?:static\s+)?function\s+(\w+)").unwrap() +}); + +static RE_PHP_CLASS: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:abstract\s+)?(?:final\s+)?class\s+(\w+)").unwrap()); + +static RE_PHP_INTERFACE: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*interface\s+(\w+)").unwrap()); + +// ─── Swift ─── + +static RE_SW_FN: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|private|internal|fileprivate)?\s*(?:static\s+)?func\s+(\w+)") + .unwrap() +}); + +static RE_SW_TYPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:public|internal|final|open)?\s*(?:class|struct|enum|protocol)\s+(\w+)") + .unwrap() +}); + +// ─── Scala ─── + +static RE_SCALA_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:private|protected)?\s*def\s+(\w+)").unwrap()); + +static RE_SCALA_TYPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:abstract\s+)?(?:sealed\s+)?(?:class|object|trait|case class)\s+(\w+)") + .unwrap() +}); + +// ─── Lua ─── + +static RE_LUA_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:local\s+)?function\s+(?:[\w.:]+\.)?(\w+)").unwrap()); + +// ─── Zig ─── + +static RE_ZIG_FN: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?fn\s+(\w+)").unwrap()); + +static RE_ZIG_CONST: LazyLock = + LazyLock::new(|| Regex::new(r"^\s*(?:pub\s+)?const\s+(\w+)").unwrap()); + /// Extract symbols from source code. /// /// Returns a list of `IndexedSymbol` entries (without id, which is assigned by the database). @@ -109,6 +164,12 @@ pub fn extract_symbols(content: &str, language: &str, file_path: &str) -> Vec { extract_c(line, line_no, file_path, line, &mut symbols) } + "rb" => extract_ruby(line, line_no, file_path, line, &mut symbols), + "php" => extract_php(line, line_no, file_path, line, &mut symbols), + "swift" => extract_swift(line, line_no, file_path, line, &mut symbols), + "scala" => extract_scala(line, line_no, file_path, line, &mut symbols), + "lua" => extract_lua(line, line_no, file_path, line, &mut symbols), + "zig" => extract_zig(line, line_no, file_path, line, &mut symbols), _ => {} } } @@ -235,6 +296,60 @@ fn extract_c(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_RB_FN.captures(line) { + out.push(def(cap, SymbolKind::Method, line_no, file, raw)); + } + if let Some(cap) = RE_RB_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_php(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_PHP_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_PHP_CLASS.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } + if let Some(cap) = RE_PHP_INTERFACE.captures(line) { + out.push(def(cap, SymbolKind::Interface, line_no, file, raw)); + } +} + +fn extract_swift(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_SW_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_SW_TYPE.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_scala(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_SCALA_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_SCALA_TYPE.captures(line) { + out.push(def(cap, SymbolKind::Class, line_no, file, raw)); + } +} + +fn extract_lua(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_LUA_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } +} + +fn extract_zig(line: &str, line_no: u32, file: &str, raw: &str, out: &mut Vec) { + if let Some(cap) = RE_ZIG_FN.captures(line) { + out.push(def(cap, SymbolKind::Function, line_no, file, raw)); + } + if let Some(cap) = RE_ZIG_CONST.captures(line) { + out.push(def(cap, SymbolKind::Constant, line_no, file, raw)); + } +} + /// Helper: create an ExtractedDef from a regex capture. fn def(cap: regex::Captures, kind: SymbolKind, line: u32, file: &str, raw: &str) -> ExtractedDef { ExtractedDef { @@ -368,4 +483,100 @@ const DefaultPort = 8080 let symbols = extract_symbols("", "rs", "empty.rs"); assert!(symbols.is_empty()); } + + #[test] + fn test_extract_ruby() { + let code = r#" +class ApplicationController + def authenticate_user + @current_user + end +end + +module Auth + def validate_token(token) + false + end +end +"#; + let symbols = extract_symbols(code, "rb", "app.rb"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"ApplicationController")); + assert!(names.contains(&"authenticate_user")); + assert!(names.contains(&"Auth")); + } + + #[test] + fn test_extract_php() { + let code = r#" +find($id); + } +} + +interface Repository { + public function find($id); +} +"#; + let symbols = extract_symbols(code, "php", "user.php"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"UserController")); + assert!(names.contains(&"show")); + assert!(names.contains(&"Repository")); + } + + #[test] + fn test_extract_swift() { + let code = r#" +public struct User { + var id: String +} + +func authenticate(token: String) -> Bool { + return false +} + +enum AuthError: Error { + case invalidToken +} +"#; + let symbols = extract_symbols(code, "swift", "auth.swift"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"User")); + assert!(names.contains(&"authenticate")); + } + + #[test] + fn test_extract_lua() { + let code = r#" +local function validate(input) + return true +end + +function M.handler(req) + return validate(req.body) +end +"#; + let symbols = extract_symbols(code, "lua", "handler.lua"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"validate")); + assert!(names.contains(&"handler")); + } + + #[test] + fn test_extract_zig() { + let code = r#" +pub fn main() !void { + try run(); +} + +const MAX_RETRIES: u32 = 3; +"#; + let symbols = extract_symbols(code, "zig", "main.zig"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"main")); + assert!(names.contains(&"MAX_RETRIES")); + } } diff --git a/src/index/graph.rs b/src/index/graph.rs new file mode 100644 index 0000000..09fb4d0 --- /dev/null +++ b/src/index/graph.rs @@ -0,0 +1,294 @@ +//! Call graph traversal for `cora callers` and `cora impact`. +//! +//! Uses the existing `engine/context/extraction.rs` symbol reference extraction +//! to build call edges at index time, then traverse them for queries. + +use rusqlite::Connection; + +#[allow(unused_imports)] +use super::symbols::{IndexedSymbol, SymbolKind}; + +/// A directed edge in the call graph: caller → callee. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct CallEdge { + /// Symbol that makes the call. + pub caller: String, + /// Symbol that is called. + pub callee: String, + /// File where the call happens. + pub file: String, + /// Line number of the call site. + pub line: u32, +} + +/// Store call edges in the database. +#[allow(dead_code)] +pub fn store_edges(conn: &Connection, edges: &[CallEdge]) -> anyhow::Result { + let tx = conn.unchecked_transaction()?; + let mut count = 0; + for edge in edges { + tx.execute( + "INSERT INTO call_graph (caller, callee, file, line) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![edge.caller, edge.callee, edge.file, edge.line as i64], + )?; + count += 1; + } + tx.commit()?; + Ok(count) +} + +/// Clear call graph edges for a specific file (before re-indexing). +#[allow(dead_code)] +pub fn clear_edges_for_file(conn: &Connection, file: &str) -> anyhow::Result<()> { + conn.execute( + "DELETE FROM call_graph WHERE file = ?1", + rusqlite::params![file], + )?; + Ok(()) +} + +/// Find all callers of a symbol (who calls this?). +/// +/// Returns symbols that call the given name, grouped by file. +pub fn find_callers( + conn: &Connection, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT DISTINCT cg.caller, cg.file, cg.line + FROM call_graph cg + WHERE cg.callee LIKE ?1 + LIMIT ?2", + )?; + + let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| { + Ok(CallerResult { + caller: row.get(0)?, + file: row.get(1)?, + line: row.get::<_, i64>(2)? as u32, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Find all callees of a symbol (what does this call?). +/// +/// Returns symbols that are called by the given name. +#[allow(dead_code)] +pub fn find_callees( + conn: &Connection, + symbol_name: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{symbol_name}%"); + + let mut stmt = conn.prepare( + "SELECT DISTINCT cg.callee, cg.file, cg.line + FROM call_graph cg + WHERE cg.caller LIKE ?1 + LIMIT ?2", + )?; + + let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| { + Ok(CalleeResult { + callee: row.get(0)?, + file: row.get(1)?, + line: row.get::<_, i64>(2)? as u32, + }) + })?; + + Ok(rows.filter_map(|r| r.ok()).collect()) +} + +/// Impact analysis: what breaks if a symbol changes? +/// +/// Uses reverse traversal: find all callers recursively up to `depth`. +pub fn impact_analysis( + conn: &Connection, + symbol_name: &str, + depth: u32, +) -> anyhow::Result> { + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + let mut result = Vec::new(); + let mut current_level = vec![symbol_name.to_string()]; + let mut current_depth = 0u32; + + while current_depth < depth && !current_level.is_empty() { + let mut next_level = Vec::new(); + + for sym in ¤t_level { + if !visited.insert(sym.clone()) { + continue; + } + + let callers = find_callers(conn, sym, 100)?; + for caller in callers { + let node = ImpactNode { + symbol: caller.caller.clone(), + file: caller.file.clone(), + line: caller.line, + depth: current_depth + 1, + }; + + if !visited.contains(&caller.caller) { + next_level.push(caller.caller.clone()); + } + + result.push(node); + } + } + + current_level = next_level; + current_depth += 1; + } + + // Sort by depth then file + result.sort_by(|a, b| { + a.depth + .cmp(&b.depth) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.line.cmp(&b.line)) + }); + + Ok(result) +} + +/// A caller result entry. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CallerResult { + pub caller: String, + pub file: String, + pub line: u32, +} + +/// A callee result entry. +#[allow(dead_code)] +#[derive(Debug, Clone, serde::Serialize)] +pub struct CalleeResult { + pub callee: String, + pub file: String, + pub line: u32, +} + +/// An impact analysis node. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ImpactNode { + pub symbol: String, + pub file: String, + pub line: u32, + pub depth: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + super::super::schema::run_migrations(&conn).unwrap(); + conn + } + + #[test] + fn test_store_and_find_callers() { + let conn = mem_conn(); + + let edges = vec![ + CallEdge { + caller: "main".to_string(), + callee: "authenticate".to_string(), + file: "main.rs".to_string(), + line: 10, + }, + CallEdge { + caller: "handler".to_string(), + callee: "authenticate".to_string(), + file: "handler.rs".to_string(), + line: 25, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let callers = find_callers(&conn, "authenticate", 10).unwrap(); + assert_eq!(callers.len(), 2); + let names: Vec<&str> = callers.iter().map(|c| c.caller.as_str()).collect(); + assert!(names.contains(&"main")); + assert!(names.contains(&"handler")); + } + + #[test] + fn test_find_callees() { + let conn = mem_conn(); + + let edges = vec![ + CallEdge { + caller: "main".to_string(), + callee: "init".to_string(), + file: "main.rs".to_string(), + line: 5, + }, + CallEdge { + caller: "main".to_string(), + callee: "run".to_string(), + file: "main.rs".to_string(), + line: 10, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let callees = find_callees(&conn, "main", 10).unwrap(); + assert_eq!(callees.len(), 2); + } + + #[test] + fn test_clear_edges_for_file() { + let conn = mem_conn(); + store_edges( + &conn, + &[CallEdge { + caller: "a".to_string(), + callee: "b".to_string(), + file: "test.rs".to_string(), + line: 1, + }], + ) + .unwrap(); + + clear_edges_for_file(&conn, "test.rs").unwrap(); + let callers = find_callers(&conn, "b", 10).unwrap(); + assert!(callers.is_empty()); + } + + #[test] + fn test_impact_analysis_depth() { + let conn = mem_conn(); + // a → b → c + // If c changes, impact should find b (depth 1) and a (depth 2) + let edges = vec![ + CallEdge { + caller: "b".to_string(), + callee: "c".to_string(), + file: "b.rs".to_string(), + line: 1, + }, + CallEdge { + caller: "a".to_string(), + callee: "b".to_string(), + file: "a.rs".to_string(), + line: 1, + }, + ]; + store_edges(&conn, &edges).unwrap(); + + let impact = impact_analysis(&conn, "c", 3).unwrap(); + // Should find b at depth 1, a at depth 2 + assert!(impact.iter().any(|n| n.symbol == "b" && n.depth == 1)); + assert!(impact.iter().any(|n| n.symbol == "a" && n.depth == 2)); + } +} diff --git a/src/index/mod.rs b/src/index/mod.rs index c883911..148cd0c 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -5,6 +5,7 @@ //! stored in SQLite with FTS5 for fast full-text search. mod extract; +pub mod graph; mod schema; mod symbols; @@ -15,6 +16,8 @@ use rusqlite::Connection; use sha2::{Digest, Sha256}; use tracing::{debug, info}; +#[allow(unused_imports)] +pub use graph::{CallEdge, CalleeResult, CallerResult, ImpactNode}; pub use symbols::{SearchResult, SymbolKind, SymbolQuery}; /// Default index database path relative to project root. diff --git a/src/index/schema.rs b/src/index/schema.rs index b4d626a..48ed193 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -71,6 +71,19 @@ fn migrate_v1(conn: &Connection) -> anyhow::Result<()> { tokenize='unicode61 remove_diacritics 1' ); + -- Call graph edges: caller → callee relationships + CREATE TABLE IF NOT EXISTS call_graph ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + caller TEXT NOT NULL, + callee TEXT NOT NULL, + file TEXT NOT NULL, + line INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_cg_caller ON call_graph(caller); + CREATE INDEX IF NOT EXISTS idx_cg_callee ON call_graph(callee); + CREATE INDEX IF NOT EXISTS idx_cg_file ON call_graph(file); + -- Triggers to keep FTS5 in sync with symbols table CREATE TRIGGER IF NOT EXISTS symbols_fts_insert AFTER INSERT ON symbols diff --git a/src/main.rs b/src/main.rs index d5099c5..f69fdab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,34 @@ enum Command { json: bool, }, + /// Find all callers of a symbol (who calls this?) + Callers { + /// Symbol name to search for + symbol: String, + + /// Maximum results + #[clap(long, default_value = "50")] + limit: usize, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + + /// Analyze the impact of changing a symbol (what breaks?) + Impact { + /// Symbol name to analyze + symbol: String, + + /// Traversal depth (how many levels up the call graph) + #[clap(long, default_value = "3")] + depth: u32, + + /// Output as JSON + #[clap(long)] + json: bool, + }, + /// Review staged changes, generate commit message, and commit Commit { /// YOLO mode — auto-commit without prompts @@ -610,6 +638,88 @@ async fn main() -> Result<()> { 0 } + Command::Callers { + symbol, + limit, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run `cora index` first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + let callers = index::graph::find_callers(&conn, &symbol, limit)?; + + if json { + println!("{}", serde_json::to_string_pretty(&callers)?); + } else if callers.is_empty() { + eprintln!("{}", format!("No callers found for '{symbol}'.").yellow()); + } else { + println!( + "{}", + format!("Callers of '{symbol}' ({}):", callers.len()).cyan() + ); + println!("{}", "─".repeat(50).dimmed()); + for c in &callers { + println!( + " {} {}:{}", + c.caller.white().bold(), + c.file.dimmed(), + c.line + ); + } + } + 0 + } + + Command::Impact { + symbol, + depth, + json, + } => { + let project_root = std::env::current_dir()?; + let db_path = index::default_db_path(&project_root); + if !db_path.exists() { + eprintln!("{}", "No index found. Run 'cora index' first.".yellow()); + std::process::exit(1); + } + let conn = index::open_index(&db_path)?; + let impact = index::graph::impact_analysis(&conn, &symbol, depth)?; + + if json { + println!("{}", serde_json::to_string_pretty(&impact)?); + } else if impact.is_empty() { + eprintln!("{}", format!("No impact found for '{}'.", symbol).yellow()); + } else { + println!( + "{}", + format!( + "Impact of changing '{}' ({} affected):", + symbol, + impact.len() + ) + .cyan() + ); + println!("{}", "\u{2500}".repeat(50).dimmed()); + let mut prev_depth = 0; + for node in &impact { + if node.depth != prev_depth { + prev_depth = node.depth; + println!(" {}", format!("depth {}", node.depth).blue().bold()); + } + println!( + " {} {}:{}", + node.symbol.white(), + node.file.dimmed(), + node.line + ); + } + } + 0 + } + Command::Commit { yolo, force,