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
This commit is contained in:
slhx agent
2026-06-25 23:25:35 +02:00
parent df0fabd4fb
commit 6cf800cac6
+154 -1
View File
@@ -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#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#;
let valid = r#"<main data-hemx-root="todos"><template h-for="todo in &self.todos" h-key="todo.id"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#;
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::<Vec<_>>();
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::<Vec<_>>()).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#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#;
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::<Vec<_>>()).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<serde_json::Value> {
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::<usize>()
.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)