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:
2026-03-24 00:58:05 +00:00
parent 73d7d6c15b
commit 8726e8fbe7
11 changed files with 1112 additions and 16 deletions

View File

@@ -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"] }

View 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("}"));
}
}

View 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");
}
}

View 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)"));
}
}

View 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;

View File

@@ -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;

View File

@@ -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
View File

@@ -0,0 +1,2 @@
// Thin library export for integration tests.
pub mod code;

View File

@@ -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");
}
}