Skip to main content

rustant_tools/lsp/
client.rs

1//! LSP client implementation for communicating with language server processes.
2//!
3//! This module provides an async LSP client that speaks JSON-RPC 2.0 over
4//! Content-Length framed stdin/stdout transport. It can start a language server
5//! process, send requests and notifications, and handle server-initiated
6//! notifications such as `textDocument/publishDiagnostics`.
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10
11use serde_json::json;
12use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
13use tokio::process::{Child, ChildStdin, ChildStdout, Command};
14use tracing;
15
16use super::types::{
17    CompletionItem, CompletionResponse, Diagnostic, DocumentFormattingParams, FormattingOptions,
18    HoverResult, Location, Position, ReferenceContext, ReferenceParams, RenameParams,
19    TextDocumentIdentifier, TextDocumentItem, TextEdit, WorkspaceEdit,
20};
21
22// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Errors that can arise from LSP client operations.
27#[derive(Debug, thiserror::Error)]
28pub enum LspError {
29    #[error("Server not running: {language}")]
30    ServerNotRunning { language: String },
31
32    #[error("Server failed to start: {message}")]
33    ServerStartFailed { message: String },
34
35    #[error("Request timed out after {timeout_secs}s")]
36    Timeout { timeout_secs: u64 },
37
38    #[error("Protocol error: {message}")]
39    ProtocolError { message: String },
40
41    #[error("IO error: {0}")]
42    Io(#[from] std::io::Error),
43
44    #[error("JSON error: {0}")]
45    Json(#[from] serde_json::Error),
46
47    #[error("Server returned error: code={code}, message={message}")]
48    ServerError { code: i64, message: String },
49
50    #[error("File not found: {path}")]
51    FileNotFound { path: String },
52
53    #[error("Language not supported: {language}")]
54    UnsupportedLanguage { language: String },
55}
56
57// ---------------------------------------------------------------------------
58// LspClient
59// ---------------------------------------------------------------------------
60
61/// An asynchronous client for a single language-server process.
62///
63/// Communication uses JSON-RPC 2.0 messages framed with `Content-Length`
64/// headers on both stdin (requests/notifications we send) and stdout
65/// (responses/notifications the server sends).
66pub struct LspClient {
67    process: Child,
68    stdin: BufWriter<ChildStdin>,
69    stdout: BufReader<ChildStdout>,
70    next_id: i64,
71    initialized: bool,
72    /// URIs of documents that have been opened via `textDocument/didOpen`.
73    open_documents: HashSet<String>,
74    /// Diagnostics received from `textDocument/publishDiagnostics` notifications,
75    /// keyed by document URI.
76    cached_diagnostics: HashMap<String, Vec<Diagnostic>>,
77    root_uri: String,
78}
79
80impl LspClient {
81    // ------------------------------------------------------------------
82    // Lifecycle
83    // ------------------------------------------------------------------
84
85    /// Start a language server process and perform the LSP handshake.
86    ///
87    /// `command` is the server binary (e.g. `rust-analyzer`), `args` are any
88    /// extra CLI arguments, and `workspace` is the root directory that will
89    /// be communicated to the server as `rootUri`.
90    pub async fn start(command: &str, args: &[String], workspace: &Path) -> Result<Self, LspError> {
91        let mut child = Command::new(command)
92            .args(args)
93            .stdin(std::process::Stdio::piped())
94            .stdout(std::process::Stdio::piped())
95            .stderr(std::process::Stdio::inherit())
96            .current_dir(workspace)
97            .spawn()
98            .map_err(|e| LspError::ServerStartFailed {
99                message: format!("Failed to spawn `{command}`: {e}"),
100            })?;
101
102        let child_stdin = child
103            .stdin
104            .take()
105            .ok_or_else(|| LspError::ServerStartFailed {
106                message: "Could not capture stdin of child process".into(),
107            })?;
108        let child_stdout = child
109            .stdout
110            .take()
111            .ok_or_else(|| LspError::ServerStartFailed {
112                message: "Could not capture stdout of child process".into(),
113            })?;
114
115        let canonical =
116            std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
117        let root_uri = format!("file://{}", canonical.display());
118
119        let mut client = Self {
120            process: child,
121            stdin: BufWriter::new(child_stdin),
122            stdout: BufReader::new(child_stdout),
123            next_id: 0,
124            initialized: false,
125            open_documents: HashSet::new(),
126            cached_diagnostics: HashMap::new(),
127            root_uri,
128        };
129
130        client.initialize().await?;
131
132        Ok(client)
133    }
134
135    /// Perform the LSP `initialize` / `initialized` handshake.
136    async fn initialize(&mut self) -> Result<(), LspError> {
137        let params = json!({
138            "processId": std::process::id(),
139            "rootUri": self.root_uri,
140            "capabilities": {
141                "textDocument": {
142                    "hover": {
143                        "contentFormat": ["plaintext", "markdown"]
144                    },
145                    "completion": {
146                        "completionItem": {
147                            "snippetSupport": false
148                        }
149                    },
150                    "definition": {},
151                    "references": {},
152                    "rename": {
153                        "prepareSupport": false
154                    },
155                    "formatting": {},
156                    "publishDiagnostics": {
157                        "relatedInformation": true
158                    }
159                },
160                "workspace": {
161                    "applyEdit": true,
162                    "workspaceFolders": false
163                }
164            }
165        });
166
167        let _response = self.send_request("initialize", params).await?;
168        self.send_notification("initialized", json!({})).await?;
169        self.initialized = true;
170        tracing::info!(root_uri = %self.root_uri, "LSP server initialized");
171        Ok(())
172    }
173
174    // ------------------------------------------------------------------
175    // Low-level transport
176    // ------------------------------------------------------------------
177
178    /// Send a JSON-RPC request and wait for the matching response.
179    ///
180    /// Notifications received while waiting for the response are processed
181    /// and buffered (e.g. diagnostics) rather than returned.
182    pub async fn send_request(
183        &mut self,
184        method: &str,
185        params: serde_json::Value,
186    ) -> Result<serde_json::Value, LspError> {
187        self.next_id += 1;
188        let id = self.next_id;
189
190        let request = json!({
191            "jsonrpc": "2.0",
192            "id": id,
193            "method": method,
194            "params": params,
195        });
196
197        self.write_message(&request).await?;
198        tracing::debug!(id, method, "Sent LSP request");
199
200        // Read messages until we get the response matching our id.
201        loop {
202            let msg = self.read_message().await?;
203
204            // If the message has no `id` it is a notification.
205            if msg.get("id").is_none() {
206                self.handle_notification(&msg);
207                continue;
208            }
209
210            // Check that the id matches.
211            let resp_id = msg["id"].as_i64().unwrap_or(-1);
212            if resp_id != id {
213                // Could be a stale response or a server-initiated request;
214                // log and continue.
215                tracing::warn!(
216                    expected_id = id,
217                    received_id = resp_id,
218                    "Received response with unexpected id, skipping"
219                );
220                continue;
221            }
222
223            // Check for error.
224            if let Some(err) = msg.get("error") {
225                let code = err["code"].as_i64().unwrap_or(0);
226                let message = err["message"]
227                    .as_str()
228                    .unwrap_or("unknown error")
229                    .to_string();
230                return Err(LspError::ServerError { code, message });
231            }
232
233            // Return the result (may be null).
234            return Ok(msg
235                .get("result")
236                .cloned()
237                .unwrap_or(serde_json::Value::Null));
238        }
239    }
240
241    /// Send a JSON-RPC notification (no response expected).
242    pub async fn send_notification(
243        &mut self,
244        method: &str,
245        params: serde_json::Value,
246    ) -> Result<(), LspError> {
247        let notification = json!({
248            "jsonrpc": "2.0",
249            "method": method,
250            "params": params,
251        });
252
253        self.write_message(&notification).await?;
254        tracing::debug!(method, "Sent LSP notification");
255        Ok(())
256    }
257
258    /// Read a single Content-Length-framed JSON-RPC message from the server.
259    pub async fn read_message(&mut self) -> Result<serde_json::Value, LspError> {
260        let mut content_length: usize = 0;
261
262        // Read headers until we hit the blank line.
263        loop {
264            let mut line = String::new();
265            let bytes_read = self.stdout.read_line(&mut line).await?;
266            if bytes_read == 0 {
267                return Err(LspError::ProtocolError {
268                    message: "Unexpected EOF while reading headers".into(),
269                });
270            }
271            let trimmed = line.trim();
272            if trimmed.is_empty() {
273                break;
274            }
275            if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
276                content_length =
277                    len_str
278                        .trim()
279                        .parse::<usize>()
280                        .map_err(|_| LspError::ProtocolError {
281                            message: format!("Invalid Content-Length value: {len_str}"),
282                        })?;
283            }
284            // Ignore other headers (e.g. Content-Type).
285        }
286
287        if content_length == 0 {
288            return Err(LspError::ProtocolError {
289                message: "Missing or zero Content-Length header".into(),
290            });
291        }
292
293        let mut body = vec![0u8; content_length];
294        self.stdout.read_exact(&mut body).await?;
295
296        let message: serde_json::Value = serde_json::from_slice(&body)?;
297        Ok(message)
298    }
299
300    /// Write a serialized JSON-RPC message with Content-Length framing.
301    async fn write_message(&mut self, message: &serde_json::Value) -> Result<(), LspError> {
302        let body = serde_json::to_string(message)?;
303        let header = format!("Content-Length: {}\r\n\r\n", body.len());
304        self.stdin.write_all(header.as_bytes()).await?;
305        self.stdin.write_all(body.as_bytes()).await?;
306        self.stdin.flush().await?;
307        Ok(())
308    }
309
310    /// Process a server-initiated notification.
311    fn handle_notification(&mut self, msg: &serde_json::Value) {
312        let method = match msg.get("method").and_then(|m| m.as_str()) {
313            Some(m) => m,
314            None => return,
315        };
316
317        match method {
318            "textDocument/publishDiagnostics" => {
319                if let Some(params) = msg.get("params")
320                    && let Ok(diag_params) =
321                        serde_json::from_value::<PublishDiagnosticsNotification>(params.clone())
322                {
323                    tracing::debug!(
324                        uri = %diag_params.uri,
325                        count = diag_params.diagnostics.len(),
326                        "Received diagnostics"
327                    );
328                    self.cached_diagnostics
329                        .insert(diag_params.uri, diag_params.diagnostics);
330                }
331            }
332            other => {
333                tracing::debug!(method = other, "Received unhandled notification");
334            }
335        }
336    }
337
338    // ------------------------------------------------------------------
339    // Document management
340    // ------------------------------------------------------------------
341
342    /// Ensure a file is opened in the language server.
343    ///
344    /// If the file has not yet been opened, the client reads it from disk and
345    /// sends a `textDocument/didOpen` notification. Returns the `file://` URI
346    /// for the file.
347    pub async fn ensure_document_open(&mut self, file_path: &Path) -> Result<String, LspError> {
348        let uri = file_path_to_uri(file_path)?;
349
350        if self.open_documents.contains(&uri) {
351            return Ok(uri);
352        }
353
354        let content =
355            tokio::fs::read_to_string(file_path)
356                .await
357                .map_err(|_| LspError::FileNotFound {
358                    path: file_path.display().to_string(),
359                })?;
360
361        let language_id = detect_language_id(file_path);
362
363        let text_doc = TextDocumentItem {
364            uri: uri.clone(),
365            language_id,
366            version: 1,
367            text: content,
368        };
369
370        self.send_notification(
371            "textDocument/didOpen",
372            serde_json::to_value(json!({
373                "textDocument": text_doc
374            }))?,
375        )
376        .await?;
377
378        self.open_documents.insert(uri.clone());
379        Ok(uri)
380    }
381
382    // ------------------------------------------------------------------
383    // High-level LSP operations
384    // ------------------------------------------------------------------
385
386    /// Perform a `textDocument/hover` request.
387    pub async fn hover(
388        &mut self,
389        file: &Path,
390        line: u32,
391        character: u32,
392    ) -> Result<Option<HoverResult>, LspError> {
393        let uri = self.ensure_document_open(file).await?;
394
395        let params = make_text_document_position_params(&uri, line, character);
396        let result = self.send_request("textDocument/hover", params).await?;
397
398        if result.is_null() {
399            return Ok(None);
400        }
401
402        let hover: HoverResult = serde_json::from_value(result)?;
403        Ok(Some(hover))
404    }
405
406    /// Perform a `textDocument/definition` request.
407    ///
408    /// The response may be a single `Location` or an array of `Location`s;
409    /// both forms are handled.
410    pub async fn definition(
411        &mut self,
412        file: &Path,
413        line: u32,
414        character: u32,
415    ) -> Result<Vec<Location>, LspError> {
416        let uri = self.ensure_document_open(file).await?;
417
418        let params = make_text_document_position_params(&uri, line, character);
419        let result = self.send_request("textDocument/definition", params).await?;
420
421        parse_location_response(result)
422    }
423
424    /// Perform a `textDocument/references` request.
425    pub async fn references(
426        &mut self,
427        file: &Path,
428        line: u32,
429        character: u32,
430    ) -> Result<Vec<Location>, LspError> {
431        let uri = self.ensure_document_open(file).await?;
432
433        let params = serde_json::to_value(ReferenceParams {
434            text_document: TextDocumentIdentifier { uri },
435            position: Position { line, character },
436            context: ReferenceContext {
437                include_declaration: true,
438            },
439        })?;
440
441        let result = self.send_request("textDocument/references", params).await?;
442
443        if result.is_null() {
444            return Ok(Vec::new());
445        }
446
447        let locations: Vec<Location> = serde_json::from_value(result)?;
448        Ok(locations)
449    }
450
451    /// Perform a `textDocument/completion` request.
452    ///
453    /// Handles both a plain array response and the `CompletionList` wrapper.
454    pub async fn completions(
455        &mut self,
456        file: &Path,
457        line: u32,
458        character: u32,
459    ) -> Result<Vec<CompletionItem>, LspError> {
460        let uri = self.ensure_document_open(file).await?;
461
462        let params = make_text_document_position_params(&uri, line, character);
463        let result = self.send_request("textDocument/completion", params).await?;
464
465        if result.is_null() {
466            return Ok(Vec::new());
467        }
468
469        // CompletionResponse is an untagged enum that handles both array
470        // and CompletionList forms.
471        let response: CompletionResponse = serde_json::from_value(result)?;
472        match response {
473            CompletionResponse::Array(items) => Ok(items),
474            CompletionResponse::List(list) => Ok(list.items),
475        }
476    }
477
478    /// Return cached diagnostics for the given file.
479    ///
480    /// Diagnostics are populated asynchronously by the server via
481    /// `textDocument/publishDiagnostics` notifications which are captured
482    /// every time a response is read. This method itself is synchronous
483    /// because it only reads from the in-memory cache.
484    pub fn diagnostics(&self, file: &Path) -> Result<Vec<Diagnostic>, LspError> {
485        let uri = file_path_to_uri(file)?;
486        Ok(self
487            .cached_diagnostics
488            .get(&uri)
489            .cloned()
490            .unwrap_or_default())
491    }
492
493    /// Perform a `textDocument/rename` request.
494    pub async fn rename(
495        &mut self,
496        file: &Path,
497        line: u32,
498        character: u32,
499        new_name: &str,
500    ) -> Result<WorkspaceEdit, LspError> {
501        let uri = self.ensure_document_open(file).await?;
502
503        let params = serde_json::to_value(RenameParams {
504            text_document: TextDocumentIdentifier { uri },
505            position: Position { line, character },
506            new_name: new_name.to_string(),
507        })?;
508
509        let result = self.send_request("textDocument/rename", params).await?;
510
511        if result.is_null() {
512            return Ok(WorkspaceEdit { changes: None });
513        }
514
515        let edit: WorkspaceEdit = serde_json::from_value(result)?;
516        Ok(edit)
517    }
518
519    /// Perform a `textDocument/formatting` request.
520    pub async fn format(&mut self, file: &Path) -> Result<Vec<TextEdit>, LspError> {
521        let uri = self.ensure_document_open(file).await?;
522
523        let params = serde_json::to_value(DocumentFormattingParams {
524            text_document: TextDocumentIdentifier { uri },
525            options: FormattingOptions {
526                tab_size: 4,
527                insert_spaces: true,
528            },
529        })?;
530
531        let result = self.send_request("textDocument/formatting", params).await?;
532
533        if result.is_null() {
534            return Ok(Vec::new());
535        }
536
537        let edits: Vec<TextEdit> = serde_json::from_value(result)?;
538        Ok(edits)
539    }
540
541    /// Gracefully shut down the language server.
542    ///
543    /// Sends `shutdown` followed by `exit`, then waits for the child process
544    /// to terminate.
545    pub async fn shutdown(&mut self) -> Result<(), LspError> {
546        tracing::info!("Shutting down LSP server");
547
548        // The shutdown request expects a null result.
549        let _ = self.send_request("shutdown", json!(null)).await;
550
551        // The exit notification tells the server to terminate.
552        let _ = self.send_notification("exit", json!(null)).await;
553
554        // Wait for the child process to finish.
555        let _ = self.process.wait().await;
556
557        self.initialized = false;
558        Ok(())
559    }
560
561    // ------------------------------------------------------------------
562    // Accessors (primarily useful for testing / inspection)
563    // ------------------------------------------------------------------
564
565    /// Returns the current request id counter value.
566    pub fn current_id(&self) -> i64 {
567        self.next_id
568    }
569
570    /// Returns whether the client has been initialized.
571    pub fn is_initialized(&self) -> bool {
572        self.initialized
573    }
574
575    /// Returns a reference to the set of currently-opened document URIs.
576    pub fn open_documents(&self) -> &HashSet<String> {
577        &self.open_documents
578    }
579
580    /// Returns a reference to the cached diagnostics map.
581    pub fn cached_diagnostics(&self) -> &HashMap<String, Vec<Diagnostic>> {
582        &self.cached_diagnostics
583    }
584
585    /// Returns the root URI communicated to the language server.
586    pub fn root_uri(&self) -> &str {
587        &self.root_uri
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Helper types
593// ---------------------------------------------------------------------------
594
595/// Minimal type for deserializing `textDocument/publishDiagnostics` params.
596#[derive(serde::Deserialize)]
597struct PublishDiagnosticsNotification {
598    uri: String,
599    diagnostics: Vec<Diagnostic>,
600}
601
602// ---------------------------------------------------------------------------
603// Free functions
604// ---------------------------------------------------------------------------
605
606/// Convert a filesystem path to a `file://` URI.
607///
608/// The path is canonicalized before conversion so that the URI is absolute and
609/// consistent.
610pub fn file_path_to_uri(path: &Path) -> Result<String, LspError> {
611    let canonical = std::fs::canonicalize(path).map_err(|_| LspError::FileNotFound {
612        path: path.display().to_string(),
613    })?;
614    Ok(format!("file://{}", canonical.display()))
615}
616
617/// Build `textDocument/hover`-style positional params as a `serde_json::Value`.
618fn make_text_document_position_params(uri: &str, line: u32, character: u32) -> serde_json::Value {
619    json!({
620        "textDocument": { "uri": uri },
621        "position": { "line": line, "character": character }
622    })
623}
624
625/// Parse a definition/declaration response that can be a single `Location`,
626/// an array of `Location`s, or `null`.
627fn parse_location_response(value: serde_json::Value) -> Result<Vec<Location>, LspError> {
628    if value.is_null() {
629        return Ok(Vec::new());
630    }
631
632    // Try as array first (most common).
633    if value.is_array() {
634        let locations: Vec<Location> = serde_json::from_value(value)?;
635        return Ok(locations);
636    }
637
638    // Single location.
639    let location: Location = serde_json::from_value(value)?;
640    Ok(vec![location])
641}
642
643/// Best-effort language identifier detection from file extension.
644fn detect_language_id(path: &Path) -> String {
645    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
646
647    match ext {
648        "rs" => "rust",
649        "py" | "pyi" => "python",
650        "js" | "mjs" | "cjs" => "javascript",
651        "ts" | "mts" | "cts" => "typescript",
652        "tsx" => "typescriptreact",
653        "jsx" => "javascriptreact",
654        "c" | "h" => "c",
655        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
656        "java" => "java",
657        "go" => "go",
658        "rb" => "ruby",
659        "php" => "php",
660        "cs" => "csharp",
661        "swift" => "swift",
662        "kt" | "kts" => "kotlin",
663        "lua" => "lua",
664        "sh" | "bash" | "zsh" => "shellscript",
665        "json" => "json",
666        "yaml" | "yml" => "yaml",
667        "toml" => "toml",
668        "xml" => "xml",
669        "html" | "htm" => "html",
670        "css" => "css",
671        "scss" => "scss",
672        "md" | "markdown" => "markdown",
673        "sql" => "sql",
674        "zig" => "zig",
675        "ex" | "exs" => "elixir",
676        "erl" | "hrl" => "erlang",
677        "hs" => "haskell",
678        "ml" | "mli" => "ocaml",
679        _ => "plaintext",
680    }
681    .to_string()
682}
683
684// ===========================================================================
685// Tests
686// ===========================================================================
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use std::io::Cursor;
692
693    // ------------------------------------------------------------------
694    // Helpers
695    // ------------------------------------------------------------------
696
697    /// Build a Content-Length framed message from a JSON value.
698    fn frame_message(value: &serde_json::Value) -> Vec<u8> {
699        let body = serde_json::to_string(value).unwrap();
700        let header = format!("Content-Length: {}\r\n\r\n", body.len());
701        let mut buf = Vec::new();
702        buf.extend_from_slice(header.as_bytes());
703        buf.extend_from_slice(body.as_bytes());
704        buf
705    }
706
707    /// Read a single Content-Length framed message from a byte slice using
708    /// the same algorithm as `LspClient::read_message`.
709    async fn read_framed_message(data: &[u8]) -> Result<serde_json::Value, LspError> {
710        let mut reader = tokio::io::BufReader::new(Cursor::new(data));
711        let mut content_length: usize = 0;
712
713        loop {
714            let mut line = String::new();
715            let bytes_read = tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line).await?;
716            if bytes_read == 0 {
717                return Err(LspError::ProtocolError {
718                    message: "Unexpected EOF while reading headers".into(),
719                });
720            }
721            let trimmed = line.trim();
722            if trimmed.is_empty() {
723                break;
724            }
725            if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
726                content_length =
727                    len_str
728                        .trim()
729                        .parse::<usize>()
730                        .map_err(|_| LspError::ProtocolError {
731                            message: format!("Invalid Content-Length value: {len_str}"),
732                        })?;
733            }
734        }
735
736        if content_length == 0 {
737            return Err(LspError::ProtocolError {
738                message: "Missing or zero Content-Length header".into(),
739            });
740        }
741
742        let mut body = vec![0u8; content_length];
743        tokio::io::AsyncReadExt::read_exact(&mut reader, &mut body).await?;
744        let msg: serde_json::Value = serde_json::from_slice(&body)?;
745        Ok(msg)
746    }
747
748    // ------------------------------------------------------------------
749    // Content-Length framing
750    // ------------------------------------------------------------------
751
752    #[tokio::test]
753    async fn test_content_length_framing() {
754        let original = json!({"jsonrpc": "2.0", "id": 1, "method": "test", "params": {}});
755        let framed = frame_message(&original);
756
757        // The framed data should start with the header.
758        let header_end = "Content-Length: ".len();
759        assert!(
760            std::str::from_utf8(&framed[..header_end])
761                .unwrap()
762                .starts_with("Content-Length: "),
763            "Frame should start with Content-Length header"
764        );
765
766        // We should be able to read the message back.
767        let decoded = read_framed_message(&framed).await.unwrap();
768        assert_eq!(decoded, original);
769    }
770
771    #[tokio::test]
772    async fn test_content_length_framing_with_unicode() {
773        let original = json!({"jsonrpc": "2.0", "id": 2, "method": "test", "params": {"text": "\u{1f600} hello"}});
774        let framed = frame_message(&original);
775        let decoded = read_framed_message(&framed).await.unwrap();
776        assert_eq!(decoded, original);
777    }
778
779    #[tokio::test]
780    async fn test_content_length_framing_multiple_messages() {
781        let msg1 = json!({"jsonrpc": "2.0", "id": 1, "result": "first"});
782        let msg2 = json!({"jsonrpc": "2.0", "id": 2, "result": "second"});
783
784        let mut data = frame_message(&msg1);
785        data.extend(frame_message(&msg2));
786
787        // Read first message.
788        let mut reader = tokio::io::BufReader::new(Cursor::new(data.clone()));
789        let mut content_length: usize = 0;
790
791        // Parse first message headers.
792        loop {
793            let mut line = String::new();
794            tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line)
795                .await
796                .unwrap();
797            let trimmed = line.trim();
798            if trimmed.is_empty() {
799                break;
800            }
801            if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
802                content_length = len_str.trim().parse().unwrap();
803            }
804        }
805        let mut body = vec![0u8; content_length];
806        tokio::io::AsyncReadExt::read_exact(&mut reader, &mut body)
807            .await
808            .unwrap();
809        let first: serde_json::Value = serde_json::from_slice(&body).unwrap();
810        assert_eq!(first["id"], 1);
811        assert_eq!(first["result"], "first");
812
813        // Parse second message headers.
814        content_length = 0;
815        loop {
816            let mut line = String::new();
817            tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line)
818                .await
819                .unwrap();
820            let trimmed = line.trim();
821            if trimmed.is_empty() {
822                break;
823            }
824            if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
825                content_length = len_str.trim().parse().unwrap();
826            }
827        }
828        let mut body2 = vec![0u8; content_length];
829        tokio::io::AsyncReadExt::read_exact(&mut reader, &mut body2)
830            .await
831            .unwrap();
832        let second: serde_json::Value = serde_json::from_slice(&body2).unwrap();
833        assert_eq!(second["id"], 2);
834        assert_eq!(second["result"], "second");
835    }
836
837    // ------------------------------------------------------------------
838    // Request ID incrementing
839    // ------------------------------------------------------------------
840
841    #[tokio::test]
842    async fn test_request_id_incrementing() {
843        // We cannot easily construct an LspClient without a real process, but
844        // we can verify the framing logic plus id assignment by building
845        // request JSON the same way the client does.
846        let mut next_id: i64 = 0;
847
848        let mut ids = Vec::new();
849        for _ in 0..5 {
850            next_id += 1;
851            ids.push(next_id);
852        }
853
854        assert_eq!(ids, vec![1, 2, 3, 4, 5]);
855
856        // Verify the request bodies have the right ids.
857        for (i, id) in ids.iter().enumerate() {
858            let request = json!({
859                "jsonrpc": "2.0",
860                "id": id,
861                "method": "test",
862                "params": {}
863            });
864            assert_eq!(request["id"], (i as i64) + 1);
865        }
866    }
867
868    // ------------------------------------------------------------------
869    // file:// URI conversion
870    // ------------------------------------------------------------------
871
872    #[tokio::test]
873    async fn test_file_to_uri_conversion() {
874        let tmp = tempfile::NamedTempFile::new().unwrap();
875        let path = tmp.path();
876
877        let uri = file_path_to_uri(path).unwrap();
878
879        assert!(uri.starts_with("file://"), "URI should start with file://");
880        // The URI should contain the canonical path (no relative components).
881        assert!(
882            !uri.contains(".."),
883            "URI should not contain relative components"
884        );
885        // The canonical path from the URI should resolve to the same file.
886        let canonical = std::fs::canonicalize(path).unwrap();
887        assert_eq!(uri, format!("file://{}", canonical.display()));
888    }
889
890    #[tokio::test]
891    async fn test_file_to_uri_nonexistent() {
892        let result = file_path_to_uri(Path::new("/tmp/absolutely_nonexistent_file_xyz.rs"));
893        assert!(result.is_err());
894        match result.unwrap_err() {
895            LspError::FileNotFound { path } => {
896                assert!(path.contains("absolutely_nonexistent_file_xyz.rs"));
897            }
898            other => panic!("Expected FileNotFound, got: {other:?}"),
899        }
900    }
901
902    // ------------------------------------------------------------------
903    // Hover response parsing
904    // ------------------------------------------------------------------
905
906    #[tokio::test]
907    async fn test_hover_response_parsing_with_contents() {
908        // A typical hover response with a string contents field.
909        let response = json!({
910            "contents": "fn main()",
911            "range": {
912                "start": {"line": 0, "character": 3},
913                "end": {"line": 0, "character": 7}
914            }
915        });
916
917        let hover: Result<HoverResult, _> = serde_json::from_value(response);
918        assert!(hover.is_ok(), "Should parse hover with string contents");
919    }
920
921    #[tokio::test]
922    async fn test_hover_response_parsing_null() {
923        let value = serde_json::Value::Null;
924        assert!(value.is_null(), "Null response should indicate no hover");
925    }
926
927    // ------------------------------------------------------------------
928    // Definition response parsing
929    // ------------------------------------------------------------------
930
931    #[tokio::test]
932    async fn test_definition_response_parsing_single() {
933        let response = json!({
934            "uri": "file:///src/main.rs",
935            "range": {
936                "start": {"line": 10, "character": 0},
937                "end": {"line": 10, "character": 5}
938            }
939        });
940
941        let locations = parse_location_response(response).unwrap();
942        assert_eq!(locations.len(), 1);
943        assert_eq!(locations[0].uri, "file:///src/main.rs");
944        assert_eq!(locations[0].range.start.line, 10);
945    }
946
947    #[tokio::test]
948    async fn test_definition_response_parsing_array() {
949        let response = json!([
950            {
951                "uri": "file:///src/lib.rs",
952                "range": {
953                    "start": {"line": 5, "character": 0},
954                    "end": {"line": 5, "character": 10}
955                }
956            },
957            {
958                "uri": "file:///src/util.rs",
959                "range": {
960                    "start": {"line": 20, "character": 4},
961                    "end": {"line": 20, "character": 15}
962                }
963            }
964        ]);
965
966        let locations = parse_location_response(response).unwrap();
967        assert_eq!(locations.len(), 2);
968        assert_eq!(locations[0].uri, "file:///src/lib.rs");
969        assert_eq!(locations[1].uri, "file:///src/util.rs");
970    }
971
972    #[tokio::test]
973    async fn test_definition_response_parsing_null() {
974        let locations = parse_location_response(serde_json::Value::Null).unwrap();
975        assert!(locations.is_empty());
976    }
977
978    // ------------------------------------------------------------------
979    // Completion response parsing
980    // ------------------------------------------------------------------
981
982    #[tokio::test]
983    async fn test_completion_response_parsing_array() {
984        let response = json!([
985            {"label": "foo", "kind": 6},
986            {"label": "bar", "kind": 3}
987        ]);
988
989        let items: Vec<CompletionItem> = serde_json::from_value(response).unwrap();
990        assert_eq!(items.len(), 2);
991        assert_eq!(items[0].label, "foo");
992        assert_eq!(items[1].label, "bar");
993    }
994
995    #[tokio::test]
996    async fn test_completion_response_parsing_completion_list() {
997        let response = json!({
998            "isIncomplete": false,
999            "items": [
1000                {"label": "println!", "kind": 3},
1001                {"label": "print!", "kind": 3}
1002            ]
1003        });
1004
1005        // CompletionResponse is an untagged enum that can decode both forms.
1006        let cr: CompletionResponse = serde_json::from_value(response).unwrap();
1007        match cr {
1008            CompletionResponse::List(list) => {
1009                assert_eq!(list.items.len(), 2);
1010                assert_eq!(list.items[0].label, "println!");
1011            }
1012            CompletionResponse::Array(items) => {
1013                // Should have matched as List, but either way verify items.
1014                assert_eq!(items.len(), 2);
1015            }
1016        }
1017    }
1018
1019    #[tokio::test]
1020    async fn test_completion_response_parsing_null() {
1021        let value = serde_json::Value::Null;
1022        assert!(value.is_null());
1023        // completions() would return Ok(Vec::new()) for null.
1024    }
1025
1026    // ------------------------------------------------------------------
1027    // Diagnostic caching
1028    // ------------------------------------------------------------------
1029
1030    #[tokio::test]
1031    async fn test_diagnostic_caching() {
1032        let mut diagnostics: HashMap<String, Vec<Diagnostic>> = HashMap::new();
1033        let uri = "file:///src/main.rs".to_string();
1034
1035        // Initially empty.
1036        assert!(!diagnostics.contains_key(&uri));
1037
1038        // Simulate receiving a publishDiagnostics notification.
1039        let notification_params = json!({
1040            "uri": "file:///src/main.rs",
1041            "diagnostics": [
1042                {
1043                    "range": {
1044                        "start": {"line": 3, "character": 0},
1045                        "end": {"line": 3, "character": 10}
1046                    },
1047                    "severity": 1,
1048                    "message": "unused variable"
1049                }
1050            ]
1051        });
1052
1053        let parsed: PublishDiagnosticsNotification =
1054            serde_json::from_value(notification_params).unwrap();
1055
1056        diagnostics.insert(parsed.uri.clone(), parsed.diagnostics);
1057
1058        let cached = diagnostics.get(&uri).unwrap();
1059        assert_eq!(cached.len(), 1);
1060        assert_eq!(cached[0].message, "unused variable");
1061
1062        // Simulate updated diagnostics (replaces previous).
1063        let updated_params = json!({
1064            "uri": "file:///src/main.rs",
1065            "diagnostics": [
1066                {
1067                    "range": {
1068                        "start": {"line": 3, "character": 0},
1069                        "end": {"line": 3, "character": 10}
1070                    },
1071                    "severity": 1,
1072                    "message": "unused variable"
1073                },
1074                {
1075                    "range": {
1076                        "start": {"line": 10, "character": 0},
1077                        "end": {"line": 10, "character": 5}
1078                    },
1079                    "severity": 2,
1080                    "message": "dead code"
1081                }
1082            ]
1083        });
1084
1085        let parsed2: PublishDiagnosticsNotification =
1086            serde_json::from_value(updated_params).unwrap();
1087        diagnostics.insert(parsed2.uri.clone(), parsed2.diagnostics);
1088
1089        let cached2 = diagnostics.get(&uri).unwrap();
1090        assert_eq!(cached2.len(), 2);
1091        assert_eq!(cached2[1].message, "dead code");
1092    }
1093
1094    // ------------------------------------------------------------------
1095    // LspError display messages
1096    // ------------------------------------------------------------------
1097
1098    #[test]
1099    fn test_lsp_error_display() {
1100        let err = LspError::ServerNotRunning {
1101            language: "rust".into(),
1102        };
1103        assert_eq!(err.to_string(), "Server not running: rust");
1104
1105        let err = LspError::ServerStartFailed {
1106            message: "binary not found".into(),
1107        };
1108        assert_eq!(err.to_string(), "Server failed to start: binary not found");
1109
1110        let err = LspError::Timeout { timeout_secs: 30 };
1111        assert_eq!(err.to_string(), "Request timed out after 30s");
1112
1113        let err = LspError::ProtocolError {
1114            message: "bad header".into(),
1115        };
1116        assert_eq!(err.to_string(), "Protocol error: bad header");
1117
1118        let err = LspError::ServerError {
1119            code: -32600,
1120            message: "Invalid request".into(),
1121        };
1122        assert_eq!(
1123            err.to_string(),
1124            "Server returned error: code=-32600, message=Invalid request"
1125        );
1126
1127        let err = LspError::FileNotFound {
1128            path: "/tmp/foo.rs".into(),
1129        };
1130        assert_eq!(err.to_string(), "File not found: /tmp/foo.rs");
1131
1132        let err = LspError::UnsupportedLanguage {
1133            language: "brainfuck".into(),
1134        };
1135        assert_eq!(err.to_string(), "Language not supported: brainfuck");
1136    }
1137
1138    // ------------------------------------------------------------------
1139    // Language detection helper
1140    // ------------------------------------------------------------------
1141
1142    #[test]
1143    fn test_detect_language_id() {
1144        assert_eq!(detect_language_id(Path::new("main.rs")), "rust");
1145        assert_eq!(detect_language_id(Path::new("app.py")), "python");
1146        assert_eq!(detect_language_id(Path::new("index.ts")), "typescript");
1147        assert_eq!(detect_language_id(Path::new("App.tsx")), "typescriptreact");
1148        assert_eq!(detect_language_id(Path::new("script.js")), "javascript");
1149        assert_eq!(detect_language_id(Path::new("main.go")), "go");
1150        assert_eq!(detect_language_id(Path::new("Main.java")), "java");
1151        assert_eq!(detect_language_id(Path::new("prog.cpp")), "cpp");
1152        assert_eq!(detect_language_id(Path::new("header.h")), "c");
1153        assert_eq!(detect_language_id(Path::new("unknown.xyz")), "plaintext");
1154        assert_eq!(detect_language_id(Path::new("no_extension")), "plaintext");
1155    }
1156
1157    // ------------------------------------------------------------------
1158    // Notification handling
1159    // ------------------------------------------------------------------
1160
1161    #[test]
1162    fn test_handle_notification_diagnostics() {
1163        // Verify that handle_notification correctly updates cached_diagnostics.
1164        // We cannot construct a full LspClient without a process, so we
1165        // directly test the deserialization path used by handle_notification.
1166        let notification = json!({
1167            "jsonrpc": "2.0",
1168            "method": "textDocument/publishDiagnostics",
1169            "params": {
1170                "uri": "file:///project/src/lib.rs",
1171                "diagnostics": [
1172                    {
1173                        "range": {
1174                            "start": {"line": 1, "character": 0},
1175                            "end": {"line": 1, "character": 5}
1176                        },
1177                        "severity": 1,
1178                        "message": "syntax error"
1179                    }
1180                ]
1181            }
1182        });
1183
1184        // Simulate what handle_notification does.
1185        let params = notification.get("params").unwrap();
1186        let parsed: PublishDiagnosticsNotification =
1187            serde_json::from_value(params.clone()).unwrap();
1188
1189        assert_eq!(parsed.uri, "file:///project/src/lib.rs");
1190        assert_eq!(parsed.diagnostics.len(), 1);
1191        assert_eq!(parsed.diagnostics[0].message, "syntax error");
1192    }
1193
1194    // ------------------------------------------------------------------
1195    // parse_location_response edge cases
1196    // ------------------------------------------------------------------
1197
1198    #[tokio::test]
1199    async fn test_parse_location_response_empty_array() {
1200        let response = json!([]);
1201        let locations = parse_location_response(response).unwrap();
1202        assert!(locations.is_empty());
1203    }
1204
1205    #[tokio::test]
1206    async fn test_parse_location_response_invalid() {
1207        let response = json!("not a location");
1208        let result = parse_location_response(response);
1209        assert!(result.is_err());
1210    }
1211}