Преглед изворни кода

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

Jobdori пре 2 месеци
родитељ
комит
d7f0dc6eba
3 измењених фајлова са 461 додато и 9 уклоњено
  1. 1 0
      rust/crates/runtime/src/lib.rs
  2. 438 0
      rust/crates/runtime/src/lsp_client.rs
  3. 22 9
      rust/crates/tools/src/lib.rs

+ 1 - 0
rust/crates/runtime/src/lib.rs

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

+ 438 - 0
rust/crates/runtime/src/lsp_client.rs

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

+ 22 - 9
rust/crates/tools/src/lib.rs

@@ -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)]