feat(diagnostics): add compiler-backed heml language service

Add hemx-lsp for stdio LSP diagnostics, completion, and hover while preserving HTML editor tooling for .heml files. Teach hemx-build to expose generated target and derive-known template context facts, including simple h-for locals, so editor help comes from build-owned facts instead of editor-only parsers. Wire VS Code/Cursor and Neovim documentation and extend the Workout exemplar with a real h-for plan loop for end-to-end proof.

req: diag/004

req: diag/005

req: diag/006
This commit is contained in:
slhx agent
2026-06-22 22:06:42 +02:00
parent 4bca046ed1
commit 797132647f
15 changed files with 1886 additions and 14 deletions
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "hemx-lsp"
version.workspace = true
edition.workspace = true
[dependencies]
hemx-build = { path = "../hemx-build" }
serde_json = "1"
+831
View File
@@ -0,0 +1,831 @@
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::<Vec<_>>().as_slice()),
Some("diagnostics") | Some("check") => run_diagnostics(args.collect::<Vec<_>>().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<R: BufRead, W: Write>(reader: &mut R, writer: &mut W) -> io::Result<()> {
let mut open_documents = HashMap::<String, String>::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<R: BufRead>(reader: &mut R) -> io::Result<Option<serde_json::Value>> {
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::<usize>().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<W: Write>(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<W: Write>(
writer: &mut W,
uri: &str,
diagnostics: Vec<Diagnostic>,
) -> 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::<Vec<_>>()
})
}
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<String> {
message
.get("params")?
.get("text")?
.as_str()
.map(str::to_owned)
}
fn text_document_uri(message: &serde_json::Value) -> Option<String> {
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<Vec<Diagnostic>> {
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<serde_json::Value> {
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<String> {
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::<Vec<_>>();
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::<String>();
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("</") {
return false;
}
let Some(open_end) = text[attr_start..].find('>').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!("</{tag_name}>");
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<String> {
let rest = text[tag_start + 1..].trim_start();
let name = rest
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-' || *ch == ':')
.collect::<String>();
(!name.is_empty()).then_some(name)
}
fn byte_offset_for_position(text: &str, position: (usize, usize)) -> Option<usize> {
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::{hemplate_completion_items, hemplate_hover, 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<ExercisePlan>, pub progress: String }
"#,
)
.expect("write lib");
let template = dir.join("workout.heml");
let text = r#"<main><p>{+ self. +}</p><li h-for="exercise in &self.plan">{+ exercise. +}{+ exercise.name +}</li><p>{+ self.progress +} {+ exercise. +} {+ exercise.name +}</p></main>"#;
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_serves_compiler_diagnostics_and_completion() {
// req: diagnostics/004 req: diagnostics/005
let bad_heml = r#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#;
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_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)
}
}