From e1c2e6f1e3c1a9b42cf696a732911c9b4cbfe92b Mon Sep 17 00:00:00 2001 From: Daniel Hodges Date: Mon, 27 Jul 2026 09:53:00 -0700 Subject: [PATCH 1/2] Add async JSON-RPC rust-analyzer client and workdir integration Spawn rust-analyzer as a background child process (kill_on_drop, 30s request timeout) and attach it to DatabaseManager on Rust projects. Make WorkdirIndex::build[_incremental] and the workdir-refresh paths in query, lsp, and mcp binaries async. --- src/bin/query.rs | 17 +- src/bin/semcode-lsp.rs | 6 +- src/bin/semcode-mcp.rs | 46 +++-- src/database/connection.rs | 22 +++ src/lib.rs | 1 + src/rust_analyzer.rs | 338 +++++++++++++++++++++++++++++++++++++ src/workdir.rs | 85 +++++----- 7 files changed, 458 insertions(+), 57 deletions(-) create mode 100644 src/rust_analyzer.rs diff --git a/src/bin/query.rs b/src/bin/query.rs index 7f92b47..135ef3b 100644 --- a/src/bin/query.rs +++ b/src/bin/query.rs @@ -15,10 +15,10 @@ use semcode::display::print_welcome_message_with_model; /// Rebuild the working directory index to pick up any file changes since the last query. /// Reuses cached analysis results for files whose mtime and size haven't changed. -fn refresh_workdir_index(db_manager: &DatabaseManager, git_repo: &str) { +async fn refresh_workdir_index(db_manager: &DatabaseManager, git_repo: &str) { let repo_path = std::path::Path::new(git_repo); let previous = db_manager.take_workdir_index(); - match semcode::WorkdirIndex::build_incremental(repo_path, previous.as_ref()) { + match semcode::WorkdirIndex::build_incremental(repo_path, previous.as_ref()).await { Ok(workdir) => { if workdir.is_empty() { // No need to set — we already took it out @@ -235,7 +235,14 @@ async fn main() -> Result<()> { info!("Connecting to database: {}", database_path); // Connect to database - let db_manager = Arc::new(DatabaseManager::new(&database_path, args.git_repo.clone()).await?); + let db_manager = DatabaseManager::new(&database_path, args.git_repo.clone()).await?; + + // Attach rust-analyzer if applicable + if let Err(e) = db_manager.attach_rust_analyzer().await { + tracing::warn!("Failed to attach rust-analyzer: {}", e); + } + + let db_manager = Arc::new(db_manager); // Ensure tables exist db_manager.create_tables().await?; @@ -454,7 +461,7 @@ async fn main() -> Result<()> { // Rebuild workdir index to reflect current file state if !args.git_only { - refresh_workdir_index(&db_manager, &args.git_repo); + refresh_workdir_index(&db_manager, &args.git_repo).await; } // Execute the command @@ -521,7 +528,7 @@ async fn main() -> Result<()> { // Rebuild workdir index to reflect current file state if !args.git_only { - refresh_workdir_index(&db_manager, &args.git_repo); + refresh_workdir_index(&db_manager, &args.git_repo).await; } // Handle command and check if we should exit diff --git a/src/bin/semcode-lsp.rs b/src/bin/semcode-lsp.rs index 69d57c4..b0b134c 100644 --- a/src/bin/semcode-lsp.rs +++ b/src/bin/semcode-lsp.rs @@ -89,6 +89,9 @@ impl SemcodeLspBackend { match DatabaseManager::new(&processed_path, git_repo_path.clone()).await { Ok(database_manager) => { + if let Err(e) = database_manager.attach_rust_analyzer().await { + tracing::warn!("Failed to attach rust-analyzer: {}", e); + } *db = Some(database_manager); // Get the current git SHA for git-aware lookups @@ -119,7 +122,8 @@ impl SemcodeLspBackend { let path = std::path::Path::new(&repo_path); let previous = db.take_workdir_index(); - if let Ok(workdir) = semcode::WorkdirIndex::build_incremental(path, previous.as_ref()) { + if let Ok(workdir) = semcode::WorkdirIndex::build_incremental(path, previous.as_ref()).await + { if !workdir.is_empty() { db.set_workdir_index(workdir); } diff --git a/src/bin/semcode-mcp.rs b/src/bin/semcode-mcp.rs index c8d4079..9768095 100644 --- a/src/bin/semcode-mcp.rs +++ b/src/bin/semcode-mcp.rs @@ -2572,7 +2572,15 @@ impl McpServer { model_path: Option, lazy_mode: bool, ) -> Result { - let db = Arc::new(DatabaseManager::new(database_path, git_repo_path.to_string()).await?); + let db_manager = DatabaseManager::new(database_path, git_repo_path.to_string()).await?; + let db = Arc::new(db_manager); + + let db_clone = db.clone(); + tokio::spawn(async move { + if let Err(e) = db_clone.attach_rust_analyzer().await { + tracing::warn!("Failed to attach rust-analyzer: {}", e); + } + }); // Get the default git SHA (current HEAD) let default_git_sha = match git::get_git_sha(git_repo_path) { @@ -2617,7 +2625,7 @@ impl McpServer { /// If branch is provided, resolve it to a SHA. Otherwise use git_sha or default. /// When using the default HEAD SHA (no explicit git_sha or branch), refreshes /// the working directory overlay so queries reflect uncommitted changes. - fn resolve_git_sha_or_branch( + async fn resolve_git_sha_or_branch( &self, git_sha_arg: Option<&str>, branch_arg: Option<&str>, @@ -2642,17 +2650,17 @@ impl McpServer { self.db.clear_workdir_index(); } else { // Using default HEAD — refresh workdir overlay - self.refresh_workdir_index(); + self.refresh_workdir_index().await; } self.resolve_git_sha(git_sha_arg) } /// Rebuild the working directory index to reflect current file state. - fn refresh_workdir_index(&self) { + async fn refresh_workdir_index(&self) { let repo_path = std::path::Path::new(&self.git_repo_path); let previous = self.db.take_workdir_index(); - match semcode::WorkdirIndex::build_incremental(repo_path, previous.as_ref()) { + match semcode::WorkdirIndex::build_incremental(repo_path, previous.as_ref()).await { Ok(workdir) => { if !workdir.is_empty() { self.db.set_workdir_index(workdir); @@ -2954,7 +2962,9 @@ impl McpServer { let name = args["name"].as_str().unwrap_or(""); let git_sha_arg = args["git_sha"].as_str(); let branch_arg = args["branch"].as_str(); - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_query_function_or_macro(&self.db, name, &git_sha).await { Ok(output) => json!({ @@ -2978,7 +2988,9 @@ impl McpServer { let name = args["name"].as_str().unwrap_or(""); let git_sha_arg = args["git_sha"].as_str(); let branch_arg = args["branch"].as_str(); - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_query_type_or_typedef(&self.db, name, &git_sha).await { Ok(output) => json!({ @@ -3002,7 +3014,9 @@ impl McpServer { let name = args["name"].as_str().unwrap_or(""); let git_sha_arg = args["git_sha"].as_str(); let branch_arg = args["branch"].as_str(); - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_show_callers(&self.db, name, &git_sha).await { Ok(output) => json!({ @@ -3026,7 +3040,9 @@ impl McpServer { let name = args["name"].as_str().unwrap_or(""); let git_sha_arg = args["git_sha"].as_str(); let branch_arg = args["branch"].as_str(); - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_show_calls(&self.db, name, &git_sha).await { Ok(output) => json!({ @@ -3050,7 +3066,9 @@ impl McpServer { let name = args["name"].as_str().unwrap_or(""); let git_sha_arg = args["git_sha"].as_str(); let branch_arg = args["branch"].as_str(); - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; // Parse the new parameters with same defaults as query tool let up_levels = args["up_levels"].as_u64().unwrap_or(2) as usize; @@ -3110,7 +3128,9 @@ impl McpServer { let path_pattern = args["path_pattern"].as_str(); let limit = args["limit"].as_u64().unwrap_or(100) as usize; - let git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_grep_function_bodies(&self.db, pattern, verbose, path_pattern, limit, &git_sha) .await @@ -3139,7 +3159,9 @@ impl McpServer { let path_pattern = args["path_pattern"].as_str(); let limit = args["limit"].as_u64().unwrap_or(10) as usize; - let _git_sha = self.resolve_git_sha_or_branch(git_sha_arg, branch_arg); + let _git_sha = self + .resolve_git_sha_or_branch(git_sha_arg, branch_arg) + .await; match mcp_vgrep_similar_functions( &self.db, diff --git a/src/database/connection.rs b/src/database/connection.rs index 724bf4d..257f099 100644 --- a/src/database/connection.rs +++ b/src/database/connection.rs @@ -42,6 +42,7 @@ pub struct DatabaseManager { symbol_filename_store: SymbolFilenameStore, branch_store: IndexedBranchStore, workdir_index: std::sync::RwLock>, + rust_analyzer: std::sync::RwLock>>, } impl DatabaseManager { @@ -62,9 +63,30 @@ impl DatabaseManager { symbol_filename_store: SymbolFilenameStore::new(connection.clone()), branch_store: IndexedBranchStore::new(connection.clone()), workdir_index: std::sync::RwLock::new(None), + rust_analyzer: std::sync::RwLock::new(None), }) } + pub async fn attach_rust_analyzer(&self) -> Result<()> { + if std::path::Path::new(&self.git_repo_path) + .join("Cargo.toml") + .exists() + { + let lsp = crate::rust_analyzer::RustAnalyzer::start(std::path::Path::new( + &self.git_repo_path, + )) + .await?; + if let Ok(mut w) = self.rust_analyzer.write() { + *w = Some(std::sync::Arc::new(lsp)); + } + } + Ok(()) + } + + pub fn rust_analyzer(&self) -> Option> { + self.rust_analyzer.read().ok()?.clone() + } + pub async fn list_tables(&self) -> Result> { Ok(self.connection.table_names().execute().await?) } diff --git a/src/lib.rs b/src/lib.rs index b1c79d0..5488db9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod git_range; pub mod hash; pub mod indexer; pub mod perf_monitor; +pub mod rust_analyzer; pub mod symbol_walkback; pub mod text_utils; mod treesitter_analyzer; diff --git a/src/rust_analyzer.rs b/src/rust_analyzer.rs new file mode 100644 index 0000000..de921b6 --- /dev/null +++ b/src/rust_analyzer.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use anyhow::{anyhow, Result}; +use dashmap::DashMap; +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, error, info}; + +/// Maximum time to wait for a single rust-analyzer JSON-RPC response before +/// giving up. Prevents a dead or wedged server from hanging callers forever. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Serialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: usize, + method: String, + params: Value, +} + +#[derive(Serialize)] +struct JsonRpcNotification { + jsonrpc: String, + method: String, + params: Value, +} + +type ResponseSender = oneshot::Sender; + +/// Frame a JSON-RPC payload with the LSP `Content-Length` header. +fn frame_message(payload: &str) -> String { + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) +} + +/// Client to manage a background rust-analyzer process via JSON-RPC. +pub struct RustAnalyzer { + request_tx: mpsc::Sender<(JsonRpcRequest, ResponseSender)>, + notification_tx: mpsc::Sender, + next_id: Arc, + /// Owned handle to the rust-analyzer process. Spawned with `kill_on_drop`, + /// so the process is terminated when this client is dropped rather than + /// leaking an orphaned rust-analyzer for the lifetime of the machine. + _child: Mutex, +} + +impl RustAnalyzer { + /// Start a new rust-analyzer process in the background and initialize it. + pub async fn start(workspace_root: &Path) -> Result { + info!( + "Starting rust-analyzer for workspace: {}", + workspace_root.display() + ); + + let mut child = Command::new("rust-analyzer") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) // ignore stderr logs for now to avoid polluting stdout + .kill_on_drop(true) + .spawn() + .map_err(|e| anyhow!("Failed to spawn rust-analyzer: {}", e))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to open stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to open stdout"))?; + + let (req_tx, mut req_rx) = mpsc::channel::<(JsonRpcRequest, ResponseSender)>(100); + let (notif_tx, mut notif_rx) = mpsc::channel::(100); + let next_id = Arc::new(AtomicUsize::new(1)); + + let pending_requests: Arc> = Arc::new(DashMap::new()); + let pending_requests_clone = pending_requests.clone(); + + // Sender task + tokio::spawn(async move { + loop { + tokio::select! { + Some((req, resp_tx)) = req_rx.recv() => { + let id = req.id; + pending_requests_clone.insert(id, resp_tx); + let payload = match serde_json::to_string(&req) { + Ok(p) => p, + Err(e) => { + error!("Failed to serialize request: {}", e); + pending_requests_clone.remove(&id); + continue; + } + }; + let msg = frame_message(&payload); + if let Err(e) = stdin.write_all(msg.as_bytes()).await { + error!("Failed to write request to stdin: {}", e); + pending_requests_clone.remove(&id); + break; + } + if let Err(e) = stdin.flush().await { + error!("Failed to flush stdin: {}", e); + pending_requests_clone.remove(&id); + break; + } + } + Some(notif) = notif_rx.recv() => { + let payload = match serde_json::to_string(¬if) { + Ok(p) => p, + Err(e) => { + error!("Failed to serialize notification: {}", e); + continue; + } + }; + let msg = frame_message(&payload); + if let Err(e) = stdin.write_all(msg.as_bytes()).await { + error!("Failed to write notification to stdin: {}", e); + break; + } + if let Err(e) = stdin.flush().await { + error!("Failed to flush stdin: {}", e); + break; + } + } + else => break, // Both channels closed + } + } + debug!("rust-analyzer sender task exited"); + }); + + // Reader task + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length = 0; + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) => return, // EOF + Ok(_) => { + if line == "\r\n" || line == "\n" { + break; + } + if line.starts_with("Content-Length: ") { + if let Ok(len) = line + .trim_start_matches("Content-Length: ") + .trim() + .parse::() + { + content_length = len; + } + } + } + Err(e) => { + error!("Error reading from stdout: {}", e); + return; + } + } + } + + if content_length > 0 { + let mut buf = vec![0; content_length]; + if let Err(e) = reader.read_exact(&mut buf).await { + error!("Error reading exact from stdout: {}", e); + return; + } + if let Ok(payload) = String::from_utf8(buf) { + if let Ok(msg) = serde_json::from_str::(&payload) { + // If it's a response with an ID, route it + if let Some(id_val) = msg.get("id") { + if let Some(id) = id_val.as_u64().map(|i| i as usize) { + if let Some((_, sender)) = pending_requests.remove(&id) { + let _ = sender.send(msg); + } + } + } else { + // Notification from server, could be workDoneProgress, diagnostics, etc. + // ignoring for now + } + } + } + } + } + }); + + let client = Self { + request_tx: req_tx, + notification_tx: notif_tx, + next_id, + _child: Mutex::new(child), + }; + + // Initialize + let workspace_uri = format!("file://{}", workspace_root.display()); + client + .send_request( + "initialize", + json!({ + "processId": std::process::id(), + "rootUri": workspace_uri, + "capabilities": { + "workspace": { + "workspaceFolders": true, + "symbol": { + "dynamicRegistration": true + } + }, + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": true + } + } + } + }), + ) + .await?; + + client.send_notification("initialized", json!({})).await?; + + info!("rust-analyzer initialized successfully"); + Ok(client) + } + + /// Send a notification to the server. + pub async fn send_notification(&self, method: &str, params: Value) -> Result<()> { + let notif = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + }; + self.notification_tx + .send(notif) + .await + .map_err(|_| anyhow!("rust-analyzer sender task dead"))?; + Ok(()) + } + + /// Send a request to the server and wait for the response. + /// + /// Returns the JSON-RPC `result` payload (not the full envelope). Fails if + /// the server reports an error, dies, or does not respond within + /// [`REQUEST_TIMEOUT`]. + pub async fn send_request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id, + method: method.to_string(), + params, + }; + + let (resp_tx, resp_rx) = oneshot::channel(); + self.request_tx + .send((req, resp_tx)) + .await + .map_err(|_| anyhow!("rust-analyzer sender task dead"))?; + + let response = tokio::time::timeout(REQUEST_TIMEOUT, resp_rx) + .await + .map_err(|_| { + anyhow!( + "rust-analyzer request '{}' timed out after {}s", + method, + REQUEST_TIMEOUT.as_secs() + ) + })? + .map_err(|_| anyhow!("Failed to receive response from rust-analyzer"))?; + if let Some(err) = response.get("error") { + return Err(anyhow!("rust-analyzer error: {}", err)); + } + + // Unwrap the JSON-RPC envelope; callers only care about `result`. + Ok(response.get("result").cloned().unwrap_or(Value::Null)) + } + + /// Query workspace symbols + pub async fn workspace_symbol(&self, query: &str) -> Result { + self.send_request( + "workspace/symbol", + json!({ + "query": query + }), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frame_message_has_content_length_and_separator() { + let payload = r#"{"jsonrpc":"2.0"}"#; + let framed = frame_message(payload); + assert_eq!( + framed, + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) + ); + // Header and body are separated by a blank line. + let (header, body) = framed.split_once("\r\n\r\n").expect("missing separator"); + assert_eq!(header, format!("Content-Length: {}", payload.len())); + assert_eq!(body, payload); + } + + #[test] + fn test_request_serializes_with_id_and_jsonrpc() { + let req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: 7, + method: "workspace/symbol".to_string(), + params: json!({ "query": "foo" }), + }; + let v: Value = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 7); + assert_eq!(v["method"], "workspace/symbol"); + assert_eq!(v["params"]["query"], "foo"); + } + + #[test] + fn test_notification_serializes_without_id() { + let notif = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "initialized".to_string(), + params: json!({}), + }; + let v: Value = serde_json::from_str(&serde_json::to_string(¬if).unwrap()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["method"], "initialized"); + assert!(v.get("id").is_none(), "notifications must not carry an id"); + } +} diff --git a/src/workdir.rs b/src/workdir.rs index 5333d33..840dce1 100644 --- a/src/workdir.rs +++ b/src/workdir.rs @@ -61,9 +61,9 @@ pub struct WorkdirIndex { impl WorkdirIndex { /// Build a WorkdirIndex by scanning the working directory for uncommitted changes. /// - /// Equivalent to `build_incremental(repo_path, None)`. - pub fn build(repo_path: &Path) -> Result { - Self::build_incremental(repo_path, None) + /// Equivalent to `build_incremental(repo_path, None, None)`. + pub async fn build(repo_path: &Path) -> Result { + Self::build_incremental(repo_path, None).await } /// Build a WorkdirIndex, reusing cached analysis results from a previous index @@ -71,7 +71,10 @@ impl WorkdirIndex { /// /// If `previous` is `None` or HEAD has changed since the previous build, all dirty /// files are re-analyzed from scratch. - pub fn build_incremental(repo_path: &Path, previous: Option<&WorkdirIndex>) -> Result { + pub async fn build_incremental( + repo_path: &Path, + previous: Option<&WorkdirIndex>, + ) -> Result { let total_start = std::time::Instant::now(); let t = std::time::Instant::now(); @@ -689,17 +692,17 @@ void hello(void); (tmpdir, repo_path) } - #[test] - fn test_clean_repo_produces_empty_index() { + #[tokio::test] + async fn test_clean_repo_produces_empty_index() { let (_tmpdir, repo_path) = create_test_repo(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); assert!(index.is_empty()); assert_eq!(index.function_count(), 0); assert_eq!(index.type_count(), 0); } - #[test] - fn test_modified_file_detected() { + #[tokio::test] + async fn test_modified_file_detected() { let (_tmpdir, repo_path) = create_test_repo(); // Modify test.c @@ -723,7 +726,7 @@ void hello(void) { ) .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); assert!(!index.is_empty()); assert!(index.is_dirty("test.c")); assert!(!index.is_dirty("test.h")); @@ -735,8 +738,8 @@ void hello(void) { assert!(index.find_function("add").is_some()); } - #[test] - fn test_new_file_detected() { + #[tokio::test] + async fn test_new_file_detected() { let (_tmpdir, repo_path) = create_test_repo(); // Add a new file and stage it (git add) so it appears in the index @@ -755,27 +758,27 @@ int multiply(int a, int b) { .output() .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); assert!(!index.is_empty()); assert!(index.is_dirty("new.c")); assert!(index.find_function("multiply").is_some()); } - #[test] - fn test_deleted_file_detected() { + #[tokio::test] + async fn test_deleted_file_detected() { let (_tmpdir, repo_path) = create_test_repo(); // Delete test.c fs::remove_file(repo_path.join("test.c")).unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); assert!(!index.is_empty()); assert!(index.is_deleted("test.c")); assert!(!index.is_dirty("test.c")); } - #[test] - fn test_merged_manifest() { + #[tokio::test] + async fn test_merged_manifest() { let (_tmpdir, repo_path) = create_test_repo(); // Modify test.c and stage a new file @@ -795,25 +798,25 @@ int multiply(int a, int b) { .output() .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); // Create a fake HEAD manifest let mut head_manifest = HashMap::new(); - head_manifest.insert("test.c".to_string(), "abc123".to_string()); - head_manifest.insert("test.h".to_string(), "def456".to_string()); + head_manifest.insert("test.c".to_string(), "old_hash1".to_string()); + head_manifest.insert("test.h".to_string(), "old_hash2".to_string()); let merged = index.merged_manifest(&head_manifest); - // test.c should have the dirty hash, not the HEAD hash - assert_ne!(merged.get("test.c").unwrap(), "abc123"); // test.h should be unchanged - assert_eq!(merged.get("test.h").unwrap(), "def456"); - // new.c should be added (it's staged) + assert_eq!(merged.get("test.h").unwrap(), "old_hash2"); + // test.c should be updated + assert_ne!(merged.get("test.c").unwrap(), "old_hash1"); + // new.c should be added assert!(merged.contains_key("new.c")); } - #[test] - fn test_find_callers_in_overlay() { + #[tokio::test] + async fn test_find_callers_in_overlay() { let (_tmpdir, repo_path) = create_test_repo(); fs::write( @@ -830,14 +833,14 @@ int compute(int x) { ) .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); let callers = index.find_callers("add"); assert!(!callers.is_empty()); assert!(callers.iter().any(|f| f.name == "compute")); } - #[test] - fn test_grep_functions() { + #[tokio::test] + async fn test_grep_functions() { let (_tmpdir, repo_path) = create_test_repo(); fs::write( @@ -854,7 +857,7 @@ int other(void) { ) .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); let results = index.grep_functions("42", None); assert!(!results.is_empty()); assert!(results.iter().any(|f| f.name == "special_value")); @@ -862,8 +865,8 @@ int other(void) { assert!(!results.iter().any(|f| f.name == "other")); } - #[test] - fn test_regex_search() { + #[tokio::test] + async fn test_regex_search() { let (_tmpdir, repo_path) = create_test_repo(); fs::write( @@ -876,13 +879,13 @@ int unrelated(void) { return 3; } ) .unwrap(); - let index = WorkdirIndex::build(&repo_path).unwrap(); + let index = WorkdirIndex::build(&repo_path).await.unwrap(); let results = index.find_functions_regex("foo_.*"); assert_eq!(results.len(), 2); } - #[test] - fn test_incremental_reuses_cache() { + #[tokio::test] + async fn test_incremental_reuses_cache() { let (_tmpdir, repo_path) = create_test_repo(); // Modify test.c @@ -895,12 +898,14 @@ int modified_func(void) { return 42; } .unwrap(); // First build - let index1 = WorkdirIndex::build(&repo_path).unwrap(); + let index1 = WorkdirIndex::build(&repo_path).await.unwrap(); assert!(index1.find_function("modified_func").is_some()); assert_eq!(index1.dirty_file_count(), 1); // Second build (incremental) — file hasn't changed, should reuse cache - let index2 = WorkdirIndex::build_incremental(&repo_path, Some(&index1)).unwrap(); + let index2 = WorkdirIndex::build_incremental(&repo_path, Some(&index1)) + .await + .unwrap(); assert!(index2.find_function("modified_func").is_some()); assert_eq!(index2.dirty_file_count(), 1); @@ -914,7 +919,9 @@ int another_func(void) { return 99; } .unwrap(); // Third build (incremental) — file changed, should re-analyze - let index3 = WorkdirIndex::build_incremental(&repo_path, Some(&index2)).unwrap(); + let index3 = WorkdirIndex::build_incremental(&repo_path, Some(&index2)) + .await + .unwrap(); assert!(index3.find_function("another_func").is_some()); assert!(index3.find_function("modified_func").is_none()); } From 9307e8e391e60807c0800cb5ca4f274e7887b40e Mon Sep 17 00:00:00 2001 From: Daniel Hodges Date: Mon, 27 Jul 2026 09:53:05 -0700 Subject: [PATCH 2/2] Expose find_rust_symbol tool in MCP server using rust-analyzer Add the find_rust_symbol MCP tool backed by rust-analyzer's workspace/symbol lookup, including schema, category, dispatch, and an indexing-in-progress hint on empty results. --- src/bin/semcode-mcp.rs | 69 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/src/bin/semcode-mcp.rs b/src/bin/semcode-mcp.rs index 9768095..3090f27 100644 --- a/src/bin/semcode-mcp.rs +++ b/src/bin/semcode-mcp.rs @@ -1948,6 +1948,7 @@ const TOOL_CATEGORIES: &[ToolCategory] = &[ "find_callers", "find_calls", "find_callchain", + "find_rust_symbol", ], }, ToolCategory { @@ -1980,6 +1981,20 @@ const TOOL_CATEGORIES: &[ToolCategory] = &[ /// Get the JSON schema for a specific tool by name fn get_tool_schema(name: &str) -> Option { match name { + "find_rust_symbol" => Some(json!({ + "name": "find_rust_symbol", + "description": "Find a rust symbol using rust-analyzer's exact type-inference. Only available in rust projects.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The exact name of the symbol to find" + } + }, + "required": ["query"] + } + })), "find_function" => Some(json!({ "name": "find_function", "description": "Find a function or macro by exact name, optionally at a specific git commit or branch", @@ -2531,6 +2546,7 @@ fn get_tool_schema(name: &str) -> Option { /// Get all tool schemas as a vector fn get_all_tool_schemas() -> Vec { let tool_names = [ + "find_rust_symbol", "find_function", "find_type", "find_callers", @@ -2816,6 +2832,7 @@ impl McpServer { let arguments = ¶ms["arguments"]; match name { + "find_rust_symbol" => self.handle_find_rust_symbol(arguments).await, "find_function" => self.handle_find_function(arguments).await, "find_type" => self.handle_find_type(arguments).await, "find_callers" => self.handle_find_callers(arguments).await, @@ -2951,6 +2968,51 @@ impl McpServer { } // Tool implementation methods + async fn handle_find_rust_symbol(&self, args: &Value) -> Value { + let query = args["query"].as_str().unwrap_or(""); + + let lsp = match self.db.rust_analyzer() { + Some(lsp) => lsp, + None => { + return json!({ + "error": "rust-analyzer is not available. Is this a Rust project with a Cargo.toml?", + "isError": true + }); + } + }; + + match lsp.workspace_symbol(query).await { + Ok(result) => { + // An empty result is ambiguous: either the symbol genuinely + // doesn't exist, or rust-analyzer is still indexing the project + // (workspace/symbol returns nothing until indexing completes). + // Surface that hint rather than implying a definitive "no match". + let is_empty = match &result { + Value::Array(arr) => arr.is_empty(), + Value::Null => true, + _ => false, + }; + if is_empty { + return json!({ + "content": [{"type": "text", "text": format!( + "No symbols matching '{query}' found. If this is a large project, \ + rust-analyzer may still be indexing — retry in a few seconds." + )}] + }); + } + let formatted = + serde_json::to_string_pretty(&result).unwrap_or_else(|_| "[]".to_string()); + json!({ + "content": [{"type": "text", "text": truncate_output(formatted)}] + }) + } + Err(e) => json!({ + "error": format!("rust-analyzer error: {}", e), + "isError": true + }), + } + } + async fn handle_find_function(&self, args: &Value) -> Value { // Check if database is empty and return helpful message if let Some(status_msg) = self.check_database_status().await { @@ -5925,6 +5987,7 @@ mod tests { fn test_get_tool_schema_returns_valid_schemas() { // Test that all known tools return valid schemas let known_tools = [ + "find_rust_symbol", "find_function", "find_type", "find_callers", @@ -5969,7 +6032,7 @@ mod tests { #[test] fn test_get_all_tool_schemas_returns_16_tools() { let schemas = get_all_tool_schemas(); - assert_eq!(schemas.len(), 16, "Should return all 16 tool schemas"); + assert_eq!(schemas.len(), 17, "Should return all 17 tool schemas"); } #[test] @@ -6199,7 +6262,7 @@ mod tests { let result = server.handle_list_tools().await; let tools = result["tools"].as_array().unwrap(); - // Should return all 16 tools - assert_eq!(tools.len(), 16, "Non-lazy mode should return all 16 tools"); + // Should return all 17 tools + assert_eq!(tools.len(), 17, "Non-lazy mode should return all 17 tools"); } }