Skip to main content

codineer_lsp/
lib.rs

1//! Language Server Protocol client for workspace diagnostics and symbols.
2
3mod client;
4mod error;
5mod manager;
6mod types;
7
8pub use error::LspError;
9pub use lsp_types::{DiagnosticSeverity, Position};
10pub use manager::LspManager;
11pub use types::{
12    diagnostic_severity_label, CompletionItem, DocumentSymbolInfo, FileDiagnostics, HoverResult,
13    LspContextEnrichment, LspServerConfig, LspTextEdit, SymbolLocation, WorkspaceDiagnostics,
14};
15
16#[cfg(test)]
17mod tests {
18    use std::collections::BTreeMap;
19    use std::fs;
20    use std::path::PathBuf;
21    use std::process::Command;
22    use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24    use lsp_types::{DiagnosticSeverity, Position};
25
26    use crate::{LspManager, LspServerConfig};
27
28    fn temp_dir(label: &str) -> PathBuf {
29        let nanos = SystemTime::now()
30            .duration_since(UNIX_EPOCH)
31            .expect("time should be after epoch")
32            .as_nanos();
33        std::env::temp_dir().join(format!("lsp-{label}-{nanos}"))
34    }
35
36    fn python3_path() -> Option<String> {
37        let candidates = ["python3", "/usr/bin/python3"];
38        candidates.iter().find_map(|candidate| {
39            Command::new(candidate)
40                .arg("--version")
41                .output()
42                .ok()
43                .filter(|output| output.status.success())
44                .map(|_| (*candidate).to_string())
45        })
46    }
47
48    #[allow(clippy::too_many_lines)]
49    fn write_mock_server_script(root: &std::path::Path) -> PathBuf {
50        let script_path = root.join("mock_lsp_server.py");
51        fs::write(
52            &script_path,
53            r#"import json
54import sys
55
56
57def read_message():
58    headers = {}
59    while True:
60        line = sys.stdin.buffer.readline()
61        if not line:
62            return None
63        if line == b"\r\n":
64            break
65        key, value = line.decode("utf-8").split(":", 1)
66        headers[key.lower()] = value.strip()
67    length = int(headers["content-length"])
68    body = sys.stdin.buffer.read(length)
69    return json.loads(body)
70
71
72def write_message(payload):
73    raw = json.dumps(payload).encode("utf-8")
74    sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8"))
75    sys.stdout.buffer.write(raw)
76    sys.stdout.buffer.flush()
77
78
79opened_uri = None
80
81while True:
82    message = read_message()
83    if message is None:
84        break
85
86    method = message.get("method")
87    if method == "initialize":
88        write_message({
89            "jsonrpc": "2.0",
90            "id": message["id"],
91            "result": {
92                "capabilities": {
93                    "definitionProvider": True,
94                    "referencesProvider": True,
95                    "hoverProvider": True,
96                    "completionProvider": {"triggerCharacters": ["."]},
97                    "documentSymbolProvider": True,
98                    "workspaceSymbolProvider": True,
99                    "renameProvider": True,
100                    "documentFormattingProvider": True,
101                    "textDocumentSync": 1,
102                }
103            },
104        })
105    elif method == "initialized":
106        continue
107    elif method == "textDocument/didOpen":
108        document = message["params"]["textDocument"]
109        opened_uri = document["uri"]
110        write_message({
111            "jsonrpc": "2.0",
112            "method": "textDocument/publishDiagnostics",
113            "params": {
114                "uri": document["uri"],
115                "diagnostics": [
116                    {
117                        "range": {
118                            "start": {"line": 0, "character": 0},
119                            "end": {"line": 0, "character": 3},
120                        },
121                        "severity": 1,
122                        "source": "mock-server",
123                        "message": "mock error",
124                    }
125                ],
126            },
127        })
128    elif method == "textDocument/didChange":
129        continue
130    elif method == "textDocument/didSave":
131        continue
132    elif method == "textDocument/definition":
133        uri = message["params"]["textDocument"]["uri"]
134        write_message({
135            "jsonrpc": "2.0",
136            "id": message["id"],
137            "result": [
138                {
139                    "uri": uri,
140                    "range": {
141                        "start": {"line": 0, "character": 0},
142                        "end": {"line": 0, "character": 3},
143                    },
144                }
145            ],
146        })
147    elif method == "textDocument/references":
148        uri = message["params"]["textDocument"]["uri"]
149        write_message({
150            "jsonrpc": "2.0",
151            "id": message["id"],
152            "result": [
153                {
154                    "uri": uri,
155                    "range": {
156                        "start": {"line": 0, "character": 0},
157                        "end": {"line": 0, "character": 3},
158                    },
159                },
160                {
161                    "uri": uri,
162                    "range": {
163                        "start": {"line": 1, "character": 4},
164                        "end": {"line": 1, "character": 7},
165                    },
166                },
167            ],
168        })
169    elif method == "textDocument/hover":
170        write_message({
171            "jsonrpc": "2.0",
172            "id": message["id"],
173            "result": {
174                "contents": {
175                    "kind": "markdown",
176                    "value": "**mock hover** documentation",
177                },
178                "range": {
179                    "start": {"line": 0, "character": 0},
180                    "end": {"line": 0, "character": 3},
181                },
182            },
183        })
184    elif method == "textDocument/completion":
185        write_message({
186            "jsonrpc": "2.0",
187            "id": message["id"],
188            "result": [
189                {"label": "foo", "kind": 3},
190                {"label": "bar", "kind": 6, "detail": "i32"},
191            ],
192        })
193    elif method == "textDocument/documentSymbol":
194        uri = message["params"]["textDocument"]["uri"]
195        write_message({
196            "jsonrpc": "2.0",
197            "id": message["id"],
198            "result": [
199                {
200                    "name": "main",
201                    "kind": 12,
202                    "location": {
203                        "uri": uri,
204                        "range": {
205                            "start": {"line": 0, "character": 0},
206                            "end": {"line": 0, "character": 12},
207                        },
208                    },
209                }
210            ],
211        })
212    elif method == "workspace/symbol":
213        ws_uri = opened_uri if opened_uri else "file:///mock/symbol.rs"
214        write_message({
215            "jsonrpc": "2.0",
216            "id": message["id"],
217            "result": [
218                {
219                    "name": "MockSymbol",
220                    "kind": 5,
221                    "location": {
222                        "uri": ws_uri,
223                        "range": {
224                            "start": {"line": 2, "character": 0},
225                            "end": {"line": 2, "character": 10},
226                        },
227                    },
228                }
229            ],
230        })
231    elif method == "textDocument/rename":
232        uri = message["params"]["textDocument"]["uri"]
233        new_name = message["params"]["newName"]
234        write_message({
235            "jsonrpc": "2.0",
236            "id": message["id"],
237            "result": {
238                "changes": {
239                    uri: [
240                        {
241                            "range": {
242                                "start": {"line": 0, "character": 0},
243                                "end": {"line": 0, "character": 3},
244                            },
245                            "newText": new_name,
246                        }
247                    ]
248                }
249            },
250        })
251    elif method == "textDocument/formatting":
252        uri = message["params"]["textDocument"]["uri"]
253        write_message({
254            "jsonrpc": "2.0",
255            "id": message["id"],
256            "result": [
257                {
258                    "range": {
259                        "start": {"line": 0, "character": 0},
260                        "end": {"line": 0, "character": 0},
261                    },
262                    "newText": "// formatted\n",
263                }
264            ],
265        })
266    elif method == "shutdown":
267        write_message({"jsonrpc": "2.0", "id": message["id"], "result": None})
268    elif method == "exit":
269        break
270"#,
271        )
272        .expect("mock server should be written");
273        script_path
274    }
275
276    async fn wait_for_diagnostics(manager: &LspManager) {
277        tokio::time::timeout(Duration::from_secs(2), async {
278            loop {
279                if manager
280                    .collect_workspace_diagnostics()
281                    .await
282                    .expect("diagnostics snapshot should load")
283                    .total_diagnostics()
284                    > 0
285                {
286                    break;
287                }
288                tokio::time::sleep(Duration::from_millis(10)).await;
289            }
290        })
291        .await
292        .expect("diagnostics should arrive from mock server");
293    }
294
295    fn make_manager(python: String, root: &std::path::Path, script_path: PathBuf) -> LspManager {
296        LspManager::new(vec![LspServerConfig {
297            name: "rust-analyzer".to_string(),
298            command: python,
299            args: vec![script_path.display().to_string()],
300            env: BTreeMap::new(),
301            workspace_root: root.to_path_buf(),
302            initialization_options: None,
303            extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
304        }])
305        .expect("manager should build")
306    }
307
308    #[tokio::test(flavor = "current_thread")]
309    async fn collects_diagnostics_and_symbol_navigation_from_mock_server() {
310        let Some(python) = python3_path() else {
311            return;
312        };
313
314        let root = temp_dir("manager");
315        fs::create_dir_all(root.join("src")).expect("workspace root should exist");
316        let script_path = write_mock_server_script(&root);
317        let source_path = root.join("src").join("main.rs");
318        fs::write(&source_path, "fn main() {}\nlet value = 1;\n")
319            .expect("source file should exist");
320        let manager = make_manager(python, &root, script_path);
321        manager
322            .open_document(
323                &source_path,
324                &fs::read_to_string(&source_path).expect("source read should succeed"),
325            )
326            .await
327            .expect("document should open");
328        wait_for_diagnostics(&manager).await;
329
330        let diagnostics = manager
331            .collect_workspace_diagnostics()
332            .await
333            .expect("diagnostics should be available");
334        let definitions = manager
335            .go_to_definition(&source_path, Position::new(0, 0))
336            .await
337            .expect("definition request should succeed");
338        let references = manager
339            .find_references(&source_path, Position::new(0, 0), true)
340            .await
341            .expect("references request should succeed");
342
343        assert_eq!(diagnostics.files.len(), 1);
344        assert_eq!(diagnostics.total_diagnostics(), 1);
345        assert_eq!(
346            diagnostics.files[0].diagnostics[0].severity,
347            Some(DiagnosticSeverity::ERROR)
348        );
349        assert_eq!(definitions.len(), 1);
350        assert_eq!(definitions[0].start_line(), 1);
351        assert_eq!(references.len(), 2);
352
353        manager.shutdown().await.expect("shutdown should succeed");
354        fs::remove_dir_all(root).expect("temp workspace should be removed");
355    }
356
357    #[tokio::test(flavor = "current_thread")]
358    async fn hover_completion_symbols_rename_formatting_from_mock_server() {
359        let Some(python) = python3_path() else {
360            return;
361        };
362
363        let root = temp_dir("new-methods");
364        fs::create_dir_all(root.join("src")).expect("workspace root should exist");
365        let script_path = write_mock_server_script(&root);
366        let source_path = root.join("src").join("lib.rs");
367        fs::write(&source_path, "fn main() {}\n").expect("source file should exist");
368        let manager = make_manager(python, &root, script_path);
369        manager
370            .open_document(&source_path, "fn main() {}\n")
371            .await
372            .expect("document should open");
373        wait_for_diagnostics(&manager).await;
374
375        // hover
376        let hover = manager
377            .hover(&source_path, Position::new(0, 0))
378            .await
379            .expect("hover should succeed");
380        assert!(hover.is_some());
381        assert!(hover.unwrap().contents.contains("mock hover"));
382
383        // completion
384        let items = manager
385            .completion(&source_path, Position::new(0, 0))
386            .await
387            .expect("completion should succeed");
388        assert_eq!(items.len(), 2);
389        assert_eq!(items[0].label, "foo");
390        assert_eq!(items[0].kind.as_deref(), Some("Function"));
391        assert_eq!(items[1].label, "bar");
392        assert_eq!(items[1].kind.as_deref(), Some("Variable"));
393
394        // document symbols
395        let symbols = manager
396            .document_symbols(&source_path)
397            .await
398            .expect("document symbols should succeed");
399        assert_eq!(symbols.len(), 1);
400        assert_eq!(symbols[0].name, "main");
401        assert_eq!(symbols[0].kind, "Function");
402
403        // workspace symbols
404        let ws_symbols = manager
405            .workspace_symbols("Mock")
406            .await
407            .expect("workspace symbols should succeed");
408        assert_eq!(ws_symbols.len(), 1);
409        assert_eq!(ws_symbols[0].start_line(), 3);
410
411        // rename
412        let edits = manager
413            .rename(&source_path, Position::new(0, 0), "new_main")
414            .await
415            .expect("rename should succeed");
416        assert!(!edits.is_empty());
417        let (_, file_edits) = edits.into_iter().next().unwrap();
418        assert_eq!(file_edits[0].new_text, "new_main");
419
420        // formatting
421        let fmt_edits = manager
422            .formatting(&source_path, 4, true)
423            .await
424            .expect("formatting should succeed");
425        assert_eq!(fmt_edits.len(), 1);
426        assert!(fmt_edits[0].new_text.contains("formatted"));
427
428        manager.shutdown().await.expect("shutdown should succeed");
429        fs::remove_dir_all(root).expect("temp workspace should be removed");
430    }
431
432    #[tokio::test(flavor = "current_thread")]
433    async fn renders_runtime_context_enrichment_for_prompt_usage() {
434        let Some(python) = python3_path() else {
435            return;
436        };
437
438        let root = temp_dir("prompt");
439        fs::create_dir_all(root.join("src")).expect("workspace root should exist");
440        let script_path = write_mock_server_script(&root);
441        let source_path = root.join("src").join("lib.rs");
442        fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n")
443            .expect("source file should exist");
444        let manager = make_manager(python, &root, script_path);
445        manager
446            .open_document(
447                &source_path,
448                &fs::read_to_string(&source_path).expect("source read should succeed"),
449            )
450            .await
451            .expect("document should open");
452        wait_for_diagnostics(&manager).await;
453
454        let enrichment = manager
455            .context_enrichment(&source_path, Position::new(0, 0))
456            .await
457            .expect("context enrichment should succeed");
458        let rendered = enrichment.render_prompt_section();
459
460        assert!(rendered.contains("# LSP context"));
461        assert!(rendered.contains("Workspace diagnostics: 1 across 1 file(s)"));
462        assert!(rendered.contains("Definitions:"));
463        assert!(rendered.contains("References:"));
464        assert!(rendered.contains("mock error"));
465
466        manager.shutdown().await.expect("shutdown should succeed");
467        fs::remove_dir_all(root).expect("temp workspace should be removed");
468    }
469
470    #[tokio::test(flavor = "current_thread")]
471    async fn from_json_config_builds_manager() {
472        let config_json = serde_json::json!([
473            {
474                "name": "rust-analyzer",
475                "command": "echo",
476                "args": [],
477                "env": {},
478                "workspace_root": "/workspace",
479                "initialization_options": null,
480                "extension_to_language": {".rs": "rust"},
481            }
482        ]);
483        let manager =
484            LspManager::from_json_config(&config_json).expect("manager should build from JSON");
485        assert!(manager.supports_path(std::path::Path::new("main.rs")));
486        assert!(!manager.supports_path(std::path::Path::new("main.py")));
487    }
488}