1use std::collections::HashMap;
75use std::path::{Path, PathBuf};
76use std::sync::Arc;
77use std::time::Duration;
78
79use serde_json::{json, Value};
80use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
81
82use crate::error::{Error, Result};
83
84pub const LSP_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
90
91pub const DEFAULT_LSP_MAX_DIAGNOSTICS: usize = 20;
95
96pub const DEFAULT_LSP_TIMEOUT_SECS: u64 = 5;
100
101#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct LspServerSpec {
108 pub command: String,
110 pub args: Vec<String>,
112 pub extensions: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
123struct DiagnosticEntry {
124 severity: &'static str,
125 line: u32,
126 character: u32,
127 message: String,
128}
129
130struct LspIo {
136 stdin: tokio::process::ChildStdin,
137 stdout: BufReader<tokio::process::ChildStdout>,
138 next_id: i64,
139 opened: HashMap<String, i64>,
142 initialized: bool,
144}
145
146#[derive(Debug)]
149pub struct LspClient {
150 name: String,
151 child: std::sync::Mutex<tokio::process::Child>,
157 io: tokio::sync::Mutex<LspIo>,
158}
159
160impl std::fmt::Debug for LspIo {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 f.debug_struct("LspIo")
163 .field("next_id", &self.next_id)
164 .field("opened", &self.opened.keys().collect::<Vec<_>>())
165 .field("initialized", &self.initialized)
166 .finish()
167 }
168}
169
170async fn write_message(stdin: &mut tokio::process::ChildStdin, val: &Value) -> Result<()> {
172 let body = serde_json::to_vec(val).map_err(|e| Error::tool("lsp", format!("encode: {e}")))?;
173 let header = format!("Content-Length: {}\r\n\r\n", body.len());
174 stdin
175 .write_all(header.as_bytes())
176 .await
177 .map_err(|e| Error::tool("lsp", format!("write: {e}")))?;
178 stdin
179 .write_all(&body)
180 .await
181 .map_err(|e| Error::tool("lsp", format!("write: {e}")))?;
182 stdin
183 .flush()
184 .await
185 .map_err(|e| Error::tool("lsp", format!("flush: {e}")))?;
186 Ok(())
187}
188
189async fn read_message(stdout: &mut BufReader<tokio::process::ChildStdout>) -> Result<Value> {
193 let mut content_length: Option<usize> = None;
194 loop {
195 let mut line = String::new();
196 let n = stdout
197 .read_line(&mut line)
198 .await
199 .map_err(|e| Error::tool("lsp", format!("read: {e}")))?;
200 if n == 0 {
201 return Err(Error::tool("lsp", "server closed stdout (eof)"));
202 }
203 let trimmed = line.trim_end_matches(['\r', '\n']);
204 if trimmed.is_empty() {
205 break; }
207 if let Some(v) = trimmed.strip_prefix("Content-Length:") {
208 content_length = v.trim().parse().ok();
209 }
210 }
212 let len = content_length
213 .ok_or_else(|| Error::tool("lsp", "message missing Content-Length header"))?;
214 if len > LSP_MAX_MESSAGE_BYTES {
215 return Err(Error::tool(
216 "lsp",
217 format!("message too large ({len} bytes) — refusing to buffer"),
218 ));
219 }
220 let mut buf = vec![0u8; len];
221 stdout
222 .read_exact(&mut buf)
223 .await
224 .map_err(|e| Error::tool("lsp", format!("read body: {e}")))?;
225 serde_json::from_slice(&buf).map_err(|e| Error::tool("lsp", format!("decode: {e}")))
226}
227
228fn path_to_uri(path: &Path) -> String {
235 let s = path.to_string_lossy().replace('\\', "/");
236 if let Some(stripped) = s.strip_prefix('/') {
237 format!("file:///{stripped}")
238 } else {
239 format!("file:///{s}")
240 }
241}
242
243fn language_id_for(path: &Path) -> &'static str {
248 match path
249 .extension()
250 .and_then(|e| e.to_str())
251 .unwrap_or_default()
252 .to_ascii_lowercase()
253 .as_str()
254 {
255 "rs" => "rust",
256 "py" => "python",
257 "js" | "mjs" | "cjs" => "javascript",
258 "jsx" => "javascriptreact",
259 "ts" | "mts" | "cts" => "typescript",
260 "tsx" => "typescriptreact",
261 "go" => "go",
262 "rb" => "ruby",
263 "java" => "java",
264 "c" | "h" => "c",
265 "cpp" | "cc" | "cxx" | "hpp" => "cpp",
266 "cs" => "csharp",
267 "json" => "json",
268 "toml" => "toml",
269 "yaml" | "yml" => "yaml",
270 "md" => "markdown",
271 "sh" | "bash" => "shellscript",
272 _ => "plaintext",
273 }
274}
275
276#[cfg(unix)]
289pub(crate) fn kill_process_group(pid: u32) {
290 unsafe {
291 libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
292 }
293}
294
295fn severity_label(sev: Option<i64>) -> &'static str {
296 match sev {
297 Some(1) => "error",
298 Some(2) => "warning",
299 Some(3) => "information",
300 Some(4) => "hint",
301 _ => "diagnostic",
302 }
303}
304
305fn is_publish_diagnostics_for(msg: &Value, uri: &str) -> bool {
310 msg.get("method").and_then(|m| m.as_str()) == Some("textDocument/publishDiagnostics")
311 && msg
312 .get("params")
313 .and_then(|p| p.get("uri"))
314 .and_then(|u| u.as_str())
315 == Some(uri)
316}
317
318fn diagnostics_from_message(msg: &Value, cap: usize) -> (usize, Vec<DiagnosticEntry>) {
324 let Some(arr) = msg
325 .get("params")
326 .and_then(|p| p.get("diagnostics"))
327 .and_then(|d| d.as_array())
328 else {
329 return (0, Vec::new());
330 };
331 let total = arr.len();
332 let entries = arr
333 .iter()
334 .take(cap)
335 .map(|d| DiagnosticEntry {
336 severity: severity_label(d.get("severity").and_then(|s| s.as_i64())),
337 line: d
338 .get("range")
339 .and_then(|r| r.get("start"))
340 .and_then(|s| s.get("line"))
341 .and_then(|v| v.as_u64())
342 .unwrap_or(0) as u32,
343 character: d
344 .get("range")
345 .and_then(|r| r.get("start"))
346 .and_then(|s| s.get("character"))
347 .and_then(|v| v.as_u64())
348 .unwrap_or(0) as u32,
349 message: d
350 .get("message")
351 .and_then(|m| m.as_str())
352 .unwrap_or_default()
353 .to_string(),
354 })
355 .collect();
356 (total, entries)
357}
358
359const MAX_DIAGNOSTIC_MESSAGE_CHARS: usize = 400;
363
364fn format_diagnostics(
365 server: &str,
366 path: &Path,
367 total: usize,
368 entries: &[DiagnosticEntry],
369) -> String {
370 let mut out = format!(
371 "LSP diagnostics ({server}) for {}: {total} issue(s)",
372 path.display()
373 );
374 for d in entries {
375 let mut msg = d.message.clone();
376 if msg.chars().count() > MAX_DIAGNOSTIC_MESSAGE_CHARS {
377 msg = msg.chars().take(MAX_DIAGNOSTIC_MESSAGE_CHARS).collect();
378 msg.push('\u{2026}');
379 }
380 out.push_str(&format!(
381 "\n {}:{}: {}: {}",
382 d.line + 1,
383 d.character + 1,
384 d.severity,
385 msg
386 ));
387 }
388 if total > entries.len() {
389 out.push_str(&format!(
390 "\n ... and {} more diagnostic(s) not shown",
391 total - entries.len()
392 ));
393 }
394 out
395}
396
397impl LspClient {
398 async fn spawn(name: &str, spec: &LspServerSpec) -> Result<Arc<LspClient>> {
412 let mut cmd = tokio::process::Command::new(&spec.command);
413 cmd.args(&spec.args)
414 .stdin(std::process::Stdio::piped())
415 .stdout(std::process::Stdio::piped())
416 .stderr(std::process::Stdio::null())
417 .kill_on_drop(true);
418 #[cfg(unix)]
419 cmd.process_group(0);
420 let mut child = cmd
421 .spawn()
422 .map_err(|e| Error::tool("lsp", format!("spawn {}: {e}", spec.command)))?;
423 let stdin = child
424 .stdin
425 .take()
426 .ok_or_else(|| Error::tool("lsp", "no stdin"))?;
427 let stdout = BufReader::new(
428 child
429 .stdout
430 .take()
431 .ok_or_else(|| Error::tool("lsp", "no stdout"))?,
432 );
433 Ok(Arc::new(LspClient {
434 name: name.to_string(),
435 child: std::sync::Mutex::new(child),
436 io: tokio::sync::Mutex::new(LspIo {
437 stdin,
438 stdout,
439 next_id: 0,
440 opened: HashMap::new(),
441 initialized: false,
442 }),
443 }))
444 }
445
446 async fn open_or_change_and_diagnose(
454 &self,
455 root: &Path,
456 uri: &str,
457 text: &str,
458 language_id: &str,
459 timeout: Duration,
460 cap: usize,
461 ) -> Result<(usize, Vec<DiagnosticEntry>)> {
462 let mut io = self.io.lock().await;
463
464 if !io.initialized {
465 let id = io.next_id;
466 io.next_id += 1;
467 let req = json!({
468 "jsonrpc": "2.0",
469 "id": id,
470 "method": "initialize",
471 "params": {
472 "processId": std::process::id(),
473 "rootUri": path_to_uri(root),
474 "capabilities": {},
475 }
476 });
477 write_message(&mut io.stdin, &req).await?;
478 let deadline = tokio::time::Instant::now() + timeout;
479 loop {
480 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
481 if remaining.is_zero() {
482 return Err(Error::tool(
483 "lsp",
484 "timed out waiting for initialize response",
485 ));
486 }
487 let msg = tokio::time::timeout(remaining, read_message(&mut io.stdout))
488 .await
489 .map_err(|_| {
490 Error::tool("lsp", "timed out waiting for initialize response")
491 })??;
492 if msg.get("id").and_then(|v| v.as_i64()) == Some(id) {
493 break; }
495 }
496 let notif = json!({"jsonrpc": "2.0", "method": "initialized", "params": {}});
497 write_message(&mut io.stdin, ¬if).await?;
498 io.initialized = true;
499 }
500
501 let msg = match io.opened.get(uri).copied() {
502 Some(version) => {
503 let next = version + 1;
504 io.opened.insert(uri.to_string(), next);
505 json!({
506 "jsonrpc": "2.0",
507 "method": "textDocument/didChange",
508 "params": {
509 "textDocument": {"uri": uri, "version": next},
510 "contentChanges": [{"text": text}],
511 }
512 })
513 }
514 None => {
515 io.opened.insert(uri.to_string(), 1);
516 json!({
517 "jsonrpc": "2.0",
518 "method": "textDocument/didOpen",
519 "params": {
520 "textDocument": {
521 "uri": uri,
522 "languageId": language_id,
523 "version": 1,
524 "text": text,
525 }
526 }
527 })
528 }
529 };
530 write_message(&mut io.stdin, &msg).await?;
531
532 let deadline = tokio::time::Instant::now() + timeout;
533 loop {
534 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
535 if remaining.is_zero() {
536 return Err(Error::tool("lsp", "timed out waiting for diagnostics"));
537 }
538 let msg = tokio::time::timeout(remaining, read_message(&mut io.stdout))
539 .await
540 .map_err(|_| Error::tool("lsp", "timed out waiting for diagnostics"))??;
541 if is_publish_diagnostics_for(&msg, uri) {
542 return Ok(diagnostics_from_message(&msg, cap));
543 }
544 }
548 }
549
550 fn kill(&self) {
558 if let Ok(mut child) = self.child.lock() {
559 #[cfg(unix)]
560 if let Some(pid) = child.id() {
561 kill_process_group(pid);
562 }
563 let _ = child.start_kill();
564 let _ = child.try_wait();
565 }
566 }
567
568 async fn shutdown(&self) {
573 let graceful = async {
574 let mut io = self.io.lock().await;
575 if !io.initialized {
576 return; }
578 let id = io.next_id;
579 io.next_id += 1;
580 let req = json!({"jsonrpc": "2.0", "id": id, "method": "shutdown", "params": null});
581 if write_message(&mut io.stdin, &req).await.is_ok() {
582 let _ =
583 tokio::time::timeout(Duration::from_millis(500), read_message(&mut io.stdout))
584 .await;
585 }
586 let notif = json!({"jsonrpc": "2.0", "method": "exit", "params": null});
587 let _ = write_message(&mut io.stdin, ¬if).await;
588 };
589 let _ = tokio::time::timeout(Duration::from_secs(2), graceful).await;
590 self.kill();
591 }
592}
593
594#[derive(Debug)]
601pub struct LspManager {
602 servers: std::sync::Mutex<HashMap<String, Arc<LspClient>>>,
603 specs: Vec<(String, LspServerSpec)>,
604 root: PathBuf,
605 timeout: Duration,
606 max_diagnostics: usize,
607}
608
609impl LspManager {
610 pub fn new(
614 root: PathBuf,
615 specs: Vec<(String, LspServerSpec)>,
616 timeout: Duration,
617 max_diagnostics: usize,
618 ) -> Self {
619 LspManager {
620 servers: std::sync::Mutex::new(HashMap::new()),
621 specs,
622 root,
623 timeout,
624 max_diagnostics,
625 }
626 }
627
628 fn spec_for_extension(&self, path: &Path) -> Option<(String, LspServerSpec)> {
629 let ext = path
630 .extension()
631 .and_then(|e| e.to_str())?
632 .to_ascii_lowercase();
633 self.specs
634 .iter()
635 .find(|(_, s)| {
636 s.extensions
637 .iter()
638 .any(|e| e.trim_start_matches('.').to_ascii_lowercase() == ext)
639 })
640 .cloned()
641 }
642
643 async fn get_or_spawn(&self, name: &str, spec: &LspServerSpec) -> Result<Arc<LspClient>> {
644 if let Some(existing) = self.servers.lock().unwrap().get(name).cloned() {
645 return Ok(existing);
646 }
647 let client = LspClient::spawn(name, spec).await?;
648 let mut map = self.servers.lock().unwrap();
649 let winner = map.entry(name.to_string()).or_insert(client).clone();
654 Ok(winner)
655 }
656
657 pub async fn diagnostics_after_write(&self, path: &Path) -> Option<String> {
670 if !crate::safe_path::contained(&self.root, path) {
671 return None; }
673 let (name, spec) = self.spec_for_extension(path)?;
674 let client = match self.get_or_spawn(&name, &spec).await {
675 Ok(c) => c,
676 Err(e) => {
677 tracing::warn!(server = %name, "lsp: failed to spawn/reuse server: {e}");
678 return None;
679 }
680 };
681 let text = match tokio::fs::read_to_string(path).await {
682 Ok(t) => t,
683 Err(_) => return None, };
685 let uri = path_to_uri(path);
686 let language_id = language_id_for(path);
687 match client
688 .open_or_change_and_diagnose(
689 &self.root,
690 &uri,
691 &text,
692 language_id,
693 self.timeout,
694 self.max_diagnostics,
695 )
696 .await
697 {
698 Ok((total, entries)) if total > 0 => {
699 Some(format_diagnostics(&client.name, path, total, &entries))
700 }
701 Ok(_) => None, Err(e) => {
703 tracing::warn!(server = %name, "lsp: diagnostics unavailable: {e}");
704 None
705 }
706 }
707 }
708
709 pub fn kill_all_sync(&self) {
714 let drained: Vec<Arc<LspClient>> = self
715 .servers
716 .lock()
717 .map(|mut m| m.drain().map(|(_, c)| c).collect())
718 .unwrap_or_default();
719 for client in drained {
720 client.kill();
721 }
722 }
723
724 pub async fn shutdown_all(&self) {
730 let drained: Vec<Arc<LspClient>> = self
731 .servers
732 .lock()
733 .map(|mut m| m.drain().map(|(_, c)| c).collect())
734 .unwrap_or_default();
735 for client in drained {
736 client.shutdown().await;
737 }
738 }
739
740 pub fn running_server_count(&self) -> usize {
742 self.servers.lock().map(|m| m.len()).unwrap_or(0)
743 }
744}
745
746#[derive(Debug)]
751pub struct LspDiagnosticsObserver {
752 manager: Arc<LspManager>,
753}
754
755impl LspDiagnosticsObserver {
756 pub fn new(manager: Arc<LspManager>) -> Self {
758 LspDiagnosticsObserver { manager }
759 }
760}
761
762#[async_trait::async_trait]
763impl crate::tools::WriteObserver for LspDiagnosticsObserver {
764 async fn before_write(&self, _path: &Path) {}
765 async fn after_write(&self, path: &Path) -> Option<String> {
766 self.manager.diagnostics_after_write(path).await
767 }
768}
769
770pub fn manager_for_config(config: &crate::Config) -> Option<Arc<LspManager>> {
779 if !config.lsp_enabled {
780 return None;
781 }
782 if config.lsp_servers.is_empty() {
783 eprintln!(
784 "warning: [capabilities.lsp] is enabled but no servers are configured under \
785 [capabilities.lsp.servers.<name>] — no language server will ever be launched"
786 );
787 }
788 Some(Arc::new(LspManager::new(
789 config.cwd.clone(),
790 config.lsp_servers.clone(),
791 Duration::from_secs(config.lsp_timeout_secs.max(1)),
792 config.lsp_max_diagnostics.max(1),
793 )))
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 const STUB_SERVER_PY: &str = r#"
812import sys, json
813
814def read_message():
815 headers = {}
816 while True:
817 line = sys.stdin.buffer.readline()
818 if not line:
819 return None
820 line = line.decode("utf-8", "replace").rstrip("\r\n")
821 if line == "":
822 break
823 if ":" in line:
824 k, v = line.split(":", 1)
825 headers[k.strip()] = v.strip()
826 length = int(headers.get("Content-Length", "0"))
827 body = sys.stdin.buffer.read(length)
828 return json.loads(body.decode("utf-8"))
829
830def write_message(obj):
831 body = json.dumps(obj).encode("utf-8")
832 sys.stdout.buffer.write(("Content-Length: %d\r\n\r\n" % len(body)).encode("utf-8"))
833 sys.stdout.buffer.write(body)
834 sys.stdout.buffer.flush()
835
836def diagnostics_for(text):
837 if "TODO" in text:
838 return [{
839 "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 4}},
840 "severity": 1,
841 "message": "found a TODO marker",
842 }]
843 return []
844
845while True:
846 msg = read_message()
847 if msg is None:
848 break
849 method = msg.get("method")
850 if method == "initialize":
851 write_message({"jsonrpc": "2.0", "id": msg["id"], "result": {"capabilities": {}}})
852 elif method == "initialized":
853 pass
854 elif method in ("textDocument/didOpen", "textDocument/didChange"):
855 params = msg["params"]
856 if method == "textDocument/didOpen":
857 uri = params["textDocument"]["uri"]
858 text = params["textDocument"]["text"]
859 else:
860 uri = params["textDocument"]["uri"]
861 text = params["contentChanges"][0]["text"]
862 write_message({
863 "jsonrpc": "2.0",
864 "method": "textDocument/publishDiagnostics",
865 "params": {"uri": uri, "diagnostics": diagnostics_for(text)},
866 })
867 elif method == "shutdown":
868 write_message({"jsonrpc": "2.0", "id": msg["id"], "result": None})
869 elif method == "exit":
870 break
871"#;
872
873 const SILENT_SERVER_PY: &str = r#"
876import sys, time
877while True:
878 line = sys.stdin.buffer.readline()
879 if not line:
880 break
881 time.sleep(3600)
882"#;
883
884 const WORKER_STUB_SERVER_PY: &str = r#"
897import sys, json, subprocess
898
899def read_message():
900 headers = {}
901 while True:
902 line = sys.stdin.buffer.readline()
903 if not line:
904 return None
905 line = line.decode("utf-8", "replace").rstrip("\r\n")
906 if line == "":
907 break
908 if ":" in line:
909 k, v = line.split(":", 1)
910 headers[k.strip()] = v.strip()
911 length = int(headers.get("Content-Length", "0"))
912 body = sys.stdin.buffer.read(length)
913 return json.loads(body.decode("utf-8"))
914
915def write_message(obj):
916 body = json.dumps(obj).encode("utf-8")
917 sys.stdout.buffer.write(("Content-Length: %d\r\n\r\n" % len(body)).encode("utf-8"))
918 sys.stdout.buffer.write(body)
919 sys.stdout.buffer.flush()
920
921worker = subprocess.Popen(["sleep", "3600"])
922with open(sys.argv[1], "w") as f:
923 f.write(str(worker.pid))
924 f.flush()
925
926while True:
927 msg = read_message()
928 if msg is None:
929 break
930 method = msg.get("method")
931 if method == "initialize":
932 write_message({"jsonrpc": "2.0", "id": msg["id"], "result": {"capabilities": {}}})
933 elif method == "initialized":
934 pass
935 elif method in ("textDocument/didOpen", "textDocument/didChange"):
936 uri = msg["params"]["textDocument"]["uri"]
937 write_message({
938 "jsonrpc": "2.0",
939 "method": "textDocument/publishDiagnostics",
940 "params": {"uri": uri, "diagnostics": []},
941 })
942 elif method == "shutdown":
943 write_message({"jsonrpc": "2.0", "id": msg["id"], "result": None})
944 elif method == "exit":
945 break
946"#;
947
948 fn write_stub(dir: &Path, name: &str, source: &str) -> PathBuf {
949 let path = dir.join(name);
950 std::fs::write(&path, source).unwrap();
951 path
952 }
953
954 fn python() -> Option<String> {
955 for candidate in ["python3", "python"] {
956 if std::process::Command::new(candidate)
957 .arg("--version")
958 .output()
959 .is_ok()
960 {
961 return Some(candidate.to_string());
962 }
963 }
964 None
965 }
966
967 fn tmp(tag: &str) -> PathBuf {
968 let dir = std::env::temp_dir().join(format!(
969 "supercode-lsp-test-{tag}-{}-{}",
970 std::process::id(),
971 std::time::SystemTime::now()
972 .duration_since(std::time::UNIX_EPOCH)
973 .unwrap()
974 .as_nanos()
975 ));
976 std::fs::create_dir_all(&dir).unwrap();
977 dir
978 }
979
980 fn stub_spec(script: &Path) -> LspServerSpec {
981 LspServerSpec {
982 command: python().expect("python3/python required for lsp tests"),
983 args: vec![script.to_string_lossy().into_owned()],
984 extensions: vec![".rs".to_string()],
985 }
986 }
987
988 #[tokio::test]
989 async fn manager_for_config_is_none_when_disabled_default_off_byte_identity() {
990 let config = crate::Config::builder().model("m").build();
991 assert!(!config.lsp_enabled);
992 assert!(manager_for_config(&config).is_none());
993 }
994
995 #[tokio::test]
996 async fn diagnostics_after_write_surfaces_a_diagnostic_from_the_stub_server() {
997 let Some(_py) = python() else {
998 eprintln!("skipping: no python3/python on PATH");
999 return;
1000 };
1001 let project = tmp("diag-hit");
1002 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1003 let manager = LspManager::new(
1004 project.clone(),
1005 vec![("stub".to_string(), stub_spec(&script))],
1006 Duration::from_secs(5),
1007 DEFAULT_LSP_MAX_DIAGNOSTICS,
1008 );
1009 let file = project.join("has_todo.rs");
1010 std::fs::write(&file, "// TODO fix this\nfn main() {}\n").unwrap();
1011 let result = manager.diagnostics_after_write(&file).await;
1012 let text = result.expect("expected diagnostics for a file containing TODO");
1013 assert!(text.contains("stub"), "names the server: {text}");
1014 assert!(text.contains("found a TODO marker"), "{text}");
1015 manager.shutdown_all().await;
1016 std::fs::remove_dir_all(&project).ok();
1017 }
1018
1019 #[tokio::test]
1020 async fn diagnostics_after_write_is_none_for_a_clean_file() {
1021 let Some(_py) = python() else {
1022 eprintln!("skipping: no python3/python on PATH");
1023 return;
1024 };
1025 let project = tmp("diag-clean");
1026 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1027 let manager = LspManager::new(
1028 project.clone(),
1029 vec![("stub".to_string(), stub_spec(&script))],
1030 Duration::from_secs(5),
1031 DEFAULT_LSP_MAX_DIAGNOSTICS,
1032 );
1033 let file = project.join("clean.rs");
1034 std::fs::write(&file, "fn main() {}\n").unwrap();
1035 let result = manager.diagnostics_after_write(&file).await;
1036 assert!(
1037 result.is_none(),
1038 "a clean file must not produce a diagnostics annotation: {result:?}"
1039 );
1040 manager.shutdown_all().await;
1041 std::fs::remove_dir_all(&project).ok();
1042 }
1043
1044 #[tokio::test]
1045 async fn diagnostics_after_write_is_none_for_an_unconfigured_extension() {
1046 let Some(_py) = python() else {
1047 eprintln!("skipping: no python3/python on PATH");
1048 return;
1049 };
1050 let project = tmp("diag-unconfigured");
1051 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1052 let manager = LspManager::new(
1053 project.clone(),
1054 vec![("stub".to_string(), stub_spec(&script))], Duration::from_secs(5),
1056 DEFAULT_LSP_MAX_DIAGNOSTICS,
1057 );
1058 let file = project.join("has_todo.py");
1059 std::fs::write(&file, "# TODO\n").unwrap();
1060 let result = manager.diagnostics_after_write(&file).await;
1061 assert!(result.is_none());
1062 assert_eq!(
1063 manager.running_server_count(),
1064 0,
1065 "an unconfigured extension must never spawn anything"
1066 );
1067 std::fs::remove_dir_all(&project).ok();
1068 }
1069
1070 #[tokio::test]
1071 async fn a_second_write_to_the_same_server_reuses_the_running_process() {
1072 let Some(_py) = python() else {
1073 eprintln!("skipping: no python3/python on PATH");
1074 return;
1075 };
1076 let project = tmp("diag-reuse");
1077 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1078 let manager = LspManager::new(
1079 project.clone(),
1080 vec![("stub".to_string(), stub_spec(&script))],
1081 Duration::from_secs(5),
1082 DEFAULT_LSP_MAX_DIAGNOSTICS,
1083 );
1084 let file = project.join("f.rs");
1085 std::fs::write(&file, "fn main() {}\n").unwrap();
1086 manager.diagnostics_after_write(&file).await;
1087 assert_eq!(manager.running_server_count(), 1);
1088 std::fs::write(&file, "// TODO\nfn main() {}\n").unwrap();
1089 manager.diagnostics_after_write(&file).await;
1090 assert_eq!(
1091 manager.running_server_count(),
1092 1,
1093 "a second write to the same extension must REUSE the already-running server, not spawn a second one"
1094 );
1095 manager.shutdown_all().await;
1096 std::fs::remove_dir_all(&project).ok();
1097 }
1098
1099 #[tokio::test]
1102 async fn a_silent_server_degrades_to_none_within_the_timeout_bound() {
1103 let Some(_py) = python() else {
1104 eprintln!("skipping: no python3/python on PATH");
1105 return;
1106 };
1107 let project = tmp("diag-silent");
1108 let script = write_stub(&project, "silent_lsp.py", SILENT_SERVER_PY);
1109 let manager = LspManager::new(
1110 project.clone(),
1111 vec![("silent".to_string(), stub_spec(&script))],
1112 Duration::from_millis(500),
1113 DEFAULT_LSP_MAX_DIAGNOSTICS,
1114 );
1115 let file = project.join("f.rs");
1116 std::fs::write(&file, "fn main() {}\n").unwrap();
1117 let started = std::time::Instant::now();
1118 let result = tokio::time::timeout(
1119 Duration::from_secs(10),
1120 manager.diagnostics_after_write(&file),
1121 )
1122 .await
1123 .expect("must not hang past the configured lsp timeout");
1124 assert!(result.is_none());
1125 assert!(
1126 started.elapsed() < Duration::from_secs(5),
1127 "took {:?}, expected to bail out near the 500ms configured timeout",
1128 started.elapsed()
1129 );
1130 manager.kill_all_sync();
1131 std::fs::remove_dir_all(&project).ok();
1132 }
1133
1134 #[tokio::test]
1138 async fn kill_all_sync_actually_terminates_the_os_process() {
1139 let Some(_py) = python() else {
1140 eprintln!("skipping: no python3/python on PATH");
1141 return;
1142 };
1143 let project = tmp("diag-kill");
1144 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1145 let manager = LspManager::new(
1146 project.clone(),
1147 vec![("stub".to_string(), stub_spec(&script))],
1148 Duration::from_secs(5),
1149 DEFAULT_LSP_MAX_DIAGNOSTICS,
1150 );
1151 let file = project.join("f.rs");
1152 std::fs::write(&file, "fn main() {}\n").unwrap();
1153 manager.diagnostics_after_write(&file).await;
1154 assert_eq!(manager.running_server_count(), 1);
1155
1156 let pid = {
1157 let servers = manager.servers.lock().unwrap();
1158 let client = servers.values().next().unwrap();
1159 let id = client.child.lock().unwrap().id();
1160 id
1161 };
1162 let pid = pid.expect("spawned child must have a pid before it's reaped");
1163
1164 manager.kill_all_sync();
1165 assert_eq!(
1166 manager.running_server_count(),
1167 0,
1168 "kill_all_sync must drain the manager's bookkeeping"
1169 );
1170
1171 let mut still_alive = true;
1175 for _ in 0..50 {
1176 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) == 0 };
1177 if !alive {
1178 still_alive = false;
1179 break;
1180 }
1181 tokio::time::sleep(Duration::from_millis(20)).await;
1182 }
1183 assert!(
1184 !still_alive,
1185 "pid {pid} must be dead after kill_all_sync (no orphaned language server)"
1186 );
1187 std::fs::remove_dir_all(&project).ok();
1188 }
1189
1190 #[cfg(unix)]
1202 #[tokio::test]
1203 async fn kill_all_sync_reaps_grandchild_worker_processes() {
1204 let Some(py) = python() else {
1205 eprintln!("skipping: no python3/python on PATH");
1206 return;
1207 };
1208 let project = tmp("diag-grandchild");
1209 let script = write_stub(&project, "worker_stub_lsp.py", WORKER_STUB_SERVER_PY);
1210 let pidfile = project.join("worker.pid");
1211 let spec = LspServerSpec {
1212 command: py,
1213 args: vec![
1214 script.to_string_lossy().into_owned(),
1215 pidfile.to_string_lossy().into_owned(),
1216 ],
1217 extensions: vec![".rs".to_string()],
1218 };
1219 let manager = LspManager::new(
1220 project.clone(),
1221 vec![("worker".to_string(), spec)],
1222 Duration::from_secs(5),
1223 DEFAULT_LSP_MAX_DIAGNOSTICS,
1224 );
1225 let file = project.join("f.rs");
1226 std::fs::write(&file, "fn main() {}\n").unwrap();
1227 manager.diagnostics_after_write(&file).await;
1228 assert_eq!(manager.running_server_count(), 1);
1229
1230 let mut grandchild_pid: Option<i32> = None;
1232 for _ in 0..100 {
1233 if let Ok(s) = std::fs::read_to_string(&pidfile) {
1234 if let Ok(pid) = s.trim().parse::<i32>() {
1235 grandchild_pid = Some(pid);
1236 break;
1237 }
1238 }
1239 tokio::time::sleep(Duration::from_millis(20)).await;
1240 }
1241 let grandchild_pid =
1242 grandchild_pid.expect("stub server must have recorded its worker grandchild's pid");
1243 assert!(
1244 unsafe { libc::kill(grandchild_pid, 0) == 0 },
1245 "grandchild worker pid {grandchild_pid} must be alive before kill_all_sync"
1246 );
1247
1248 manager.kill_all_sync(); let mut still_alive = true;
1251 for _ in 0..100 {
1252 let alive = unsafe { libc::kill(grandchild_pid, 0) == 0 };
1253 if !alive {
1254 still_alive = false;
1255 break;
1256 }
1257 tokio::time::sleep(Duration::from_millis(20)).await;
1258 }
1259 assert!(
1260 !still_alive,
1261 "grandchild worker pid {grandchild_pid} must be dead after kill_all_sync — \
1262 it must not orphan (P5-11 review repro)"
1263 );
1264 std::fs::remove_dir_all(&project).ok();
1265 }
1266
1267 #[tokio::test]
1270 async fn diagnostics_after_write_refuses_a_path_outside_the_root() {
1271 let Some(_py) = python() else {
1272 eprintln!("skipping: no python3/python on PATH");
1273 return;
1274 };
1275 let project = tmp("diag-outside-project");
1276 let outside = tmp("diag-outside-elsewhere");
1277 let script = write_stub(&project, "stub_lsp.py", STUB_SERVER_PY);
1278 let manager = LspManager::new(
1279 project.clone(),
1280 vec![("stub".to_string(), stub_spec(&script))],
1281 Duration::from_secs(5),
1282 DEFAULT_LSP_MAX_DIAGNOSTICS,
1283 );
1284 let victim = outside.join("victim.rs");
1285 std::fs::write(&victim, "// TODO\nfn main() {}\n").unwrap();
1286 let result = manager.diagnostics_after_write(&victim).await;
1287 assert!(result.is_none());
1288 assert_eq!(manager.running_server_count(), 0);
1289 std::fs::remove_dir_all(&project).ok();
1290 std::fs::remove_dir_all(&outside).ok();
1291 }
1292
1293 #[tokio::test]
1294 async fn lsp_diagnostics_observer_before_write_is_a_true_noop() {
1295 let project = tmp("obs-noop");
1296 let manager = Arc::new(LspManager::new(
1297 project.clone(),
1298 vec![],
1299 Duration::from_secs(5),
1300 DEFAULT_LSP_MAX_DIAGNOSTICS,
1301 ));
1302 let observer = LspDiagnosticsObserver::new(manager);
1303 <LspDiagnosticsObserver as crate::tools::WriteObserver>::before_write(
1305 &observer,
1306 &project.join("f.rs"),
1307 )
1308 .await;
1309 std::fs::remove_dir_all(&project).ok();
1310 }
1311}