fix(lsp): select offending template attributes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-19
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user