use std::collections::HashMap; use std::env; use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use hemx_build::{Diagnostic, DiagnosticCode, DiagnosticSeverity}; fn main() -> ExitCode { let mut args = env::args().skip(1); match args.next().as_deref() { None | Some("lsp") | Some("serve") => run_lsp(args.collect::>().as_slice()), Some("diagnostics") | Some("check") => run_diagnostics(args.collect::>().as_slice()), Some("help") | Some("--help") | Some("-h") => { print_help(); ExitCode::SUCCESS } Some(command) => { eprintln!("unknown hemx-lsp command `{command}`"); print_help(); ExitCode::from(2) } } } fn print_help() { println!( "usage:\n hemx-lsp lsp\n hemx-lsp diagnostics FILE.heml\n\nrepo usage:\n cargo run -p hemx-lsp -- lsp\n cargo run -p hemx-lsp -- diagnostics FILE.heml" ); } fn run_diagnostics(operands: &[String]) -> ExitCode { let [file] = operands else { eprintln!("usage: hemx-lsp diagnostics FILE.heml"); return ExitCode::from(2); }; match hemx_build::diagnostics_for_heml_file(file) { Ok(diagnostics) => { let path = canonical_path(file); let payload = publish_diagnostics_payload(&file_uri(&path), &diagnostics); println!( "{}", serde_json::to_string_pretty(&payload).expect("json diagnostics") ); if diagnostics .iter() .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) { ExitCode::FAILURE } else { ExitCode::SUCCESS } } Err(err) => { eprintln!("{file}: {err}"); ExitCode::FAILURE } } } fn run_lsp(operands: &[String]) -> ExitCode { if !operands.is_empty() { eprintln!("usage: hemx-lsp lsp"); return ExitCode::from(2); } let stdin = io::stdin(); let mut reader = BufReader::new(stdin.lock()); let stdout = io::stdout(); let mut writer = stdout.lock(); match serve_lsp(&mut reader, &mut writer) { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("hemx-lsp: {err}"); ExitCode::FAILURE } } } fn serve_lsp(reader: &mut R, writer: &mut W) -> io::Result<()> { let mut open_documents = HashMap::::new(); while let Some(message) = read_lsp_message(reader)? { let method = message .get("method") .and_then(|method| method.as_str()) .unwrap_or_default(); match (message.get("id"), method) { (Some(id), "initialize") => write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { "capabilities": { "textDocumentSync": { "openClose": true, "change": 1, "save": true }, "completionProvider": { "triggerCharacters": ["h", "+", "d", "="] }, "hoverProvider": true }, "serverInfo": { "name": "hemx-lsp", "version": env!("CARGO_PKG_VERSION") } } }), )?, (Some(id), "shutdown") => { write_lsp_message( writer, &serde_json::json!({"jsonrpc": "2.0", "id": id, "result": null}), )?; } (Some(id), "textDocument/completion") => { let uri = text_document_uri(&message).unwrap_or_default(); let text = open_documents .get(&uri) .map(String::as_str) .unwrap_or_default(); let position = text_document_position(&message); write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { "isIncomplete": false, "items": hemplate_completion_items(&uri, text, position) } }), )?; } (Some(id), "textDocument/hover") => { let uri = text_document_uri(&message).unwrap_or_default(); let text = open_documents .get(&uri) .map(String::as_str) .unwrap_or_default(); let position = text_document_position(&message); write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": hemplate_hover(&uri, text, position) }), )?; } (Some(id), unknown) => write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("unknown hemx LSP request `{unknown}`") } }), )?, (None, "textDocument/didOpen") => { if let Some((uri, text)) = did_open_document(&message) { open_documents.insert(uri.clone(), text.clone()); publish_lsp_diagnostics(writer, &uri, diagnostics_for_uri_source(&uri, text)?)?; } } (None, "textDocument/didChange") => { if let Some((uri, text)) = did_change_document(&message) { open_documents.insert(uri.clone(), text.clone()); publish_lsp_diagnostics(writer, &uri, diagnostics_for_uri_source(&uri, text)?)?; } } (None, "textDocument/didSave") => { if let Some(uri) = text_document_uri(&message) { let diagnostics = if let Some(text) = did_save_text(&message) { diagnostics_for_uri_source(&uri, text)? } else if let Some(text) = open_documents.get(&uri) { diagnostics_for_uri_source(&uri, text.clone())? } else { let path = path_from_file_uri(&uri); hemx_build::diagnostics_for_heml_file(&path)? }; publish_lsp_diagnostics(writer, &uri, diagnostics)?; } } (None, "textDocument/didClose") => { if let Some(uri) = text_document_uri(&message) { open_documents.remove(&uri); publish_lsp_diagnostics(writer, &uri, Vec::new())?; } } (None, "exit") => break, (None, "initialized") | (None, "") => {} (None, _) => {} } } Ok(()) } fn read_lsp_message(reader: &mut R) -> io::Result> { let mut content_length = None; let mut saw_header = false; loop { let mut line = String::new(); let bytes = reader.read_line(&mut line)?; if bytes == 0 { return Ok(None); } let trimmed = line.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { break; } saw_header = true; if let Some((name, value)) = trimmed.split_once(':') { if name.eq_ignore_ascii_case("content-length") { content_length = Some(value.trim().parse::().map_err(|err| { io::Error::new( io::ErrorKind::InvalidData, format!("bad Content-Length: {err}"), ) })?); } } } if !saw_header { return Ok(None); } let len = content_length.ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header") })?; let mut body = vec![0; len]; reader.read_exact(&mut body)?; serde_json::from_slice(&body) .map(Some) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } fn write_lsp_message(writer: &mut W, message: &serde_json::Value) -> io::Result<()> { let body = serde_json::to_vec(message).expect("serialize LSP message"); write!(writer, "Content-Length: {}\r\n\r\n", body.len())?; writer.write_all(&body)?; writer.flush() } fn publish_lsp_diagnostics( writer: &mut W, uri: &str, diagnostics: Vec, ) -> io::Result<()> { write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": publish_diagnostics_payload(uri, &diagnostics), }), ) } fn publish_diagnostics_payload(uri: &str, diagnostics: &[Diagnostic]) -> serde_json::Value { serde_json::json!({ "uri": uri, "diagnostics": diagnostics .iter() .map(lsp_diagnostic) .collect::>() }) } fn lsp_diagnostic(diagnostic: &Diagnostic) -> serde_json::Value { serde_json::json!({ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "severity": diagnostic_lsp_severity(diagnostic.severity), "source": "hemx-build", "code": diagnostic_code(diagnostic.code), "message": diagnostic.message, "data": { "file": diagnostic.file.display().to_string(), "directive": diagnostic.directive, "target": diagnostic.target, "expected": diagnostic.expected, "repair": diagnostic.repair, } }) } fn did_open_document(message: &serde_json::Value) -> Option<(String, String)> { let doc = message.get("params")?.get("textDocument")?; Some(( doc.get("uri")?.as_str()?.to_owned(), doc.get("text")?.as_str()?.to_owned(), )) } fn did_change_document(message: &serde_json::Value) -> Option<(String, String)> { let uri = text_document_uri(message)?; let text = message .get("params")? .get("contentChanges")? .as_array()? .last()? .get("text")? .as_str()? .to_owned(); Some((uri, text)) } fn did_save_text(message: &serde_json::Value) -> Option { message .get("params")? .get("text")? .as_str() .map(str::to_owned) } fn text_document_uri(message: &serde_json::Value) -> Option { message .get("params")? .get("textDocument")? .get("uri")? .as_str() .map(str::to_owned) } fn text_document_position(message: &serde_json::Value) -> Option<(usize, usize)> { let position = message.get("params")?.get("position")?; Some(( position.get("line")?.as_u64()? as usize, position.get("character")?.as_u64()? as usize, )) } fn diagnostics_for_uri_source(uri: &str, source: String) -> io::Result> { hemx_build::diagnostics_for_heml_source(path_from_file_uri(uri), source) } fn canonical_path(path: &str) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) } fn file_uri(path: &Path) -> String { format!("file://{}", path.display()) } fn path_from_file_uri(uri: &str) -> PathBuf { PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri)) } fn hemplate_completion_items( uri: &str, text: &str, position: Option<(usize, usize)>, ) -> Vec { let mut items = vec![ completion_item( "h-for", "h-for=\"item in &self.items\"", "Repeat children using a Rust-shaped iterator expression; add `h-key` when generated targets are inside the loop.", ), completion_item( "h-key", "h-key=\"item.id\"", "Stable template key for generated targets inside `h-for` loops.", ), completion_item( "h-if", "h-if=\"self.ready\"", "Render an element when a Rust-shaped condition is true.", ), completion_item( "h-match", "h-match=\"&self.state\"", "Match a Rust-shaped value with child `h-case` arms.", ), completion_item( "h-case", "h-case=\"State::Ready(value)\"", "Arm for `h-match`; use `h-case=\"_\"` for the default arm.", ), completion_item( "+attr", "+class=\"self.class_name()\"", "Dynamic HTML attribute expression.", ), completion_item( "{+ expr +}", "{+ self.title +}", "Escaped text expression.", ), completion_item( "{+= expr =+}", "{+= trusted_html =+}", "Trusted/rendered HTML expression; prefer escaped `{+ expr +}` for user content.", ), completion_item( "data-hemx-slot", "data-hemx-slot=\"content\"", "Generated partial target, e.g. `ui::content.replace(value)`.", ), completion_item( "data-hemx-form", "data-hemx-form=\"save\"", "Generated form target wired through hemx build output.", ), completion_item( "data-hemx-handle", "data-hemx-handle=\"button\"", "Generated handle target wired through hemx build output.", ), ]; if let Ok(targets) = hemx_build::generated_targets_for_heml_source(path_from_file_uri(uri), text) { for target in targets { items.push(completion_item( &format!("ui::{}", target.name), &format!("ui::{}", target.name), "Generated hemx target discovered by hemx-build in the open `.heml` document.", )); } } if let Ok(Some(facts)) = hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text) { let prefix = position .and_then(|position| line_prefix(text, position)) .unwrap_or_default(); if prefix.ends_with("self.") { for field in &facts.self_fields { items.push(field_completion_item(&field.name, &field.type_name, "self")); } } for local in &facts.locals { if prefix.ends_with(&format!("{}.", local.name)) && position.is_some_and(|position| active_h_for_local(text, position, &local.name)) { for field in &local.fields { items.push(field_completion_item( &field.name, &field.type_name, &local.name, )); } } } } items } fn field_completion_item(name: &str, type_name: &str, owner: &str) -> serde_json::Value { serde_json::json!({ "label": name, "kind": 5, "detail": format!("{owner}.{name}: {type_name}"), "insertText": name, "documentation": { "kind": "markdown", "value": format!("`{owner}.{name}: {type_name}`\n\nSource: hemx-build template context facts.") } }) } fn completion_item(label: &str, insert_text: &str, detail: &str) -> serde_json::Value { serde_json::json!({ "label": label, "kind": 10, "detail": detail, "insertText": insert_text, "documentation": { "kind": "markdown", "value": format!("{detail}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`.") } }) } fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> serde_json::Value { if let Some((owner, field)) = position.and_then(|position| dotted_name_at(text, position)) { if let Ok(Some(facts)) = hemx_build::template_context_facts_for_heml_source(path_from_file_uri(uri), text) { if owner == "self" { if let Some(fact) = facts.self_fields.iter().find(|fact| fact.name == field) { return hover_markdown(&format!( "`self.{}: {}`\n\nSource: hemx-build template context facts for `{}`.", fact.name, fact.type_name, facts.context_type )); } } if position.is_some_and(|position| active_h_for_local(text, position, &owner)) { if let Some(local) = facts.locals.iter().find(|local| local.name == owner) { if let Some(fact) = local.fields.iter().find(|fact| fact.name == field) { return hover_markdown(&format!( "`{}.{}: {}`\n\nSource: hemx-build `h-for` local fact from `{}`.", local.name, fact.name, fact.type_name, local.type_name )); } } } } } let line = position .and_then(|(line, _)| text.lines().nth(line)) .unwrap_or_default(); let value = if line.contains("data-hemx-slot") { Some("`data-hemx-slot` declares a generated partial target. Inside `h-for`, add template `h-key`; compiler diagnostics come from `hemx-build`.") } else if line.contains("data-hemx-form") { Some("`data-hemx-form` declares a generated form target wired through hemx build output.") } else if line.contains("data-hemx-handle") { Some("`data-hemx-handle` declares a generated handle target wired through hemx build output.") } else if line.contains("h-key") { Some("`h-key` is the stable template key used by generated targets inside `h-for` loops.") } else if line.contains("h-for") { Some("`h-for` repeats children from a Rust-shaped iterator expression; generated targets inside the loop require `h-key`.") } else if line.contains("h-if") { Some("`h-if` renders an element when a Rust-shaped condition is true.") } else if line.contains("h-match") || line.contains("h-case") { Some("`h-match`/`h-case` use Rust-shaped pattern arms; `h-case=\"_\"` is the default arm.") } else if line.contains("{+=") { Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.") } else if line.contains("{+") { Some("`{+ expr +}` inserts escaped text.") } else if line.contains('+') { Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.") } else { None }; match value { Some(value) => hover_markdown(&format!( "{value}\n\nSource: `docs/hemplate-syntax.md` and `hemx-build`." )), None => serde_json::Value::Null, } } fn hover_markdown(value: &str) -> serde_json::Value { serde_json::json!({ "contents": { "kind": "markdown", "value": value } }) } fn line_prefix(text: &str, position: (usize, usize)) -> Option { let line = text.lines().nth(position.0)?; Some(line.chars().take(position.1).collect()) } fn dotted_name_at(text: &str, position: (usize, usize)) -> Option<(String, String)> { let line = text.lines().nth(position.0)?; let chars = line.chars().collect::>(); let cursor = position.1.min(chars.len()); let mut start = cursor; while start > 0 && is_ident_or_dot(chars[start - 1]) { start -= 1; } let mut end = cursor; while end < chars.len() && is_ident_or_dot(chars[end]) { end += 1; } let token = chars[start..end].iter().collect::(); let (owner, field) = token.split_once('.')?; if owner.is_empty() || field.is_empty() || field.contains('.') { return None; } Some((owner.to_owned(), field.to_owned())) } fn is_ident_or_dot(ch: char) -> bool { ch == '_' || ch == '.' || ch.is_ascii_alphanumeric() } fn active_h_for_local(text: &str, position: (usize, usize), local_name: &str) -> bool { let Some(offset) = byte_offset_for_position(text, position) else { return false; }; let mut search_from = 0; while let Some(relative) = text[search_from..].find("h-for=\"") { let attr_start = search_from + relative; let value_start = attr_start + "h-for=\"".len(); let Some(value_end) = text[value_start..].find('"').map(|end| value_start + end) else { return false; }; if let Some((local, _)) = h_for_local_and_self_field(&text[value_start..value_end]) { if local == local_name && h_for_attribute_scope_contains(text, attr_start, offset) { return true; } } search_from = value_end + 1; } false } fn h_for_attribute_scope_contains(text: &str, attr_start: usize, offset: usize) -> bool { let Some(tag_start) = text[..attr_start].rfind('<') else { return false; }; if text[tag_start..].starts_with("').map(|end| attr_start + end) else { return false; }; if offset < tag_start || offset < open_end { return offset >= tag_start && offset <= open_end; } if text[..=open_end].ends_with("/>") { return false; } let Some(tag_name) = tag_name_at(text, tag_start) else { return false; }; let close = format!(""); let Some(close_start) = text[open_end + 1..] .find(&close) .map(|relative| open_end + 1 + relative) else { return false; }; offset <= close_start + close.len() } fn tag_name_at(text: &str, tag_start: usize) -> Option { let rest = text[tag_start + 1..].trim_start(); let name = rest .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == ':') .collect::(); (!name.is_empty()).then_some(name) } fn byte_offset_for_position(text: &str, position: (usize, usize)) -> Option { let mut offset = 0; for (line_index, line) in text.split_inclusive('\n').enumerate() { let line_without_newline = line.strip_suffix('\n').unwrap_or(line); if line_index == position.0 { let column_offset = line_without_newline .char_indices() .map(|(index, _)| index) .chain(std::iter::once(line_without_newline.len())) .nth(position.1)?; return Some(offset + column_offset); } offset += line.len(); } if position.0 == text.lines().count() { return Some(text.len()); } None } fn h_for_local_and_self_field(value: &str) -> Option<(String, String)> { let (local, expr) = value.split_once(" in ")?; let local = local.trim(); if local.is_empty() || local.contains(['(', ',', ' ']) { return None; } let expr = expr.trim().strip_prefix('&').unwrap_or(expr.trim()).trim(); let field = expr .strip_prefix("self.")? .split(['.', '(', '[']) .next()? .trim(); (!field.is_empty()).then(|| (local.to_owned(), field.to_owned())) } fn diagnostic_code(code: DiagnosticCode) -> &'static str { match code { DiagnosticCode::UnkeyedGeneratedTarget => "unkeyed-generated-target", } } fn diagnostic_lsp_severity(severity: DiagnosticSeverity) -> u8 { match severity { DiagnosticSeverity::Error => 1, } } #[cfg(test)] mod tests { 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() { // req: diagnostics/006 let dir = std::env::temp_dir().join(format!("hemx-lsp-context-facts-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("src")).expect("create src dir"); std::fs::write( dir.join("Cargo.toml"), "[package]\nname = \"facts\"\nversion = \"0.1.0\"\n", ) .expect("write manifest"); std::fs::write( dir.join("src/lib.rs"), r#" pub struct ExercisePlan { pub name: String, pub kg: f32 } #[derive(Hemplate)] pub struct Workout { pub plan: Vec, pub progress: String } "#, ) .expect("write lib"); let template = dir.join("workout.heml"); let text = r#"

{+ self. +}

  • {+ exercise. +}{+ exercise.name +}
  • {+ self.progress +} {+ exercise. +} {+ exercise.name +}

    "#; std::fs::write(&template, text).expect("write heml"); let uri = format!("file://{}", template.display()); let self_items = hemplate_completion_items( &uri, text, Some((0, text.find("self.").unwrap() + "self.".len())), ); assert!(self_items .iter() .any(|item| item["label"] == "progress" && item["detail"] == "self.progress: String")); let exercise_cursor = text.find("exercise. +").unwrap() + "exercise.".len(); let exercise_items = hemplate_completion_items(&uri, text, Some((0, exercise_cursor))); assert!(exercise_items .iter() .any(|item| item["label"] == "name" && item["detail"] == "exercise.name: String")); let progress_hover = hemplate_hover(&uri, text, Some((0, text.find("progress").unwrap() + 1))); assert!(progress_hover.to_string().contains("self.progress: String")); let inside_name = text.find("exercise.name").unwrap() + "exercise.".len() + 1; let name_hover = hemplate_hover(&uri, text, Some((0, inside_name))); assert!(name_hover.to_string().contains("exercise.name: String")); let outside_cursor = text.rfind("exercise. +").unwrap() + "exercise.".len(); let outside_items = hemplate_completion_items(&uri, text, Some((0, outside_cursor))); assert!(!outside_items .iter() .any(|item| item["detail"] == "exercise.name: String")); let outside_hover = hemplate_hover(&uri, text, Some((0, text.rfind("name").unwrap() + 1))); assert!(!outside_hover.to_string().contains("exercise.name: String")); } #[test] fn workout_template_fields_are_available_from_repo_facts() { // req: diagnostics/006 let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap(); let template = repo.join("examples/workout/templates/workout.heml"); let text = std::fs::read_to_string(&template).expect("workout template"); let uri = format!("file://{}", template.display()); let self_line = text .lines() .position(|line| line.contains("self.progress")) .expect("self expression line"); let self_column = text.lines().nth(self_line).unwrap().find("self.").unwrap() + "self.".len(); let self_items = hemplate_completion_items(&uri, &text, Some((self_line, self_column))); assert!(self_items.iter().any(|item| item["label"] == "progress")); let exercise_line = text .lines() .position(|line| line.contains("exercise.name")) .expect("exercise local line"); let exercise_column = text .lines() .nth(exercise_line) .unwrap() .find("exercise.") .unwrap() + "exercise.".len(); let exercise_items = hemplate_completion_items(&uri, &text, Some((exercise_line, exercise_column))); assert!(exercise_items .iter() .any(|item| item["detail"] == "exercise.name: &'static str")); let hover = hemplate_hover(&uri, &text, Some((exercise_line, exercise_column + 1))); 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 let bad_heml = r#"
    "#; 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": "file:///tmp/todo.heml", "languageId": "heml", "version": 1, "text": bad_heml}} })), lsp_message(serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "textDocument/completion", "params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 1}} })), lsp_message(serde_json::json!({ "jsonrpc": "2.0", "id": 3, "method": "textDocument/hover", "params": {"textDocument": {"uri": "file:///tmp/todo.heml"}, "position": {"line": 0, "character": 80}} })), 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 output = String::from_utf8(output).expect("utf8 output"); assert!(output.contains("completionProvider")); assert!(output.contains("hoverProvider")); assert!(output.contains("textDocument/publishDiagnostics")); assert!(output.contains("unkeyed-generated-target")); assert!(output.contains("add h-key")); assert!(output.contains("ui::todo_row")); 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) } }