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