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
This commit is contained in:
slhx agent
2026-06-25 23:34:04 +02:00
parent baf4b1529c
commit 258524f3b8
+94 -10
View File
@@ -510,25 +510,29 @@ fn hemplate_hover(uri: &str, text: &str, position: Option<(usize, usize)>) -> se
let line = position let line = position
.and_then(|(line, _)| text.lines().nth(line)) .and_then(|(line, _)| text.lines().nth(line))
.unwrap_or_default(); .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`.") 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.") 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.") 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.") 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`.") 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.") 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.") 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.") Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.")
} else if line.contains("{+") { } else if at("{+") {
Some("`{+ expr +}` inserts escaped text.") Some("`{+ expr +}` inserts escaped text.")
} else if line.contains('+') { } else if at("+") {
Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.") Some("`+attr=\"expr\"` evaluates a dynamic HTML attribute expression.")
} else { } else {
None 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 { fn hover_markdown(value: &str) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"contents": { "contents": {
@@ -826,6 +835,74 @@ mod tests {
assert!(labels.contains("{+ expr +}")); assert!(labels.contains("{+ expr +}"));
} }
#[test]
fn hover_items_cover_documented_hemplate_surface() {
// req: diagnostics/007 req: diagnostics/008
let text = r#"<main data-hemx-root="app"><template h-for="item in &self.items" h-key="item.id"><p +class="self.class_name">{+ self.title +}{+= trusted =+}</p></template></main>"#;
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#"<main data-hemx-root="app"><template h-for="item in &self.items" h-key="item.id"><p>{+ item.title +}</p></template></main>"#;
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] #[test]
fn workout_template_fields_are_available_from_repo_facts() { fn workout_template_fields_are_available_from_repo_facts() {
// req: diagnostics/006 // req: diagnostics/006
@@ -1034,6 +1111,13 @@ mod tests {
assert!(output.contains("docs/hemplate-syntax.md")); 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<serde_json::Value> { fn lsp_messages(output: &[u8]) -> Vec<serde_json::Value> {
let mut bytes = output; let mut bytes = output;
let mut messages = Vec::new(); let mut messages = Vec::new();