Skip to main content

leta_daemon/
session.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use fastrace::trace;
6use leta_config::Config;
7use leta_fs::{get_language_id, path_to_uri, read_file_content, uri_to_path};
8use leta_lsp::LspClient;
9use leta_servers::{get_server_env, get_server_for_file, get_server_for_language, ServerConfig};
10use serde_json::Value;
11use tokio::sync::{Mutex, RwLock};
12use tracing::{debug, info};
13
14#[derive(Clone)]
15pub struct OpenDocument {
16    _uri: String,
17    _version: i32,
18    pub content: String,
19    _language_id: String,
20}
21
22pub struct Workspace {
23    root: PathBuf,
24    server_config: &'static ServerConfig,
25    client: Option<Arc<LspClient>>,
26    open_documents: HashMap<String, OpenDocument>,
27    startup_stats: Option<leta_types::ServerStartupStats>,
28}
29
30impl Workspace {
31    pub fn new(root: PathBuf, server_config: &'static ServerConfig) -> Self {
32        Self {
33            root,
34            server_config,
35            client: None,
36            open_documents: HashMap::new(),
37            startup_stats: None,
38        }
39    }
40
41    pub fn startup_stats(&self) -> Option<leta_types::ServerStartupStats> {
42        self.startup_stats.clone()
43    }
44
45    pub fn client(&self) -> Option<Arc<LspClient>> {
46        self.client.clone()
47    }
48
49    pub fn server_name(&self) -> &str {
50        self.server_config.name
51    }
52
53    pub fn open_document_uris(&self) -> Vec<String> {
54        self.open_documents.keys().cloned().collect()
55    }
56
57    #[trace]
58    pub async fn start_server(&mut self) -> Result<leta_types::ServerStartupStats, String> {
59        let total_start = std::time::Instant::now();
60
61        if self.client.is_some() {
62            return Ok(leta_types::ServerStartupStats {
63                server_name: self.server_config.name.to_string(),
64                workspace_root: self.root.to_string_lossy().to_string(),
65                init_time_ms: 0,
66                ready_time_ms: 0,
67                total_time_ms: 0,
68                functions: Vec::new(),
69            });
70        }
71
72        info!(
73            "Starting {} for {}",
74            self.server_config.name,
75            self.root.display()
76        );
77
78        let env = get_server_env();
79        let init_options = self.get_init_options();
80
81        let cmd: Vec<&str> = self.server_config.command.to_vec();
82
83        let start_time = std::time::Instant::now();
84        match LspClient::start(&cmd, &self.root, self.server_config.name, env, init_options).await {
85            Ok(client) => {
86                let init_time = start_time.elapsed();
87
88                if self.server_config.name == "clangd" {
89                    client.wait_for_indexing(60).await;
90                    self.ensure_workspace_indexed(&client).await;
91                }
92
93                self.client = Some(client);
94                let total_time = total_start.elapsed();
95                let ready_time = std::time::Duration::ZERO;
96
97                info!(
98                    "Server {} initialized and ready in {:?}",
99                    self.server_config.name, total_time
100                );
101
102                let stats = leta_types::ServerStartupStats {
103                    server_name: self.server_config.name.to_string(),
104                    workspace_root: self.root.to_string_lossy().to_string(),
105                    init_time_ms: init_time.as_millis() as u64,
106                    ready_time_ms: ready_time.as_millis() as u64,
107                    total_time_ms: total_time.as_millis() as u64,
108                    functions: Vec::new(),
109                };
110                self.startup_stats = Some(stats.clone());
111                Ok(stats)
112            }
113            Err(e) => {
114                let mut msg = format!(
115                    "Language server '{}' for {} failed to start: {}",
116                    self.server_config.name,
117                    self.server_config.languages.join(", "),
118                    e
119                );
120                if let Some(install_cmd) = self.server_config.install_cmd {
121                    msg.push_str(&format!(
122                        "\n\nTo install {}, run:\n  {}\n\nIf you just installed it, run `leta daemon restart` to pick up PATH changes.",
123                        self.server_config.name, install_cmd
124                    ));
125                }
126                Err(msg)
127            }
128        }
129    }
130
131    fn get_init_options(&self) -> Option<Value> {
132        if self.server_config.name == "gopls" {
133            Some(serde_json::json!({
134                "linksInHover": false,
135            }))
136        } else {
137            None
138        }
139    }
140
141    /// Open and close all source files to ensure clangd indexes them.
142    ///
143    /// clangd does lazy indexing - it only indexes files when they're opened.
144    /// This means documentSymbol won't work on files that haven't been opened yet.
145    /// We work around this by opening all source files during initialization.
146    #[trace]
147    async fn ensure_workspace_indexed(&mut self, client: &Arc<LspClient>) {
148        let walkdir_start = std::time::Instant::now();
149        let source_extensions = [".c", ".h", ".cpp", ".hpp", ".cc", ".cxx", ".hxx"];
150        let exclude_dirs: std::collections::HashSet<&str> =
151            ["build", ".git", "node_modules"].into_iter().collect();
152
153        let mut files_to_index = Vec::new();
154        for entry in jwalk::WalkDir::new(&self.root).process_read_dir(
155            move |_depth, _path, _state, children| {
156                children.retain(|entry| {
157                    entry
158                        .as_ref()
159                        .map(|e| {
160                            let name = e.file_name().to_string_lossy();
161                            !exclude_dirs.contains(name.as_ref())
162                        })
163                        .unwrap_or(false)
164                });
165            },
166        ) {
167            let Ok(entry) = entry else { continue };
168            if entry.file_type().is_file() {
169                let path = entry.path();
170                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
171                    if source_extensions
172                        .iter()
173                        .any(|s| s.trim_start_matches('.') == ext)
174                    {
175                        files_to_index.push(path);
176                    }
177                }
178            }
179        }
180        let walkdir_elapsed = walkdir_start.elapsed();
181
182        if files_to_index.is_empty() {
183            fastrace::local::LocalSpan::add_properties(|| {
184                [
185                    ("files_found", "0".to_string()),
186                    (
187                        "walkdir_ms",
188                        format!("{:.1}", walkdir_elapsed.as_secs_f64() * 1000.0),
189                    ),
190                ]
191            });
192            return;
193        }
194
195        info!("Pre-indexing {} files for clangd", files_to_index.len());
196
197        let open_start = std::time::Instant::now();
198        for file_path in &files_to_index {
199            let _ = self.ensure_document_open(file_path).await;
200        }
201        let open_elapsed = open_start.elapsed();
202
203        client.wait_for_indexing(30).await;
204
205        info!(
206            "Pre-indexing complete, closing {} documents",
207            self.open_documents.len()
208        );
209
210        let close_start = std::time::Instant::now();
211        self.close_all_documents().await;
212        let close_elapsed = close_start.elapsed();
213
214        fastrace::local::LocalSpan::add_properties(|| {
215            [
216                ("files_found", files_to_index.len().to_string()),
217                (
218                    "walkdir_ms",
219                    format!("{:.1}", walkdir_elapsed.as_secs_f64() * 1000.0),
220                ),
221                (
222                    "open_docs_ms",
223                    format!("{:.1}", open_elapsed.as_secs_f64() * 1000.0),
224                ),
225                (
226                    "close_docs_ms",
227                    format!("{:.1}", close_elapsed.as_secs_f64() * 1000.0),
228                ),
229            ]
230        });
231    }
232
233    #[trace]
234    pub async fn stop_server(&mut self) {
235        if let Some(client) = self.client.take() {
236            info!("Stopping {}", self.server_config.name);
237            let _ = client.stop().await;
238        }
239        self.open_documents.clear();
240    }
241
242    pub async fn ensure_document_open(&mut self, path: &Path) -> Result<(), String> {
243        self.cleanup_deleted_documents().await;
244
245        let uri = path_to_uri(path);
246
247        if let Some(doc) = self.open_documents.get(&uri) {
248            let current_content = read_file_content(path).map_err(|e| e.to_string())?;
249            if current_content != doc.content {
250                self.close_document(path).await;
251            } else {
252                return Ok(());
253            }
254        }
255
256        let content = read_file_content(path).map_err(|e| e.to_string())?;
257        let language_id = get_language_id(path).to_string();
258
259        let doc = OpenDocument {
260            _uri: uri.clone(),
261            _version: 1,
262            content: content.clone(),
263            _language_id: language_id.clone(),
264        };
265
266        self.open_documents.insert(uri.clone(), doc);
267
268        if let Some(client) = &self.client {
269            let params = serde_json::json!({
270                "textDocument": {
271                    "uri": uri,
272                    "languageId": language_id,
273                    "version": 1,
274                    "text": content,
275                }
276            });
277            let _ = client
278                .send_notification("textDocument/didOpen", params)
279                .await;
280
281            // ruby-lsp processes messages asynchronously in a queue, so we need to ensure
282            // the didOpen is fully processed before subsequent operations can succeed.
283            // We do this by sending a simple request and waiting for its response.
284            if client.server_name() == "ruby-lsp" {
285                let symbol_params = serde_json::json!({
286                    "textDocument": {"uri": uri}
287                });
288                let _ = client
289                    .send_request_raw("textDocument/documentSymbol", symbol_params)
290                    .await;
291            }
292        }
293
294        Ok(())
295    }
296
297    #[trace]
298    pub async fn close_document(&mut self, path: &Path) {
299        let uri = path_to_uri(path);
300        if self.open_documents.remove(&uri).is_none() {
301            return;
302        }
303
304        if let Some(client) = &self.client {
305            let params = serde_json::json!({
306                "textDocument": {"uri": uri}
307            });
308            let _ = client
309                .send_notification("textDocument/didClose", params)
310                .await;
311        }
312    }
313
314    #[trace]
315    pub async fn close_all_documents(&mut self) {
316        if let Some(client) = &self.client {
317            for uri in self.open_documents.keys() {
318                let params = serde_json::json!({
319                    "textDocument": {"uri": uri}
320                });
321                let _ = client
322                    .send_notification("textDocument/didClose", params)
323                    .await;
324            }
325        }
326        self.open_documents.clear();
327    }
328
329    pub async fn cleanup_deleted_documents(&mut self) {
330        let deleted_uris: Vec<String> = self
331            .open_documents
332            .keys()
333            .filter(|uri| !uri_to_path(uri).exists())
334            .cloned()
335            .collect();
336
337        for uri in deleted_uris {
338            self.open_documents.remove(&uri);
339            if let Some(client) = &self.client {
340                let params = serde_json::json!({
341                    "textDocument": {"uri": uri}
342                });
343                let _ = client
344                    .send_notification("textDocument/didClose", params)
345                    .await;
346            }
347        }
348    }
349}
350
351type StartupLockKey = (PathBuf, String);
352type StartupLocks = Mutex<HashMap<StartupLockKey, Arc<Mutex<()>>>>;
353
354pub struct Session {
355    workspaces: RwLock<HashMap<PathBuf, HashMap<String, Workspace>>>,
356    config: RwLock<Config>,
357    workspace_profiling: RwLock<Vec<leta_types::WorkspaceProfilingData>>,
358    startup_locks: StartupLocks,
359}
360
361impl Session {
362    pub fn new(config: Config) -> Self {
363        Self {
364            workspaces: RwLock::new(HashMap::new()),
365            config: RwLock::new(config),
366            workspace_profiling: RwLock::new(Vec::new()),
367            startup_locks: Mutex::new(HashMap::new()),
368        }
369    }
370
371    pub async fn add_workspace_profiling(&self, data: leta_types::WorkspaceProfilingData) {
372        let mut profiling = self.workspace_profiling.write().await;
373        profiling.retain(|p| p.workspace_root != data.workspace_root);
374        profiling.push(data);
375    }
376
377    pub async fn get_workspace_profiling(&self) -> Vec<leta_types::WorkspaceProfilingData> {
378        self.workspace_profiling.read().await.clone()
379    }
380
381    #[trace]
382    pub async fn config(&self) -> Config {
383        self.config.read().await.clone()
384    }
385
386    #[trace]
387    pub async fn get_or_create_workspace(
388        &self,
389        file_path: &Path,
390        workspace_root: &Path,
391    ) -> Result<WorkspaceHandle<'_>, String> {
392        let server_config = {
393            let config = self.config.read().await;
394            get_server_for_file(file_path, Some(&config))
395                .ok_or_else(|| format!("No language server found for {}", file_path.display()))?
396        };
397        // config lock dropped here before acquiring workspace lock
398
399        self.get_or_create_workspace_for_server(workspace_root, server_config)
400            .await
401    }
402
403    #[trace]
404    pub async fn get_or_create_workspace_for_language(
405        &self,
406        language_id: &str,
407        workspace_root: &Path,
408    ) -> Result<WorkspaceHandle<'_>, String> {
409        let server_config = {
410            let config = self.config.read().await;
411            get_server_for_language(language_id, Some(&config))
412                .ok_or_else(|| format!("No language server found for language {}", language_id))?
413        };
414        // config lock dropped here before acquiring workspace lock
415
416        self.get_or_create_workspace_for_server(workspace_root, server_config)
417            .await
418    }
419
420    #[trace]
421    async fn get_or_create_workspace_for_server(
422        &self,
423        workspace_root: &Path,
424        server_config: &'static ServerConfig,
425    ) -> Result<WorkspaceHandle<'_>, String> {
426        let workspace_root = workspace_root
427            .canonicalize()
428            .unwrap_or_else(|_| workspace_root.to_path_buf());
429
430        // Get or create a per-workspace/server lock to prevent concurrent starts
431        let startup_lock = {
432            let mut locks = self.startup_locks.lock().await;
433            let key = (workspace_root.clone(), server_config.name.to_string());
434            locks
435                .entry(key)
436                .or_insert_with(|| Arc::new(Mutex::new(())))
437                .clone()
438        };
439
440        // Hold the startup lock while we check and potentially start the server
441        let _startup_guard = startup_lock.lock().await;
442
443        // Check if workspace exists (read lock only)
444        let needs_create = {
445            let workspaces = self.workspaces.read().await;
446            if let Some(servers) = workspaces.get(&workspace_root) {
447                if let Some(ws) = servers.get(server_config.name) {
448                    ws.client.is_none() // needs restart
449                } else {
450                    true // needs create
451                }
452            } else {
453                true // needs create
454            }
455        };
456
457        if needs_create {
458            debug!(
459                "Starting {} for {}",
460                server_config.name,
461                workspace_root.display()
462            );
463            let mut new_workspace = Workspace::new(workspace_root.clone(), server_config);
464            new_workspace.start_server().await?;
465
466            // Insert with write lock (quick operation)
467            let mut workspaces = self.workspaces.write().await;
468            let servers = workspaces
469                .entry(workspace_root.clone())
470                .or_insert_with(HashMap::new);
471            servers.insert(server_config.name.to_string(), new_workspace);
472        }
473
474        Ok(WorkspaceHandle {
475            session: self,
476            workspace_root,
477            server_name: server_config.name.to_string(),
478        })
479    }
480
481    #[allow(dead_code)]
482    #[trace]
483    pub async fn get_workspace_for_file(&self, file_path: &Path) -> Option<WorkspaceHandle<'_>> {
484        let file_path = file_path
485            .canonicalize()
486            .unwrap_or_else(|_| file_path.to_path_buf());
487        let config = self.config.read().await;
488        let server_config = get_server_for_file(&file_path, Some(&config))?;
489
490        let workspaces = self.workspaces.read().await;
491        for (root, servers) in workspaces.iter() {
492            if file_path.starts_with(root) && servers.contains_key(server_config.name) {
493                return Some(WorkspaceHandle {
494                    session: self,
495                    workspace_root: root.clone(),
496                    server_name: server_config.name.to_string(),
497                });
498            }
499        }
500        None
501    }
502
503    #[trace]
504    pub async fn list_workspaces(&self) -> Vec<(String, String, Option<u32>, Vec<String>)> {
505        let workspaces = self.workspaces.read().await;
506        let mut result = Vec::new();
507
508        for (root, servers) in workspaces.iter() {
509            for (_, ws) in servers.iter() {
510                let server_pid = ws.client.as_ref().and_then(|c| c.pid());
511                result.push((
512                    root.to_string_lossy().to_string(),
513                    ws.server_name().to_string(),
514                    server_pid,
515                    ws.open_document_uris(),
516                ));
517            }
518        }
519
520        result
521    }
522
523    #[trace]
524    pub async fn restart_workspace(&self, root: &Path) -> Result<Vec<String>, String> {
525        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
526        let mut workspaces = self.workspaces.write().await;
527
528        let mut restarted = Vec::new();
529        if let Some(servers) = workspaces.get_mut(&root) {
530            for (name, workspace) in servers.iter_mut() {
531                workspace.stop_server().await;
532                workspace.start_server().await?;
533                restarted.push(name.clone());
534            }
535        }
536        Ok(restarted)
537    }
538
539    #[trace]
540    pub async fn remove_workspace(&self, root: &Path) -> Result<Vec<String>, String> {
541        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
542        let mut workspaces = self.workspaces.write().await;
543
544        let mut stopped = Vec::new();
545        if let Some(mut servers) = workspaces.remove(&root) {
546            for (name, mut workspace) in servers.drain() {
547                workspace.stop_server().await;
548                stopped.push(name);
549            }
550        }
551        Ok(stopped)
552    }
553
554    #[trace]
555    pub async fn close_all(&self) {
556        let mut workspaces = self.workspaces.write().await;
557        for (_, mut servers) in workspaces.drain() {
558            for (_, mut workspace) in servers.drain() {
559                workspace.stop_server().await;
560            }
561        }
562    }
563}
564
565pub struct WorkspaceHandle<'a> {
566    session: &'a Session,
567    workspace_root: PathBuf,
568    server_name: String,
569}
570
571impl<'a> WorkspaceHandle<'a> {
572    #[trace]
573    pub async fn client(&self) -> Option<Arc<LspClient>> {
574        let workspaces = self.session.workspaces.read().await;
575        workspaces
576            .get(&self.workspace_root)
577            .and_then(|servers| servers.get(&self.server_name))
578            .and_then(|ws| ws.client())
579    }
580
581    pub fn server_name(&self) -> &str {
582        &self.server_name
583    }
584
585    pub async fn get_startup_stats(&self) -> Option<leta_types::ServerStartupStats> {
586        let workspaces = self.session.workspaces.read().await;
587        workspaces
588            .get(&self.workspace_root)
589            .and_then(|servers| servers.get(&self.server_name))
590            .and_then(|ws| ws.startup_stats())
591    }
592
593    #[trace]
594    pub async fn wait_for_ready(&self, timeout_secs: u64) -> bool {
595        if let Some(client) = self.client().await {
596            client.wait_for_indexing(timeout_secs).await
597        } else {
598            false
599        }
600    }
601
602    #[trace]
603    pub async fn ensure_document_open(&self, path: &Path) -> Result<(), String> {
604        self.cleanup_deleted_documents().await;
605
606        let uri = path_to_uri(path);
607
608        // First check if document needs updating (read lock only)
609        let (needs_open, needs_reopen, client) = {
610            let workspaces = self.session.workspaces.read().await;
611            let workspace = workspaces
612                .get(&self.workspace_root)
613                .and_then(|servers| servers.get(&self.server_name))
614                .ok_or_else(|| "Workspace not found".to_string())?;
615
616            let client = workspace.client();
617
618            if let Some(doc) = workspace.open_documents.get(&uri) {
619                let current_content = read_file_content(path).map_err(|e| e.to_string())?;
620                if current_content != doc.content {
621                    (false, true, client) // needs reopen (close then open)
622                } else {
623                    (false, false, client) // already open with same content
624                }
625            } else {
626                (true, false, client) // needs open
627            }
628        };
629
630        if !needs_open && !needs_reopen {
631            return Ok(());
632        }
633
634        // Close first if needed
635        if needs_reopen {
636            self.close_document(path).await;
637        }
638
639        // Read file content
640        let content = read_file_content(path).map_err(|e| e.to_string())?;
641        let language_id = get_language_id(path).to_string();
642
643        // Insert document record (write lock, but no LSP call)
644        {
645            let mut workspaces = self.session.workspaces.write().await;
646            let workspace = workspaces
647                .get_mut(&self.workspace_root)
648                .and_then(|servers| servers.get_mut(&self.server_name))
649                .ok_or_else(|| "Workspace not found".to_string())?;
650
651            let doc = OpenDocument {
652                _uri: uri.clone(),
653                _version: 1,
654                content: content.clone(),
655                _language_id: language_id.clone(),
656            };
657            workspace.open_documents.insert(uri.clone(), doc);
658        }
659
660        // Send LSP notification OUTSIDE the lock
661        if let Some(client) = client {
662            let params = serde_json::json!({
663                "textDocument": {
664                    "uri": uri,
665                    "languageId": language_id,
666                    "version": 1,
667                    "text": content,
668                }
669            });
670            let _ = client
671                .send_notification("textDocument/didOpen", params)
672                .await;
673
674            // ruby-lsp processes messages asynchronously in a queue
675            if client.server_name() == "ruby-lsp" {
676                let symbol_params = serde_json::json!({
677                    "textDocument": {"uri": uri}
678                });
679                let _ = client
680                    .send_request_raw("textDocument/documentSymbol", symbol_params)
681                    .await;
682            }
683        }
684
685        Ok(())
686    }
687
688    #[trace]
689    pub async fn close_document(&self, path: &Path) {
690        tracing::trace!("WorkspaceHandle::close_document acquiring write lock");
691        let mut workspaces = self.session.workspaces.write().await;
692        if let Some(servers) = workspaces.get_mut(&self.workspace_root) {
693            if let Some(workspace) = servers.get_mut(&self.server_name) {
694                workspace.close_document(path).await;
695            }
696        }
697        tracing::trace!("WorkspaceHandle::close_document releasing write lock");
698    }
699
700    #[trace]
701    pub async fn is_document_open(&self, path: &Path) -> bool {
702        let uri = path_to_uri(path);
703        let workspaces = self.session.workspaces.read().await;
704        if let Some(servers) = workspaces.get(&self.workspace_root) {
705            if let Some(workspace) = servers.get(&self.server_name) {
706                return workspace.open_documents.contains_key(&uri);
707            }
708        }
709        false
710    }
711
712    async fn cleanup_deleted_documents(&self) {
713        let mut workspaces = self.session.workspaces.write().await;
714        if let Some(servers) = workspaces.get_mut(&self.workspace_root) {
715            if let Some(workspace) = servers.get_mut(&self.server_name) {
716                workspace.cleanup_deleted_documents().await;
717            }
718        }
719    }
720
721    #[trace]
722    pub async fn notify_files_changed(
723        &self,
724        changes: &[(PathBuf, leta_lsp::lsp_types::FileChangeType)],
725    ) {
726        let client = match self.client().await {
727            Some(c) => c,
728            None => return,
729        };
730
731        let file_events: Vec<leta_lsp::lsp_types::FileEvent> = changes
732            .iter()
733            .map(|(path, change_type)| leta_lsp::lsp_types::FileEvent {
734                uri: path_to_uri(path).parse().unwrap(),
735                typ: *change_type,
736            })
737            .collect();
738
739        let params = leta_lsp::lsp_types::DidChangeWatchedFilesParams {
740            changes: file_events,
741        };
742
743        let _ = client
744            .send_notification("workspace/didChangeWatchedFiles", params)
745            .await;
746    }
747}