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

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