Skip to main content

lean_ctx/lsp/
client.rs

1#![allow(clippy::wildcard_imports, clippy::default_trait_access)]
2
3use lsp_types::*;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{HashMap, VecDeque};
7use std::io::{BufRead, BufReader, Write};
8use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
11use std::sync::mpsc::{self, Receiver};
12use std::time::{Duration, Instant};
13
14use super::capabilities::{SemanticBackendInfo, SemanticBackendKind, SemanticCapabilities};
15use super::config::ResolvedServer;
16
17const INIT_TIMEOUT_SECS: u64 = 60;
18const REQUEST_TIMEOUT_SECS: u64 = 30;
19const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
20
21pub fn file_path_to_uri(path: &str) -> Result<Uri, String> {
22    let abs = if path.starts_with('/') || (path.len() >= 2 && path.as_bytes()[1] == b':') {
23        path.to_string()
24    } else {
25        std::fs::canonicalize(path)
26            .map(|p| p.to_string_lossy().to_string())
27            .map_err(|e| format!("Cannot resolve path '{path}': {e}"))?
28    };
29    let normalized = percent_encode_path(&abs.replace('\\', "/"));
30    let uri_str = if normalized.starts_with('/') {
31        format!("file://{normalized}")
32    } else {
33        format!("file:///{normalized}")
34    };
35    uri_str
36        .parse::<Uri>()
37        .map_err(|e| format!("Invalid URI: {e}"))
38}
39
40/// Percent-encodes everything a URI path may not carry literally — spaces,
41/// non-ASCII, `#`, `?`, `%` … — byte by byte (RFC 3986 `pchar` plus `/`).
42/// `uri_to_file_path` decodes it again.
43fn percent_encode_path(path: &str) -> String {
44    let mut out = String::with_capacity(path.len());
45    for b in path.bytes() {
46        if b.is_ascii_alphanumeric() || b"-._~/!$&'()*+,;=:@".contains(&b) {
47            out.push(b as char);
48        } else {
49            out.push_str(&format!("%{b:02X}"));
50        }
51    }
52    out
53}
54
55pub fn uri_to_file_path(uri: &Uri) -> Option<String> {
56    let s = uri.as_str();
57    s.strip_prefix("file://")
58        .map(|p| urlencoding::decode(p).map_or_else(|_| p.to_string(), |d| d.to_string()))
59        .map(|p| {
60            // `file:///C:/x` → `C:/x`, not `/C:/x` (Windows drive URIs).
61            let b = p.as_bytes();
62            if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
63                p[1..].to_string()
64            } else {
65                p
66            }
67        })
68}
69
70pub struct LspClient {
71    child: Child,
72    stdin: ChildStdin,
73    response_rx: Receiver<Result<Value, String>>,
74    next_id: AtomicI64,
75    initialized: bool,
76    /// Cleared by the reader thread once the server's stdout ends (exit/crash),
77    /// so the router can evict a dead server without `&mut` access.
78    alive: Arc<AtomicBool>,
79    /// Server identity, negotiated capabilities and position encoding from
80    /// the `initialize` handshake.
81    info: SemanticBackendInfo,
82    documents: DocumentStore,
83    /// Per-request timeout; see `LspBackend::set_request_timeout`.
84    request_timeout: Duration,
85    /// Workspace root path; type-hierarchy answers are made relative to it.
86    root_path: String,
87}
88
89/// Open documents: URI → (version, content hash). Keeps repeated queries from
90/// re-sending `didOpen` and turns content changes into `didChange`.
91#[derive(Default)]
92struct DocumentStore {
93    documents: HashMap<String, (i32, u64)>,
94    /// URIs in open order, oldest first — bounded by [`MAX_OPEN_DOCUMENTS`].
95    open_order: VecDeque<String>,
96}
97
98impl DocumentStore {
99    /// Records `text` for `uri` and says which notification brings the server
100    /// up to date, plus a document to close when the bound is exceeded.
101    fn sync(&mut self, uri: &str, text: &str) -> (DocumentSync, Option<String>) {
102        let hash = content_hash(text);
103        if let Some((version, known)) = self.documents.get_mut(uri) {
104            if *known == hash {
105                return (DocumentSync::Unchanged, None);
106            }
107            *version += 1;
108            *known = hash;
109            return (DocumentSync::Change(*version), None);
110        }
111        self.documents.insert(uri.to_string(), (1, hash));
112        self.open_order.push_back(uri.to_string());
113        let evicted = (self.open_order.len() > MAX_OPEN_DOCUMENTS)
114            .then(|| self.open_order.pop_front())
115            .flatten();
116        if let Some(old) = &evicted {
117            self.documents.remove(old);
118        }
119        (DocumentSync::Open, evicted)
120    }
121}
122
123/// Upper bound on documents held open in one server; beyond it the oldest is
124/// closed. Bounds server memory during bulk semantic enrichment.
125const MAX_OPEN_DOCUMENTS: usize = 128;
126
127/// What the document store must send for a `sync_document` call.
128#[derive(Debug, PartialEq, Eq)]
129enum DocumentSync {
130    Open,
131    Change(i32),
132    Unchanged,
133}
134
135fn content_hash(text: &str) -> u64 {
136    use std::hash::{Hash, Hasher};
137    let mut h = std::collections::hash_map::DefaultHasher::new();
138    text.hash(&mut h);
139    h.finish()
140}
141
142/// How `read_response` handles one incoming message.
143#[derive(Debug, PartialEq)]
144enum Incoming {
145    /// A server request or notification: send the reply (if any), keep waiting.
146    FromServer(Option<Value>),
147    /// The awaited response (or its error).
148    Response(Result<Option<Value>, String>),
149    /// A response to another request (e.g. one that timed out): ignore.
150    Other,
151}
152
153/// A message with `method` is a server request or notification — never our
154/// response, even when its id equals ours (client and server ids are separate
155/// id spaces).
156fn route_incoming(msg: Value, expected_id: i64) -> Incoming {
157    if msg.get("method").is_some() {
158        return Incoming::FromServer(server_request_reply(&msg));
159    }
160    if msg.get("id").and_then(Value::as_i64) != Some(expected_id) {
161        return Incoming::Other;
162    }
163    Incoming::Response(
164        serde_json::from_value::<JsonRpcResponse>(msg)
165            .map_err(|e| e.to_string())
166            .and_then(|resp| match resp.error {
167                Some(err) => Err(format!("LSP error: {}", err.message)),
168                None => Ok(resp.result),
169            }),
170    )
171}
172
173/// Answers a server→client *request* (has both `id` and `method`). Servers
174/// block on these, so each gets a reply: harmless acknowledgements succeed,
175/// `workspace/configuration` gets one `null` per requested item (= "use your
176/// defaults"), everything else is `MethodNotFound`.
177fn server_request_reply(msg: &Value) -> Option<Value> {
178    let id = msg.get("id")?.clone();
179    let method = msg.get("method")?.as_str()?;
180    let reply = match method {
181        "window/workDoneProgress/create"
182        | "client/registerCapability"
183        | "client/unregisterCapability"
184        | "window/showMessageRequest" => {
185            serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": Value::Null })
186        }
187        "workspace/configuration" => {
188            let n = msg
189                .pointer("/params/items")
190                .and_then(Value::as_array)
191                .map_or(0, Vec::len);
192            serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": vec![Value::Null; n] })
193        }
194        _ => serde_json::json!({
195            "jsonrpc": "2.0",
196            "id": id,
197            "error": { "code": -32601, "message": format!("lean-ctx does not handle {method}") }
198        }),
199    };
200    Some(reply)
201}
202
203/// Credential-bearing variables are not inherited by language servers: they
204/// read source and must not be able to reuse the user's tokens. This holds for
205/// toolchain tokens too (`NPM_TOKEN`, `CARGO_REGISTRIES_*_TOKEN`); toolchain
206/// *configuration* (`CARGO_HOME`, `GOPROXY`, `GOPRIVATE`, …) is kept, and
207/// file-based credentials (`~/.cargo/credentials.toml`, `.npmrc`, `.netrc`)
208/// remain available for private registry resolution.
209///
210/// Deliberately kept: `SSH_AUTH_SOCK` / askpass helpers. A server that runs
211/// project code (build scripts, proc macros) executes as the user and can
212/// reach the agent socket without the variable, so stripping it is no
213/// isolation boundary — but it breaks `cargo metadata` / `go list` for
214/// private git dependencies over SSH, i.e. the server's workspace load. The
215/// scrub targets reusable secrets that could leak into server logs or crash
216/// reports, not process isolation.
217fn is_credential_env(name: &str) -> bool {
218    const SECRET_MARKERS: &[&str] = &[
219        "TOKEN",
220        "SECRET",
221        "PASSWORD",
222        "PASSWD",
223        "API_KEY",
224        "APIKEY",
225        "ACCESS_KEY",
226        "SECRET_KEY",
227        "PRIVATE_KEY",
228        "CREDENTIALS",
229        "AUTH",
230        "AUTH_CONFIG",
231    ];
232    let upper = name.to_ascii_uppercase();
233    SECRET_MARKERS
234        .iter()
235        .any(|m| upper == *m || upper.ends_with(&format!("_{m}")))
236}
237
238#[derive(Serialize)]
239struct JsonRpcRequest {
240    jsonrpc: &'static str,
241    id: i64,
242    method: String,
243    params: Value,
244}
245
246#[derive(Deserialize)]
247struct JsonRpcResponse {
248    #[serde(rename = "id")]
249    _id: Option<i64>,
250    result: Option<Value>,
251    error: Option<JsonRpcError>,
252}
253
254#[derive(Deserialize)]
255struct JsonRpcError {
256    #[serde(rename = "code")]
257    _code: i64,
258    message: String,
259}
260
261fn read_one_message(reader: &mut BufReader<ChildStdout>) -> Result<Value, String> {
262    let mut content_length = 0usize;
263    loop {
264        let mut line = String::new();
265        let bytes_read = reader
266            .read_line(&mut line)
267            .map_err(|e| format!("Read header: {e}"))?;
268        if bytes_read == 0 {
269            return Err("LSP server closed connection (EOF)".into());
270        }
271        let trimmed = line.trim();
272        if trimmed.is_empty() {
273            break;
274        }
275        if let Some(val) = trimmed.strip_prefix("Content-Length: ") {
276            content_length = val.parse().map_err(|e| format!("Parse length: {e}"))?;
277        }
278    }
279    if content_length == 0 {
280        return Err("Zero content length from LSP server".into());
281    }
282    let mut body = vec![0u8; content_length];
283    std::io::Read::read_exact(reader, &mut body).map_err(|e| format!("Read body: {e}"))?;
284    let text = String::from_utf8_lossy(&body);
285    serde_json::from_str(&text).map_err(|e| format!("Parse response: {e}"))
286}
287
288fn spawn_reader(stdout: ChildStdout, alive: Arc<AtomicBool>) -> Receiver<Result<Value, String>> {
289    let (tx, rx) = mpsc::channel();
290    std::thread::Builder::new()
291        .name("lsp-reader".into())
292        .spawn(move || {
293            let mut reader = BufReader::new(stdout);
294            loop {
295                match read_one_message(&mut reader) {
296                    Ok(msg) => {
297                        if tx.send(Ok(msg)).is_err() {
298                            break;
299                        }
300                    }
301                    Err(e) => {
302                        alive.store(false, Ordering::Release);
303                        let _ = tx.send(Err(e));
304                        break;
305                    }
306                }
307            }
308        })
309        .ok();
310    rx
311}
312
313impl LspClient {
314    /// Starts and initializes a server. `init_timeout` bounds the `initialize`
315    /// handshake (default 60 s) — budgeted background work passes what is left
316    /// of its own deadline.
317    pub fn start(
318        server: &ResolvedServer,
319        root_uri: &Uri,
320        init_timeout: Option<Duration>,
321    ) -> Result<Self, String> {
322        let config = &server.config;
323        let mut cmd = Command::new(&config.command);
324        cmd.args(&config.args)
325            .stdin(Stdio::piped())
326            .stdout(Stdio::piped())
327            .stderr(Stdio::null());
328        for (name, _) in std::env::vars_os() {
329            if name.to_str().is_some_and(is_credential_env) {
330                cmd.env_remove(&name);
331            }
332        }
333        let mut child = cmd
334            .spawn()
335            .map_err(|e| format!("Failed to start LSP server '{}': {e}", config.command))?;
336
337        let stdin = child.stdin.take().ok_or("No stdin")?;
338        let stdout = child.stdout.take().ok_or("No stdout")?;
339        let alive = Arc::new(AtomicBool::new(true));
340        let response_rx = spawn_reader(stdout, Arc::clone(&alive));
341
342        let mut client = Self {
343            child,
344            stdin,
345            response_rx,
346            next_id: AtomicI64::new(1),
347            initialized: false,
348            alive,
349            info: SemanticBackendInfo {
350                kind: SemanticBackendKind::Lsp,
351                server_name: None,
352                server_version: None,
353                capabilities: SemanticCapabilities::default(),
354                utf8_positions: false,
355            },
356            documents: DocumentStore::default(),
357            request_timeout: Duration::from_secs(REQUEST_TIMEOUT_SECS),
358            root_path: String::new(),
359        };
360
361        client.initialize(
362            root_uri,
363            init_timeout.unwrap_or(Duration::from_secs(INIT_TIMEOUT_SECS)),
364            server.init_options.clone(),
365        )?;
366        // `serverInfo` is optional (typescript-language-server omits it); the
367        // binary still distinguishes servers in identities and cache keys.
368        if client.info.server_name.is_none() {
369            client.info.server_name = Some(server.binary_name());
370        }
371        Ok(client)
372    }
373
374    fn check_alive(&mut self) -> Result<(), String> {
375        match self.child.try_wait() {
376            Ok(Some(status)) => Err(format!("LSP server exited: {status}")),
377            Ok(None) => Ok(()),
378            Err(e) => Err(format!("Cannot check LSP server status: {e}")),
379        }
380    }
381
382    #[allow(deprecated)]
383    fn initialize(
384        &mut self,
385        root_uri: &Uri,
386        timeout: Duration,
387        init_options: Option<serde_json::Value>,
388    ) -> Result<(), String> {
389        let params = InitializeParams {
390            root_uri: Some(root_uri.clone()),
391            initialization_options: init_options,
392            capabilities: ClientCapabilities {
393                text_document: Some(TextDocumentClientCapabilities {
394                    rename: Some(RenameClientCapabilities {
395                        dynamic_registration: Some(false),
396                        prepare_support: Some(true),
397                        ..Default::default()
398                    }),
399                    references: Some(DynamicRegistrationClientCapabilities {
400                        dynamic_registration: Some(false),
401                    }),
402                    definition: Some(GotoCapability {
403                        dynamic_registration: Some(false),
404                        link_support: Some(false),
405                    }),
406                    implementation: Some(GotoCapability {
407                        dynamic_registration: Some(false),
408                        link_support: Some(false),
409                    }),
410                    type_hierarchy: Some(DynamicRegistrationClientCapabilities {
411                        dynamic_registration: Some(false),
412                    }),
413                    ..Default::default()
414                }),
415                // Prefer UTF-8 so tree-sitter byte columns map 1:1; servers
416                // without 3.17 support fall back to the UTF-16 default.
417                general: Some(GeneralClientCapabilities {
418                    position_encodings: Some(vec![
419                        PositionEncodingKind::UTF8,
420                        PositionEncodingKind::UTF16,
421                    ]),
422                    ..Default::default()
423                }),
424                ..Default::default()
425            },
426            ..Default::default()
427        };
428
429        // Raw first: `lsp-types` has no field for `typeHierarchyProvider`.
430        let raw = self
431            .request_raw_with_timeout(
432                <request::Initialize as request::Request>::METHOD,
433                serde_json::to_value(params).map_err(|e| e.to_string())?,
434                timeout,
435            )?
436            .unwrap_or(Value::Null);
437        let result: InitializeResult =
438            serde_json::from_value(raw.clone()).map_err(|e| format!("Deserialize error: {e}"))?;
439        let mut capabilities = SemanticCapabilities::from_server(&result.capabilities);
440        capabilities.type_hierarchy = raw
441            .pointer("/capabilities/typeHierarchyProvider")
442            .is_some_and(|p| p.as_bool() != Some(false) && !p.is_null());
443        self.root_path = uri_to_file_path(root_uri).unwrap_or_default();
444        self.info = SemanticBackendInfo {
445            kind: SemanticBackendKind::Lsp,
446            server_name: result.server_info.as_ref().map(|s| s.name.clone()),
447            server_version: result
448                .server_info
449                .and_then(|s| s.version)
450                .map(|v| crate::lsp::capabilities::compact_server_version(&v)),
451            capabilities,
452            utf8_positions: result.capabilities.position_encoding
453                == Some(PositionEncodingKind::UTF8),
454        };
455        self.send_notification::<notification::Initialized>(InitializedParams {})?;
456        self.initialized = true;
457        Ok(())
458    }
459
460    pub fn info(&self) -> &SemanticBackendInfo {
461        &self.info
462    }
463
464    /// Fails fast with a clear message when the server did not advertise the
465    /// feature, instead of sending a request it cannot answer.
466    fn require(&self, supported: bool, method: &str) -> Result<(), String> {
467        if supported {
468            return Ok(());
469        }
470        Err(format!(
471            "UNSUPPORTED: language server '{}' does not provide {method}",
472            self.info.server_name.as_deref().unwrap_or("unknown")
473        ))
474    }
475
476    /// Brings the server's view of `uri` up to date: `didOpen` once, then
477    /// `didChange` (full text) only when the content changed.
478    pub fn did_open(&mut self, uri: &Uri, language_id: &str, text: &str) -> Result<(), String> {
479        self.check_alive()?;
480        let (sync, evicted) = self.documents.sync(uri.as_str(), text);
481        if let Some(old) = evicted
482            && let Ok(old_uri) = old.parse::<Uri>()
483        {
484            self.send_notification::<notification::DidCloseTextDocument>(
485                DidCloseTextDocumentParams {
486                    text_document: TextDocumentIdentifier { uri: old_uri },
487                },
488            )?;
489        }
490        match sync {
491            DocumentSync::Unchanged => Ok(()),
492            DocumentSync::Open => self.send_notification::<notification::DidOpenTextDocument>(
493                DidOpenTextDocumentParams {
494                    text_document: TextDocumentItem {
495                        uri: uri.clone(),
496                        language_id: language_id.to_string(),
497                        version: 1,
498                        text: text.to_string(),
499                    },
500                },
501            ),
502            DocumentSync::Change(version) => self
503                .send_notification::<notification::DidChangeTextDocument>(
504                    DidChangeTextDocumentParams {
505                        text_document: VersionedTextDocumentIdentifier {
506                            uri: uri.clone(),
507                            version,
508                        },
509                        content_changes: vec![TextDocumentContentChangeEvent {
510                            range: None,
511                            range_length: None,
512                            text: text.to_string(),
513                        }],
514                    },
515                ),
516        }
517    }
518
519    pub fn references(&mut self, uri: &Uri, position: Position) -> Result<Vec<Location>, String> {
520        self.check_alive()?;
521        self.require(self.info.capabilities.references, "textDocument/references")?;
522        let params = ReferenceParams {
523            text_document_position: TextDocumentPositionParams {
524                text_document: TextDocumentIdentifier { uri: uri.clone() },
525                position,
526            },
527            context: ReferenceContext {
528                include_declaration: true,
529            },
530            work_done_progress_params: Default::default(),
531            partial_result_params: Default::default(),
532        };
533        let result =
534            self.request_with_timeout::<request::References>(params, self.request_timeout)?;
535        Ok(result.unwrap_or_default())
536    }
537
538    pub fn definition(
539        &mut self,
540        uri: &Uri,
541        position: Position,
542    ) -> Result<GotoDefinitionResponse, String> {
543        self.check_alive()?;
544        self.require(self.info.capabilities.definition, "textDocument/definition")?;
545        let params = GotoDefinitionParams {
546            text_document_position_params: TextDocumentPositionParams {
547                text_document: TextDocumentIdentifier { uri: uri.clone() },
548                position,
549            },
550            work_done_progress_params: Default::default(),
551            partial_result_params: Default::default(),
552        };
553        let result =
554            self.request_with_timeout::<request::GotoDefinition>(params, self.request_timeout)?;
555        Ok(result.unwrap_or(GotoDefinitionResponse::Array(vec![])))
556    }
557
558    pub fn rename(
559        &mut self,
560        uri: &Uri,
561        position: Position,
562        new_name: &str,
563    ) -> Result<Option<WorkspaceEdit>, String> {
564        self.check_alive()?;
565        self.require(self.info.capabilities.rename, "textDocument/rename")?;
566        let params = RenameParams {
567            text_document_position: TextDocumentPositionParams {
568                text_document: TextDocumentIdentifier { uri: uri.clone() },
569                position,
570            },
571            new_name: new_name.to_string(),
572            work_done_progress_params: Default::default(),
573        };
574        self.request_with_timeout::<request::Rename>(params, self.request_timeout)
575    }
576
577    /// Direct super- or subtypes of the type at `position` (LSP 3.17
578    /// `prepareTypeHierarchy` + `typeHierarchy/supertypes|subtypes`), shaped
579    /// like the JetBrains bridge answer: root = the type itself, children =
580    /// one level of related types. Paths inside the project root are
581    /// project-relative, others absolute; lines are 1-based.
582    pub fn type_hierarchy(
583        &mut self,
584        uri: &Uri,
585        position: Position,
586        direction: crate::lsp::backend::HierarchyDirection,
587    ) -> Result<crate::lsp::backend::TypeHierarchyNode, String> {
588        use crate::lsp::backend::{HierarchyDirection, TypeHierarchyNode};
589        self.check_alive()?;
590        self.require(
591            self.info.capabilities.type_hierarchy,
592            "textDocument/prepareTypeHierarchy",
593        )?;
594        let items = self
595            .request_with_timeout::<request::TypeHierarchyPrepare>(
596                TypeHierarchyPrepareParams {
597                    text_document_position_params: TextDocumentPositionParams {
598                        text_document: TextDocumentIdentifier { uri: uri.clone() },
599                        position,
600                    },
601                    work_done_progress_params: Default::default(),
602                },
603                self.request_timeout,
604            )?
605            .unwrap_or_default();
606        let Some(item) = items.into_iter().next() else {
607            return Ok(TypeHierarchyNode {
608                name: String::new(),
609                path: String::new(),
610                line: 0,
611                children: vec![],
612            });
613        };
614        let related = match direction {
615            HierarchyDirection::Supertypes => self
616                .request_with_timeout::<request::TypeHierarchySupertypes>(
617                    TypeHierarchySupertypesParams {
618                        item: item.clone(),
619                        work_done_progress_params: Default::default(),
620                        partial_result_params: Default::default(),
621                    },
622                    self.request_timeout,
623                )?,
624            HierarchyDirection::Subtypes => self
625                .request_with_timeout::<request::TypeHierarchySubtypes>(
626                    TypeHierarchySubtypesParams {
627                        item: item.clone(),
628                        work_done_progress_params: Default::default(),
629                        partial_result_params: Default::default(),
630                    },
631                    self.request_timeout,
632                )?,
633        }
634        .unwrap_or_default();
635        let node = |i: &TypeHierarchyItem, children| TypeHierarchyNode {
636            name: i.name.clone(),
637            path: relative_to_root(&self.root_path, &i.uri),
638            line: i.selection_range.start.line + 1,
639            children,
640        };
641        let children = related.iter().map(|r| node(r, vec![])).collect();
642        Ok(node(&item, children))
643    }
644
645    pub fn implementations(
646        &mut self,
647        uri: &Uri,
648        position: Position,
649    ) -> Result<Vec<Location>, String> {
650        self.check_alive()?;
651        self.require(
652            self.info.capabilities.implementations,
653            "textDocument/implementation",
654        )?;
655        let params = GotoDefinitionParams {
656            text_document_position_params: TextDocumentPositionParams {
657                text_document: TextDocumentIdentifier { uri: uri.clone() },
658                position,
659            },
660            work_done_progress_params: Default::default(),
661            partial_result_params: Default::default(),
662        };
663        let value = self.request_raw_with_timeout(
664            "textDocument/implementation",
665            serde_json::to_value(params).unwrap_or_default(),
666            self.request_timeout,
667        )?;
668        match value {
669            Some(v) => {
670                let locations: Vec<Location> = serde_json::from_value(v).unwrap_or_default();
671                Ok(locations)
672            }
673            None => Ok(vec![]),
674        }
675    }
676
677    fn request_with_timeout<R: request::Request>(
678        &mut self,
679        params: R::Params,
680        timeout: Duration,
681    ) -> Result<R::Result, String>
682    where
683        R::Params: Serialize,
684        R::Result: for<'de> Deserialize<'de>,
685    {
686        let value = self.request_raw_with_timeout(
687            R::METHOD,
688            serde_json::to_value(params).map_err(|e| e.to_string())?,
689            timeout,
690        )?;
691        match value {
692            Some(v) => serde_json::from_value(v).map_err(|e| format!("Deserialize error: {e}")),
693            None => serde_json::from_value(Value::Null).map_err(|e| format!("Null result: {e}")),
694        }
695    }
696
697    fn request_raw_with_timeout(
698        &mut self,
699        method: &str,
700        params: Value,
701        timeout: Duration,
702    ) -> Result<Option<Value>, String> {
703        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
704        let req = JsonRpcRequest {
705            jsonrpc: "2.0",
706            id,
707            method: method.to_string(),
708            params,
709        };
710        self.send_message(&serde_json::to_value(req).map_err(|e| e.to_string())?)?;
711        self.read_response(id, timeout)
712    }
713
714    fn send_notification<N: notification::Notification>(
715        &mut self,
716        params: N::Params,
717    ) -> Result<(), String>
718    where
719        N::Params: Serialize,
720    {
721        let msg = serde_json::json!({
722            "jsonrpc": "2.0",
723            "method": N::METHOD,
724            "params": serde_json::to_value(params).map_err(|e| e.to_string())?
725        });
726        self.send_message(&msg)
727    }
728
729    fn send_message(&mut self, msg: &Value) -> Result<(), String> {
730        let body = serde_json::to_string(msg).map_err(|e| e.to_string())?;
731        let header = format!("Content-Length: {}\r\n\r\n", body.len());
732        self.stdin
733            .write_all(header.as_bytes())
734            .map_err(|e| format!("Write to LSP server: {e}"))?;
735        self.stdin
736            .write_all(body.as_bytes())
737            .map_err(|e| format!("Write to LSP server: {e}"))?;
738        self.stdin
739            .flush()
740            .map_err(|e| format!("Flush LSP server: {e}"))?;
741        Ok(())
742    }
743
744    fn read_response(
745        &mut self,
746        expected_id: i64,
747        timeout: Duration,
748    ) -> Result<Option<Value>, String> {
749        let deadline = Instant::now() + timeout;
750        loop {
751            let remaining = deadline.saturating_duration_since(Instant::now());
752            if remaining.is_zero() {
753                return Err(format!(
754                    "LSP response timeout ({}s) for request id={expected_id}",
755                    timeout.as_secs()
756                ));
757            }
758
759            match self.response_rx.recv_timeout(remaining) {
760                Ok(Ok(msg)) => match route_incoming(msg, expected_id) {
761                    Incoming::FromServer(reply) => {
762                        if let Some(reply) = reply {
763                            self.send_message(&reply)?;
764                        }
765                    }
766                    Incoming::Response(result) => return result,
767                    Incoming::Other => {}
768                },
769                Ok(Err(e)) => return Err(format!("LSP reader error: {e}")),
770                Err(mpsc::RecvTimeoutError::Timeout) => {
771                    return Err(format!("LSP response timeout ({}s)", timeout.as_secs()));
772                }
773                Err(mpsc::RecvTimeoutError::Disconnected) => {
774                    return Err("LSP server connection lost".into());
775                }
776            }
777        }
778    }
779
780    pub fn shutdown(&mut self) {
781        if self.initialized {
782            let _ = self.request_raw_with_timeout(
783                "shutdown",
784                Value::Null,
785                Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
786            );
787            let _ = self.send_notification::<notification::Exit>(());
788            self.initialized = false;
789            self.reap(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS));
790        } else {
791            // Never finished `initialize` (e.g. start-up exceeded its budget):
792            // there is no protocol shutdown to wait for — kill it right away,
793            // so a failed start costs no extra time on the caller's budget.
794            self.reap(Duration::ZERO);
795        }
796    }
797
798    /// Waits up to `grace` for the server to exit, then kills it. A server that
799    /// ignores `exit` (or never finished `initialize`) can therefore neither
800    /// block the caller — the idle reaper, `shutdown_all` — nor be left behind.
801    fn reap(&mut self, grace: Duration) {
802        let deadline = Instant::now() + grace;
803        while Instant::now() < deadline {
804            match self.child.try_wait() {
805                Ok(Some(_)) => return,
806                Ok(None) => std::thread::sleep(Duration::from_millis(20)),
807                Err(_) => break,
808            }
809        }
810        let _ = self.child.kill();
811        let _ = self.child.wait();
812    }
813}
814
815impl Drop for LspClient {
816    fn drop(&mut self) {
817        self.shutdown();
818    }
819}
820
821impl crate::lsp::backend::LspBackend for LspClient {
822    fn open_file(
823        &mut self,
824        uri: &lsp_types::Uri,
825        language_id: &str,
826        text: &str,
827    ) -> Result<(), String> {
828        LspClient::did_open(self, uri, language_id, text)
829    }
830    fn references(
831        &mut self,
832        uri: &lsp_types::Uri,
833        position: lsp_types::Position,
834        _scope: &str,
835    ) -> Result<Vec<lsp_types::Location>, String> {
836        LspClient::references(self, uri, position)
837    }
838    fn definition(
839        &mut self,
840        uri: &lsp_types::Uri,
841        position: lsp_types::Position,
842    ) -> Result<lsp_types::GotoDefinitionResponse, String> {
843        LspClient::definition(self, uri, position)
844    }
845    fn implementations(
846        &mut self,
847        uri: &lsp_types::Uri,
848        position: lsp_types::Position,
849        _scope: &str,
850    ) -> Result<Vec<lsp_types::Location>, String> {
851        LspClient::implementations(self, uri, position)
852    }
853    fn rename(
854        &mut self,
855        uri: &lsp_types::Uri,
856        position: lsp_types::Position,
857        new_name: &str,
858    ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
859        LspClient::rename(self, uri, position, new_name)
860    }
861    /// A server whose stdout closed (exit or crash) is stale; the router evicts
862    /// it and starts a fresh one on the next call.
863    fn is_stale(&self, _project_root: &str) -> bool {
864        !self.alive.load(Ordering::Acquire)
865    }
866    fn set_request_timeout(&mut self, timeout: Option<Duration>) {
867        self.request_timeout = timeout.unwrap_or(Duration::from_secs(REQUEST_TIMEOUT_SECS));
868    }
869    fn backend_info(&self) -> SemanticBackendInfo {
870        self.info.clone()
871    }
872    fn type_hierarchy(
873        &mut self,
874        uri: &lsp_types::Uri,
875        position: lsp_types::Position,
876        direction: crate::lsp::backend::HierarchyDirection,
877    ) -> Result<crate::lsp::backend::TypeHierarchyNode, String> {
878        LspClient::type_hierarchy(self, uri, position, direction)
879    }
880    // declaration/symbols_overview/format/inspections: Default-Err (Backing A).
881}
882
883/// `uri`'s file path relative to `root` (with `/`), or absolute when it lies
884/// outside the root.
885fn relative_to_root(root: &str, uri: &Uri) -> String {
886    let path = uri_to_file_path(uri).unwrap_or_default().replace('\\', "/");
887    let root = root.replace('\\', "/");
888    path.strip_prefix(root.trim_end_matches('/'))
889        .and_then(|rest| rest.strip_prefix('/'))
890        .map_or_else(|| path.clone(), str::to_string)
891}
892
893#[cfg(test)]
894mod tests {
895    use super::{DocumentStore, DocumentSync, MAX_OPEN_DOCUMENTS, is_credential_env};
896    use super::{Incoming, route_incoming, server_request_reply};
897
898    /// Paths with spaces, non-ASCII or URI delimiters (a project in
899    /// "My Projects", an editor bundled under "Visual Studio Code.app")
900    /// must still become valid URIs and map back unchanged.
901    #[test]
902    fn file_uris_round_trip_paths_with_special_characters() {
903        use super::{file_path_to_uri, uri_to_file_path};
904        for path in [
905            "/Applications/Visual Studio Code.app/lib.es5.d.ts",
906            "/Users/zoë/My Projects/a#1 (copy)%.rs",
907            "C:/Users/A B/src/main.rs",
908        ] {
909            let uri = file_path_to_uri(path).expect(path);
910            assert!(!uri.as_str().contains(' '), "{}", uri.as_str());
911            assert_eq!(uri_to_file_path(&uri).as_deref(), Some(path));
912        }
913    }
914
915    #[test]
916    fn document_store_opens_once_changes_on_edit_and_stays_bounded() {
917        let mut docs = DocumentStore::default();
918        assert_eq!(docs.sync("file:///a.rs", "v1"), (DocumentSync::Open, None));
919        assert_eq!(
920            docs.sync("file:///a.rs", "v1"),
921            (DocumentSync::Unchanged, None)
922        );
923        assert_eq!(
924            docs.sync("file:///a.rs", "v2"),
925            (DocumentSync::Change(2), None)
926        );
927        for i in 0..MAX_OPEN_DOCUMENTS - 1 {
928            docs.sync(&format!("file:///{i}.rs"), "x");
929        }
930        let (sync, evicted) = docs.sync("file:///overflow.rs", "x");
931        assert_eq!(sync, DocumentSync::Open);
932        assert_eq!(evicted.as_deref(), Some("file:///a.rs"), "oldest is closed");
933        assert_eq!(
934            docs.sync("file:///a.rs", "v2").0,
935            DocumentSync::Open,
936            "a closed document must be reopened, not changed"
937        );
938    }
939
940    #[test]
941    fn server_requests_get_a_reply_and_are_never_mistaken_for_responses() {
942        let config = serde_json::json!({
943            "jsonrpc": "2.0", "id": 1, "method": "workspace/configuration",
944            "params": { "items": [{}, {}] }
945        });
946        // Same numeric id as the client's in-flight request 1: still routed
947        // to the server-request path, never returned as our response.
948        let Incoming::FromServer(Some(reply)) = route_incoming(config, 1) else {
949            panic!("server request must be answered, not returned as response");
950        };
951        assert_eq!(reply["id"], 1);
952        assert_eq!(reply["result"], serde_json::json!([null, null]));
953        assert_eq!(
954            route_incoming(
955                serde_json::json!({ "jsonrpc": "2.0", "id": 1, "result": 7 }),
956                1
957            ),
958            Incoming::Response(Ok(Some(serde_json::json!(7))))
959        );
960        assert_eq!(
961            route_incoming(
962                serde_json::json!({ "jsonrpc": "2.0", "id": 9, "result": 7 }),
963                1
964            ),
965            Incoming::Other,
966            "late response to another request is ignored"
967        );
968
969        let unknown = serde_json::json!({ "jsonrpc": "2.0", "id": "x", "method": "foo/bar" });
970        assert_eq!(
971            server_request_reply(&unknown).unwrap()["error"]["code"],
972            -32601
973        );
974
975        let notification = serde_json::json!({ "jsonrpc": "2.0", "method": "$/progress" });
976        assert!(server_request_reply(&notification).is_none());
977    }
978
979    #[test]
980    fn credential_env_is_scrubbed_but_toolchain_config_is_kept() {
981        for scrubbed in [
982            "GITHUB_TOKEN",
983            "ANTHROPIC_API_KEY",
984            "AWS_SECRET_ACCESS_KEY",
985            "AWS_SESSION_TOKEN",
986            "DB_PASSWORD",
987            "CARGO_REGISTRIES_CORP_TOKEN",
988            "NPM_TOKEN",
989            "DOCKER_AUTH_CONFIG",
990            "token",
991        ] {
992            assert!(is_credential_env(scrubbed), "{scrubbed} must be scrubbed");
993        }
994        for kept in [
995            "PATH",
996            "HOME",
997            "CARGO_HOME",
998            "GOPROXY",
999            "GOPRIVATE",
1000            "GOAUTH",
1001            "SSH_AUTH_SOCK",
1002            "SSL_CERT_FILE",
1003            "VIRTUAL_ENV",
1004            "TOKENIZERS_PARALLELISM",
1005        ] {
1006            assert!(!is_credential_env(kept), "{kept} must be kept");
1007        }
1008    }
1009}