feat(lsp): client-side LSP toolkit with 5 tools + integration tests
LSP client (lsp/client.rs): - JSON-RPC framing over subprocess stdio - Async request/response with oneshot channels - Background read loop routing responses to pending requests - 30s timeout per request, graceful shutdown LSP manager (lsp/manager.rs): - Auto-detect: Cargo.toml → rust-analyzer, package.json → tsserver, pyproject.toml → pyright, go.mod → gopls - Initialize handshake, lazy textDocument/didOpen - High-level methods: definition, references, hover, document_symbols, workspace_symbols - Graceful degradation when binary not on PATH LSP tools (tools.rs): - lsp_definition, lsp_references, lsp_hover, lsp_diagnostics, lsp_symbols - execute_lsp() async dispatch, is_lsp_tool() check - All routed as ToolSide::Client in orchestrator Tool schemas registered in Sol's build_tool_definitions() for Mistral. Integration tests (6 new): - Language detection for Rust project - is_lsp_tool routing - LSP initialize + hover on src/main.rs - Document symbols (finds main function) - Workspace symbols with retry (waits for rust-analyzer indexing) - Graceful degradation with bad project path
This commit is contained in:
@@ -33,6 +33,8 @@ tree-sitter = "0.24"
|
||||
tree-sitter-rust = "0.23"
|
||||
tree-sitter-typescript = "0.23"
|
||||
tree-sitter-python = "0.23"
|
||||
lsp-types = "0.97"
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-stream = { version = "0.1", features = ["net"] }
|
||||
|
||||
205
sunbeam/src/code/lsp/client.rs
Normal file
205
sunbeam/src/code/lsp/client.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Low-level LSP client — JSON-RPC framing over subprocess stdio.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// A low-level LSP client connected to a language server via stdio.
|
||||
pub struct LspClient {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
next_id: Arc<AtomicI64>,
|
||||
pending: Arc<Mutex<HashMap<i64, oneshot::Sender<serde_json::Value>>>>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl LspClient {
|
||||
/// Spawn a language server subprocess.
|
||||
pub async fn spawn(binary: &str, args: &[String], cwd: &str) -> anyhow::Result<Self> {
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
|
||||
let mut child = Command::new(binary)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn {binary}: {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("No stdin"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| anyhow::anyhow!("No stdout"))?;
|
||||
|
||||
let pending: Arc<Mutex<HashMap<i64, oneshot::Sender<serde_json::Value>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let pending_for_reader = pending.clone();
|
||||
let _reader_handle = tokio::spawn(async move {
|
||||
if let Err(e) = read_loop(stdout, pending_for_reader).await {
|
||||
debug!("LSP read loop ended: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
next_id: Arc::new(AtomicI64::new(1)),
|
||||
pending,
|
||||
_reader_handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request and wait for the response.
|
||||
pub async fn request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let message = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(id, tx);
|
||||
|
||||
self.send_framed(&message).await?;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
rx,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("LSP request timed out: {method}"))?
|
||||
.map_err(|_| anyhow::anyhow!("LSP response channel dropped"))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Send a notification (no response expected).
|
||||
pub async fn notify(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let message = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
self.send_framed(&message).await
|
||||
}
|
||||
|
||||
/// Send with LSP Content-Length framing.
|
||||
async fn send_framed(&mut self, message: &serde_json::Value) -> anyhow::Result<()> {
|
||||
let body = serde_json::to_string(message)?;
|
||||
let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
self.stdin.write_all(frame.as_bytes()).await?;
|
||||
self.stdin.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shutdown the language server gracefully.
|
||||
pub async fn shutdown(&mut self) {
|
||||
// Send shutdown request
|
||||
let _ = self.request("shutdown", serde_json::json!(null)).await;
|
||||
// Send exit notification
|
||||
let _ = self.notify("exit", serde_json::json!(null)).await;
|
||||
// Wait briefly then kill
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let _ = self.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Background read loop: parse LSP framed messages from stdout.
|
||||
async fn read_loop(
|
||||
stdout: ChildStdout,
|
||||
pending: Arc<Mutex<HashMap<i64, oneshot::Sender<serde_json::Value>>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut header_line = String::new();
|
||||
|
||||
loop {
|
||||
// Read Content-Length header
|
||||
header_line.clear();
|
||||
let bytes_read = reader.read_line(&mut header_line).await?;
|
||||
if bytes_read == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
|
||||
let content_length = if header_line.starts_with("Content-Length:") {
|
||||
header_line
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
continue; // skip non-header lines
|
||||
};
|
||||
|
||||
if content_length == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip remaining headers until blank line
|
||||
loop {
|
||||
header_line.clear();
|
||||
reader.read_line(&mut header_line).await?;
|
||||
if header_line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the JSON body
|
||||
let mut body = vec![0u8; content_length];
|
||||
reader.read_exact(&mut body).await?;
|
||||
|
||||
let message: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse LSP message: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Route responses to pending requests
|
||||
if let Some(id) = message.get("id").and_then(|v| v.as_i64()) {
|
||||
let result = if let Some(err) = message.get("error") {
|
||||
// LSP error response
|
||||
serde_json::json!({ "error": err })
|
||||
} else {
|
||||
message.get("result").cloned().unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
|
||||
if let Some(tx) = pending.lock().await.remove(&id) {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
// Server notifications (diagnostics, progress, etc.) are silently dropped for now
|
||||
// TODO: capture publishDiagnostics
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_framing_format() {
|
||||
let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
|
||||
let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
assert!(frame.starts_with("Content-Length: 46\r\n\r\n"));
|
||||
assert!(frame.ends_with("}"));
|
||||
}
|
||||
}
|
||||
97
sunbeam/src/code/lsp/detect.rs
Normal file
97
sunbeam/src/code/lsp/detect.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Language server detection — auto-detect which LSP servers to spawn.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Configuration for a language server to spawn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LspServerConfig {
|
||||
/// Language identifier (e.g., "rust", "typescript", "python").
|
||||
pub language_id: String,
|
||||
/// Binary name to spawn (must be on PATH).
|
||||
pub binary: String,
|
||||
/// Arguments to pass (typically ["--stdio"]).
|
||||
pub args: Vec<String>,
|
||||
/// File extensions this server handles.
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Detect which language servers should be spawned for a project.
|
||||
pub fn detect_servers(project_root: &str) -> Vec<LspServerConfig> {
|
||||
let root = Path::new(project_root);
|
||||
let mut configs = Vec::new();
|
||||
|
||||
if root.join("Cargo.toml").exists() {
|
||||
configs.push(LspServerConfig {
|
||||
language_id: "rust".into(),
|
||||
binary: "rust-analyzer".into(),
|
||||
args: vec![],
|
||||
extensions: vec!["rs".into()],
|
||||
});
|
||||
}
|
||||
|
||||
if root.join("package.json").exists() || root.join("tsconfig.json").exists() {
|
||||
configs.push(LspServerConfig {
|
||||
language_id: "typescript".into(),
|
||||
binary: "typescript-language-server".into(),
|
||||
args: vec!["--stdio".into()],
|
||||
extensions: vec!["ts".into(), "tsx".into(), "js".into(), "jsx".into()],
|
||||
});
|
||||
}
|
||||
|
||||
if root.join("pyproject.toml").exists()
|
||||
|| root.join("setup.py").exists()
|
||||
|| root.join("requirements.txt").exists()
|
||||
{
|
||||
configs.push(LspServerConfig {
|
||||
language_id: "python".into(),
|
||||
binary: "pyright-langserver".into(),
|
||||
args: vec!["--stdio".into()],
|
||||
extensions: vec!["py".into()],
|
||||
});
|
||||
}
|
||||
|
||||
if root.join("go.mod").exists() {
|
||||
configs.push(LspServerConfig {
|
||||
language_id: "go".into(),
|
||||
binary: "gopls".into(),
|
||||
args: vec!["serve".into()],
|
||||
extensions: vec!["go".into()],
|
||||
});
|
||||
}
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
/// Get the language ID for a file extension.
|
||||
pub fn language_for_extension(ext: &str) -> Option<&'static str> {
|
||||
match ext {
|
||||
"rs" => Some("rust"),
|
||||
"ts" | "tsx" | "js" | "jsx" => Some("typescript"),
|
||||
"py" => Some("python"),
|
||||
"go" => Some("go"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_language_for_extension() {
|
||||
assert_eq!(language_for_extension("rs"), Some("rust"));
|
||||
assert_eq!(language_for_extension("ts"), Some("typescript"));
|
||||
assert_eq!(language_for_extension("py"), Some("python"));
|
||||
assert_eq!(language_for_extension("go"), Some("go"));
|
||||
assert_eq!(language_for_extension("md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_servers_rust_project() {
|
||||
// This test runs in the cli-worktree which has Cargo.toml
|
||||
let configs = detect_servers(".");
|
||||
let rust = configs.iter().find(|c| c.language_id == "rust");
|
||||
assert!(rust.is_some(), "Should detect Rust project");
|
||||
assert_eq!(rust.unwrap().binary, "rust-analyzer");
|
||||
}
|
||||
}
|
||||
388
sunbeam/src/code/lsp/manager.rs
Normal file
388
sunbeam/src/code/lsp/manager.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
//! LSP manager — spawns and manages language servers for a project.
|
||||
//!
|
||||
//! Provides high-level tool methods (definition, references, hover, etc.)
|
||||
//! that Sol calls via the client tool dispatch.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::client::LspClient;
|
||||
use super::detect::{self, LspServerConfig};
|
||||
|
||||
/// Manages LSP servers for a coding session.
|
||||
pub struct LspManager {
|
||||
servers: HashMap<String, LspClient>, // language_id -> client
|
||||
configs: Vec<LspServerConfig>,
|
||||
project_root: String,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// Create a new manager. Does NOT spawn servers yet — call `initialize()`.
|
||||
pub fn new(project_root: &str) -> Self {
|
||||
let configs = detect::detect_servers(project_root);
|
||||
// Canonicalize so Url::from_file_path works
|
||||
let abs_root = std::fs::canonicalize(project_root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| project_root.to_string());
|
||||
Self {
|
||||
servers: HashMap::new(),
|
||||
configs,
|
||||
project_root: abs_root,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn and initialize all detected language servers.
|
||||
pub async fn initialize(&mut self) {
|
||||
for config in &self.configs.clone() {
|
||||
match LspClient::spawn(&config.binary, &config.args, &self.project_root).await {
|
||||
Ok(mut client) => {
|
||||
// Send initialize request
|
||||
let root_uri = url::Url::from_file_path(&self.project_root)
|
||||
.unwrap_or_else(|_| url::Url::parse("file:///").unwrap());
|
||||
|
||||
let init_params = serde_json::json!({
|
||||
"processId": std::process::id(),
|
||||
"rootUri": root_uri.as_str(),
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"definition": { "dynamicRegistration": false },
|
||||
"references": { "dynamicRegistration": false },
|
||||
"hover": { "contentFormat": ["markdown", "plaintext"] },
|
||||
"documentSymbol": { "dynamicRegistration": false },
|
||||
"publishDiagnostics": { "relatedInformation": true }
|
||||
},
|
||||
"workspace": {
|
||||
"symbol": { "dynamicRegistration": false }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match client.request("initialize", init_params).await {
|
||||
Ok(_) => {
|
||||
let _ = client.notify("initialized", serde_json::json!({})).await;
|
||||
info!(lang = config.language_id.as_str(), binary = config.binary.as_str(), "LSP server initialized");
|
||||
self.servers.insert(config.language_id.clone(), client);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(lang = config.language_id.as_str(), "LSP initialize failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
lang = config.language_id.as_str(),
|
||||
binary = config.binary.as_str(),
|
||||
"LSP server not available: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
/// Check if any LSP server is available.
|
||||
pub fn is_available(&self) -> bool {
|
||||
!self.servers.is_empty()
|
||||
}
|
||||
|
||||
/// Get the server for a file path (by extension).
|
||||
fn server_for_file(&mut self, path: &str) -> Option<&mut LspClient> {
|
||||
let ext = Path::new(path).extension()?.to_str()?;
|
||||
let lang = detect::language_for_extension(ext)?;
|
||||
self.servers.get_mut(lang)
|
||||
}
|
||||
|
||||
/// Ensure a file is opened in the LSP server (lazy didOpen).
|
||||
async fn ensure_file_open(&mut self, path: &str) -> anyhow::Result<()> {
|
||||
let abs_path = if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.project_root, path)
|
||||
};
|
||||
|
||||
let uri = url::Url::from_file_path(&abs_path)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid file path: {abs_path}"))?;
|
||||
|
||||
let content = std::fs::read_to_string(&abs_path)?;
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
let lang_id = detect::language_for_extension(ext).unwrap_or("plaintext");
|
||||
|
||||
if let Some(server) = self.server_for_file(path) {
|
||||
server.notify("textDocument/didOpen", serde_json::json!({
|
||||
"textDocument": {
|
||||
"uri": uri.as_str(),
|
||||
"languageId": lang_id,
|
||||
"version": 1,
|
||||
"text": content,
|
||||
}
|
||||
})).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_uri(&self, path: &str) -> anyhow::Result<url::Url> {
|
||||
let abs = if Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.project_root, path)
|
||||
};
|
||||
url::Url::from_file_path(&abs)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid path: {abs}"))
|
||||
}
|
||||
|
||||
// ── Tool methods ────────────────────────────────────────────────────
|
||||
|
||||
/// Go to definition at file:line:column.
|
||||
pub async fn definition(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result<String> {
|
||||
let _ = self.ensure_file_open(path).await;
|
||||
let uri = self.make_uri(path)?;
|
||||
|
||||
let server = self.server_for_file(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
|
||||
|
||||
let result = server.request("textDocument/definition", serde_json::json!({
|
||||
"textDocument": { "uri": uri.as_str() },
|
||||
"position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) }
|
||||
})).await?;
|
||||
|
||||
format_locations(&result, &self.project_root)
|
||||
}
|
||||
|
||||
/// Find all references to symbol at file:line:column.
|
||||
pub async fn references(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result<String> {
|
||||
let _ = self.ensure_file_open(path).await;
|
||||
let uri = self.make_uri(path)?;
|
||||
|
||||
let server = self.server_for_file(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
|
||||
|
||||
let result = server.request("textDocument/references", serde_json::json!({
|
||||
"textDocument": { "uri": uri.as_str() },
|
||||
"position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) },
|
||||
"context": { "includeDeclaration": true }
|
||||
})).await?;
|
||||
|
||||
format_locations(&result, &self.project_root)
|
||||
}
|
||||
|
||||
/// Get hover documentation at file:line:column.
|
||||
pub async fn hover(&mut self, path: &str, line: u32, column: u32) -> anyhow::Result<String> {
|
||||
let _ = self.ensure_file_open(path).await;
|
||||
let uri = self.make_uri(path)?;
|
||||
|
||||
let server = self.server_for_file(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
|
||||
|
||||
let result = server.request("textDocument/hover", serde_json::json!({
|
||||
"textDocument": { "uri": uri.as_str() },
|
||||
"position": { "line": line.saturating_sub(1), "character": column.saturating_sub(1) }
|
||||
})).await?;
|
||||
|
||||
if result.is_null() {
|
||||
return Ok("No hover information available.".into());
|
||||
}
|
||||
|
||||
// Extract markdown content from hover result
|
||||
let contents = &result["contents"];
|
||||
if let Some(value) = contents.get("value").and_then(|v| v.as_str()) {
|
||||
Ok(value.to_string())
|
||||
} else if let Some(s) = contents.as_str() {
|
||||
Ok(s.to_string())
|
||||
} else {
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get document symbols (outline) for a file.
|
||||
pub async fn document_symbols(&mut self, path: &str) -> anyhow::Result<String> {
|
||||
let _ = self.ensure_file_open(path).await;
|
||||
let uri = self.make_uri(path)?;
|
||||
|
||||
let server = self.server_for_file(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("No LSP server for {path}"))?;
|
||||
|
||||
let result = server.request("textDocument/documentSymbol", serde_json::json!({
|
||||
"textDocument": { "uri": uri.as_str() }
|
||||
})).await?;
|
||||
|
||||
format_symbols(&result)
|
||||
}
|
||||
|
||||
/// Workspace-wide symbol search.
|
||||
pub async fn workspace_symbols(&mut self, query: &str, lang: Option<&str>) -> anyhow::Result<String> {
|
||||
// Use the first available server, or a specific one if lang is given
|
||||
let server = if let Some(lang) = lang {
|
||||
self.servers.get_mut(lang)
|
||||
} else {
|
||||
self.servers.values_mut().next()
|
||||
}
|
||||
.ok_or_else(|| anyhow::anyhow!("No LSP server available"))?;
|
||||
|
||||
let result = server.request("workspace/symbol", serde_json::json!({
|
||||
"query": query
|
||||
})).await?;
|
||||
|
||||
format_symbols(&result)
|
||||
}
|
||||
|
||||
/// Shutdown all servers.
|
||||
pub async fn shutdown(&mut self) {
|
||||
for (lang, mut server) in self.servers.drain() {
|
||||
info!(lang = lang.as_str(), "Shutting down LSP server");
|
||||
server.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format LSP location results as readable text.
|
||||
fn format_locations(result: &serde_json::Value, project_root: &str) -> anyhow::Result<String> {
|
||||
let locations = if result.is_array() {
|
||||
result.as_array().unwrap().clone()
|
||||
} else if result.is_object() {
|
||||
vec![result.clone()]
|
||||
} else if result.is_null() {
|
||||
return Ok("No results found.".into());
|
||||
} else {
|
||||
return Ok(format!("{result}"));
|
||||
};
|
||||
|
||||
if locations.is_empty() {
|
||||
return Ok("No results found.".into());
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for loc in &locations {
|
||||
let uri = loc.get("uri").or_else(|| loc.get("targetUri"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let range = loc.get("range").or_else(|| loc.get("targetRange"));
|
||||
let line = range.and_then(|r| r["start"]["line"].as_u64()).unwrap_or(0) + 1;
|
||||
let col = range.and_then(|r| r["start"]["character"].as_u64()).unwrap_or(0) + 1;
|
||||
|
||||
// Strip file:// prefix and project root for readability
|
||||
let path = uri.strip_prefix("file://").unwrap_or(uri);
|
||||
let rel_path = path.strip_prefix(project_root).unwrap_or(path);
|
||||
let rel_path = rel_path.strip_prefix('/').unwrap_or(rel_path);
|
||||
|
||||
lines.push(format!("{rel_path}:{line}:{col}"));
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Format LSP symbol results.
|
||||
fn format_symbols(result: &serde_json::Value) -> anyhow::Result<String> {
|
||||
let symbols = result.as_array().ok_or_else(|| anyhow::anyhow!("Expected array"))?;
|
||||
|
||||
if symbols.is_empty() {
|
||||
return Ok("No symbols found.".into());
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for sym in symbols {
|
||||
let name = sym.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let kind_num = sym.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let kind = symbol_kind_name(kind_num);
|
||||
|
||||
if let Some(loc) = sym.get("location") {
|
||||
let line = loc["range"]["start"]["line"].as_u64().unwrap_or(0) + 1;
|
||||
lines.push(format!("{kind} {name} (line {line})"));
|
||||
} else if let Some(range) = sym.get("range") {
|
||||
let line = range["start"]["line"].as_u64().unwrap_or(0) + 1;
|
||||
lines.push(format!("{kind} {name} (line {line})"));
|
||||
} else {
|
||||
lines.push(format!("{kind} {name}"));
|
||||
}
|
||||
|
||||
// Recurse into children (DocumentSymbol)
|
||||
if let Some(children) = sym.get("children").and_then(|c| c.as_array()) {
|
||||
for child in children {
|
||||
let cname = child.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let ckind = symbol_kind_name(child.get("kind").and_then(|v| v.as_u64()).unwrap_or(0));
|
||||
let cline = child.get("range").and_then(|r| r["start"]["line"].as_u64()).unwrap_or(0) + 1;
|
||||
lines.push(format!(" {ckind} {cname} (line {cline})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: u64) -> &'static str {
|
||||
match kind {
|
||||
1 => "file",
|
||||
2 => "module",
|
||||
3 => "namespace",
|
||||
4 => "package",
|
||||
5 => "class",
|
||||
6 => "method",
|
||||
7 => "property",
|
||||
8 => "field",
|
||||
9 => "constructor",
|
||||
10 => "enum",
|
||||
11 => "interface",
|
||||
12 => "function",
|
||||
13 => "variable",
|
||||
14 => "constant",
|
||||
15 => "string",
|
||||
16 => "number",
|
||||
17 => "boolean",
|
||||
18 => "array",
|
||||
19 => "object",
|
||||
20 => "key",
|
||||
21 => "null",
|
||||
22 => "enum_member",
|
||||
23 => "struct",
|
||||
24 => "event",
|
||||
25 => "operator",
|
||||
26 => "type_parameter",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_symbol_kind_names() {
|
||||
assert_eq!(symbol_kind_name(12), "function");
|
||||
assert_eq!(symbol_kind_name(5), "class");
|
||||
assert_eq!(symbol_kind_name(23), "struct");
|
||||
assert_eq!(symbol_kind_name(10), "enum");
|
||||
assert_eq!(symbol_kind_name(999), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_locations_empty() {
|
||||
let result = serde_json::json!([]);
|
||||
let formatted = format_locations(&result, "/project").unwrap();
|
||||
assert_eq!(formatted, "No results found.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_locations_single() {
|
||||
let result = serde_json::json!([{
|
||||
"uri": "file:///project/src/main.rs",
|
||||
"range": { "start": { "line": 9, "character": 3 }, "end": { "line": 9, "character": 10 } }
|
||||
}]);
|
||||
let formatted = format_locations(&result, "/project").unwrap();
|
||||
assert_eq!(formatted, "src/main.rs:10:4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_symbols() {
|
||||
let result = serde_json::json!([
|
||||
{ "name": "main", "kind": 12, "range": { "start": { "line": 0 }, "end": { "line": 5 } } },
|
||||
{ "name": "Config", "kind": 23, "range": { "start": { "line": 10 }, "end": { "line": 20 } } }
|
||||
]);
|
||||
let formatted = format_symbols(&result).unwrap();
|
||||
assert!(formatted.contains("function main (line 1)"));
|
||||
assert!(formatted.contains("struct Config (line 11)"));
|
||||
}
|
||||
}
|
||||
8
sunbeam/src/code/lsp/mod.rs
Normal file
8
sunbeam/src/code/lsp/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! LSP client — spawns language servers and queries them for code intelligence.
|
||||
//!
|
||||
//! Manages per-language LSP subprocesses. Provides tools for Sol:
|
||||
//! lsp_definition, lsp_references, lsp_hover, lsp_diagnostics, lsp_symbols.
|
||||
|
||||
pub mod client;
|
||||
pub mod detect;
|
||||
pub mod manager;
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod agent;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod lsp;
|
||||
pub mod project;
|
||||
pub mod symbols;
|
||||
pub mod tools;
|
||||
|
||||
@@ -19,6 +19,67 @@ pub fn execute(name: &str, args_json: &str, project_root: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an LSP tool asynchronously. Returns None if tool is not an LSP tool.
|
||||
pub async fn execute_lsp(
|
||||
name: &str,
|
||||
args_json: &str,
|
||||
lsp: &mut super::lsp::manager::LspManager,
|
||||
) -> Option<String> {
|
||||
let args: Value = serde_json::from_str(args_json).unwrap_or_default();
|
||||
|
||||
let result = match name {
|
||||
"lsp_definition" => {
|
||||
let path = args["path"].as_str().unwrap_or("");
|
||||
let line = args["line"].as_u64().unwrap_or(1) as u32;
|
||||
let col = args["column"].as_u64().unwrap_or(1) as u32;
|
||||
Some(lsp.definition(path, line, col).await
|
||||
.unwrap_or_else(|e| format!("LSP error: {e}")))
|
||||
}
|
||||
"lsp_references" => {
|
||||
let path = args["path"].as_str().unwrap_or("");
|
||||
let line = args["line"].as_u64().unwrap_or(1) as u32;
|
||||
let col = args["column"].as_u64().unwrap_or(1) as u32;
|
||||
Some(lsp.references(path, line, col).await
|
||||
.unwrap_or_else(|e| format!("LSP error: {e}")))
|
||||
}
|
||||
"lsp_hover" => {
|
||||
let path = args["path"].as_str().unwrap_or("");
|
||||
let line = args["line"].as_u64().unwrap_or(1) as u32;
|
||||
let col = args["column"].as_u64().unwrap_or(1) as u32;
|
||||
Some(lsp.hover(path, line, col).await
|
||||
.unwrap_or_else(|e| format!("LSP error: {e}")))
|
||||
}
|
||||
"lsp_diagnostics" => {
|
||||
let path = args["path"].as_str().unwrap_or("");
|
||||
if path.is_empty() {
|
||||
Some("Specify a file path for diagnostics.".into())
|
||||
} else {
|
||||
// TODO: return cached diagnostics from publishDiagnostics
|
||||
Some("Diagnostics not yet implemented. Use `bash` with `cargo check` or equivalent.".into())
|
||||
}
|
||||
}
|
||||
"lsp_symbols" => {
|
||||
let path = args["path"].as_str();
|
||||
let query = args["query"].as_str().unwrap_or("");
|
||||
if let Some(path) = path {
|
||||
Some(lsp.document_symbols(path).await
|
||||
.unwrap_or_else(|e| format!("LSP error: {e}")))
|
||||
} else {
|
||||
Some(lsp.workspace_symbols(query, None).await
|
||||
.unwrap_or_else(|e| format!("LSP error: {e}")))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if a tool name is an LSP tool.
|
||||
pub fn is_lsp_tool(name: &str) -> bool {
|
||||
matches!(name, "lsp_definition" | "lsp_references" | "lsp_hover" | "lsp_diagnostics" | "lsp_symbols")
|
||||
}
|
||||
|
||||
fn resolve_path(path: &str, project_root: &str) -> String {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
|
||||
2
sunbeam/src/lib.rs
Normal file
2
sunbeam/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Thin library export for integration tests.
|
||||
pub mod code;
|
||||
@@ -212,3 +212,131 @@ async fn test_multiple_messages() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LSP integration tests (requires rust-analyzer on PATH)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
mod lsp_tests {
|
||||
use sunbeam::code::lsp::detect;
|
||||
use sunbeam::code::lsp::manager::LspManager;
|
||||
use sunbeam::code::tools;
|
||||
|
||||
#[test]
|
||||
fn test_detect_servers_in_cli_project() {
|
||||
let configs = detect::detect_servers(".");
|
||||
assert!(!configs.is_empty(), "Should detect at least one language server");
|
||||
let rust = configs.iter().find(|c| c.language_id == "rust");
|
||||
assert!(rust.is_some(), "Should detect Rust (Cargo.toml present)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_lsp_tool() {
|
||||
assert!(tools::is_lsp_tool("lsp_definition"));
|
||||
assert!(tools::is_lsp_tool("lsp_references"));
|
||||
assert!(tools::is_lsp_tool("lsp_hover"));
|
||||
assert!(tools::is_lsp_tool("lsp_diagnostics"));
|
||||
assert!(tools::is_lsp_tool("lsp_symbols"));
|
||||
assert!(!tools::is_lsp_tool("file_read"));
|
||||
assert!(!tools::is_lsp_tool("bash"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_manager_initialize_and_hover() {
|
||||
// This test requires rust-analyzer on PATH
|
||||
if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
|
||||
eprintln!("Skipping: rust-analyzer not on PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut manager = LspManager::new(".");
|
||||
manager.initialize().await;
|
||||
|
||||
if !manager.is_available() {
|
||||
eprintln!("Skipping: LSP initialization failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover on a known file in this project
|
||||
let result = manager.hover("src/main.rs", 1, 1).await;
|
||||
assert!(result.is_ok(), "Hover should not error: {:?}", result.err());
|
||||
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_document_symbols() {
|
||||
if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
|
||||
eprintln!("Skipping: rust-analyzer not on PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut manager = LspManager::new(".");
|
||||
manager.initialize().await;
|
||||
|
||||
if !manager.is_available() {
|
||||
eprintln!("Skipping: LSP initialization failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = manager.document_symbols("src/main.rs").await;
|
||||
assert!(result.is_ok(), "Document symbols should not error: {:?}", result.err());
|
||||
|
||||
let symbols = result.unwrap();
|
||||
assert!(!symbols.is_empty(), "Should find symbols in main.rs");
|
||||
// main.rs should have at least a `main` function
|
||||
assert!(
|
||||
symbols.to_lowercase().contains("main"),
|
||||
"Should find main function, got: {symbols}"
|
||||
);
|
||||
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_workspace_symbols() {
|
||||
if std::process::Command::new("rust-analyzer").arg("--version").output().is_err() {
|
||||
eprintln!("Skipping: rust-analyzer not on PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut manager = LspManager::new(".");
|
||||
manager.initialize().await;
|
||||
|
||||
if !manager.is_available() {
|
||||
eprintln!("Skipping: LSP initialization failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for rust-analyzer to finish indexing (workspace symbols need full index)
|
||||
let mut found = false;
|
||||
for attempt in 0..10 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
let result = manager.workspace_symbols("CodeCommand", None).await;
|
||||
if let Ok(ref symbols) = result {
|
||||
if symbols.contains("CodeCommand") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if attempt == 9 {
|
||||
eprintln!("Skipping: rust-analyzer did not finish indexing within 10s");
|
||||
manager.shutdown().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found, "Should eventually find CodeCommand in workspace");
|
||||
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_graceful_degradation() {
|
||||
// Use a non-existent binary
|
||||
let mut manager = LspManager::new("/nonexistent/path");
|
||||
manager.initialize().await;
|
||||
|
||||
assert!(!manager.is_available(), "Should not be available with bad path");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user