Skip to main content

oxi/lsp/
manager.rs

1//! LSP manager — multi-server lifecycle and config layering.
2//!
3//! Owns one [`oxi_lsp::LspClient`] per language server. The manager
4//! routes file-based requests to the right server based on file
5//! extension and exposes a unified surface via [`super::CliLspProvider`].
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12use oxi_lsp::{LspClient, LspClientConfig, LspError};
13use parking_lot::RwLock;
14use tracing::info;
15
16/// Configuration for a single language server.
17#[derive(Debug, Clone)]
18pub struct LspServerConfig {
19    /// Display name (e.g. `"rust-analyzer"`).
20    pub name: String,
21    /// Executable to spawn.
22    pub command: String,
23    /// Arguments passed to the executable.
24    pub args: Vec<String>,
25    /// File extensions this server owns (without leading dot).
26    /// e.g. `["rs"]` for rust-analyzer.
27    pub extensions: Vec<String>,
28}
29
30impl LspServerConfig {
31    /// Construct a config for a single-extension server.
32    pub fn new(
33        name: impl Into<String>,
34        command: impl Into<String>,
35        args: Vec<String>,
36        extension: impl Into<String>,
37    ) -> Self {
38        Self {
39            name: name.into(),
40            command: command.into(),
41            args,
42            extensions: vec![extension.into()],
43        }
44    }
45
46    /// Construct a config covering multiple extensions.
47    pub fn multi(
48        name: impl Into<String>,
49        command: impl Into<String>,
50        args: Vec<String>,
51        extensions: Vec<String>,
52    ) -> Self {
53        Self {
54            name: name.into(),
55            command: command.into(),
56            args,
57            extensions,
58        }
59    }
60
61    fn matches(&self, path: &Path) -> bool {
62        path.extension()
63            .and_then(|e| e.to_str())
64            .map(|ext| self.extensions.iter().any(|x| x == ext))
65            .unwrap_or(false)
66    }
67}
68
69/// Default server table: rust-analyzer for `.rs` files when on PATH.
70///
71/// Returns an empty vec when `rust-analyzer` is not installed, so
72/// callers can unconditionally feed the result to [`LspManager::new`]
73/// and gracefully get "no servers configured" behavior.
74pub fn default_servers() -> Vec<LspServerConfig> {
75    let mut out = Vec::new();
76    if which("rust-analyzer").is_some() {
77        out.push(LspServerConfig::new(
78            "rust-analyzer",
79            "rust-analyzer",
80            vec![],
81            "rs",
82        ));
83    }
84    out
85}
86
87fn which(bin: &str) -> Option<PathBuf> {
88    std::env::var_os("PATH").and_then(|paths| {
89        std::env::split_paths(&paths).find_map(|dir| {
90            let candidate = dir.join(bin);
91            if candidate.is_file() {
92                Some(candidate)
93            } else {
94                None
95            }
96        })
97    })
98}
99
100/// Multi-server LSP manager.
101///
102/// Holds zero or more [`LspServerConfig`]s plus the lazily-spawned
103/// [`LspClient`] for each. Thread-safe via `parking_lot::RwLock`.
104pub struct LspManager {
105    /// Static server config table (set at construction time).
106    servers: Vec<LspServerConfig>,
107    /// Live clients keyed by server name. Populated lazily on first
108    /// request that routes to that server.
109    clients: Arc<RwLock<HashMap<String, Arc<LspClient>>>>,
110    /// Workspace root passed to every spawned server.
111    workspace_root: PathBuf,
112}
113
114impl std::fmt::Debug for LspManager {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("LspManager")
117            .field("servers", &self.servers)
118            .field("workspace_root", &self.workspace_root)
119            .field(
120                "active_clients",
121                &self.clients.read().keys().collect::<Vec<_>>(),
122            )
123            .finish()
124    }
125}
126
127impl LspManager {
128    /// Construct with the given server table and workspace root.
129    pub fn new(servers: Vec<LspServerConfig>, workspace_root: PathBuf) -> Self {
130        Self {
131            servers,
132            clients: Arc::new(RwLock::new(HashMap::new())),
133            workspace_root,
134        }
135    }
136
137    /// Construct using [`default_servers()`] for the configured
138    /// workspace root.
139    pub fn with_defaults(workspace_root: PathBuf) -> Self {
140        Self::new(default_servers(), workspace_root)
141    }
142
143    /// Number of configured (static) servers — does not reflect
144    /// lazy-spawned live clients.
145    pub fn server_count(&self) -> usize {
146        self.servers.len()
147    }
148
149    /// Whether any configured server handles this file extension.
150    pub fn handles_path(&self, path: &Path) -> bool {
151        self.servers.iter().any(|s| s.matches(path))
152    }
153
154    /// Borrow the workspace root.
155    pub fn workspace_root(&self) -> &Path {
156        &self.workspace_root
157    }
158
159    /// Look up the server name owning `path`, if any.
160    fn server_for_path(&self, path: &Path) -> Option<&LspServerConfig> {
161        self.servers.iter().find(|s| s.matches(path))
162    }
163
164    /// Get-or-spawn the live client for `path`'s owning server.
165    ///
166    /// Returns `None` when no server is configured for `path`'s
167    /// extension. Returns `Err` when the server fails to spawn or
168    /// initialize.
169    pub async fn client_for_path(&self, path: &Path) -> Result<Option<Arc<LspClient>>, LspError> {
170        let Some(config) = self.server_for_path(path) else {
171            return Ok(None);
172        };
173        let name = config.name.clone();
174
175        // Fast path: already running.
176        if let Some(client) = self.clients.read().get(&name).cloned() {
177            return Ok(Some(client));
178        }
179
180        // Spawn.
181        let lsp_config = LspClientConfig::new(
182            config.name.clone(),
183            config.command.clone(),
184            config.args.clone(),
185        );
186        let client = LspClient::start(lsp_config, self.workspace_root.clone()).await?;
187        info!(server = %name, "LSP server started");
188        self.clients.write().insert(name, client.clone());
189        Ok(Some(client))
190    }
191
192    /// Look up the client by server name (must already be running).
193    pub fn running_client(&self, name: &str) -> Option<Arc<LspClient>> {
194        self.clients.read().get(name).cloned()
195    }
196
197    /// Iterate live clients.
198    pub fn live_clients(&self) -> Vec<(String, Arc<LspClient>)> {
199        self.clients
200            .read()
201            .iter()
202            .map(|(k, v)| (k.clone(), v.clone()))
203            .collect()
204    }
205
206    /// Best-effort shutdown of every live client.
207    pub async fn shutdown_all(&self) {
208        let clients: Vec<(String, Arc<LspClient>)> = self.clients.write().drain().collect();
209        for (name, client) in clients {
210            // `LspClient::shutdown` takes `&mut self`, but we hold the
211            // client through `Arc`. Drop the strong ref to get exclusive
212            // ownership via `Arc::try_unwrap`; if other handles still
213            // hold it (rare during teardown), skip the graceful
214            // shutdown — `kill_on_drop` on the child still reaps the
215            // process when the last ref drops.
216            if let Ok(mut exclusive) = Arc::try_unwrap(client) {
217                let _ = exclusive.shutdown().await;
218                tracing::debug!(server = %name, "LSP server shut down");
219            } else {
220                tracing::debug!(server = %name, "LSP server has external refs; skipping graceful shutdown");
221            }
222        }
223    }
224
225    /// Diagnostic drain timeout for `drain_diagnostics`. 50ms is
226    /// short enough to not stall the agent loop but long enough to
227    /// catch a fresh `publishDiagnostics` after a write.
228    pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_millis(50);
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn server_config_matches_by_extension() {
237        let cfg = LspServerConfig::new("rust-analyzer", "rust-analyzer", vec![], "rs");
238        assert!(cfg.matches(Path::new("src/main.rs")));
239        assert!(!cfg.matches(Path::new("src/main.ts")));
240        assert!(!cfg.matches(Path::new("README")));
241    }
242
243    #[test]
244    fn manager_handles_path() {
245        let mgr = LspManager::new(
246            vec![LspServerConfig::new(
247                "rust-analyzer",
248                "rust-analyzer",
249                vec![],
250                "rs",
251            )],
252            PathBuf::from("."),
253        );
254        assert!(mgr.handles_path(Path::new("foo.rs")));
255        assert!(!mgr.handles_path(Path::new("foo.ts")));
256    }
257
258    #[test]
259    fn manager_no_servers_returns_zero() {
260        let mgr = LspManager::new(vec![], PathBuf::from("."));
261        assert_eq!(mgr.server_count(), 0);
262        assert!(!mgr.handles_path(Path::new("anything.rs")));
263    }
264
265    #[test]
266    fn manager_server_for_path_returns_owner() {
267        let mgr = LspManager::new(
268            vec![
269                LspServerConfig::new("rust-analyzer", "rust-analyzer", vec![], "rs"),
270                LspServerConfig::new("tsserver", "typescript-language-server", vec![], "ts"),
271            ],
272            PathBuf::from("."),
273        );
274        let p = Path::new("foo.ts");
275        let owner = mgr.server_for_path(p).unwrap();
276        assert_eq!(owner.name, "tsserver");
277    }
278}