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 source = std::fs::read_to_string(&path).ok(); let payload = publish_diagnostics_payload(&file_uri(&path), &diagnostics, source.as_deref()); 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.clone())?, Some(&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.clone())?, Some(&text), )?; } } (None, "textDocument/didSave") => { if let Some(uri) = text_document_uri(&message) { let (diagnostics, source) = if let Some(text) = did_save_text(&message) { (diagnostics_for_uri_source(&uri, text.clone())?, Some(text)) } else if let Some(text) = open_documents.get(&uri) { ( diagnostics_for_uri_source(&uri, text.clone())?, Some(text.clone()), ) } else { let path = path_from_file_uri(&uri); ( hemx_build::diagnostics_for_heml_file(&path)?, std::fs::read_to_string(&path).ok(), ) }; publish_lsp_diagnostics(writer, &uri, diagnostics, source.as_deref())?; } } (None, "textDocument/didClose") => { if let Some(uri) = text_document_uri(&message) { open_documents.remove(&uri); publish_lsp_diagnostics(writer, &uri, Vec::new(), None)?; } } (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, source: Option<&str>, ) -> io::Result<()> { write_lsp_message( writer, &serde_json::json!({ "jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": publish_diagnostics_payload(uri, &diagnostics, source), }), ) } fn publish_diagnostics_payload( uri: &str, diagnostics: &[Diagnostic], source: Option<&str>, ) -> serde_json::Value { serde_json::json!({ "uri": uri, "diagnostics": diagnostics .iter() .map(|diagnostic| lsp_diagnostic(diagnostic, source)) .collect::>() }) } fn lsp_diagnostic(diagnostic: &Diagnostic, source: Option<&str>) -> serde_json::Value { serde_json::json!({ "range": diagnostic_range(diagnostic, source), "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-root", "data-hemx-root=\"app\"", "Generated hemx root for delegated runtime behavior.", ), 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), &format!( "Generated hemx {} target discovered by hemx-build in the open `.heml` document.", target.kind ), )); } } 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((kind, name)) = position.and_then(|position| generated_target_at(text, position)) { if let Ok(targets) = hemx_build::generated_targets_for_heml_source(path_from_file_uri(uri), text) { if targets .iter() .any(|target| target.kind == kind && target.name == name) { return hover_markdown(&format!( "Generated hemx `{kind}` target `{name}`.\n\nRust symbol: `ui::{name}`.\n\nSource: hemx-build facts for the current `.heml` document." )); } } } 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 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 at("data-hemx-form") { Some("`data-hemx-form` declares a generated form target wired through hemx build output.") } else if at("data-hemx-handle") { Some("`data-hemx-handle` declares a generated handle target wired through hemx build output.") } else if at("h-key") { Some("`h-key` is the stable template key used by generated targets inside `h-for` loops.") } 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 at("h-if") { Some("`h-if` renders an element when a Rust-shaped condition is true.") } 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 at("{+=") { Some("`{+= expr =+}` inserts trusted/rendered HTML. Prefer escaped `{+ expr +}` for user content.") } else if at("{+") { Some("`{+ expr +}` inserts escaped text.") } else if at("+") { 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 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": { "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 generated_target_at(text: &str, position: (usize, usize)) -> Option<(String, String)> { let line = text.lines().nth(position.0)?; let cursor = position.1.min(line.len()); [ ("root", "data-hemx-root=\""), ("slot", "data-hemx-slot=\""), ("form", "data-hemx-form=\""), ("handle", "data-hemx-handle=\""), ] .into_iter() .find_map(|(kind, prefix)| { line.match_indices(prefix).find_map(|(attribute_start, _)| { let value_start = attribute_start + prefix.len(); let value_end = value_start + line[value_start..].find('"')?; (cursor >= value_start && cursor <= value_end) .then(|| (kind.to_owned(), line[value_start..value_end].to_owned())) }) }) } 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_range(diagnostic: &Diagnostic, source: Option<&str>) -> serde_json::Value { let Some(source) = source else { return zero_width_range(0, 0); }; let needle = format!("{}=\"{}\"", diagnostic.directive, diagnostic.target); source .find(&needle) .map(|start| { let end = start + needle.len(); serde_json::json!({ "start": source_position(source, start), "end": source_position(source, end), }) }) .unwrap_or_else(|| zero_width_range(0, 0)) } fn source_position(source: &str, byte_offset: usize) -> serde_json::Value { let mut line = 0_usize; let mut line_start = 0_usize; for (index, byte) in source.bytes().enumerate() { if index >= byte_offset { break; } if byte == b'\n' { line += 1; line_start = index + 1; } } let character = source[line_start..byte_offset].encode_utf16().count(); serde_json::json!({ "line": line, "character": character }) } fn zero_width_range(line: usize, character: usize) -> serde_json::Value { serde_json::json!({ "start": { "line": line, "character": character }, "end": { "line": line, "character": character } }) } 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 completion_items_cover_documented_hemplate_surface() { // req: diagnostics/006 let text = r#"
    "#; let items = hemplate_completion_items("file:///tmp/app.heml", text, Some((0, 6))); let labels = items .iter() .filter_map(|item| item["label"].as_str()) .collect::>(); for label in [ "h-for", "h-key", "h-if", "h-match", "h-case", "+attr", "{+ expr +}", "{+= expr =+}", "data-hemx-root", "data-hemx-slot", "data-hemx-form", "data-hemx-handle", ] { assert!(labels.contains(label), "missing completion item `{label}`"); } assert!(items.iter().all(|item| item["documentation"]["value"] .as_str() .unwrap_or_default() .contains("docs/hemplate-syntax.md"))); } #[test] fn lsp_completion_returns_documented_items() { // req: diagnostics/006 let text = r#"
    {+ self.title +}
    "#; let uri = "file:///tmp/completion.heml"; 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/completion", "params": {"textDocument": {"uri": uri}, "position": {"line": 0, "character": 6}} })), 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 completion = messages .iter() .find(|message| message["id"] == 2) .expect("completion response"); let completion_items = completion["result"] .as_array() .or_else(|| completion["result"]["items"].as_array()) .expect("completion items"); let labels = completion_items .iter() .filter_map(|item| item["label"].as_str()) .collect::>(); assert!(labels.contains("h-if")); assert!(labels.contains("data-hemx-root")); assert!(labels.contains("data-hemx-slot")); 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 hemplate_highlighting_fixture_covers_documented_overlay_tokens() { // req: diagnostics/004 req: diagnostics/008 let fixture = include_str!("../../docs/fixtures/hemplate-highlighting/hemplate.heml"); let captures = include_str!("../../docs/fixtures/hemplate-highlighting/captures.tsv"); let allowed_captures = [ "@attribute.hemx", "@attribute.dynamic.hemplate", "@keyword.control.hemplate", "@punctuation.special.hemplate.escaped.open", "@punctuation.special.hemplate.escaped.close", "@punctuation.special.hemplate.trusted.open", "@punctuation.special.hemplate.trusted.close", "@embedded.rust.hemplate", ] .into_iter() .collect::>(); let mut seen = std::collections::BTreeSet::new(); for (index, line) in captures.lines().enumerate().skip(1) { let (capture, literal) = line .split_once('\t') .unwrap_or_else(|| panic!("capture row {index} must be TSV")); assert!( allowed_captures.contains(capture), "unexpected capture `{capture}` in row {index}" ); assert!( fixture.contains(literal), "highlight fixture missing literal `{literal}` for capture `{capture}`" ); seen.insert(capture); } assert_eq!( seen, allowed_captures, "highlight fixture should exercise every documented overlay capture class" ); } #[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_protocol_initialize_shutdown_and_unknown_requests_are_framed() { // req: diagnostics/008 let input = [ lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})), lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 2, "method": "hemx/unknown", "params": {}})), lsp_message(serde_json::json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown", "params": null})), 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); assert_eq!( messages.len(), 3, "initialize, unknown request, shutdown responses" ); assert_eq!(messages[0]["id"], 1); assert_eq!( messages[0]["result"]["capabilities"]["textDocumentSync"]["openClose"], true ); assert_eq!( messages[0]["result"]["capabilities"]["completionProvider"]["triggerCharacters"][0], "h" ); assert_eq!(messages[0]["result"]["capabilities"]["hoverProvider"], true); assert_eq!(messages[1]["id"], 2); assert_eq!(messages[1]["error"]["code"], -32601); assert!(messages[1]["error"]["message"] .as_str() .unwrap_or_default() .contains("hemx/unknown")); assert_eq!( messages[2], serde_json::json!({"jsonrpc": "2.0", "id": 3, "result": null}) ); } #[test] fn hover_reports_generated_target_kind_and_rust_symbol() { // req: diag/010 let uri = "file:///tmp/workout.heml"; let text = r#"
    "#; let cursor = text.find("progress_panel").expect("fixture target") + 2; let hover = hemplate_hover(uri, text, Some((0, cursor))).to_string(); assert!(hover.contains("`slot` target `progress_panel`"), "{hover}"); assert!(hover.contains("`ui::progress_panel`"), "{hover}"); } #[test] fn source_positions_use_lsp_utf16_columns() { assert_eq!( super::source_position("🙂 data-hemx-slot=\"row\"", "🙂 ".len()), serde_json::json!({ "line": 0, "character": 3 }) ); } #[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); let offending_attribute = "data-hemx-slot=\"todo_row\""; let attribute_start = invalid .find(offending_attribute) .expect("fixture attribute"); assert_eq!( published[0]["params"]["diagnostics"][0]["range"], serde_json::json!({ "start": { "line": 0, "character": attribute_start }, "end": { "line": 0, "character": attribute_start + offending_attribute.len() } }), "diagnostic must select the offending generated target" ); // req: diag/009 assert_eq!( published[0]["params"]["diagnostics"], serde_json::to_value( expected .iter() .map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid))) .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(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid))) .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 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(); 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) } }