Skip to main content

codineer_lsp/
lib.rs

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        // given
192        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        // when
218        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        // then
232        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        // given
253        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        // when
279        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        // then
286        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}