Skip to main content

heartbit_core/lsp/
mod.rs

1//! Language Server Protocol (LSP) client for inline diagnostics in agent tool responses.
2
3mod client;
4mod language;
5mod server;
6mod types;
7
8pub use language::{LanguageConfig, detect_language, find_server_config, is_file_modifying_tool};
9pub use types::{Diagnostic, DiagnosticSeverity, format_diagnostics};
10
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use server::LspServer;
16
17/// Manages LSP servers lazily — one per language.
18///
19/// When a file is changed, the manager detects the language, spawns a server
20/// if needed, notifies it, then pulls diagnostics. Servers that fail to start
21/// are marked broken and not retried for the session.
22pub struct LspManager {
23    servers: tokio::sync::Mutex<HashMap<String, LspServer>>,
24    broken: Mutex<HashSet<String>>,
25    workspace_root: PathBuf,
26}
27
28impl LspManager {
29    /// Create a new LSP manager rooted at `workspace_root`.
30    pub fn new(workspace_root: PathBuf) -> Self {
31        Self {
32            servers: tokio::sync::Mutex::new(HashMap::new()),
33            broken: Mutex::new(HashSet::new()),
34            workspace_root,
35        }
36    }
37
38    /// Notify that a file was changed (written/edited/patched).
39    ///
40    /// Reads the file content, spawns the language server lazily, sends
41    /// didOpen/didChange, waits a debounce period, then pulls diagnostics.
42    ///
43    /// Returns empty if the language is unsupported, the server is broken,
44    /// or the file cannot be read.
45    pub async fn notify_file_changed(&self, path: &Path) -> Vec<Diagnostic> {
46        let lang_id = match detect_language(path) {
47            Some(id) => id,
48            None => return Vec::new(),
49        };
50
51        // Check broken set (std::sync::Mutex — never held across .await)
52        {
53            let broken = self.broken.lock().expect("broken lock poisoned");
54            if broken.contains(lang_id) {
55                return Vec::new();
56            }
57        }
58
59        // Read the file content
60        let content = match tokio::fs::read_to_string(path).await {
61            Ok(c) => c,
62            Err(e) => {
63                tracing::debug!(path = %path.display(), error = %e, "failed to read file for LSP");
64                return Vec::new();
65            }
66        };
67
68        let mut servers = self.servers.lock().await;
69
70        // Spawn server lazily
71        if !servers.contains_key(lang_id) {
72            let config = match find_server_config(lang_id) {
73                Some(c) => c,
74                None => return Vec::new(),
75            };
76            tracing::debug!(
77                lang = %lang_id,
78                workspace = %self.workspace_root.display(),
79                "spawning LSP server"
80            );
81            match LspServer::spawn(config, &self.workspace_root).await {
82                Ok(srv) => {
83                    tracing::debug!(lang = %lang_id, "LSP server initialized");
84                    servers.insert(lang_id.to_string(), srv);
85                }
86                Err(e) => {
87                    tracing::warn!(lang = %lang_id, error = %e, "LSP server failed to start, marking broken");
88                    self.broken
89                        .lock()
90                        .expect("broken lock poisoned")
91                        .insert(lang_id.to_string());
92                    return Vec::new();
93                }
94            }
95        }
96
97        let srv = servers.get_mut(lang_id).expect("server just inserted");
98
99        // Notify the server
100        if let Err(e) = srv.notify_file_changed(path, &content).await {
101            tracing::debug!(error = %e, "failed to notify LSP server of file change");
102            return Vec::new();
103        }
104
105        // Pull diagnostics (handles debounce and push/pull fallback internally)
106        srv.pull_diagnostics(path).await
107    }
108
109    /// Get diagnostics for a file on demand (without notifying a change).
110    pub async fn diagnostics(&self, path: &Path) -> Vec<Diagnostic> {
111        let lang_id = match detect_language(path) {
112            Some(id) => id,
113            None => return Vec::new(),
114        };
115
116        let servers = self.servers.lock().await;
117        match servers.get(lang_id) {
118            Some(srv) => srv.pull_diagnostics(path).await,
119            None => Vec::new(),
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::path::PathBuf;
128
129    #[test]
130    fn lsp_manager_new_creates_empty() {
131        let mgr = LspManager::new(PathBuf::from("/tmp/test"));
132        assert!(mgr.broken.lock().unwrap().is_empty());
133    }
134
135    #[tokio::test]
136    async fn notify_unsupported_language_returns_empty() {
137        let mgr = LspManager::new(PathBuf::from("/tmp"));
138        let diagnostics = mgr.notify_file_changed(Path::new("/tmp/README.md")).await;
139        assert!(diagnostics.is_empty());
140    }
141
142    #[tokio::test]
143    async fn diagnostics_without_server_returns_empty() {
144        let mgr = LspManager::new(PathBuf::from("/tmp"));
145        let diagnostics = mgr.diagnostics(Path::new("/tmp/test.rs")).await;
146        assert!(diagnostics.is_empty());
147    }
148
149    #[test]
150    fn broken_server_not_retried() {
151        let mgr = LspManager::new(PathBuf::from("/tmp"));
152        mgr.broken.lock().unwrap().insert("rust".to_string());
153        // Verify it's in the broken set
154        assert!(mgr.broken.lock().unwrap().contains("rust"));
155    }
156
157    #[tokio::test]
158    async fn notify_broken_language_returns_empty() {
159        let mgr = LspManager::new(PathBuf::from("/tmp"));
160        mgr.broken.lock().unwrap().insert("rust".to_string());
161        let diagnostics = mgr.notify_file_changed(Path::new("/tmp/test.rs")).await;
162        assert!(diagnostics.is_empty());
163    }
164
165    #[tokio::test]
166    async fn notify_nonexistent_file_returns_empty() {
167        let mgr = LspManager::new(PathBuf::from("/tmp"));
168        let diagnostics = mgr
169            .notify_file_changed(Path::new("/tmp/does_not_exist_12345.rs"))
170            .await;
171        assert!(diagnostics.is_empty());
172    }
173}