1mod 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 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 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 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 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 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 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}