Skip to main content

atomcode_core/lsp/
manager.rs

1//! LspManager — lazily starts and manages LSP clients per file extension.
2//!
3//! Provides a unified interface for diagnostics, file notifications, and
4//! lifecycle management across multiple language servers.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use anyhow::Result;
11use tokio::sync::{mpsc, RwLock};
12
13use super::client::LspClient;
14use super::registry::LspServerRegistry;
15use super::types::Diagnostic;
16use crate::config::LspConfig;
17
18/// Status events emitted by `LspManager` when servers start, fail, or
19/// hit non-fatal trouble. Mirrors `mcp::McpConnectEvent` so the TUI
20/// event loop can render them as scrollback lines instead of having
21/// the manager write directly to stderr (which leaks into the input
22/// box while the renderer owns the screen — see `lsp/mod.rs` for the
23/// full plumbing rationale).
24#[derive(Debug, Clone)]
25pub enum LspConnectEvent {
26    /// A language server was successfully started for the given extension.
27    Started { command: String, ext: String },
28    /// Starting the server failed; LSP is best-effort, so the agent loop
29    /// continues without diagnostics for that file type.
30    Failed {
31        command: String,
32        ext: String,
33        error: String,
34    },
35    /// Non-fatal trouble (e.g. shutdown error during teardown). Routed
36    /// to the trace log by the TUI rather than scrollback so it doesn't
37    /// churn the UI for things the user can't act on.
38    Warning { ext: String, message: String },
39}
40
41/// Extension-to-language_id mapping for LSP `textDocument/didOpen`.
42fn extension_to_language_id(ext: &str) -> &str {
43    match ext {
44        "rs" => "rust",
45        "ts" => "typescript",
46        "tsx" => "typescriptreact",
47        "js" => "javascript",
48        "jsx" => "javascriptreact",
49        "py" => "python",
50        "go" => "go",
51        "java" => "java",
52        "c" => "c",
53        "cpp" | "cc" | "cxx" => "cpp",
54        "cs" => "csharp",
55        "rb" => "ruby",
56        "php" => "php",
57        "swift" => "swift",
58        "kt" | "kts" => "kotlin",
59        "scala" => "scala",
60        _ => ext,
61    }
62}
63
64/// Manages lifecycle of multiple language server clients.
65///
66/// Lazily starts LSP servers on-demand based on file extension.
67/// Each extension maps to at most one running server instance.
68/// Servers are started when first needed and remain running until
69/// explicitly shut down or the manager is dropped.
70pub struct LspManager {
71    /// Running clients keyed by file extension.
72    clients: Arc<RwLock<HashMap<String, Arc<LspClient>>>>,
73    /// Server registry (default + user overrides).
74    registry: LspServerRegistry,
75    /// Project root for LSP initialize.
76    project_root: PathBuf,
77    /// Whether LSP integration is enabled.
78    enabled: bool,
79    /// Time in milliseconds to wait after file sync before reading diagnostics.
80    diagnostics_settle_delay_ms: u64,
81    /// Optional channel for connection status events. Some(tx) when a
82    /// listener (TUI event loop) is consuming them; None in headless
83    /// mode where eprintln-to-stderr would be visible to CI logs anyway
84    /// — but we still don't print, since send-on-None is just a no-op
85    /// and the headless path doesn't need the diagnostics.
86    connect_events: Option<mpsc::UnboundedSender<LspConnectEvent>>,
87}
88
89impl LspManager {
90    /// Create a new LSP manager without an event channel. Status changes
91    /// are silently dropped — appropriate for tests and headless mode
92    /// where no UI consumes them.
93    pub fn new(
94        project_root: PathBuf,
95        registry: LspServerRegistry,
96        enabled: bool,
97        diagnostics_settle_delay_ms: u64,
98    ) -> Self {
99        Self {
100            clients: Arc::new(RwLock::new(HashMap::new())),
101            registry,
102            project_root,
103            enabled,
104            diagnostics_settle_delay_ms,
105            connect_events: None,
106        }
107    }
108
109    /// Create a new LSP manager paired with a receiver for connection
110    /// status events. The TUI event loop consumes the receiver and
111    /// renders `Started` / `Failed` events as scrollback lines next to
112    /// the existing MCP rendering.
113    pub fn with_event_channel(
114        project_root: PathBuf,
115        registry: LspServerRegistry,
116        enabled: bool,
117        diagnostics_settle_delay_ms: u64,
118    ) -> (Self, mpsc::UnboundedReceiver<LspConnectEvent>) {
119        let (tx, rx) = mpsc::unbounded_channel();
120        let mgr = Self {
121            clients: Arc::new(RwLock::new(HashMap::new())),
122            registry,
123            project_root,
124            enabled,
125            diagnostics_settle_delay_ms,
126            connect_events: Some(tx),
127        };
128        (mgr, rx)
129    }
130
131    /// Send a status event to the listener, if any. No-op when no
132    /// channel is wired (`new()` constructor or after the receiver was
133    /// dropped — `send` returns Err which we ignore intentionally).
134    fn emit(&self, event: LspConnectEvent) {
135        if let Some(tx) = &self.connect_events {
136            let _ = tx.send(event);
137        }
138    }
139
140    /// Get the configured diagnostics settle delay in milliseconds.
141    pub fn diagnostics_settle_delay_ms(&self) -> u64 {
142        self.diagnostics_settle_delay_ms
143    }
144
145    /// Ensure a language server is running for the given file's extension.
146    /// Returns `Ok(true)` if a server is (now) running, `Ok(false)` if no
147    /// server is configured or the command is not installed.
148    pub async fn ensure_server(&self, file_path: &Path) -> Result<bool> {
149        if !self.enabled {
150            return Ok(false);
151        }
152
153        let ext = match file_path.extension().and_then(|e| e.to_str()) {
154            Some(e) => e.to_string(),
155            None => return Ok(false),
156        };
157
158        // Fast path: check under read lock first.
159        {
160            let clients = self.clients.read().await;
161            if clients.contains_key(&ext) {
162                return Ok(true);
163            }
164        }
165
166        // Look up server config.
167        let config = match self.registry.get(&ext) {
168            Some(c) => c.clone(),
169            None => return Ok(false),
170        };
171
172        // Check if the command exists on PATH.
173        if which::which(&config.command).is_err() {
174            return Ok(false);
175        }
176
177        let language_id = extension_to_language_id(&ext);
178
179        // Acquire write lock and double-check to prevent TOCTOU race.
180        let mut clients = self.clients.write().await;
181        if clients.contains_key(&ext) {
182            return Ok(true);
183        }
184
185        // Start the client while holding the write lock.
186        match LspClient::start(&config, &self.project_root, language_id).await {
187            Ok(client) => {
188                let arc = Arc::new(client);
189                clients.insert(ext.clone(), arc);
190                self.emit(LspConnectEvent::Started {
191                    command: config.command.clone(),
192                    ext,
193                });
194                Ok(true)
195            }
196            Err(e) => {
197                // LSP is best-effort: a missing or broken language server
198                // must not propagate as a tool error. Surface the failure
199                // through the event channel so the TUI can render it in
200                // scrollback (or, in headless mode with no listener, drop
201                // it silently — `emit` is a no-op when no channel).
202                self.emit(LspConnectEvent::Failed {
203                    command: config.command.clone(),
204                    ext,
205                    error: e.to_string(),
206                });
207                Ok(false)
208            }
209        }
210    }
211
212    /// Get diagnostics for a specific file.
213    /// Returns an empty vector if no server is running for that file type.
214    pub async fn diagnostics(&self, path: &Path) -> Vec<Diagnostic> {
215        let ext = match path.extension().and_then(|e| e.to_str()) {
216            Some(e) => e.to_string(),
217            None => return Vec::new(),
218        };
219
220        let clients = self.clients.read().await;
221        match clients.get(&ext) {
222            Some(client) => client.diagnostics(path).await,
223            None => Vec::new(),
224        }
225    }
226
227    /// Get all diagnostics from all running servers.
228    /// Aggregates diagnostics across all file types with active servers.
229    pub async fn all_diagnostics(&self) -> Vec<Diagnostic> {
230        let clients = self.clients.read().await;
231        let mut all = Vec::new();
232        for client in clients.values() {
233            all.extend(client.all_diagnostics().await);
234        }
235        all
236    }
237
238    /// Ensure the appropriate server is running, then notify it that a file changed.
239    /// This triggers the server to re-analyze the file and publish updated diagnostics.
240    /// Returns `Ok(true)` if a server received the notification, `Ok(false)` otherwise.
241    pub async fn notify_file_changed(&self, path: &Path, content: &str) -> Result<bool> {
242        if !self.ensure_server(path).await? {
243            return Ok(false);
244        }
245
246        let ext = match path.extension().and_then(|e| e.to_str()) {
247            Some(e) => e.to_string(),
248            None => return Ok(false),
249        };
250
251        let clients = self.clients.read().await;
252        if let Some(client) = clients.get(&ext) {
253            let language_id = extension_to_language_id(&ext);
254            // Use sync_document for proper didOpen/didChange versioning.
255            client.sync_document(path, content, language_id).await?;
256            return Ok(true);
257        }
258
259        Ok(false)
260    }
261
262    /// List the file extensions that have active servers.
263    /// Useful for debugging and status display.
264    pub async fn active_servers(&self) -> Vec<String> {
265        let clients = self.clients.read().await;
266        let mut exts: Vec<String> = clients.keys().cloned().collect();
267        exts.sort();
268        exts
269    }
270
271    /// Shutdown all running language servers gracefully.
272    /// Sends shutdown request, exit notification, then kills the process.
273    /// Errors are logged but not propagated.
274    pub async fn shutdown(&self) {
275        let mut clients = self.clients.write().await;
276        for (ext, client) in clients.drain() {
277            if let Err(e) = client.shutdown().await {
278                // Shutdown errors are not actionable for the user — the
279                // process is exiting anyway. Route to Warning so the TUI
280                // can decide to log/swallow rather than scrollback-spam.
281                self.emit(LspConnectEvent::Warning {
282                    ext,
283                    message: format!("shutdown error: {}", e),
284                });
285            }
286        }
287    }
288}
289
290/// Build an LspManager from config, providing a unified entry point for CLI and daemon.
291/// Returns `None` if LSP is disabled in config.
292pub fn build_lsp_manager(config: &LspConfig, project_root: &Path) -> Option<Arc<LspManager>> {
293    if !config.enabled {
294        return None;
295    }
296    let registry = build_registry(config);
297    let manager = LspManager::new(
298        project_root.to_path_buf(),
299        registry,
300        true,
301        config.diagnostics_settle_delay_ms,
302    );
303    Some(Arc::new(manager))
304}
305
306/// Build an LspManager paired with a receiver for connection-status
307/// events. TUI mode wires the receiver into the event loop so server
308/// start/failure surfaces as `✓ LSP server …` / `✗ LSP server …`
309/// scrollback lines, matching the MCP server flow. Returns `None` when
310/// LSP is disabled in config.
311pub fn build_lsp_manager_with_events(
312    config: &LspConfig,
313    project_root: &Path,
314) -> Option<(Arc<LspManager>, mpsc::UnboundedReceiver<LspConnectEvent>)> {
315    if !config.enabled {
316        return None;
317    }
318    let registry = build_registry(config);
319    let (manager, rx) = LspManager::with_event_channel(
320        project_root.to_path_buf(),
321        registry,
322        true,
323        config.diagnostics_settle_delay_ms,
324    );
325    Some((Arc::new(manager), rx))
326}
327
328/// Shared registry construction used by both `build_lsp_manager`
329/// constructors. Defaults + user overrides merged into one registry.
330fn build_registry(config: &LspConfig) -> LspServerRegistry {
331    let mut registry = if config.auto_detect {
332        LspServerRegistry::with_defaults()
333    } else {
334        LspServerRegistry::empty()
335    };
336    registry.merge_user_config(config.servers.clone());
337    registry
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn extension_to_language_id_maps_common_langs() {
346        assert_eq!(extension_to_language_id("rs"), "rust");
347        assert_eq!(extension_to_language_id("ts"), "typescript");
348        assert_eq!(extension_to_language_id("tsx"), "typescriptreact");
349        assert_eq!(extension_to_language_id("py"), "python");
350        assert_eq!(extension_to_language_id("go"), "go");
351        assert_eq!(extension_to_language_id("java"), "java");
352        assert_eq!(extension_to_language_id("js"), "javascript");
353    }
354
355    #[test]
356    fn extension_to_language_id_unknown_returns_self() {
357        assert_eq!(extension_to_language_id("xyz"), "xyz");
358    }
359
360    #[tokio::test]
361    async fn disabled_manager_returns_false() {
362        let registry = LspServerRegistry::with_defaults();
363        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, false, 150);
364        let result = mgr.ensure_server(Path::new("test.rs")).await.unwrap();
365        assert!(!result);
366    }
367
368    #[tokio::test]
369    async fn no_config_for_extension_returns_false() {
370        let registry = LspServerRegistry::with_defaults();
371        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
372        let result = mgr.ensure_server(Path::new("test.xyz")).await.unwrap();
373        assert!(!result);
374    }
375
376    #[tokio::test]
377    async fn no_extension_returns_false() {
378        let registry = LspServerRegistry::with_defaults();
379        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
380        let result = mgr.ensure_server(Path::new("Makefile")).await.unwrap();
381        assert!(!result);
382    }
383
384    #[tokio::test]
385    async fn empty_diagnostics_for_unknown_file() {
386        let registry = LspServerRegistry::with_defaults();
387        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
388        let diags = mgr.diagnostics(Path::new("test.xyz")).await;
389        assert!(diags.is_empty());
390    }
391
392    #[tokio::test]
393    async fn active_servers_empty_initially() {
394        let registry = LspServerRegistry::with_defaults();
395        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
396        assert!(mgr.active_servers().await.is_empty());
397    }
398
399    #[tokio::test]
400    async fn all_diagnostics_empty_initially() {
401        let registry = LspServerRegistry::with_defaults();
402        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
403        assert!(mgr.all_diagnostics().await.is_empty());
404    }
405
406    #[tokio::test]
407    async fn shutdown_on_empty_is_noop() {
408        let registry = LspServerRegistry::with_defaults();
409        let mgr = LspManager::new(PathBuf::from("/tmp"), registry, true, 150);
410        mgr.shutdown().await; // Should not panic.
411    }
412
413    #[test]
414    fn build_lsp_manager_returns_none_when_disabled() {
415        let config = LspConfig {
416            enabled: false,
417            auto_detect: true,
418            servers: Default::default(),
419            diagnostics_settle_delay_ms: 150,
420        };
421        let result = build_lsp_manager(&config, Path::new("/tmp"));
422        assert!(result.is_none());
423    }
424
425    #[test]
426    fn build_lsp_manager_returns_some_when_enabled() {
427        let config = LspConfig {
428            enabled: true,
429            auto_detect: true,
430            servers: Default::default(),
431            diagnostics_settle_delay_ms: 150,
432        };
433        let result = build_lsp_manager(&config, Path::new("/tmp"));
434        assert!(result.is_some());
435    }
436
437    #[test]
438    fn build_lsp_manager_respects_auto_detect() {
439        // auto_detect=false should start with empty registry
440        let config = LspConfig {
441            enabled: true,
442            auto_detect: false,
443            servers: Default::default(),
444            diagnostics_settle_delay_ms: 150,
445        };
446        let result = build_lsp_manager(&config, Path::new("/tmp"));
447        assert!(result.is_some());
448        // The manager should have no servers configured (empty registry)
449    }
450
451    #[test]
452    fn build_lsp_manager_merges_user_servers() {
453        let mut servers = std::collections::HashMap::new();
454        servers.insert(
455            "xyz".to_string(),
456            super::super::registry::LspServerConfig {
457                command: "my-lsp".to_string(),
458                args: vec![],
459                root_markers: vec![],
460            },
461        );
462        let config = LspConfig {
463            enabled: true,
464            auto_detect: true,
465            servers,
466            diagnostics_settle_delay_ms: 150,
467        };
468        let result = build_lsp_manager(&config, Path::new("/tmp"));
469        assert!(result.is_some());
470    }
471
472    /// `with_event_channel` returns a paired `(manager, receiver)` and
473    /// the receiver starts empty. Behaves identically to `new()` when
474    /// no events have fired yet.
475    #[tokio::test]
476    async fn with_event_channel_yields_empty_receiver_initially() {
477        let registry = LspServerRegistry::with_defaults();
478        let (mgr, mut rx) =
479            LspManager::with_event_channel(PathBuf::from("/tmp"), registry, true, 150);
480        assert!(mgr.active_servers().await.is_empty());
481        assert!(rx.try_recv().is_err(), "no events expected before any ensure_server call");
482    }
483
484    /// A failed `ensure_server` (server command not on PATH won't even
485    /// reach the start path; a registered server pointing at a
486    /// guaranteed-nonexistent binary will). We use a custom registry
487    /// with a known-bad command + which::which check disabled by
488    /// passing the .xyz extension so registry lookup hits but `which`
489    /// will fail — that triggers `return Ok(false)` BEFORE the start
490    /// attempt though, so we won't emit `Failed` (which is the right
491    /// behavior: not installed ≠ failed). Verify with active_servers
492    /// that nothing got registered AND no event fired.
493    #[tokio::test]
494    async fn ensure_server_silent_when_command_missing() {
495        let mut servers = std::collections::HashMap::new();
496        servers.insert(
497            "xyz".to_string(),
498            super::super::registry::LspServerConfig {
499                command: "atomcode-lsp-does-not-exist".to_string(),
500                args: vec![],
501                root_markers: vec![],
502            },
503        );
504        let mut registry = LspServerRegistry::empty();
505        registry.merge_user_config(servers);
506        let (mgr, mut rx) =
507            LspManager::with_event_channel(PathBuf::from("/tmp"), registry, true, 150);
508        let result = mgr.ensure_server(Path::new("test.xyz")).await.unwrap();
509        assert!(!result, "missing command must return Ok(false)");
510        // `which` failed BEFORE start_client — no event fires, agent
511        // continues silently. This matches MCP's "command not found"
512        // behavior and avoids spamming scrollback for projects that
513        // don't have the language tooling installed.
514        assert!(rx.try_recv().is_err(), "no event expected for missing command");
515    }
516
517    /// Sender drops cleanly: dropping the receiver before any send
518    /// happens must not panic. Confirms `emit` handles the closed-
519    /// receiver case (Result ignored).
520    #[tokio::test]
521    async fn emit_no_op_when_receiver_dropped() {
522        let registry = LspServerRegistry::with_defaults();
523        let (mgr, rx) =
524            LspManager::with_event_channel(PathBuf::from("/tmp"), registry, true, 150);
525        drop(rx);
526        // Trigger the path that calls `emit` — non-existent file ext
527        // takes the early-return; we explicitly call the emit helper
528        // via a synthetic Warning to exercise the post-drop send.
529        mgr.emit(LspConnectEvent::Warning {
530            ext: "rs".to_string(),
531            message: "synthetic post-drop emit".to_string(),
532        });
533        // No panic = pass.
534    }
535}