Merge jobdori/lsp-client: LspRegistry dispatch for all LSP tool actions

This commit is contained in:
Jobdori
2026-04-03 17:46:17 +09:00
3 changed files with 461 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ mod conversation;
mod file_ops;
mod hooks;
mod json;
pub mod lsp_client;
mod mcp;
mod mcp_client;
mod mcp_stdio;

View File

@@ -0,0 +1,438 @@
//! LSP (Language Server Protocol) client registry for tool dispatch.
//!
//! Provides a stateful registry of LSP server connections, supporting
//! the LSP tool actions: diagnostics, hover, definition, references,
//! completion, symbols, and formatting.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
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,
}
}
}
/// A diagnostic entry from an LSP server.
#[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>,
}
/// A location result (definition, references).
#[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>,
}
/// A hover result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspHoverResult {
pub content: String,
pub language: Option<String>,
}
/// A completion item.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspCompletionItem {
pub label: String,
pub kind: Option<String>,
pub detail: Option<String>,
pub insert_text: Option<String>,
}
/// A document symbol.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspSymbol {
pub name: String,
pub kind: String,
pub path: String,
pub line: u32,
pub character: u32,
}
/// Connection status.
#[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"),
}
}
}
/// Tracked state of an LSP server.
#[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>,
}
/// Thread-safe LSP server registry.
#[derive(Debug, Clone, Default)]
pub struct LspRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
#[derive(Debug, Default)]
struct RegistryInner {
servers: HashMap<String, LspServerState>,
}
impl LspRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Register an LSP server for a language.
pub fn register(
&self,
language: &str,
status: LspServerStatus,
root_path: Option<&str>,
capabilities: Vec<String>,
) {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.insert(
language.to_owned(),
LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
},
);
}
/// Get server state by language.
pub fn get(&self, language: &str) -> Option<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.get(language).cloned()
}
/// 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)
}
/// List all registered servers.
pub fn list_servers(&self) -> Vec<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.values().cloned().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 server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.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(|s| &s.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 server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.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)
}
#[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
}
/// Dispatch an LSP action and return a structured result.
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 can 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(|s| &s.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 server = self
.find_server_for_path(path)
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
if server.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {})",
server.language, server.status
));
}
// Return structured placeholder — actual LSP JSON-RPC calls would
// go through the real LSP process here.
Ok(serde_json::json!({
"action": action,
"path": path,
"line": line,
"character": character,
"language": server.language,
"status": "dispatched",
"message": format!("LSP {} dispatched to {} server", action, server.language)
}))
}
}
#[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());
}
}

View File

@@ -12,6 +12,7 @@ use plugins::PluginTool;
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt,
lsp_client::LspRegistry,
mcp_tool_bridge::McpToolRegistry,
read_file,
task_registry::TaskRegistry,
@@ -24,6 +25,12 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// Global task registry shared across tool invocations within a session.
fn global_lsp_registry() -> &'static LspRegistry {
use std::sync::OnceLock;
static REGISTRY: OnceLock<LspRegistry> = OnceLock::new();
REGISTRY.get_or_init(LspRegistry::new)
}
fn global_mcp_registry() -> &'static McpToolRegistry {
use std::sync::OnceLock;
static REGISTRY: OnceLock<McpToolRegistry> = OnceLock::new();
@@ -1113,15 +1120,21 @@ fn run_cron_list(_input: Value) -> Result<String, String> {
#[allow(clippy::needless_pass_by_value)]
fn run_lsp(input: LspInput) -> Result<String, String> {
to_pretty_json(json!({
"action": input.action,
"path": input.path,
"line": input.line,
"character": input.character,
"query": input.query,
"results": [],
"message": "LSP server not connected"
}))
let registry = global_lsp_registry();
let action = &input.action;
let path = input.path.as_deref();
let line = input.line;
let character = input.character;
let query = input.query.as_deref();
match registry.dispatch(action, path, line, character, query) {
Ok(result) => to_pretty_json(result),
Err(e) => to_pretty_json(json!({
"action": action,
"error": e,
"status": "error"
})),
}
}
#[allow(clippy::needless_pass_by_value)]