From 217061c2422949c02f37ad12779fa3ef446d72a3 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 13 Jul 2026 10:50:54 +0200 Subject: [PATCH] fix(lsp): select offending template attributes --- AGENTS.md | 2 +- REQUIREMENTS.md | 3 + hemx-lsp/src/main.rs | 130 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73cc3e5..0e0b6ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 -- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: invariant/004 req: invariant/006 req: check/001 req: check/003 +- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: invariant/004 req: invariant/006 req: check/001 req: check/003 - JS runtime changes must preserve root-scoped lookup, delegated listeners, postcard EffectBatch application, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 - Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012 - Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 9e01267..098cb9c 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -825,6 +825,9 @@ what a valid business email is. [north_star] ### req: diag/008 0 008 `hemx-lsp` must not proxy rust-analyzer or own a second Rust type system. [north_star] +### req: diag/009 +0 009 `hemx-lsp` diagnostics select the offending directive and target in the current `.heml` source when compiler metadata identifies them, rather than defaulting every error to line 0 column 0. [north_star] + --- ## test diff --git a/hemx-lsp/src/main.rs b/hemx-lsp/src/main.rs index fd32ee2..9221f98 100644 --- a/hemx-lsp/src/main.rs +++ b/hemx-lsp/src/main.rs @@ -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(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( 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), + "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::>() }) } -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::>(); 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::>()).unwrap(), + serde_json::to_value( + expected + .iter() + .map(|diagnostic| lsp_diagnostic(diagnostic, Some(invalid))) + .collect::>() + ) + .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::>()).unwrap(), + 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();