From 258524f3b8b54e0fb4326f8ed06052aab7714b80 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 25 Jun 2026 23:34:04 +0200 Subject: [PATCH] test(lsp): cover documented hover facts Make hover token-position aware and add protocol/helper coverage for documented hemplate hover facts. req: diagnostics/007 req: diagnostics/008 --- hemx-lsp/src/main.rs | 104 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/hemx-lsp/src/main.rs b/hemx-lsp/src/main.rs index fb912f7..3438e94 100644 --- a/hemx-lsp/src/main.rs +++ b/hemx-lsp/src/main.rs @@ -510,25 +510,29 @@ fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> se let line = position .and_then(|(line, _)| text.lines().nth(line)) .unwrap_or_default(); - let value = if line.contains("data-hemx-slot") { + let character = position.map(|(_, character)| character).unwrap_or_default(); + let at = |token: &str| token_at(line, character, token); + let value = if at("data-hemx-root") { + Some("`data-hemx-root` declares the root where the hemx runtime scopes delegated handlers and generated resources.") + } else if at("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") { + } else if at("data-hemx-form") { Some("`data-hemx-form` declares a generated form target wired through hemx build output.") - } else if line.contains("data-hemx-handle") { + } else if at("data-hemx-handle") { Some("`data-hemx-handle` declares a generated handle target wired through hemx build output.") - } else if line.contains("h-key") { + } else if at("h-key") { Some("`h-key` is the stable template key used by generated targets inside `h-for` loops.") - } else if line.contains("h-for") { + } else if at("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") { + } else if at("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") { + } else if at("h-match") || at("h-case") { Some("`h-match`/`h-case` use Rust-shaped pattern arms; `h-case=\"_\"` is the default arm.") - } else if line.contains("{+=") { + } else if at("{+=") { Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.") - } else if line.contains("{+") { + } else if at("{+") { Some("`{+ expr +}` inserts escaped text.") - } else if line.contains('+') { + } else if at("+") { Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.") } else { None @@ -541,6 +545,11 @@ fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> se } } +fn token_at(line: &str, character: usize, token: &str) -> bool { + line.match_indices(token) + .any(|(start, matched)| character >= start && character < start + matched.len()) +} + fn hover_markdown(value: &str) -> serde_json::Value { serde_json::json!({ "contents": { @@ -826,6 +835,74 @@ mod tests { assert!(labels.contains("{+ expr +}")); } + #[test] + fn hover_items_cover_documented_hemplate_surface() { + // req: diagnostics/007 req: diagnostics/008 + let text = r#"
"#; + let cases = [ + ("data-hemx-root", "runtime scopes delegated handlers"), + ("h-for", "repeats children"), + ("h-key", "stable template key"), + ("+class", "dynamic HTML attribute"), + ("{+ self.title +}", "escaped text"), + ("{+= trusted =+}", "trusted/rendered HTML"), + ]; + for (needle, expected) in cases { + let character = text.find(needle).expect("fixture needle"); + let hover = hemplate_hover("file:///tmp/hover.heml", text, Some((0, character))); + let value = hover_markdown_value(&hover); + assert!( + value.contains(expected), + "hover for `{needle}` should mention `{expected}`, got {value:?}" + ); + assert!( + value.contains("docs/hemplate-syntax.md"), + "hover for `{needle}` should cite syntax docs, got {value:?}" + ); + } + assert_eq!( + hemplate_hover("file:///tmp/hover.heml", text, Some((0, 0))), + serde_json::Value::Null, + "ordinary HTML text is left to normal editor tooling" + ); + } + + #[test] + fn lsp_hover_returns_documented_markdown() { + // req: diagnostics/007 req: diagnostics/008 + let text = r#"
"#; + let uri = "file:///tmp/hover.heml"; + let character = text.find("h-key").expect("h-key position"); + 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": text}} + })), + lsp_message(serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/hover", + "params": {"textDocument": {"uri": uri}, "position": {"line": 0, "character": character}} + })), + 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 hover = messages + .iter() + .find(|message| message["id"] == 2) + .expect("hover response"); + let value = hover_markdown_value(&hover["result"]); + assert!(value.contains("stable template key")); + assert!(value.contains("docs/hemplate-syntax.md")); + } + #[test] fn workout_template_fields_are_available_from_repo_facts() { // req: diagnostics/006 @@ -1034,6 +1111,13 @@ mod tests { assert!(output.contains("docs/hemplate-syntax.md")); } + fn hover_markdown_value(hover: &serde_json::Value) -> String { + hover["contents"]["value"] + .as_str() + .unwrap_or_default() + .to_string() + } + fn lsp_messages(output: &[u8]) -> Vec { let mut bytes = output; let mut messages = Vec::new();