From 6cf800cac6727179258609291a86296867f3c15f Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 25 Jun 2026 23:25:35 +0200 Subject: [PATCH] test(lsp): cover diagnostic publish lifecycle Add protocol-level LSP tests for build-equivalent diagnostics across open/change/save/close and file-backed save without text. req: diagnostics/004 req: diagnostics/005 --- hemx-lsp/src/main.rs | 155 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/hemx-lsp/src/main.rs b/hemx-lsp/src/main.rs index cadd4da..8d046b5 100644 --- a/hemx-lsp/src/main.rs +++ b/hemx-lsp/src/main.rs @@ -682,7 +682,10 @@ fn diagnostic_lsp_severity(severity: DiagnosticSeverity) -> u8 { #[cfg(test)] mod tests { - use super::{hemplate_completion_items, hemplate_hover, serve_lsp}; + use super::{ + diagnostics_for_uri_source, file_uri, hemplate_completion_items, hemplate_hover, + lsp_diagnostic, serve_lsp, + }; #[test] fn completion_and_hover_use_template_context_facts() { @@ -783,6 +786,133 @@ mod tests { assert!(hover.to_string().contains("exercise.name: &'static str")); } + #[test] + fn lsp_publishes_and_clears_build_equivalent_diagnostics() { + // req: diagnostics/004 req: diagnostics/005 + let uri = "file:///tmp/todo.heml"; + let invalid = r#"
"#; + let valid = r#"
"#; + let expected = + diagnostics_for_uri_source(uri, invalid.to_string()).expect("build diagnostics"); + assert!( + !expected.is_empty(), + "fixture should exercise build diagnostics" + ); + assert!( + diagnostics_for_uri_source(uri, valid.to_string()) + .expect("valid diagnostics") + .is_empty(), + "valid fixture should clear diagnostics" + ); + + let input = [ + lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": uri, "languageId": "heml", "version": 1, "text": invalid}} + })), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": {"textDocument": {"uri": uri, "version": 2}, "contentChanges": [{"text": valid}]} + })), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didSave", + "params": {"textDocument": {"uri": uri}, "text": invalid} + })), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": {"textDocument": {"uri": uri}} + })), + lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), + ] + .join(""); + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut output = Vec::new(); + + serve_lsp(&mut reader, &mut output).expect("serve LSP"); + let messages = lsp_messages(&output); + let published = messages + .iter() + .filter(|message| message["method"] == "textDocument/publishDiagnostics") + .collect::>(); + assert_eq!(published.len(), 4, "open/change/save/close should publish"); + assert_eq!(published[0]["params"]["uri"], uri); + assert_eq!( + published[0]["params"]["diagnostics"], + serde_json::to_value(expected.iter().map(lsp_diagnostic).collect::>()).unwrap(), + "didOpen diagnostics should match build diagnostics" + ); + assert_eq!( + published[1]["params"]["diagnostics"] + .as_array() + .unwrap() + .len(), + 0, + "didChange should clear fixed diagnostics" + ); + assert_eq!( + published[2]["params"]["diagnostics"], published[0]["params"]["diagnostics"], + "didSave text diagnostics should match didOpen/build diagnostics" + ); + assert_eq!( + published[3]["params"]["diagnostics"] + .as_array() + .unwrap() + .len(), + 0, + "didClose should clear diagnostics" + ); + } + + #[test] + fn did_save_without_text_reads_file_when_document_is_not_open() { + // req: diagnostics/004 req: diagnostics/005 + let path = std::env::temp_dir().join(format!( + "hemx-lsp-save-{}-{}.heml", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let invalid = r#"
"#; + std::fs::write(&path, invalid).expect("write temp heml fixture"); + let uri = file_uri(&path); + let expected = hemx_build::diagnostics_for_heml_file(&path).expect("file diagnostics"); + assert!( + !expected.is_empty(), + "file fixture should produce diagnostics" + ); + + let input = [ + lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didSave", + "params": {"textDocument": {"uri": uri}} + })), + lsp_message(serde_json::json!({"jsonrpc": "2.0", "method": "exit"})), + ] + .join(""); + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut output = Vec::new(); + + serve_lsp(&mut reader, &mut output).expect("serve LSP"); + let messages = lsp_messages(&output); + let published = messages + .iter() + .find(|message| message["method"] == "textDocument/publishDiagnostics") + .expect("publish diagnostics"); + assert_eq!(published["params"]["uri"], uri); + assert_eq!( + published["params"]["diagnostics"], + serde_json::to_value(expected.iter().map(lsp_diagnostic).collect::>()).unwrap(), + "didSave without text/open document should match file diagnostics" + ); + std::fs::remove_file(path).ok(); + } + #[test] fn lsp_serves_compiler_diagnostics_and_completion() { // req: diagnostics/004 req: diagnostics/005 @@ -824,6 +954,29 @@ mod tests { assert!(output.contains("docs/hemplate-syntax.md")); } + fn lsp_messages(output: &[u8]) -> Vec { + let mut bytes = output; + let mut messages = Vec::new(); + while !bytes.is_empty() { + let header_end = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("LSP header terminator"); + let header = std::str::from_utf8(&bytes[..header_end]).expect("utf8 header"); + let length = header + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .expect("content length") + .parse::() + .expect("numeric content length"); + let body_start = header_end + 4; + let body_end = body_start + length; + messages.push(serde_json::from_slice(&bytes[body_start..body_end]).expect("LSP json")); + bytes = &bytes[body_end..]; + } + messages + } + fn lsp_message(message: serde_json::Value) -> String { let body = serde_json::to_string(&message).expect("lsp json"); format!("Content-Length: {}\r\n\r\n{}", body.len(), body)