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#"{+ todo.title +}"#;
+ let valid = r#"{+ todo.title +}"#;
+ 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#"{+ todo.title +}"#;
+ 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)