feat(build): surface structured template diagnostics

Add public .heml syntax authority and refactor the unkeyed generated-target error into structured diagnostic data while preserving the human compiler error.

req: diagnostics/001

req: diagnostics/002
This commit is contained in:
slhx agent
2026-06-12 18:11:39 +02:00
parent e7ff4541a7
commit b8c83fee25
5 changed files with 187 additions and 11 deletions
+95 -8
View File
@@ -9,6 +9,47 @@ use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiagnosticSeverity {
Error,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiagnosticCode {
UnkeyedGeneratedTarget,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Diagnostic {
pub code: DiagnosticCode,
pub severity: DiagnosticSeverity,
pub file: PathBuf,
pub directive: String,
pub target: String,
pub message: String,
pub expected: String,
pub repair: String,
}
impl Diagnostic {
pub fn to_io_error(&self) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, self.to_string())
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {}; expected {}; repair: {}",
self.file.display(),
self.message,
self.expected,
self.repair
)
}
}
#[derive(Clone, Debug)]
pub struct AppBuilder {
out_dir: Option<PathBuf>,
@@ -1549,13 +1590,9 @@ fn reject_unkeyed_loop(
key_expr: None,
} = &current.kind
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key; add a stable h-key=\"{pattern}.id\" to that h-for so generated keyed helpers such as ui::{name}.replace(row) can target this partial. Dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets.",
path.display()
),
));
return Err(
unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(),
);
}
let Some(parent) = current.parent else {
return Ok(());
@@ -1564,6 +1601,31 @@ fn reject_unkeyed_loop(
}
}
fn unkeyed_generated_target_diagnostic(
path: &Path,
kind: &str,
name: &str,
pattern: &str,
expr: &str,
) -> Diagnostic {
Diagnostic {
code: DiagnosticCode::UnkeyedGeneratedTarget,
severity: DiagnosticSeverity::Error,
file: path.to_path_buf(),
directive: format!("data-hemx-{kind}"),
target: name.to_owned(),
message: format!(
"data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key"
),
expected: format!(
"a stable template h-key on h-for=\"{pattern} in {expr}\" so generated keyed helpers such as ui::{name}.replace(row) can target this partial"
),
repair: format!(
"add h-key=\"{pattern}.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets"
),
}
}
fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
@@ -2055,6 +2117,31 @@ fn main() {{
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn unkeyed_generated_target_diagnostic_is_structured() {
// req: diagnostics/002
let diagnostic = unkeyed_generated_target_diagnostic(
Path::new("templates/todo.heml"),
"slot",
"todo_row",
"todo",
"&self.todos",
);
assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget);
assert_eq!(diagnostic.severity, DiagnosticSeverity::Error);
assert_eq!(diagnostic.file, PathBuf::from("templates/todo.heml"));
assert_eq!(diagnostic.directive, "data-hemx-slot");
assert_eq!(diagnostic.target, "todo_row");
assert!(diagnostic
.message
.contains("h-for=\"todo in &self.todos\" without h-key"));
assert!(diagnostic.expected.contains("ui::todo_row.replace(row)"));
assert!(diagnostic.repair.contains("h-key=\"todo.id\""));
assert!(diagnostic.repair.contains("dynamic +data-key on the child"));
assert!(diagnostic.to_string().contains("templates/todo.heml"));
}
#[test]
fn rejects_hemx_resources_inside_unkeyed_for() {
// req: scope/001 req: diagnostics/002
@@ -2096,7 +2183,7 @@ fn main() {{
assert!(err.contains("h-key=\"todo.id\""));
assert!(err.contains("generated keyed helpers"));
assert!(err.contains(helper));
assert!(err.contains("Dynamic +data-key on the child"));
assert!(err.contains("dynamic +data-key on the child"));
let _ = std::fs::remove_dir_all(&base);
}