refactor: split LSP modules under 500 lines each
Split the three large LSP files into module directories with sub-files: lsp_transport/ (was 560 lines): - mod.rs (425) — types + LspTransport impl - tests.rs (134) — test module lsp_process/ (was 929 lines): - mod.rs (436) — LspProcess struct + public methods + error types - parse.rs (311) — helper functions and LSP response parsers - tests.rs (194) — test module lsp_client/ (was 1338 lines): - mod.rs (466) — LspRegistry struct + impl, re-exports from types - types.rs (103) — LspAction, LspDiagnostic, LspServerStatus, etc. - dispatch.rs (224) — LspRegistry::dispatch() method - tests.rs (273) — core registry tests - tests_lifecycle.rs (294) — lifecycle and integration tests All files under 500 lines. All 501 runtime tests pass. Clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5fee7fdf39
commit
d610657cf0
|
|
@ -0,0 +1,224 @@
|
|||
//! LSP action dispatch: routes actions to the appropriate server process.
|
||||
|
||||
use super::types::{LspAction, LspServerStatus};
|
||||
|
||||
impl super::LspRegistry {
|
||||
/// 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}"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
#![allow(clippy::should_implement_trait, clippy::must_use_candidate)]
|
||||
//! LSP (Language Server Protocol) client registry for tool dispatch.
|
||||
|
||||
mod dispatch;
|
||||
mod types;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_lifecycle;
|
||||
|
||||
pub use types::{
|
||||
LspAction, LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerState,
|
||||
LspServerStatus, LspSymbol,
|
||||
};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::lsp_discovery::{discover_available_servers, LspServerDescriptor};
|
||||
use crate::lsp_process::LspProcess;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
//! Tests for the LSP client registry: registration, diagnostics, and type unit tests.
|
||||
|
||||
use super::*;
|
||||
use super::types::*;
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
//! Tests for the LSP client registry: extension mapping, server lifecycle,
|
||||
//! and diagnostics edge cases.
|
||||
|
||||
use super::*;
|
||||
use super::types::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
//! LSP type definitions: action enums, diagnostic/location types, server status.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
//! LSP process manager: spawns language servers and drives the LSP lifecycle.
|
||||
|
||||
mod parse;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::lsp_client::{
|
||||
LspCompletionItem, LspDiagnostic, LspHoverResult, LspLocation, LspServerStatus, LspSymbol,
|
||||
};
|
||||
use crate::lsp_transport::{LspTransport, LspTransportError};
|
||||
|
||||
use parse::{
|
||||
canonicalize_root, language_id_for_path, parse_completions, parse_hover, parse_locations,
|
||||
parse_symbols, path_to_uri, severity_name, text_document_position_params, uri_to_path,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspProcess {
|
||||
transport: LspTransport,
|
||||
language: String,
|
||||
root_uri: String,
|
||||
capabilities: Option<JsonValue>,
|
||||
status: LspServerStatus,
|
||||
open_files: HashSet<String>,
|
||||
version_counter: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
impl LspProcess {
|
||||
/// Spawn a language server process and perform the LSP initialize handshake.
|
||||
pub async fn start(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
root_path: &Path,
|
||||
) -> Result<Self, LspProcessError> {
|
||||
let transport = LspTransport::spawn(command, args)
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?;
|
||||
|
||||
let canonical = canonicalize_root(root_path)?;
|
||||
let root_uri = format!("file://{canonical}");
|
||||
|
||||
let mut process = Self {
|
||||
transport,
|
||||
language: command.to_owned(),
|
||||
root_uri: root_uri.clone(),
|
||||
capabilities: None,
|
||||
status: LspServerStatus::Starting,
|
||||
open_files: HashSet::new(),
|
||||
version_counter: HashMap::new(),
|
||||
};
|
||||
|
||||
process.initialize(&canonical).await?;
|
||||
process.status = LspServerStatus::Connected;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
/// Send the LSP `initialize` request followed by the `initialized` notification.
|
||||
async fn initialize(&mut self, root_path: &str) -> Result<JsonValue, LspProcessError> {
|
||||
let root_uri = format!("file://{root_path}");
|
||||
let pid = std::process::id();
|
||||
|
||||
let params = serde_json::json!({
|
||||
"processId": pid,
|
||||
"rootUri": root_uri,
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"hover": { "contentFormat": ["markdown", "plaintext"] },
|
||||
"definition": { "linkSupport": true },
|
||||
"references": {},
|
||||
"completion": {
|
||||
"completionItem": { "snippetSupport": false }
|
||||
},
|
||||
"documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
|
||||
"publishDiagnostics": { "relatedInformation": true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("initialize", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
self.capabilities = Some(result.clone());
|
||||
|
||||
self.transport
|
||||
.send_notification("initialized", Some(serde_json::json!({})))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Gracefully shut down the language server.
|
||||
pub async fn shutdown(&mut self) -> Result<(), LspProcessError> {
|
||||
self.status = LspServerStatus::Disconnected;
|
||||
|
||||
let shutdown_result = self
|
||||
.transport
|
||||
.send_request("shutdown", None)
|
||||
.await
|
||||
.map_err(LspProcessError::Transport);
|
||||
|
||||
if shutdown_result.is_ok() {
|
||||
self.transport
|
||||
.send_notification("exit", None)
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
}
|
||||
|
||||
self.transport
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query hover information at a position.
|
||||
pub async fn hover(
|
||||
&mut self,
|
||||
path: &str,
|
||||
line: u32,
|
||||
character: u32,
|
||||
) -> Result<Option<LspHoverResult>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = text_document_position_params(&uri, line, character);
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/hover", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
if result.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(parse_hover(&result))
|
||||
}
|
||||
|
||||
/// Go to definition at a position.
|
||||
pub async fn goto_definition(
|
||||
&mut self,
|
||||
path: &str,
|
||||
line: u32,
|
||||
character: u32,
|
||||
) -> Result<Vec<LspLocation>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = text_document_position_params(&uri, line, character);
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/definition", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
Ok(parse_locations(&result))
|
||||
}
|
||||
|
||||
/// Find references at a position.
|
||||
pub async fn references(
|
||||
&mut self,
|
||||
path: &str,
|
||||
line: u32,
|
||||
character: u32,
|
||||
) -> Result<Vec<LspLocation>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = serde_json::json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": { "includeDeclaration": true }
|
||||
});
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/references", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
Ok(parse_locations(&result))
|
||||
}
|
||||
|
||||
/// Get document symbols for a file.
|
||||
pub async fn document_symbols(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<Vec<LspSymbol>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = serde_json::json!({
|
||||
"textDocument": { "uri": uri }
|
||||
});
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/documentSymbol", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
if result.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(parse_symbols(&result, path))
|
||||
}
|
||||
|
||||
/// Get completions at a position.
|
||||
pub async fn completion(
|
||||
&mut self,
|
||||
path: &str,
|
||||
line: u32,
|
||||
character: u32,
|
||||
) -> Result<Vec<LspCompletionItem>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = text_document_position_params(&uri, line, character);
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/completion", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
if result.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// The response may be a CompletionList or a plain array.
|
||||
let items = if let Some(list) = result.get("items") {
|
||||
list
|
||||
} else {
|
||||
&result
|
||||
};
|
||||
|
||||
Ok(parse_completions(items))
|
||||
}
|
||||
|
||||
/// Format a document.
|
||||
pub async fn format(&mut self, path: &str) -> Result<Vec<JsonValue>, LspProcessError> {
|
||||
let uri = path_to_uri(path);
|
||||
let params = serde_json::json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"options": { "tabSize": 4, "insertSpaces": true }
|
||||
});
|
||||
|
||||
let response = self
|
||||
.transport
|
||||
.send_request("textDocument/formatting", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
let result = response
|
||||
.into_result()
|
||||
.map_err(|e| LspProcessError::Transport(LspTransportError::JsonRpc(e)))?;
|
||||
|
||||
if result.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
match result.as_array() {
|
||||
Some(arr) => Ok(arr.clone()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the server that a file was opened. Sends `textDocument/didOpen`.
|
||||
/// No-op if the file is already tracked as open.
|
||||
pub async fn did_open(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> {
|
||||
if self.open_files.contains(path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let uri = path_to_uri(path);
|
||||
let language_id = language_id_for_path(path);
|
||||
let params = serde_json::json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": 0,
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
|
||||
self.transport
|
||||
.send_notification("textDocument/didOpen", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
self.open_files.insert(path.to_owned());
|
||||
self.version_counter.insert(path.to_owned(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notify the server that a file's content changed. Sends `textDocument/didChange`.
|
||||
pub async fn did_change(&mut self, path: &str, content: &str) -> Result<(), LspProcessError> {
|
||||
let version = self.version_counter.get(path).map_or(1, |v| v + 1);
|
||||
|
||||
let uri = path_to_uri(path);
|
||||
let params = serde_json::json!({
|
||||
"textDocument": { "uri": uri, "version": version },
|
||||
"contentChanges": [{ "text": content }]
|
||||
});
|
||||
|
||||
self.transport
|
||||
.send_notification("textDocument/didChange", Some(params))
|
||||
.await
|
||||
.map_err(LspProcessError::Transport)?;
|
||||
|
||||
self.version_counter.insert(path.to_owned(), version);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain queued server notifications and extract `publishDiagnostics`.
|
||||
#[allow(clippy::redundant_closure_for_method_calls)]
|
||||
pub fn drain_diagnostics(&mut self) -> Vec<LspDiagnostic> {
|
||||
let notifications = self.transport.drain_notifications();
|
||||
let mut diagnostics = Vec::new();
|
||||
for n in ¬ifications {
|
||||
if n.method == "textDocument/publishDiagnostics" {
|
||||
if let Some(params) = &n.params {
|
||||
if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) {
|
||||
let path = uri_to_path(uri);
|
||||
if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array())
|
||||
{
|
||||
for d in diags {
|
||||
diagnostics.push(LspDiagnostic {
|
||||
path: path.clone(),
|
||||
line: d
|
||||
.get("range")
|
||||
.and_then(|r| r.get("start"))
|
||||
.and_then(|s| s.get("line"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map_or(0, |v| v as u32),
|
||||
character: d
|
||||
.get("range")
|
||||
.and_then(|r| r.get("start"))
|
||||
.and_then(|s| s.get("character"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map_or(0, |v| v as u32),
|
||||
severity: d
|
||||
.get("severity")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map_or_else(|| "error".to_owned(), severity_name),
|
||||
message: d
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
source: d
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn status(&self) -> LspServerStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn language(&self) -> &str {
|
||||
&self.language
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn root_uri(&self) -> &str {
|
||||
&self.root_uri
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspProcessError {
|
||||
Transport(LspTransportError),
|
||||
InvalidPath(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LspProcessError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Transport(e) => write!(f, "LSP transport error: {e}"),
|
||||
Self::InvalidPath(p) => write!(f, "invalid path: {p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LspProcessError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Transport(e) => Some(e),
|
||||
Self::InvalidPath(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
//! Helper functions for LSP URI/path conversion, parameter building, and
|
||||
//! response parsing.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::lsp_client::{LspCompletionItem, LspHoverResult, LspLocation, LspSymbol};
|
||||
use crate::lsp_process::LspProcessError;
|
||||
|
||||
pub(super) fn canonicalize_root(path: &Path) -> Result<String, LspProcessError> {
|
||||
path.canonicalize()
|
||||
.map_err(|e| LspProcessError::InvalidPath(format!("{}: {e}", path.display())))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(super) fn path_to_uri(path: &str) -> String {
|
||||
let canonical = std::path::Path::new(path);
|
||||
if canonical.is_absolute() {
|
||||
format!("file://{path}")
|
||||
} else {
|
||||
let resolved = std::env::current_dir()
|
||||
.map_or_else(|_| canonical.to_path_buf(), |d| d.join(path));
|
||||
let canonicalized = resolved
|
||||
.canonicalize()
|
||||
.unwrap_or(resolved)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
format!("file://{canonicalized}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn text_document_position_params(uri: &str, line: u32, character: u32) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn uri_to_path(uri: &str) -> String {
|
||||
uri.strip_prefix("file://").unwrap_or(uri).to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn language_id_for_path(path: &str) -> String {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
match ext {
|
||||
"rs" => "rust",
|
||||
"ts" => "typescript",
|
||||
"tsx" => "typescriptreact",
|
||||
"js" => "javascript",
|
||||
"jsx" => "javascriptreact",
|
||||
"py" => "python",
|
||||
"go" => "go",
|
||||
"java" => "java",
|
||||
"c" | "h" => "c",
|
||||
"cpp" | "hpp" | "cc" => "cpp",
|
||||
"rb" => "ruby",
|
||||
"lua" => "lua",
|
||||
_ => ext,
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub(super) fn severity_name(code: u64) -> String {
|
||||
match code {
|
||||
1 => "error".to_owned(),
|
||||
2 => "warning".to_owned(),
|
||||
3 => "info".to_owned(),
|
||||
4 => "hint".to_owned(),
|
||||
_ => format!("unknown({code})"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_hover(value: &JsonValue) -> Option<LspHoverResult> {
|
||||
let contents = value.get("contents")?;
|
||||
|
||||
// MarkupContent: { kind, value }
|
||||
if let (Some(kind), Some(val)) = (contents.get("kind"), contents.get("value")) {
|
||||
let language = if kind.as_str() == Some("plaintext") {
|
||||
None
|
||||
} else {
|
||||
Some(kind.as_str().unwrap_or("markdown").to_owned())
|
||||
};
|
||||
return Some(LspHoverResult {
|
||||
content: val.as_str().unwrap_or("").to_owned(),
|
||||
language,
|
||||
});
|
||||
}
|
||||
|
||||
// MarkedString object: { language, value }
|
||||
if let (Some(lang), Some(val)) = (contents.get("language"), contents.get("value")) {
|
||||
return Some(LspHoverResult {
|
||||
content: val.as_str().unwrap_or("").to_owned(),
|
||||
language: Some(lang.as_str().unwrap_or("").to_owned()),
|
||||
});
|
||||
}
|
||||
|
||||
// Plain string MarkedString
|
||||
if let Some(s) = contents.as_str() {
|
||||
return Some(LspHoverResult {
|
||||
content: s.to_owned(),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Array of MarkedString
|
||||
if let Some(arr) = contents.as_array() {
|
||||
let parts: Vec<&str> = arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Some(s) = item.as_str() {
|
||||
Some(s)
|
||||
} else {
|
||||
item.get("value").and_then(JsonValue::as_str)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(LspHoverResult {
|
||||
content: parts.join("\n"),
|
||||
language: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
pub(super) fn parse_locations(value: &JsonValue) -> Vec<LspLocation> {
|
||||
let Some(locations) = value.as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
locations
|
||||
.iter()
|
||||
.filter_map(|loc| {
|
||||
let uri = loc.get("uri")?.as_str()?;
|
||||
let path = uri_to_path(uri);
|
||||
let range = loc.get("range")?;
|
||||
let start = range.get("start")?;
|
||||
let end = range.get("end")?;
|
||||
|
||||
Some(LspLocation {
|
||||
path,
|
||||
line: start.get("line")?.as_u64()? as u32,
|
||||
character: start.get("character")?.as_u64()? as u32,
|
||||
end_line: end
|
||||
.get("line")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map(|v| v as u32),
|
||||
end_character: end
|
||||
.get("character")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map(|v| v as u32),
|
||||
preview: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_symbols(items: &[JsonValue], path: &str, out: &mut Vec<LspSymbol>) {
|
||||
for item in items {
|
||||
let name = item.get("name").and_then(JsonValue::as_str).unwrap_or("");
|
||||
let kind = item
|
||||
.get("kind")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map_or_else(|| "Unknown".into(), symbol_kind_name);
|
||||
|
||||
let (sym_path, line, character) = if let Some(range) = item.get("range") {
|
||||
let start = range.get("start");
|
||||
(
|
||||
path.to_owned(),
|
||||
u32::try_from(
|
||||
start
|
||||
.and_then(|s| s.get("line"))
|
||||
.and_then(JsonValue::as_u64)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.unwrap_or(0),
|
||||
u32::try_from(
|
||||
start
|
||||
.and_then(|s| s.get("character"))
|
||||
.and_then(JsonValue::as_u64)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(path.to_owned(), 0, 0)
|
||||
};
|
||||
|
||||
out.push(LspSymbol {
|
||||
name: name.to_owned(),
|
||||
kind: kind.clone(),
|
||||
path: sym_path,
|
||||
line,
|
||||
character,
|
||||
});
|
||||
|
||||
if let Some(children) = item.get("children").and_then(JsonValue::as_array) {
|
||||
extract_symbols(children, path, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_symbols(value: &JsonValue, default_path: &str) -> Vec<LspSymbol> {
|
||||
let Some(items) = value.as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
extract_symbols(items, default_path, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn parse_completions(value: &JsonValue) -> Vec<LspCompletionItem> {
|
||||
let Some(items) = value.as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
items
|
||||
.iter()
|
||||
.map(|item| LspCompletionItem {
|
||||
label: item
|
||||
.get("label")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
kind: item
|
||||
.get("kind")
|
||||
.and_then(JsonValue::as_u64)
|
||||
.map(completion_kind_name),
|
||||
detail: item
|
||||
.get("detail")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_owned),
|
||||
insert_text: item
|
||||
.get("insertText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_owned),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn symbol_kind_name(kind: u64) -> String {
|
||||
match kind {
|
||||
1 => "File".into(),
|
||||
2 => "Module".into(),
|
||||
3 => "Namespace".into(),
|
||||
4 => "Package".into(),
|
||||
5 => "Class".into(),
|
||||
6 => "Method".into(),
|
||||
7 => "Property".into(),
|
||||
8 => "Field".into(),
|
||||
9 => "Constructor".into(),
|
||||
10 => "Enum".into(),
|
||||
11 => "Interface".into(),
|
||||
12 => "Function".into(),
|
||||
13 => "Variable".into(),
|
||||
14 => "Constant".into(),
|
||||
15 => "String".into(),
|
||||
16 => "Number".into(),
|
||||
17 => "Boolean".into(),
|
||||
18 => "Array".into(),
|
||||
19 => "Object".into(),
|
||||
20 => "Key".into(),
|
||||
21 => "Null".into(),
|
||||
22 => "EnumMember".into(),
|
||||
23 => "Struct".into(),
|
||||
24 => "Event".into(),
|
||||
25 => "Operator".into(),
|
||||
26 => "TypeParameter".into(),
|
||||
_ => format!("Unknown({kind})"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn completion_kind_name(kind: u64) -> String {
|
||||
match kind {
|
||||
1 => "Text".into(),
|
||||
2 => "Method".into(),
|
||||
3 => "Function".into(),
|
||||
4 => "Constructor".into(),
|
||||
5 => "Field".into(),
|
||||
6 => "Variable".into(),
|
||||
7 => "Class".into(),
|
||||
8 => "Interface".into(),
|
||||
9 => "Module".into(),
|
||||
10 => "Property".into(),
|
||||
11 => "Unit".into(),
|
||||
12 => "Value".into(),
|
||||
13 => "Enum".into(),
|
||||
14 => "Keyword".into(),
|
||||
15 => "Snippet".into(),
|
||||
16 => "Color".into(),
|
||||
17 => "File".into(),
|
||||
18 => "Reference".into(),
|
||||
19 => "Folder".into(),
|
||||
20 => "EnumMember".into(),
|
||||
21 => "Constant".into(),
|
||||
22 => "Struct".into(),
|
||||
23 => "Event".into(),
|
||||
24 => "Operator".into(),
|
||||
25 => "TypeParameter".into(),
|
||||
_ => format!("Unknown({kind})"),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
use super::*;
|
||||
use super::parse::*;
|
||||
|
||||
/// Requires rust-analyzer to be installed on the system.
|
||||
/// Run with: cargo test -p runtime -- --ignored
|
||||
#[tokio::test]
|
||||
#[ignore = "requires rust-analyzer installed on PATH"]
|
||||
async fn spawn_and_initialize_rust_analyzer() {
|
||||
let root = std::env::current_dir().expect("should have cwd");
|
||||
let process = LspProcess::start("rust-analyzer", &[], &root).await;
|
||||
assert!(process.is_ok(), "should spawn and initialize rust-analyzer");
|
||||
|
||||
let mut process = process.unwrap();
|
||||
assert_eq!(process.status(), LspServerStatus::Connected);
|
||||
assert_eq!(process.language(), "rust-analyzer");
|
||||
|
||||
let shutdown_result = process.shutdown().await;
|
||||
assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}");
|
||||
}
|
||||
|
||||
/// Requires rust-analyzer to be installed and a Rust project on disk.
|
||||
/// Run with: cargo test -p runtime -- --ignored
|
||||
#[tokio::test]
|
||||
#[ignore = "requires rust-analyzer installed on PATH"]
|
||||
async fn hover_on_real_file() {
|
||||
let root = std::env::current_dir().expect("should have cwd");
|
||||
let mut process = LspProcess::start("rust-analyzer", &[], &root)
|
||||
.await
|
||||
.expect("should start rust-analyzer");
|
||||
|
||||
// Try hover on src/main.rs — the result might be None if the file
|
||||
// doesn't exist at that path, but the call itself should not error.
|
||||
let file_path = root.join("src").join("main.rs");
|
||||
let path_str = file_path.to_string_lossy();
|
||||
let result = process.hover(&path_str, 0, 0).await;
|
||||
assert!(result.is_ok(), "hover should not return an error: {:?}", result.err());
|
||||
|
||||
let _ = process.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hover_markup_content() {
|
||||
let value = serde_json::json!({
|
||||
"contents": {
|
||||
"kind": "plaintext",
|
||||
"value": "fn main()"
|
||||
}
|
||||
});
|
||||
let result = parse_hover(&value);
|
||||
assert!(result.is_some());
|
||||
let hover = result.unwrap();
|
||||
assert_eq!(hover.content, "fn main()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hover_marked_string_object() {
|
||||
let value = serde_json::json!({
|
||||
"contents": {
|
||||
"language": "rust",
|
||||
"value": "pub fn foo()"
|
||||
}
|
||||
});
|
||||
let result = parse_hover(&value);
|
||||
assert!(result.is_some());
|
||||
let hover = result.unwrap();
|
||||
assert_eq!(hover.content, "pub fn foo()");
|
||||
assert_eq!(hover.language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hover_plain_string() {
|
||||
let value = serde_json::json!({
|
||||
"contents": "some text"
|
||||
});
|
||||
let result = parse_hover(&value);
|
||||
assert!(result.is_some());
|
||||
let hover = result.unwrap();
|
||||
assert_eq!(hover.content, "some text");
|
||||
assert!(hover.language.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hover_array_of_marked_strings() {
|
||||
let value = serde_json::json!({
|
||||
"contents": [
|
||||
"first line",
|
||||
{ "language": "rust", "value": "fn bar()" }
|
||||
]
|
||||
});
|
||||
let result = parse_hover(&value);
|
||||
assert!(result.is_some());
|
||||
let hover = result.unwrap();
|
||||
assert!(hover.content.contains("first line"));
|
||||
assert!(hover.content.contains("fn bar()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_locations_empty_array() {
|
||||
let value = serde_json::json!([]);
|
||||
let locations = parse_locations(&value);
|
||||
assert!(locations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_locations_valid() {
|
||||
let value = serde_json::json!([
|
||||
{
|
||||
"uri": "file:///tmp/test.rs",
|
||||
"range": {
|
||||
"start": { "line": 5, "character": 10 },
|
||||
"end": { "line": 5, "character": 15 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
let locations = parse_locations(&value);
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].line, 5);
|
||||
assert_eq!(locations[0].character, 10);
|
||||
assert_eq!(locations[0].end_line, Some(5));
|
||||
assert_eq!(locations[0].end_character, Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbols_basic() {
|
||||
let value = serde_json::json!([
|
||||
{
|
||||
"name": "main",
|
||||
"kind": 12,
|
||||
"range": {
|
||||
"start": { "line": 1, "character": 0 },
|
||||
"end": { "line": 5, "character": 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
let symbols = parse_symbols(&value, "/tmp/test.rs");
|
||||
assert_eq!(symbols.len(), 1);
|
||||
assert_eq!(symbols[0].name, "main");
|
||||
assert_eq!(symbols[0].kind, "Function");
|
||||
assert_eq!(symbols[0].line, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_completions_basic() {
|
||||
let value = serde_json::json!([
|
||||
{ "label": "foo", "kind": 3, "detail": "fn foo()" },
|
||||
{ "label": "bar", "kind": 6 }
|
||||
]);
|
||||
let completions = parse_completions(&value);
|
||||
assert_eq!(completions.len(), 2);
|
||||
assert_eq!(completions[0].label, "foo");
|
||||
assert_eq!(completions[0].kind.as_deref(), Some("Function"));
|
||||
assert_eq!(completions[0].detail.as_deref(), Some("fn foo()"));
|
||||
assert_eq!(completions[1].label, "bar");
|
||||
assert_eq!(completions[1].kind.as_deref(), Some("Variable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_kind_name_all_variants() {
|
||||
assert_eq!(symbol_kind_name(1), "File");
|
||||
assert_eq!(symbol_kind_name(6), "Method");
|
||||
assert_eq!(symbol_kind_name(12), "Function");
|
||||
assert_eq!(symbol_kind_name(13), "Variable");
|
||||
assert_eq!(symbol_kind_name(23), "Struct");
|
||||
assert_eq!(symbol_kind_name(99), "Unknown(99)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_kind_name_all_variants() {
|
||||
assert_eq!(completion_kind_name(1), "Text");
|
||||
assert_eq!(completion_kind_name(3), "Function");
|
||||
assert_eq!(completion_kind_name(6), "Variable");
|
||||
assert_eq!(completion_kind_name(14), "Keyword");
|
||||
assert_eq!(completion_kind_name(99), "Unknown(99)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_document_position_params_structure() {
|
||||
let params = text_document_position_params("file:///test.rs", 5, 10);
|
||||
assert_eq!(params["textDocument"]["uri"], "file:///test.rs");
|
||||
assert_eq!(params["position"]["line"], 5);
|
||||
assert_eq!(params["position"]["character"], 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_to_uri_absolute() {
|
||||
let uri = path_to_uri("/tmp/test.rs");
|
||||
assert_eq!(uri, "file:///tmp/test.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_to_path_extracts_path() {
|
||||
assert_eq!(uri_to_path("file:///tmp/test.rs"), "/tmp/test.rs");
|
||||
assert_eq!(uri_to_path("/no/prefix"), "/no/prefix");
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
use std::io;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum LspId {
|
||||
Number(u64),
|
||||
String(String),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LspRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: LspId,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<JsonValue>,
|
||||
}
|
||||
|
||||
impl LspRequest {
|
||||
pub fn new(id: LspId, method: impl Into<String>, params: Option<JsonValue>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LspNotification {
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<JsonValue>,
|
||||
}
|
||||
|
||||
impl LspNotification {
|
||||
pub fn new(method: impl Into<String>, params: Option<JsonValue>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LspError {
|
||||
pub code: i64,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LspResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: LspId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<JsonValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<LspError>,
|
||||
}
|
||||
|
||||
impl LspResponse {
|
||||
#[must_use]
|
||||
pub fn is_error(&self) -> bool {
|
||||
self.error.is_some()
|
||||
}
|
||||
|
||||
pub fn into_result(self) -> Result<JsonValue, LspError> {
|
||||
if let Some(error) = self.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(self.result.unwrap_or(JsonValue::Null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message received from an LSP server — either a response to a request
|
||||
/// or a server-initiated notification (e.g. `textDocument/publishDiagnostics`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LspServerMessage {
|
||||
Response(LspResponse),
|
||||
Notification(LspNotification),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspTransportError {
|
||||
Io(io::Error),
|
||||
Timeout { method: String, timeout: Duration },
|
||||
JsonRpc(LspError),
|
||||
InvalidResponse { method: String, details: String },
|
||||
ServerExited,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LspTransportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Timeout { method, timeout } => {
|
||||
write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
Self::JsonRpc(error) => {
|
||||
write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code)
|
||||
}
|
||||
Self::InvalidResponse { method, details } => {
|
||||
write!(f, "LSP invalid response for `{method}`: {details}")
|
||||
}
|
||||
Self::ServerExited => write!(f, "LSP server process exited unexpectedly"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LspTransportError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for LspTransportError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspTransport {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next_id: u64,
|
||||
request_timeout: Duration,
|
||||
pending_notifications: Vec<LspNotification>,
|
||||
}
|
||||
|
||||
impl LspTransport {
|
||||
pub fn spawn(command: &str, args: &[String]) -> io::Result<Self> {
|
||||
Self::spawn_with_timeout(command, args, DEFAULT_REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
pub fn spawn_with_timeout(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
request_timeout: Duration,
|
||||
) -> io::Result<Self> {
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("LSP process missing stdin pipe"))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("LSP process missing stdout pipe"))?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
next_id: 1,
|
||||
request_timeout,
|
||||
pending_notifications: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct an `LspTransport` from an already-spawned child process.
|
||||
/// Primarily useful for testing.
|
||||
#[cfg(test)]
|
||||
fn from_child(mut child: Child, request_timeout: Duration) -> Self {
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("LSP process missing stdin pipe");
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.expect("LSP process missing stdout pipe");
|
||||
Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout: BufReader::new(stdout),
|
||||
next_id: 1,
|
||||
request_timeout,
|
||||
pending_notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn allocate_id(&mut self) -> LspId {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
LspId::Number(id)
|
||||
}
|
||||
|
||||
pub async fn send_notification(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<JsonValue>,
|
||||
) -> Result<(), LspTransportError> {
|
||||
let notification = LspNotification::new(method, params);
|
||||
let body = serde_json::to_vec(¬ification)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
self.write_frame(&body).await
|
||||
}
|
||||
|
||||
pub async fn send_request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<JsonValue>,
|
||||
) -> Result<LspResponse, LspTransportError> {
|
||||
let id = self.allocate_id();
|
||||
self.send_request_with_id(method, params, id).await
|
||||
}
|
||||
|
||||
pub async fn send_request_with_id(
|
||||
&mut self,
|
||||
method: &str,
|
||||
params: Option<JsonValue>,
|
||||
id: LspId,
|
||||
) -> Result<LspResponse, LspTransportError> {
|
||||
let request = LspRequest::new(id.clone(), method, params);
|
||||
let body = serde_json::to_vec(&request)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
self.write_frame(&body).await?;
|
||||
|
||||
let method_owned = method.to_string();
|
||||
let timeout_duration = self.request_timeout;
|
||||
let response = match timeout(timeout_duration, async {
|
||||
loop {
|
||||
match self.read_message().await {
|
||||
Ok(LspServerMessage::Response(r)) => break Ok(r),
|
||||
Ok(LspServerMessage::Notification(n)) => {
|
||||
self.pending_notifications.push(n);
|
||||
}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(inner) => inner,
|
||||
Err(_) => {
|
||||
return Err(LspTransportError::Timeout {
|
||||
method: method_owned,
|
||||
timeout: timeout_duration,
|
||||
})
|
||||
}
|
||||
}?;
|
||||
|
||||
if response.jsonrpc != "2.0" {
|
||||
return Err(LspTransportError::InvalidResponse {
|
||||
method: method.to_string(),
|
||||
details: format!("unsupported jsonrpc version `{}`", response.jsonrpc),
|
||||
});
|
||||
}
|
||||
|
||||
if response.id != id {
|
||||
return Err(LspTransportError::InvalidResponse {
|
||||
method: method.to_string(),
|
||||
details: format!(
|
||||
"mismatched id: expected {:?}, got {:?}",
|
||||
id, response.id
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(error) = &response.error {
|
||||
return Err(LspTransportError::JsonRpc(error.clone()));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Read a single message from the server, returning either a response or
|
||||
/// a server-initiated notification (e.g. `publishDiagnostics`).
|
||||
pub async fn read_message(&mut self) -> Result<LspServerMessage, LspTransportError> {
|
||||
let payload = self.read_frame().await?;
|
||||
let value: JsonValue = serde_json::from_slice(&payload).map_err(|error| {
|
||||
LspTransportError::InvalidResponse {
|
||||
method: "unknown".to_string(),
|
||||
details: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Responses have an "id" field; notifications have "method" but no "id"
|
||||
if value.get("id").is_some() {
|
||||
let response: LspResponse = serde_json::from_value(value).map_err(|error| {
|
||||
LspTransportError::InvalidResponse {
|
||||
method: "unknown".to_string(),
|
||||
details: format!("failed to parse response: {error}"),
|
||||
}
|
||||
})?;
|
||||
Ok(LspServerMessage::Response(response))
|
||||
} else if value.get("method").is_some() {
|
||||
let notification: LspNotification = serde_json::from_value(value).map_err(|error| {
|
||||
LspTransportError::InvalidResponse {
|
||||
method: "unknown".to_string(),
|
||||
details: format!("failed to parse notification: {error}"),
|
||||
}
|
||||
})?;
|
||||
Ok(LspServerMessage::Notification(notification))
|
||||
} else {
|
||||
Err(LspTransportError::InvalidResponse {
|
||||
method: "unknown".to_string(),
|
||||
details: "message has neither 'id' nor 'method'".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a response from the server. Interleaved notifications are queued.
|
||||
pub async fn read_response(&mut self) -> Result<LspResponse, LspTransportError> {
|
||||
loop {
|
||||
match self.read_message().await? {
|
||||
LspServerMessage::Response(r) => return Ok(r),
|
||||
LspServerMessage::Notification(n) => {
|
||||
self.pending_notifications.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain and return all queued server-initiated notifications.
|
||||
pub fn drain_notifications(&mut self) -> Vec<LspNotification> {
|
||||
std::mem::take(&mut self.pending_notifications)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) -> Result<(), LspTransportError> {
|
||||
let _ = self
|
||||
.send_notification("shutdown", None)
|
||||
.await;
|
||||
|
||||
let _ = self.send_notification("exit", None).await;
|
||||
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = self.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
|
||||
async fn write_frame(&mut self, payload: &[u8]) -> Result<(), LspTransportError> {
|
||||
let header = format!("Content-Length: {}\r\n\r\n", payload.len());
|
||||
self.stdin.write_all(header.as_bytes()).await?;
|
||||
self.stdin.write_all(payload).await?;
|
||||
self.stdin.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_frame(&mut self) -> Result<Vec<u8>, LspTransportError> {
|
||||
let mut content_length: Option<usize> = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = self.stdout.read_line(&mut line).await?;
|
||||
if bytes_read == 0 {
|
||||
return Err(LspTransportError::ServerExited);
|
||||
}
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
let header = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some((name, value)) = header.split_once(':') {
|
||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
||||
let parsed = value
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|error| LspTransportError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
error,
|
||||
)))?;
|
||||
content_length = Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_length = content_length.ok_or_else(|| {
|
||||
LspTransportError::InvalidResponse {
|
||||
method: "unknown".to_string(),
|
||||
details: "missing Content-Length header".to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut payload = vec![0u8; content_length];
|
||||
self.stdout.read_exact(&mut payload).await.map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::UnexpectedEof {
|
||||
LspTransportError::ServerExited
|
||||
} else {
|
||||
LspTransportError::Io(error)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
use super::*;
|
||||
use std::io::Cursor;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
||||
|
||||
#[test]
|
||||
fn content_length_header_roundtrip() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
rt.block_on(async {
|
||||
let payload = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":null}"#;
|
||||
|
||||
// Write frame into a buffer
|
||||
let mut write_buf = Vec::new();
|
||||
{
|
||||
let header = format!("Content-Length: {}\r\n\r\n", payload.len());
|
||||
write_buf.extend_from_slice(header.as_bytes());
|
||||
write_buf.extend_from_slice(payload);
|
||||
}
|
||||
|
||||
// Read frame back using the same logic as LspTransport::read_frame
|
||||
let cursor = Cursor::new(write_buf);
|
||||
let mut reader = BufReader::new(cursor);
|
||||
|
||||
let mut content_length: Option<usize> = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = reader.read_line(&mut line).await.unwrap();
|
||||
assert!(bytes_read > 0, "unexpected EOF reading header");
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
let header = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some((name, value)) = header.split_once(':') {
|
||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
||||
content_length = Some(value.trim().parse::<usize>().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_length = content_length.expect("should have Content-Length");
|
||||
assert_eq!(content_length, payload.len());
|
||||
|
||||
let mut read_payload = vec![0u8; content_length];
|
||||
reader.read_exact(&mut read_payload).await.unwrap();
|
||||
|
||||
let original: serde_json::Value = serde_json::from_slice(payload).unwrap();
|
||||
let roundtripped: serde_json::Value = serde_json::from_slice(&read_payload).unwrap();
|
||||
assert_eq!(original, roundtripped);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_has_incrementing_ids() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
rt.block_on(async {
|
||||
// Spawn cat so we can construct a real LspTransport.
|
||||
let child = tokio::process::Command::new("cat")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("cat should be available");
|
||||
|
||||
let mut transport = LspTransport::from_child(child, Duration::from_secs(5));
|
||||
|
||||
// Allocate IDs by inspecting what send_request would produce.
|
||||
let id1 = transport.allocate_id();
|
||||
let id2 = transport.allocate_id();
|
||||
let id3 = transport.allocate_id();
|
||||
|
||||
assert_eq!(id1, LspId::Number(1));
|
||||
assert_eq!(id2, LspId::Number(2));
|
||||
assert_eq!(id3, LspId::Number(3));
|
||||
|
||||
// Clean up
|
||||
let _ = transport.shutdown().await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_has_no_id() {
|
||||
let notification = LspNotification::new("initialized", Some(serde_json::json!({})));
|
||||
let serialized = serde_json::to_string(¬ification).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
|
||||
assert!(
|
||||
parsed.get("id").is_none(),
|
||||
"notification should not contain an 'id' field, got: {serialized}"
|
||||
);
|
||||
assert_eq!(parsed["jsonrpc"], "2.0");
|
||||
assert_eq!(parsed["method"], "initialized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_header_handling() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
rt.block_on(async {
|
||||
// Feed garbage bytes that don't contain a valid Content-Length header.
|
||||
let garbage = b"THIS IS NOT A VALID HEADER\r\n\r\n";
|
||||
let cursor = Cursor::new(garbage.to_vec());
|
||||
let mut reader = BufReader::new(cursor);
|
||||
|
||||
let mut content_length: Option<usize> = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = reader.read_line(&mut line).await.unwrap();
|
||||
if bytes_read == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
let header = line.trim_end_matches(['\r', '\n']);
|
||||
if let Some((name, value)) = header.split_once(':') {
|
||||
if name.trim().eq_ignore_ascii_case("Content-Length") {
|
||||
content_length = value.trim().parse::<usize>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The garbage header should not produce a valid Content-Length.
|
||||
assert!(
|
||||
content_length.is_none(),
|
||||
"garbage input should not produce a valid Content-Length"
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue