Skip to main content

codelens_engine/lsp/
session.rs

1use crate::project::ProjectRoot;
2use anyhow::{Context, Result, bail};
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10use url::Url;
11
12use super::parsers::{
13    diagnostics_from_response, method_suffix_to_hierarchy, references_from_response,
14    rename_plan_from_response, type_hierarchy_node_from_item, type_hierarchy_to_map,
15    workspace_symbols_from_response,
16};
17use super::protocol::{language_id_for_path, poll_readable, read_message, send_message};
18use super::registry::resolve_lsp_binary;
19use super::types::{
20    LspDiagnostic, LspDiagnosticRequest, LspReference, LspRenamePlan, LspRenamePlanRequest,
21    LspRequest, LspTypeHierarchyNode, LspTypeHierarchyRequest, LspWorkspaceSymbol,
22    LspWorkspaceSymbolRequest,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26struct SessionKey {
27    command: String,
28    args: Vec<String>,
29}
30
31#[derive(Debug, Clone)]
32struct OpenDocumentState {
33    version: i32,
34    text: String,
35}
36
37pub struct LspSessionPool {
38    project: ProjectRoot,
39    sessions: std::sync::Mutex<HashMap<SessionKey, LspSession>>,
40}
41
42struct LspSession {
43    project: ProjectRoot,
44    child: Child,
45    stdin: ChildStdin,
46    reader: BufReader<ChildStdout>,
47    next_request_id: u64,
48    documents: HashMap<String, OpenDocumentState>,
49    #[allow(dead_code)] // retained for future stderr diagnostics
50    stderr_buffer: std::sync::Arc<std::sync::Mutex<String>>,
51}
52
53/// Known-safe LSP server binaries. Commands not in this list are rejected.
54pub(super) fn is_allowed_lsp_command(command: &str) -> bool {
55    // Extract the binary name from the command path (e.g., "/usr/bin/pyright-langserver" → "pyright-langserver")
56    let binary = std::path::Path::new(command)
57        .file_name()
58        .and_then(|n| n.to_str())
59        .unwrap_or(command);
60
61    ALLOWED_COMMANDS.contains(&binary)
62}
63
64pub(super) const ALLOWED_COMMANDS: &[&str] = &[
65    // From LSP_RECIPES
66    "pyright-langserver",
67    "typescript-language-server",
68    "rust-analyzer",
69    "gopls",
70    "jdtls",
71    "kotlin-language-server",
72    "clangd",
73    "solargraph",
74    "intelephense",
75    "sourcekit-lsp",
76    "csharp-ls",
77    "dart",
78    // Additional well-known LSP servers
79    "metals",
80    "lua-language-server",
81    "terraform-ls",
82    "yaml-language-server",
83    // Test support: allow python3/python for mock LSP in tests
84    "python3",
85    "python",
86];
87
88fn ensure_session<'a>(
89    sessions: &'a mut HashMap<SessionKey, LspSession>,
90    project: &ProjectRoot,
91    command: &str,
92    args: &[String],
93) -> Result<&'a mut LspSession> {
94    if !is_allowed_lsp_command(command) {
95        bail!(
96            "Blocked: '{command}' is not a known LSP server. Only whitelisted LSP binaries are allowed."
97        );
98    }
99
100    let key = SessionKey {
101        command: command.to_owned(),
102        args: args.to_owned(),
103    };
104
105    // Check for dead sessions: if the child process has exited, remove the stale entry.
106    if let Some(session) = sessions.get_mut(&key) {
107        match session.child.try_wait() {
108            Ok(Some(_status)) => {
109                // Process exited — remove stale session so we start fresh below.
110                sessions.remove(&key);
111            }
112            Ok(None) => {} // Still running — will return it via Occupied below.
113            Err(_) => {
114                sessions.remove(&key);
115            }
116        }
117    }
118
119    match sessions.entry(key) {
120        std::collections::hash_map::Entry::Occupied(e) => Ok(e.into_mut()),
121        std::collections::hash_map::Entry::Vacant(e) => {
122            let session = LspSession::start(project, command, args)?;
123            Ok(e.insert(session))
124        }
125    }
126}
127
128impl LspSessionPool {
129    pub fn new(project: ProjectRoot) -> Self {
130        Self {
131            project,
132            sessions: std::sync::Mutex::new(HashMap::new()),
133        }
134    }
135
136    /// Replace the project root and close all existing sessions.
137    pub fn reset(&self, project: ProjectRoot) -> Self {
138        // Drop existing sessions so LSP processes are killed.
139        self.sessions
140            .lock()
141            .unwrap_or_else(|p| p.into_inner())
142            .clear();
143        Self::new(project)
144    }
145
146    pub fn session_count(&self) -> usize {
147        self.sessions
148            .lock()
149            .unwrap_or_else(|p| p.into_inner())
150            .len()
151    }
152
153    pub fn find_referencing_symbols(&self, request: &LspRequest) -> Result<Vec<LspReference>> {
154        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
155        let session = ensure_session(
156            &mut sessions,
157            &self.project,
158            &request.command,
159            &request.args,
160        )?;
161        session.find_references(request)
162    }
163
164    pub fn get_diagnostics(&self, request: &LspDiagnosticRequest) -> Result<Vec<LspDiagnostic>> {
165        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
166        let session = ensure_session(
167            &mut sessions,
168            &self.project,
169            &request.command,
170            &request.args,
171        )?;
172        session.get_diagnostics(request)
173    }
174
175    pub fn search_workspace_symbols(
176        &self,
177        request: &LspWorkspaceSymbolRequest,
178    ) -> Result<Vec<LspWorkspaceSymbol>> {
179        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
180        let session = ensure_session(
181            &mut sessions,
182            &self.project,
183            &request.command,
184            &request.args,
185        )?;
186        session.search_workspace_symbols(request)
187    }
188
189    pub fn get_type_hierarchy(
190        &self,
191        request: &LspTypeHierarchyRequest,
192    ) -> Result<HashMap<String, Value>> {
193        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
194        let session = ensure_session(
195            &mut sessions,
196            &self.project,
197            &request.command,
198            &request.args,
199        )?;
200        session.get_type_hierarchy(request)
201    }
202
203    pub fn get_rename_plan(&self, request: &LspRenamePlanRequest) -> Result<LspRenamePlan> {
204        let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
205        let session = ensure_session(
206            &mut sessions,
207            &self.project,
208            &request.command,
209            &request.args,
210        )?;
211        session.get_rename_plan(request)
212    }
213}
214
215impl LspSession {
216    fn start(project: &ProjectRoot, command: &str, args: &[String]) -> Result<Self> {
217        let command_path = resolve_lsp_binary(command).unwrap_or_else(|| command.into());
218        let mut child = Command::new(&command_path)
219            .args(args)
220            .stdin(Stdio::piped())
221            .stdout(Stdio::piped())
222            .stderr(Stdio::piped())
223            .spawn()
224            .with_context(|| format!("failed to spawn LSP server {}", command_path.display()))?;
225
226        let stdin = child.stdin.take().context("failed to open LSP stdin")?;
227        let stdout = child.stdout.take().context("failed to open LSP stdout")?;
228
229        // Capture stderr in a background thread (bounded 4KB ring buffer).
230        let stderr_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
231        if let Some(stderr) = child.stderr.take() {
232            let buf = std::sync::Arc::clone(&stderr_buffer);
233            thread::spawn(move || {
234                let mut reader = BufReader::new(stderr);
235                let mut line = String::new();
236                while reader.read_line(&mut line).unwrap_or(0) > 0 {
237                    if let Ok(mut b) = buf.lock() {
238                        if b.len() > 4096 {
239                            let drain_to = b.len() - 2048;
240                            b.drain(..drain_to);
241                        }
242                        b.push_str(&line);
243                    }
244                    line.clear();
245                }
246            });
247        }
248
249        let mut session = Self {
250            project: project.clone(),
251            child,
252            stdin,
253            reader: BufReader::new(stdout),
254            next_request_id: 1,
255            documents: HashMap::new(),
256            stderr_buffer,
257        };
258        session.initialize()?;
259        Ok(session)
260    }
261
262    fn initialize(&mut self) -> Result<()> {
263        let id = self.next_id();
264        let root_uri = Url::from_directory_path(self.project.as_path())
265            .ok()
266            .map(|url| url.to_string());
267        self.send_request(
268            id,
269            "initialize",
270            json!({
271                "processId":null,
272                "rootUri": root_uri,
273                "capabilities":{},
274                "workspaceFolders":[
275                    {
276                        "uri": Url::from_directory_path(self.project.as_path()).ok().map(|url| url.to_string()),
277                        "name": self.project.as_path().file_name().and_then(|n| n.to_str()).unwrap_or("workspace")
278                    }
279                ]
280            }),
281        )?;
282        let _ = self.read_response_for_id(id)?;
283        self.send_notification("initialized", json!({}))?;
284        Ok(())
285    }
286
287    fn find_references(&mut self, request: &LspRequest) -> Result<Vec<LspReference>> {
288        let absolute_path = self.project.resolve(&request.file_path)?;
289        let (uri_string, _source) = self.prepare_document(&absolute_path)?;
290
291        let id = self.next_id();
292        self.send_request(
293            id,
294            "textDocument/references",
295            json!({
296                "textDocument":{"uri":uri_string},
297                "position":{"line":request.line.saturating_sub(1),"character":request.column.saturating_sub(1)},
298                "context":{"includeDeclaration":true}
299            }),
300        )?;
301        let response = self.read_response_for_id(id)?;
302        references_from_response(&self.project, response, request.max_results)
303    }
304
305    fn get_diagnostics(&mut self, request: &LspDiagnosticRequest) -> Result<Vec<LspDiagnostic>> {
306        let absolute_path = self.project.resolve(&request.file_path)?;
307        let (uri_string, _source) = self.prepare_document(&absolute_path)?;
308
309        let id = self.next_id();
310        self.send_request(
311            id,
312            "textDocument/diagnostic",
313            json!({
314                "textDocument":{"uri":uri_string}
315            }),
316        )?;
317        let response = self.read_response_for_id(id)?;
318        diagnostics_from_response(&self.project, response, request.max_results)
319    }
320
321    fn search_workspace_symbols(
322        &mut self,
323        request: &LspWorkspaceSymbolRequest,
324    ) -> Result<Vec<LspWorkspaceSymbol>> {
325        let id = self.next_id();
326        self.send_request(
327            id,
328            "workspace/symbol",
329            json!({
330                "query": request.query
331            }),
332        )?;
333        let response = self.read_response_for_id(id)?;
334        workspace_symbols_from_response(&self.project, response, request.max_results)
335    }
336
337    fn get_type_hierarchy(
338        &mut self,
339        request: &LspTypeHierarchyRequest,
340    ) -> Result<HashMap<String, Value>> {
341        let workspace_symbols = self.search_workspace_symbols(&LspWorkspaceSymbolRequest {
342            command: request.command.clone(),
343            args: request.args.clone(),
344            query: request.query.clone(),
345            max_results: 20,
346        })?;
347        let seed = workspace_symbols
348            .into_iter()
349            .find(|symbol| match &request.relative_path {
350                Some(path) => &symbol.file_path == path,
351                None => true,
352            })
353            .with_context(|| format!("No workspace symbol found for '{}'", request.query))?;
354
355        let absolute_path = self.project.resolve(&seed.file_path)?;
356        let (uri_string, _source) = self.prepare_document(&absolute_path)?;
357
358        let id = self.next_id();
359        self.send_request(
360            id,
361            "textDocument/prepareTypeHierarchy",
362            json!({
363                "textDocument":{"uri":uri_string},
364                "position":{"line":seed.line.saturating_sub(1),"character":seed.column.saturating_sub(1)}
365            }),
366        )?;
367        let response = self.read_response_for_id(id)?;
368        let items = response
369            .get("result")
370            .and_then(Value::as_array)
371            .cloned()
372            .unwrap_or_default();
373        let root_item = items
374            .into_iter()
375            .next()
376            .context("LSP prepareTypeHierarchy returned no items")?;
377
378        let root = self.build_type_hierarchy_node(
379            &root_item,
380            request.depth,
381            request.hierarchy_type.as_str(),
382        )?;
383        Ok(type_hierarchy_to_map(&root))
384    }
385
386    fn get_rename_plan(&mut self, request: &LspRenamePlanRequest) -> Result<LspRenamePlan> {
387        let absolute_path = self.project.resolve(&request.file_path)?;
388        let (uri_string, source) = self.prepare_document(&absolute_path)?;
389
390        let id = self.next_id();
391        self.send_request(
392            id,
393            "textDocument/prepareRename",
394            json!({
395                "textDocument":{"uri":uri_string},
396                "position":{"line":request.line.saturating_sub(1),"character":request.column.saturating_sub(1)}
397            }),
398        )?;
399        let response = self.read_response_for_id(id)?;
400        rename_plan_from_response(
401            &self.project,
402            &request.file_path,
403            &source,
404            response,
405            request.new_name.clone(),
406        )
407    }
408
409    fn build_type_hierarchy_node(
410        &mut self,
411        item: &Value,
412        depth: usize,
413        hierarchy_type: &str,
414    ) -> Result<LspTypeHierarchyNode> {
415        let mut node = type_hierarchy_node_from_item(item)?;
416
417        if depth == 0 {
418            return Ok(node);
419        }
420
421        let next_depth = depth.saturating_sub(1);
422        if hierarchy_type == "super" || hierarchy_type == "both" {
423            node.supertypes = self.fetch_type_hierarchy_branch(item, "supertypes", next_depth)?;
424        }
425        if hierarchy_type == "sub" || hierarchy_type == "both" {
426            node.subtypes = self.fetch_type_hierarchy_branch(item, "subtypes", next_depth)?;
427        }
428        Ok(node)
429    }
430
431    fn fetch_type_hierarchy_branch(
432        &mut self,
433        item: &Value,
434        method_suffix: &str,
435        depth: usize,
436    ) -> Result<Vec<LspTypeHierarchyNode>> {
437        let id = self.next_id();
438        self.send_request(
439            id,
440            &format!("typeHierarchy/{method_suffix}"),
441            json!({
442                "item": item
443            }),
444        )?;
445        let response = self.read_response_for_id(id)?;
446        let Some(items) = response.get("result").and_then(Value::as_array) else {
447            return Ok(Vec::new());
448        };
449
450        let mut nodes = Vec::new();
451        for child in items {
452            nodes.push(self.build_type_hierarchy_node(
453                child,
454                depth,
455                method_suffix_to_hierarchy(method_suffix),
456            )?);
457        }
458        Ok(nodes)
459    }
460
461    fn prepare_document(&mut self, absolute_path: &Path) -> Result<(String, String)> {
462        let uri = Url::from_file_path(absolute_path).map_err(|_| {
463            anyhow::anyhow!("failed to build file uri for {}", absolute_path.display())
464        })?;
465        let uri_string = uri.to_string();
466        let source = std::fs::read_to_string(absolute_path)
467            .with_context(|| format!("failed to read {}", absolute_path.display()))?;
468        let language_id = language_id_for_path(absolute_path)?;
469        self.sync_document(&uri_string, language_id, &source)?;
470        Ok((uri_string, source))
471    }
472
473    fn sync_document(&mut self, uri: &str, language_id: &str, source: &str) -> Result<()> {
474        if let Some(state) = self.documents.get(uri)
475            && state.text == source
476        {
477            return Ok(());
478        }
479
480        if let Some(state) = self.documents.get_mut(uri) {
481            state.version += 1;
482            state.text = source.to_owned();
483            let version = state.version;
484            return self.send_notification(
485                "textDocument/didChange",
486                json!({
487                    "textDocument":{"uri":uri,"version":version},
488                    "contentChanges":[{"text":source}]
489                }),
490            );
491        }
492
493        self.documents.insert(
494            uri.to_owned(),
495            OpenDocumentState {
496                version: 1,
497                text: source.to_owned(),
498            },
499        );
500        self.send_notification(
501            "textDocument/didOpen",
502            json!({
503                "textDocument":{
504                    "uri":uri,
505                    "languageId":language_id,
506                    "version":1,
507                    "text":source
508                }
509            }),
510        )
511    }
512
513    fn next_id(&mut self) -> u64 {
514        let id = self.next_request_id;
515        self.next_request_id += 1;
516        id
517    }
518
519    fn send_request(&mut self, id: u64, method: &str, params: Value) -> Result<()> {
520        send_message(
521            &mut self.stdin,
522            &json!({
523                "jsonrpc":"2.0",
524                "id":id,
525                "method":method,
526                "params":params
527            }),
528        )
529    }
530
531    fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
532        send_message(
533            &mut self.stdin,
534            &json!({
535                "jsonrpc":"2.0",
536                "method":method,
537                "params":params
538            }),
539        )
540    }
541
542    fn read_response_for_id(&mut self, expected_id: u64) -> Result<Value> {
543        let deadline = Instant::now() + Duration::from_secs(30);
544        let mut discarded = 0u32;
545        const MAX_DISCARDED: u32 = 500;
546
547        loop {
548            let remaining = deadline.saturating_duration_since(Instant::now());
549            if remaining.is_zero() {
550                bail!(
551                    "LSP response timeout: no response for request id {expected_id} within 30s \
552                     ({discarded} unrelated messages discarded)"
553                );
554            }
555            if discarded >= MAX_DISCARDED {
556                bail!(
557                    "LSP response loop: discarded {MAX_DISCARDED} messages without finding id {expected_id}"
558                );
559            }
560
561            // Poll the pipe before blocking read — prevents infinite hang
562            if !poll_readable(self.reader.get_ref(), remaining.min(Duration::from_secs(5))) {
563                continue; // no data yet, re-check deadline
564            }
565
566            let message = read_message(&mut self.reader)?;
567            let matches_id = message
568                .get("id")
569                .and_then(Value::as_u64)
570                .map(|id| id == expected_id)
571                .unwrap_or(false);
572            if matches_id {
573                if let Some(error) = message.get("error") {
574                    let code = error.get("code").and_then(Value::as_i64).unwrap_or(-1);
575                    let error_message = error
576                        .get("message")
577                        .and_then(Value::as_str)
578                        .unwrap_or("unknown LSP error");
579                    bail!("LSP request failed ({code}): {error_message}");
580                }
581                return Ok(message);
582            }
583            discarded += 1;
584        }
585    }
586
587    fn shutdown(&mut self) -> Result<()> {
588        let id = self.next_id();
589        self.send_request(id, "shutdown", Value::Null)?;
590        let _ = self.read_response_for_id(id)?;
591        self.send_notification("exit", Value::Null)
592    }
593}
594
595impl Drop for LspSession {
596    fn drop(&mut self) {
597        let _ = self.shutdown();
598        let deadline = Instant::now() + Duration::from_millis(250);
599        while Instant::now() < deadline {
600            match self.child.try_wait() {
601                Ok(Some(_status)) => return,
602                Ok(None) => thread::sleep(Duration::from_millis(10)),
603                Err(_) => break,
604            }
605        }
606        let _ = self.child.kill();
607        let _ = self.child.wait();
608    }
609}