1339 lines
46 KiB
Rust
1339 lines
46 KiB
Rust
#![allow(clippy::should_implement_trait, clippy::must_use_candidate)]
|
|
//! LSP (Language Server Protocol) client registry for tool dispatch.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor};
|
|
use crate::lsp_process::LspProcess;
|
|
|
|
/// Supported LSP actions.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LspAction {
|
|
Diagnostics,
|
|
Hover,
|
|
Definition,
|
|
References,
|
|
Completion,
|
|
Symbols,
|
|
Format,
|
|
}
|
|
|
|
impl LspAction {
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"diagnostics" => Some(Self::Diagnostics),
|
|
"hover" => Some(Self::Hover),
|
|
"definition" | "goto_definition" => Some(Self::Definition),
|
|
"references" | "find_references" => Some(Self::References),
|
|
"completion" | "completions" => Some(Self::Completion),
|
|
"symbols" | "document_symbols" => Some(Self::Symbols),
|
|
"format" | "formatting" => Some(Self::Format),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspDiagnostic {
|
|
pub path: String,
|
|
pub line: u32,
|
|
pub character: u32,
|
|
pub severity: String,
|
|
pub message: String,
|
|
pub source: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspLocation {
|
|
pub path: String,
|
|
pub line: u32,
|
|
pub character: u32,
|
|
pub end_line: Option<u32>,
|
|
pub end_character: Option<u32>,
|
|
pub preview: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspHoverResult {
|
|
pub content: String,
|
|
pub language: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspCompletionItem {
|
|
pub label: String,
|
|
pub kind: Option<String>,
|
|
pub detail: Option<String>,
|
|
pub insert_text: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspSymbol {
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub path: String,
|
|
pub line: u32,
|
|
pub character: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LspServerStatus {
|
|
Connected,
|
|
Disconnected,
|
|
Starting,
|
|
Error,
|
|
}
|
|
|
|
impl std::fmt::Display for LspServerStatus {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Connected => write!(f, "connected"),
|
|
Self::Disconnected => write!(f, "disconnected"),
|
|
Self::Starting => write!(f, "starting"),
|
|
Self::Error => write!(f, "error"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LspServerState {
|
|
pub language: String,
|
|
pub status: LspServerStatus,
|
|
pub root_path: Option<String>,
|
|
pub capabilities: Vec<String>,
|
|
pub diagnostics: Vec<LspDiagnostic>,
|
|
}
|
|
|
|
/// Entry in the LSP registry combining process handle, descriptor, and state.
|
|
struct LspServerEntry {
|
|
/// The running LSP process, if started. Wrapped in Arc<Mutex<>> for thread-safe async access.
|
|
process: Option<Arc<Mutex<LspProcess>>>,
|
|
/// The server descriptor for lazy-start on first use.
|
|
descriptor: Option<LspServerDescriptor>,
|
|
/// The server state metadata (status, capabilities, diagnostics).
|
|
state: LspServerState,
|
|
}
|
|
|
|
impl std::fmt::Debug for LspServerEntry {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("LspServerEntry")
|
|
.field("process", &self.process.is_some())
|
|
.field("descriptor", &self.descriptor)
|
|
.field("state", &self.state)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl LspServerEntry {
|
|
fn new(state: LspServerState) -> Self {
|
|
Self {
|
|
process: None,
|
|
descriptor: None,
|
|
state,
|
|
}
|
|
}
|
|
|
|
fn with_descriptor(state: LspServerState, descriptor: LspServerDescriptor) -> Self {
|
|
Self {
|
|
process: None,
|
|
descriptor: Some(descriptor),
|
|
state,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct LspRegistry {
|
|
inner: Arc<Mutex<RegistryInner>>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct RegistryInner {
|
|
servers: HashMap<String, LspServerEntry>,
|
|
open_files: HashSet<String>,
|
|
}
|
|
|
|
impl LspRegistry {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Register an LSP server with metadata but without starting the process.
|
|
/// The server can be started later via `start_server()` or lazily on first `dispatch()`.
|
|
pub fn register(
|
|
&self,
|
|
language: &str,
|
|
status: LspServerStatus,
|
|
root_path: Option<&str>,
|
|
capabilities: Vec<String>,
|
|
) {
|
|
let state = LspServerState {
|
|
language: language.to_owned(),
|
|
status,
|
|
root_path: root_path.map(str::to_owned),
|
|
capabilities,
|
|
diagnostics: Vec::new(),
|
|
};
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner
|
|
.servers
|
|
.insert(language.to_owned(), LspServerEntry::new(state));
|
|
}
|
|
|
|
/// Register an LSP server with a descriptor for lazy-start.
|
|
/// The descriptor provides the command and args to start the server when needed.
|
|
pub fn register_with_descriptor(
|
|
&self,
|
|
language: &str,
|
|
status: LspServerStatus,
|
|
root_path: Option<&str>,
|
|
capabilities: Vec<String>,
|
|
descriptor: LspServerDescriptor,
|
|
) {
|
|
let state = LspServerState {
|
|
language: language.to_owned(),
|
|
status,
|
|
root_path: root_path.map(str::to_owned),
|
|
capabilities,
|
|
diagnostics: Vec::new(),
|
|
};
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.insert(
|
|
language.to_owned(),
|
|
LspServerEntry::with_descriptor(state, descriptor),
|
|
);
|
|
}
|
|
|
|
pub fn get(&self, language: &str) -> Option<LspServerState> {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.get(language).map(|entry| entry.state.clone())
|
|
}
|
|
|
|
/// Find the appropriate server for a file path based on extension.
|
|
pub fn find_server_for_path(&self, path: &str) -> Option<LspServerState> {
|
|
let ext = std::path::Path::new(path)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("");
|
|
|
|
let language = match ext {
|
|
"rs" => "rust",
|
|
"ts" | "tsx" => "typescript",
|
|
"js" | "jsx" => "javascript",
|
|
"py" => "python",
|
|
"go" => "go",
|
|
"java" => "java",
|
|
"c" | "h" => "c",
|
|
"cpp" | "hpp" | "cc" => "cpp",
|
|
"rb" => "ruby",
|
|
"lua" => "lua",
|
|
_ => return None,
|
|
};
|
|
|
|
self.get(language)
|
|
}
|
|
|
|
/// Get the language name for a file path based on extension.
|
|
fn language_for_path(path: &str) -> Option<String> {
|
|
let ext = std::path::Path::new(path)
|
|
.extension()
|
|
.and_then(|e| e.to_str())?;
|
|
|
|
let language = match ext {
|
|
"rs" => "rust",
|
|
"ts" | "tsx" => "typescript",
|
|
"js" | "jsx" => "javascript",
|
|
"py" => "python",
|
|
"go" => "go",
|
|
"java" => "java",
|
|
"c" | "h" => "c",
|
|
"cpp" | "hpp" | "cc" => "cpp",
|
|
"rb" => "ruby",
|
|
"lua" => "lua",
|
|
_ => return None,
|
|
};
|
|
|
|
Some(language.to_owned())
|
|
}
|
|
|
|
/// List all registered servers.
|
|
pub fn list_servers(&self) -> Vec<LspServerState> {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.values().map(|entry| entry.state.clone()).collect()
|
|
}
|
|
|
|
/// Add diagnostics to a server.
|
|
pub fn add_diagnostics(
|
|
&self,
|
|
language: &str,
|
|
diagnostics: Vec<LspDiagnostic>,
|
|
) -> Result<(), String> {
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
let entry = inner
|
|
.servers
|
|
.get_mut(language)
|
|
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
|
|
entry.state.diagnostics.extend(diagnostics);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get diagnostics for a specific file path.
|
|
pub fn get_diagnostics(&self, path: &str) -> Vec<LspDiagnostic> {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner
|
|
.servers
|
|
.values()
|
|
.flat_map(|entry| &entry.state.diagnostics)
|
|
.filter(|d| d.path == path)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Clear diagnostics for a language server.
|
|
pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> {
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
let entry = inner
|
|
.servers
|
|
.get_mut(language)
|
|
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
|
|
entry.state.diagnostics.clear();
|
|
Ok(())
|
|
}
|
|
|
|
/// Disconnect a server.
|
|
pub fn disconnect(&self, language: &str) -> Option<LspServerState> {
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.remove(language).map(|entry| entry.state)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.len()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
|
|
/// Start an LSP server process for the given language.
|
|
/// If the process is already running, this is a no-op.
|
|
/// If a descriptor is available, it is used to start the process.
|
|
/// If no descriptor is available, the discovery system is consulted.
|
|
pub fn start_server(&self, language: &str) -> Result<(), String> {
|
|
// Check if already running
|
|
{
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get(language) {
|
|
if entry.process.is_some() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to get the descriptor
|
|
let descriptor = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get(language) {
|
|
entry.descriptor.clone()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
// If no descriptor, try discovery
|
|
let descriptor = if let Some(d) = descriptor { d } else {
|
|
let available = discover_available_servers();
|
|
available
|
|
.into_iter()
|
|
.find(|d| d.language == language)
|
|
.ok_or_else(|| {
|
|
format!("no LSP server descriptor found for language: {language}")
|
|
})?
|
|
};
|
|
|
|
let root_path = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner
|
|
.servers
|
|
.get(language)
|
|
.and_then(|entry| entry.state.root_path.clone())
|
|
.unwrap_or_else(|| {
|
|
std::env::current_dir()
|
|
.map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
|
|
})
|
|
};
|
|
|
|
let process = {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
|
|
rt.block_on(LspProcess::start(
|
|
&descriptor.command,
|
|
&descriptor.args,
|
|
Path::new(&root_path),
|
|
))
|
|
.map_err(|e| format!("failed to start LSP server for '{language}': {e}"))?
|
|
};
|
|
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get_mut(language) {
|
|
entry.process = Some(Arc::new(Mutex::new(process)));
|
|
entry.state.status = LspServerStatus::Connected;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop a running LSP server process.
|
|
pub fn stop_server(&self, language: &str) -> Result<(), String> {
|
|
let process_arc = {
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
let entry = inner
|
|
.servers
|
|
.get_mut(language)
|
|
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
|
|
entry.state.status = LspServerStatus::Disconnected;
|
|
entry.process.take()
|
|
};
|
|
|
|
if let Some(process_arc) = process_arc {
|
|
let mut process = process_arc
|
|
.lock()
|
|
.map_err(|_| "lsp process lock poisoned")?;
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
|
|
rt.block_on(process.shutdown())
|
|
.map_err(|e| format!("LSP shutdown error: {e}"))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Notify the LSP server that a file was opened and collect any diagnostics.
|
|
/// Best-effort: returns empty vec if no server is available.
|
|
pub fn notify_file_open(&self, path: &str, content: &str) -> Vec<LspDiagnostic> {
|
|
let Some(language) = Self::language_for_path(path) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
// Check if already open
|
|
{
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if inner.open_files.contains(path) {
|
|
return Vec::new();
|
|
}
|
|
}
|
|
|
|
// Lazy-start the server
|
|
if self.start_server(&language).is_err() {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Get the process handle and send didOpen
|
|
let process_arc = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
match inner.servers.get(&language).and_then(|e| e.process.clone()) {
|
|
Some(p) => p,
|
|
None => return Vec::new(),
|
|
}
|
|
};
|
|
|
|
let mut diagnostics = Vec::new();
|
|
if let Ok(mut process) = process_arc.lock() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build();
|
|
if let Ok(rt) = rt {
|
|
let _ = rt.block_on(process.did_open(path, content));
|
|
diagnostics = process.drain_diagnostics();
|
|
}
|
|
}
|
|
|
|
// Cache diagnostics in registry state
|
|
if !diagnostics.is_empty() {
|
|
let diag_path = path.to_owned();
|
|
let diags = diagnostics.clone();
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get_mut(&language) {
|
|
// Replace diagnostics for this file (publishDiagnostics is full replacement)
|
|
entry.state.diagnostics.retain(|d| d.path != diag_path);
|
|
entry.state.diagnostics.extend(diags);
|
|
}
|
|
}
|
|
|
|
// Mark file as open
|
|
{
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.open_files.insert(path.to_owned());
|
|
}
|
|
|
|
diagnostics
|
|
}
|
|
|
|
/// Notify the LSP server that a file changed and collect any diagnostics.
|
|
/// Best-effort: returns empty vec if no server is available.
|
|
pub fn notify_file_change(&self, path: &str, content: &str) -> Vec<LspDiagnostic> {
|
|
let Some(language) = Self::language_for_path(path) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
// Get the process handle
|
|
let process_arc = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
match inner.servers.get(&language).and_then(|e| e.process.clone()) {
|
|
Some(p) => p,
|
|
None => return Vec::new(),
|
|
}
|
|
};
|
|
|
|
let mut diagnostics = Vec::new();
|
|
if let Ok(mut process) = process_arc.lock() {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build();
|
|
if let Ok(rt) = rt {
|
|
let _ = rt.block_on(process.did_change(path, content));
|
|
diagnostics = process.drain_diagnostics();
|
|
}
|
|
}
|
|
|
|
// Replace cached diagnostics for this file
|
|
if !diagnostics.is_empty() {
|
|
let diag_path = path.to_owned();
|
|
let diags = diagnostics.clone();
|
|
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get_mut(&language) {
|
|
entry.state.diagnostics.retain(|d| d.path != diag_path);
|
|
entry.state.diagnostics.extend(diags);
|
|
}
|
|
}
|
|
|
|
diagnostics
|
|
}
|
|
|
|
/// Fetch diagnostics for a file by draining pending server notifications
|
|
/// and returning cached diagnostics.
|
|
pub fn fetch_diagnostics_for_file(&self, path: &str) -> Vec<LspDiagnostic> {
|
|
let Some(language) = Self::language_for_path(path) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
// Drain pending notifications from the transport
|
|
let process_arc = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner.servers.get(&language).and_then(|e| e.process.clone())
|
|
};
|
|
|
|
if let Some(process_arc) = process_arc {
|
|
if let Ok(mut process) = process_arc.lock() {
|
|
let new_diags = process.drain_diagnostics();
|
|
if !new_diags.is_empty() {
|
|
let diag_path = path.to_owned();
|
|
let mut inner =
|
|
self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get_mut(&language) {
|
|
entry.state.diagnostics.retain(|d| d.path != diag_path);
|
|
entry.state.diagnostics.extend(new_diags);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.get_diagnostics(path)
|
|
}
|
|
|
|
/// Dispatch an LSP action and return a structured result.
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn dispatch(
|
|
&self,
|
|
action: &str,
|
|
path: Option<&str>,
|
|
line: Option<u32>,
|
|
character: Option<u32>,
|
|
_query: Option<&str>,
|
|
) -> Result<serde_json::Value, String> {
|
|
let lsp_action =
|
|
LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?;
|
|
|
|
// For diagnostics, we check existing cached diagnostics
|
|
if lsp_action == LspAction::Diagnostics {
|
|
if let Some(path) = path {
|
|
let diags = self.get_diagnostics(path);
|
|
return Ok(serde_json::json!({
|
|
"action": "diagnostics",
|
|
"path": path,
|
|
"diagnostics": diags,
|
|
"count": diags.len()
|
|
}));
|
|
}
|
|
// All diagnostics across all servers
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
let all_diags: Vec<_> = inner
|
|
.servers
|
|
.values()
|
|
.flat_map(|entry| &entry.state.diagnostics)
|
|
.collect();
|
|
return Ok(serde_json::json!({
|
|
"action": "diagnostics",
|
|
"diagnostics": all_diags,
|
|
"count": all_diags.len()
|
|
}));
|
|
}
|
|
|
|
// For other actions, we need a connected server for the given file
|
|
let path = path.ok_or("path is required for this LSP action")?;
|
|
let language = Self::language_for_path(path)
|
|
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
|
|
|
|
// Check the entry exists
|
|
{
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if !inner.servers.contains_key(&language) {
|
|
return Err(format!("no LSP server available for path: {path}"));
|
|
}
|
|
}
|
|
|
|
// Lazy-start: if no process yet, try to start one
|
|
let needs_start = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner
|
|
.servers
|
|
.get(&language)
|
|
.is_none_or(|entry| entry.process.is_none())
|
|
};
|
|
|
|
if needs_start {
|
|
if let Err(e) = self.start_server(&language) {
|
|
// Check the status after failed start — if still not Connected,
|
|
// return a proper error. This preserves the existing behavior
|
|
// for Disconnected/Error status servers.
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get(&language) {
|
|
if entry.state.status != LspServerStatus::Connected {
|
|
return Err(format!(
|
|
"LSP server for '{}' is not connected (status: {}): {}",
|
|
language, entry.state.status, e
|
|
));
|
|
}
|
|
}
|
|
// If somehow still marked Connected but start failed, return error JSON
|
|
return Ok(serde_json::json!({
|
|
"action": action,
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "error",
|
|
"error": e
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Check the server status
|
|
{
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
if let Some(entry) = inner.servers.get(&language) {
|
|
if entry.state.status != LspServerStatus::Connected {
|
|
return Err(format!(
|
|
"LSP server for '{}' is not connected (status: {})",
|
|
language, entry.state.status
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get the process handle (clone the Arc)
|
|
let process_arc = {
|
|
let inner = self.inner.lock().expect("lsp registry lock poisoned");
|
|
inner
|
|
.servers
|
|
.get(&language)
|
|
.and_then(|entry| entry.process.clone())
|
|
.ok_or_else(|| format!("no LSP process available for language: {language}"))?
|
|
};
|
|
|
|
// Dispatch to the real LSP process
|
|
let result = {
|
|
let mut process = process_arc
|
|
.lock()
|
|
.map_err(|_| "lsp process lock poisoned".to_owned())?;
|
|
|
|
// Create a minimal tokio runtime for async LSP calls
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|e| format!("failed to create tokio runtime: {e}"))?;
|
|
|
|
rt.block_on(async {
|
|
let line = line.unwrap_or(0);
|
|
let character = character.unwrap_or(0);
|
|
|
|
match lsp_action {
|
|
LspAction::Hover => {
|
|
let hover = process.hover(path, line, character).await;
|
|
hover.map(|opt| {
|
|
opt.map_or_else(
|
|
|| serde_json::json!({
|
|
"action": "hover",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "no_result",
|
|
}),
|
|
|h| serde_json::json!({
|
|
"action": "hover",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"result": h,
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
LspAction::Definition => {
|
|
let locations = process.goto_definition(path, line, character).await;
|
|
locations.map(|locs| serde_json::json!({
|
|
"action": "definition",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"locations": locs,
|
|
}))
|
|
}
|
|
LspAction::References => {
|
|
let locations = process.references(path, line, character).await;
|
|
locations.map(|locs| serde_json::json!({
|
|
"action": "references",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"locations": locs,
|
|
}))
|
|
}
|
|
LspAction::Completion => {
|
|
let items = process.completion(path, line, character).await;
|
|
items.map(|completions| serde_json::json!({
|
|
"action": "completion",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"items": completions,
|
|
}))
|
|
}
|
|
LspAction::Symbols => {
|
|
let symbols = process.document_symbols(path).await;
|
|
symbols.map(|syms| serde_json::json!({
|
|
"action": "symbols",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"symbols": syms,
|
|
}))
|
|
}
|
|
LspAction::Format => {
|
|
let edits = process.format(path).await;
|
|
edits.map(|text_edits| serde_json::json!({
|
|
"action": "format",
|
|
"path": path,
|
|
"line": line,
|
|
"character": character,
|
|
"language": language,
|
|
"status": "ok",
|
|
"edits": text_edits,
|
|
}))
|
|
}
|
|
LspAction::Diagnostics => unreachable!(),
|
|
}
|
|
})
|
|
};
|
|
|
|
result.map_err(|e| format!("LSP {action} failed for '{language}': {e}"))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn registers_and_retrieves_server() {
|
|
let registry = LspRegistry::new();
|
|
registry.register(
|
|
"rust",
|
|
LspServerStatus::Connected,
|
|
Some("/workspace"),
|
|
vec!["hover".into(), "completion".into()],
|
|
);
|
|
|
|
let server = registry.get("rust").expect("should exist");
|
|
assert_eq!(server.language, "rust");
|
|
assert_eq!(server.status, LspServerStatus::Connected);
|
|
assert_eq!(server.capabilities.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn finds_server_by_file_extension() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
registry.register("typescript", LspServerStatus::Connected, None, vec![]);
|
|
|
|
let rs_server = registry.find_server_for_path("src/main.rs").unwrap();
|
|
assert_eq!(rs_server.language, "rust");
|
|
|
|
let ts_server = registry.find_server_for_path("src/index.ts").unwrap();
|
|
assert_eq!(ts_server.language, "typescript");
|
|
|
|
assert!(registry.find_server_for_path("data.csv").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn manages_diagnostics() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
|
|
registry
|
|
.add_diagnostics(
|
|
"rust",
|
|
vec![LspDiagnostic {
|
|
path: "src/main.rs".into(),
|
|
line: 10,
|
|
character: 5,
|
|
severity: "error".into(),
|
|
message: "mismatched types".into(),
|
|
source: Some("rust-analyzer".into()),
|
|
}],
|
|
)
|
|
.unwrap();
|
|
|
|
let diags = registry.get_diagnostics("src/main.rs");
|
|
assert_eq!(diags.len(), 1);
|
|
assert_eq!(diags[0].message, "mismatched types");
|
|
|
|
registry.clear_diagnostics("rust").unwrap();
|
|
assert!(registry.get_diagnostics("src/main.rs").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dispatches_diagnostics_action() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
registry
|
|
.add_diagnostics(
|
|
"rust",
|
|
vec![LspDiagnostic {
|
|
path: "src/lib.rs".into(),
|
|
line: 1,
|
|
character: 0,
|
|
severity: "warning".into(),
|
|
message: "unused import".into(),
|
|
source: None,
|
|
}],
|
|
)
|
|
.unwrap();
|
|
|
|
let result = registry
|
|
.dispatch("diagnostics", Some("src/lib.rs"), None, None, None)
|
|
.unwrap();
|
|
assert_eq!(result["count"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatches_hover_action() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
|
|
let result = registry
|
|
.dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None)
|
|
.unwrap();
|
|
assert_eq!(result["action"], "hover");
|
|
assert_eq!(result["language"], "rust");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_action_on_disconnected_server() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Disconnected, None, vec![]);
|
|
|
|
assert!(registry
|
|
.dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_action() {
|
|
let registry = LspRegistry::new();
|
|
assert!(registry
|
|
.dispatch("unknown_action", Some("file.rs"), None, None, None)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn disconnects_server() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
assert_eq!(registry.len(), 1);
|
|
|
|
let removed = registry.disconnect("rust");
|
|
assert!(removed.is_some());
|
|
assert!(registry.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn lsp_action_from_str_all_aliases() {
|
|
// given
|
|
let cases = [
|
|
("diagnostics", Some(LspAction::Diagnostics)),
|
|
("hover", Some(LspAction::Hover)),
|
|
("definition", Some(LspAction::Definition)),
|
|
("goto_definition", Some(LspAction::Definition)),
|
|
("references", Some(LspAction::References)),
|
|
("find_references", Some(LspAction::References)),
|
|
("completion", Some(LspAction::Completion)),
|
|
("completions", Some(LspAction::Completion)),
|
|
("symbols", Some(LspAction::Symbols)),
|
|
("document_symbols", Some(LspAction::Symbols)),
|
|
("format", Some(LspAction::Format)),
|
|
("formatting", Some(LspAction::Format)),
|
|
("unknown", None),
|
|
];
|
|
|
|
// when
|
|
let resolved: Vec<_> = cases
|
|
.into_iter()
|
|
.map(|(input, expected)| (input, LspAction::from_str(input), expected))
|
|
.collect();
|
|
|
|
// then
|
|
for (input, actual, expected) in resolved {
|
|
assert_eq!(actual, expected, "unexpected action resolution for {input}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lsp_server_status_display_all_variants() {
|
|
// given
|
|
let cases = [
|
|
(LspServerStatus::Connected, "connected"),
|
|
(LspServerStatus::Disconnected, "disconnected"),
|
|
(LspServerStatus::Starting, "starting"),
|
|
(LspServerStatus::Error, "error"),
|
|
];
|
|
|
|
// when
|
|
let rendered: Vec<_> = cases
|
|
.into_iter()
|
|
.map(|(status, expected)| (status.to_string(), expected))
|
|
.collect();
|
|
|
|
// then
|
|
assert_eq!(
|
|
rendered,
|
|
vec![
|
|
("connected".to_string(), "connected"),
|
|
("disconnected".to_string(), "disconnected"),
|
|
("starting".to_string(), "starting"),
|
|
("error".to_string(), "error"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_diagnostics_without_path_aggregates() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
registry.register("python", LspServerStatus::Connected, None, vec![]);
|
|
registry
|
|
.add_diagnostics(
|
|
"rust",
|
|
vec![LspDiagnostic {
|
|
path: "src/lib.rs".into(),
|
|
line: 1,
|
|
character: 0,
|
|
severity: "warning".into(),
|
|
message: "unused import".into(),
|
|
source: Some("rust-analyzer".into()),
|
|
}],
|
|
)
|
|
.expect("rust diagnostics should add");
|
|
registry
|
|
.add_diagnostics(
|
|
"python",
|
|
vec![LspDiagnostic {
|
|
path: "script.py".into(),
|
|
line: 2,
|
|
character: 4,
|
|
severity: "error".into(),
|
|
message: "undefined name".into(),
|
|
source: Some("pyright".into()),
|
|
}],
|
|
)
|
|
.expect("python diagnostics should add");
|
|
|
|
// when
|
|
let result = registry
|
|
.dispatch("diagnostics", None, None, None, None)
|
|
.expect("aggregate diagnostics should work");
|
|
|
|
// then
|
|
assert_eq!(result["action"], "diagnostics");
|
|
assert_eq!(result["count"], 2);
|
|
assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_non_diagnostics_requires_path() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
|
|
// when
|
|
let result = registry.dispatch("hover", None, Some(1), Some(0), None);
|
|
|
|
// then
|
|
assert_eq!(
|
|
result.expect_err("path should be required"),
|
|
"path is required for this LSP action"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_no_server_for_path_errors() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
|
|
// when
|
|
let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None);
|
|
|
|
// then
|
|
let error = result.expect_err("missing server should fail");
|
|
assert!(error.contains("no LSP server available for path: notes.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_disconnected_server_error_payload() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
registry.register("typescript", LspServerStatus::Disconnected, None, vec![]);
|
|
|
|
// when
|
|
let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None);
|
|
|
|
// then
|
|
let error = result.expect_err("disconnected server should fail");
|
|
assert!(error.contains("typescript"));
|
|
assert!(error.contains("disconnected"));
|
|
}
|
|
|
|
#[test]
|
|
fn find_server_for_all_extensions() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
for language in [
|
|
"rust",
|
|
"typescript",
|
|
"javascript",
|
|
"python",
|
|
"go",
|
|
"java",
|
|
"c",
|
|
"cpp",
|
|
"ruby",
|
|
"lua",
|
|
] {
|
|
registry.register(language, LspServerStatus::Connected, None, vec![]);
|
|
}
|
|
let cases = [
|
|
("src/main.rs", "rust"),
|
|
("src/index.ts", "typescript"),
|
|
("src/view.tsx", "typescript"),
|
|
("src/app.js", "javascript"),
|
|
("src/app.jsx", "javascript"),
|
|
("script.py", "python"),
|
|
("main.go", "go"),
|
|
("Main.java", "java"),
|
|
("native.c", "c"),
|
|
("native.h", "c"),
|
|
("native.cpp", "cpp"),
|
|
("native.hpp", "cpp"),
|
|
("native.cc", "cpp"),
|
|
("script.rb", "ruby"),
|
|
("script.lua", "lua"),
|
|
];
|
|
|
|
// when
|
|
let resolved: Vec<_> = cases
|
|
.into_iter()
|
|
.map(|(path, expected)| {
|
|
(
|
|
path,
|
|
registry
|
|
.find_server_for_path(path)
|
|
.map(|server| server.language),
|
|
expected,
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
// then
|
|
for (path, actual, expected) in resolved {
|
|
assert_eq!(
|
|
actual.as_deref(),
|
|
Some(expected),
|
|
"unexpected mapping for {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn find_server_for_path_no_extension() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
|
|
// when
|
|
let result = registry.find_server_for_path("Makefile");
|
|
|
|
// then
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn list_servers_with_multiple() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
registry.register("typescript", LspServerStatus::Starting, None, vec![]);
|
|
registry.register("python", LspServerStatus::Error, None, vec![]);
|
|
|
|
// when
|
|
let servers = registry.list_servers();
|
|
|
|
// then
|
|
assert_eq!(servers.len(), 3);
|
|
assert!(servers.iter().any(|server| server.language == "rust"));
|
|
assert!(servers.iter().any(|server| server.language == "typescript"));
|
|
assert!(servers.iter().any(|server| server.language == "python"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_missing_server_returns_none() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
|
|
// when
|
|
let server = registry.get("missing");
|
|
|
|
// then
|
|
assert!(server.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn add_diagnostics_missing_language_errors() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
|
|
// when
|
|
let result = registry.add_diagnostics("missing", vec![]);
|
|
|
|
// then
|
|
let error = result.expect_err("missing language should fail");
|
|
assert!(error.contains("LSP server not found for language: missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_diagnostics_across_servers() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
let shared_path = "shared/file.txt";
|
|
registry.register("rust", LspServerStatus::Connected, None, vec![]);
|
|
registry.register("python", LspServerStatus::Connected, None, vec![]);
|
|
registry
|
|
.add_diagnostics(
|
|
"rust",
|
|
vec![LspDiagnostic {
|
|
path: shared_path.into(),
|
|
line: 4,
|
|
character: 1,
|
|
severity: "warning".into(),
|
|
message: "warn".into(),
|
|
source: None,
|
|
}],
|
|
)
|
|
.expect("rust diagnostics should add");
|
|
registry
|
|
.add_diagnostics(
|
|
"python",
|
|
vec![LspDiagnostic {
|
|
path: shared_path.into(),
|
|
line: 8,
|
|
character: 3,
|
|
severity: "error".into(),
|
|
message: "err".into(),
|
|
source: None,
|
|
}],
|
|
)
|
|
.expect("python diagnostics should add");
|
|
|
|
// when
|
|
let diagnostics = registry.get_diagnostics(shared_path);
|
|
|
|
// then
|
|
assert_eq!(diagnostics.len(), 2);
|
|
assert!(diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.message == "warn"));
|
|
assert!(diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.message == "err"));
|
|
}
|
|
|
|
#[test]
|
|
fn clear_diagnostics_missing_language_errors() {
|
|
// given
|
|
let registry = LspRegistry::new();
|
|
|
|
// when
|
|
let result = registry.clear_diagnostics("missing");
|
|
|
|
// then
|
|
let error = result.expect_err("missing language should fail");
|
|
assert!(error.contains("LSP server not found for language: missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn register_with_descriptor_stores_entry() {
|
|
let registry = LspRegistry::new();
|
|
let descriptor = LspServerDescriptor {
|
|
language: "rust".into(),
|
|
command: "rust-analyzer".into(),
|
|
args: vec![],
|
|
extensions: vec!["rs".into()],
|
|
};
|
|
registry.register_with_descriptor(
|
|
"rust",
|
|
LspServerStatus::Connected,
|
|
Some("/project"),
|
|
vec!["hover".into()],
|
|
descriptor,
|
|
);
|
|
|
|
let server = registry.get("rust").expect("should exist after register_with_descriptor");
|
|
assert_eq!(server.language, "rust");
|
|
assert_eq!(server.status, LspServerStatus::Connected);
|
|
assert_eq!(server.root_path.as_deref(), Some("/project"));
|
|
assert_eq!(server.capabilities, vec!["hover"]);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_server_on_nonexistent_errors() {
|
|
let registry = LspRegistry::new();
|
|
let result = registry.stop_server("missing");
|
|
assert!(result.is_err(), "stopping a nonexistent server should error");
|
|
let error = result.unwrap_err();
|
|
assert!(error.contains("missing"), "error message should reference 'missing', got: {error}");
|
|
}
|
|
|
|
/// This test requires rust-analyzer to be installed on the system.
|
|
/// Run with: cargo test -p runtime -- --ignored
|
|
#[test]
|
|
#[ignore = "requires rust-analyzer installed on PATH"]
|
|
fn start_server_without_descriptor_falls_back_to_discovery() {
|
|
let registry = LspRegistry::new();
|
|
registry.register("rust", LspServerStatus::Starting, None, vec![]);
|
|
let result = registry.start_server("rust");
|
|
assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}");
|
|
let server = registry.get("rust").expect("rust should be registered");
|
|
assert_eq!(server.status, LspServerStatus::Connected);
|
|
let _ = registry.stop_server("rust");
|
|
}
|
|
|
|
/// This test requires rust-analyzer to be installed on the system.
|
|
/// Run with: cargo test -p runtime -- --ignored
|
|
#[test]
|
|
#[ignore = "requires rust-analyzer installed on PATH"]
|
|
fn dispatch_hover_lazy_starts_server() {
|
|
let registry = LspRegistry::new();
|
|
let descriptor = crate::lsp_discovery::LspServerDescriptor {
|
|
language: "rust".into(),
|
|
command: "rust-analyzer".into(),
|
|
args: vec![],
|
|
extensions: vec!["rs".into()],
|
|
};
|
|
registry.register_with_descriptor(
|
|
"rust",
|
|
LspServerStatus::Starting,
|
|
None,
|
|
vec![],
|
|
descriptor,
|
|
);
|
|
// dispatch should trigger start_server because process is None
|
|
let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None);
|
|
// Result may be Ok or Err depending on whether rust-analyzer can actually
|
|
// respond for this path, but it should not fail with "not connected"
|
|
// (which would indicate the lazy-start didn't kick in).
|
|
if let Err(e) = &result {
|
|
assert!(
|
|
!e.contains("not connected"),
|
|
"dispatch should have lazily started the server, got: {e}"
|
|
);
|
|
}
|
|
let _ = registry.stop_server("rust");
|
|
}
|
|
|
|
/// This test requires rust-analyzer to be installed on the system.
|
|
/// Run with: cargo test -p runtime -- --ignored
|
|
#[test]
|
|
#[ignore = "requires rust-analyzer installed on PATH"]
|
|
fn start_and_stop_server() {
|
|
let registry = LspRegistry::new();
|
|
let descriptor = crate::lsp_discovery::LspServerDescriptor {
|
|
language: "rust".into(),
|
|
command: "rust-analyzer".into(),
|
|
args: vec![],
|
|
extensions: vec!["rs".into()],
|
|
};
|
|
registry.register_with_descriptor(
|
|
"rust",
|
|
LspServerStatus::Starting,
|
|
None,
|
|
vec![],
|
|
descriptor,
|
|
);
|
|
|
|
let start_result = registry.start_server("rust");
|
|
assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}");
|
|
|
|
let server = registry.get("rust").expect("rust should exist");
|
|
assert_eq!(server.status, LspServerStatus::Connected);
|
|
|
|
let stop_result = registry.stop_server("rust");
|
|
assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}");
|
|
|
|
let server = registry.get("rust").expect("rust should still be in registry");
|
|
assert_eq!(server.status, LspServerStatus::Disconnected);
|
|
}
|
|
}
|