fix(lsp): select offending template attributes

This commit is contained in:
slhx agent
2026-07-13 10:50:54 +02:00
parent 0ca48923f5
commit 217061c242
3 changed files with 115 additions and 20 deletions
+111 -19
View File
@@ -37,7 +37,9 @@ fn run_diagnostics(operands: &[String]) -> ExitCode {
match hemx_build::diagnostics_for_heml_file(file) {
Ok(diagnostics) => {
let path = canonical_path(file);
let payload = publish_diagnostics_payload(&file_uri(&path), &diagnostics);
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")
@@ -160,32 +162,48 @@ fn serve_lsp<R: BufRead, W: Write>(reader: &mut R, writer: &mut W) -> io::Result
(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)?)?;
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)?)?;
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 = if let Some(text) = did_save_text(&message) {
diagnostics_for_uri_source(&uri, text)?
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())?
(
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)?
(
hemx_build::diagnostics_for_heml_file(&path)?,
std::fs::read_to_string(&path).ok(),
)
};
publish_lsp_diagnostics(writer, &uri, diagnostics)?;
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())?;
publish_lsp_diagnostics(writer, &uri, Vec::new(), None)?;
}
}
(None, "exit") => break,
@@ -245,33 +263,35 @@ fn publish_lsp_diagnostics<W: Write>(
writer: &mut W,
uri: &str,
diagnostics: Vec<Diagnostic>,
source: Option<&str>,
) -> io::Result<()> {
write_lsp_message(
writer,
&serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": publish_diagnostics_payload(uri, &diagnostics),
"params": publish_diagnostics_payload(uri, &diagnostics, source),
}),
)
}
fn publish_diagnostics_payload(uri: &str, diagnostics: &[Diagnostic]) -> serde_json::Value {
fn publish_diagnostics_payload(
uri: &str,
diagnostics: &[Diagnostic],
source: Option<&str>,
) -> serde_json::Value {
serde_json::json!({
"uri": uri,
"diagnostics": diagnostics
.iter()
.map(lsp_diagnostic)
.map(|diagnostic| lsp_diagnostic(diagnostic, source))
.collect::<Vec<_>>()
})
}
fn lsp_diagnostic(diagnostic: &Diagnostic) -> serde_json::Value {
fn lsp_diagnostic(diagnostic: &Diagnostic, source: Option<&str>) -> serde_json::Value {
serde_json::json!({
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"range": diagnostic_range(diagnostic, source),
"severity": diagnostic_lsp_severity(diagnostic.severity),
"source": "hemx-build",
"code": diagnostic_code(diagnostic.code),
@@ -688,6 +708,46 @@ fn diagnostic_code(code: DiagnosticCode) -> &'static str {
}
}
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,
@@ -1023,6 +1083,14 @@ mod tests {
);
}
#[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
@@ -1078,9 +1146,27 @@ mod tests {
.collect::<Vec<_>>();
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(lsp_diagnostic).collect::<Vec<_>>()).unwrap(),
serde_json::to_value(
expected
.iter()
.map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid)))
.collect::<Vec<_>>()
)
.unwrap(),
"didOpen diagnostics should match build diagnostics"
);
assert_eq!(
@@ -1144,7 +1230,13 @@ mod tests {
assert_eq!(published["params"]["uri"], uri);
assert_eq!(
published["params"]["diagnostics"],
serde_json::to_value(expected.iter().map(lsp_diagnostic).collect::<Vec<_>>()).unwrap(),
serde_json::to_value(
expected
.iter()
.map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid)))
.collect::<Vec<_>>()
)
.unwrap(),
"didSave without text/open document should match file diagnostics"
);
std::fs::remove_file(path).ok();