1mod client;
2mod error;
3mod manager;
4mod types;
5
6pub use error::LspError;
7pub use manager::LspManager;
8pub use types::{
9 FileDiagnostics, LspContextEnrichment, LspServerConfig, SymbolLocation, WorkspaceDiagnostics,
10};
11
12#[cfg(test)]
13mod tests {
14 use std::collections::BTreeMap;
15 use std::fs;
16 use std::path::PathBuf;
17 use std::process::Command;
18 use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20 use lsp_types::{DiagnosticSeverity, Position};
21
22 use crate::{LspManager, LspServerConfig};
23
24 fn temp_dir(label: &str) -> PathBuf {
25 let nanos = SystemTime::now()
26 .duration_since(UNIX_EPOCH)
27 .expect("time should be after epoch")
28 .as_nanos();
29 std::env::temp_dir().join(format!("lsp-{label}-{nanos}"))
30 }
31
32 fn python3_path() -> Option<String> {
33 let candidates = ["python3", "/usr/bin/python3"];
34 candidates.iter().find_map(|candidate| {
35 Command::new(candidate)
36 .arg("--version")
37 .output()
38 .ok()
39 .filter(|output| output.status.success())
40 .map(|_| (*candidate).to_string())
41 })
42 }
43
44 #[allow(clippy::too_many_lines)]
45 fn write_mock_server_script(root: &std::path::Path) -> PathBuf {
46 let script_path = root.join("mock_lsp_server.py");
47 fs::write(
48 &script_path,
49 r#"import json
50import sys
51
52
53def read_message():
54 headers = {}
55 while True:
56 line = sys.stdin.buffer.readline()
57 if not line:
58 return None
59 if line == b"\r\n":
60 break
61 key, value = line.decode("utf-8").split(":", 1)
62 headers[key.lower()] = value.strip()
63 length = int(headers["content-length"])
64 body = sys.stdin.buffer.read(length)
65 return json.loads(body)
66
67
68def write_message(payload):
69 raw = json.dumps(payload).encode("utf-8")
70 sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8"))
71 sys.stdout.buffer.write(raw)
72 sys.stdout.buffer.flush()
73
74
75while True:
76 message = read_message()
77 if message is None:
78 break
79
80 method = message.get("method")
81 if method == "initialize":
82 write_message({
83 "jsonrpc": "2.0",
84 "id": message["id"],
85 "result": {
86 "capabilities": {
87 "definitionProvider": True,
88 "referencesProvider": True,
89 "textDocumentSync": 1,
90 }
91 },
92 })
93 elif method == "initialized":
94 continue
95 elif method == "textDocument/didOpen":
96 document = message["params"]["textDocument"]
97 write_message({
98 "jsonrpc": "2.0",
99 "method": "textDocument/publishDiagnostics",
100 "params": {
101 "uri": document["uri"],
102 "diagnostics": [
103 {
104 "range": {
105 "start": {"line": 0, "character": 0},
106 "end": {"line": 0, "character": 3},
107 },
108 "severity": 1,
109 "source": "mock-server",
110 "message": "mock error",
111 }
112 ],
113 },
114 })
115 elif method == "textDocument/didChange":
116 continue
117 elif method == "textDocument/didSave":
118 continue
119 elif method == "textDocument/definition":
120 uri = message["params"]["textDocument"]["uri"]
121 write_message({
122 "jsonrpc": "2.0",
123 "id": message["id"],
124 "result": [
125 {
126 "uri": uri,
127 "range": {
128 "start": {"line": 0, "character": 0},
129 "end": {"line": 0, "character": 3},
130 },
131 }
132 ],
133 })
134 elif method == "textDocument/references":
135 uri = message["params"]["textDocument"]["uri"]
136 write_message({
137 "jsonrpc": "2.0",
138 "id": message["id"],
139 "result": [
140 {
141 "uri": uri,
142 "range": {
143 "start": {"line": 0, "character": 0},
144 "end": {"line": 0, "character": 3},
145 },
146 },
147 {
148 "uri": uri,
149 "range": {
150 "start": {"line": 1, "character": 4},
151 "end": {"line": 1, "character": 7},
152 },
153 },
154 ],
155 })
156 elif method == "shutdown":
157 write_message({"jsonrpc": "2.0", "id": message["id"], "result": None})
158 elif method == "exit":
159 break
160"#,
161 )
162 .expect("mock server should be written");
163 script_path
164 }
165
166 async fn wait_for_diagnostics(manager: &LspManager) {
167 tokio::time::timeout(Duration::from_secs(2), async {
168 loop {
169 if manager
170 .collect_workspace_diagnostics()
171 .await
172 .expect("diagnostics snapshot should load")
173 .total_diagnostics()
174 > 0
175 {
176 break;
177 }
178 tokio::time::sleep(Duration::from_millis(10)).await;
179 }
180 })
181 .await
182 .expect("diagnostics should arrive from mock server");
183 }
184
185 #[tokio::test(flavor = "current_thread")]
186 async fn collects_diagnostics_and_symbol_navigation_from_mock_server() {
187 let Some(python) = python3_path() else {
188 return;
189 };
190
191 let root = temp_dir("manager");
193 fs::create_dir_all(root.join("src")).expect("workspace root should exist");
194 let script_path = write_mock_server_script(&root);
195 let source_path = root.join("src").join("main.rs");
196 fs::write(&source_path, "fn main() {}\nlet value = 1;\n")
197 .expect("source file should exist");
198 let manager = LspManager::new(vec![LspServerConfig {
199 name: "rust-analyzer".to_string(),
200 command: python,
201 args: vec![script_path.display().to_string()],
202 env: BTreeMap::new(),
203 workspace_root: root.clone(),
204 initialization_options: None,
205 extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
206 }])
207 .expect("manager should build");
208 manager
209 .open_document(
210 &source_path,
211 &fs::read_to_string(&source_path).expect("source read should succeed"),
212 )
213 .await
214 .expect("document should open");
215 wait_for_diagnostics(&manager).await;
216
217 let diagnostics = manager
219 .collect_workspace_diagnostics()
220 .await
221 .expect("diagnostics should be available");
222 let definitions = manager
223 .go_to_definition(&source_path, Position::new(0, 0))
224 .await
225 .expect("definition request should succeed");
226 let references = manager
227 .find_references(&source_path, Position::new(0, 0), true)
228 .await
229 .expect("references request should succeed");
230
231 assert_eq!(diagnostics.files.len(), 1);
233 assert_eq!(diagnostics.total_diagnostics(), 1);
234 assert_eq!(
235 diagnostics.files[0].diagnostics[0].severity,
236 Some(DiagnosticSeverity::ERROR)
237 );
238 assert_eq!(definitions.len(), 1);
239 assert_eq!(definitions[0].start_line(), 1);
240 assert_eq!(references.len(), 2);
241
242 manager.shutdown().await.expect("shutdown should succeed");
243 fs::remove_dir_all(root).expect("temp workspace should be removed");
244 }
245
246 #[tokio::test(flavor = "current_thread")]
247 async fn renders_runtime_context_enrichment_for_prompt_usage() {
248 let Some(python) = python3_path() else {
249 return;
250 };
251
252 let root = temp_dir("prompt");
254 fs::create_dir_all(root.join("src")).expect("workspace root should exist");
255 let script_path = write_mock_server_script(&root);
256 let source_path = root.join("src").join("lib.rs");
257 fs::write(&source_path, "pub fn answer() -> i32 { 42 }\n")
258 .expect("source file should exist");
259 let manager = LspManager::new(vec![LspServerConfig {
260 name: "rust-analyzer".to_string(),
261 command: python,
262 args: vec![script_path.display().to_string()],
263 env: BTreeMap::new(),
264 workspace_root: root.clone(),
265 initialization_options: None,
266 extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
267 }])
268 .expect("manager should build");
269 manager
270 .open_document(
271 &source_path,
272 &fs::read_to_string(&source_path).expect("source read should succeed"),
273 )
274 .await
275 .expect("document should open");
276 wait_for_diagnostics(&manager).await;
277
278 let enrichment = manager
280 .context_enrichment(&source_path, Position::new(0, 0))
281 .await
282 .expect("context enrichment should succeed");
283 let rendered = enrichment.render_prompt_section();
284
285 assert!(rendered.contains("# LSP context"));
287 assert!(rendered.contains("Workspace diagnostics: 1 across 1 file(s)"));
288 assert!(rendered.contains("Definitions:"));
289 assert!(rendered.contains("References:"));
290 assert!(rendered.contains("mock error"));
291
292 manager.shutdown().await.expect("shutdown should succeed");
293 fs::remove_dir_all(root).expect("temp workspace should be removed");
294 }
295}