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
+2
View File
@@ -9,3 +9,5 @@ path = "src/lib.rs"
[dependencies]
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
hemx-core = { path = "../hemx-core" }
quote = "1"
syn = { version = "2", features = ["full"] }
+466 -12
View File
@@ -1,10 +1,11 @@
use hemplate_core::ast::build_ast;
use hemplate_core::surface::{
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
SurfaceDocument, SurfaceNodeKind,
SurfaceDocument, SurfaceNodeKind, SurfaceScope,
};
use hemx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
use std::collections::{BTreeMap, BTreeSet};
use quote::ToTokens;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -58,6 +59,114 @@ pub struct AppBuilder {
surfaces: Vec<(PathBuf, SurfaceDocument)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedTarget {
pub kind: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateContextFacts {
pub context_type: String,
pub self_fields: Vec<TemplateFieldFact>,
pub locals: Vec<TemplateLocalFact>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateFieldFact {
pub name: String,
pub type_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateLocalFact {
pub name: String,
pub type_name: String,
pub fields: Vec<TemplateFieldFact>,
}
#[derive(Debug, Clone)]
struct RustStructFact {
fields: Vec<TemplateFieldFact>,
derives_hemplate: bool,
}
pub fn diagnostics_for_heml_file(path: impl AsRef<Path>) -> io::Result<Vec<Diagnostic>> {
let path = path.as_ref();
let source = std::fs::read_to_string(path)?;
diagnostics_for_heml_source(path, source)
}
pub fn diagnostics_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Vec<Diagnostic>> {
let path = path.as_ref();
let surface = surface_for_heml_source(path, source.into())?;
Ok(unkeyed_generated_target_diagnostics(path, &surface))
}
pub fn generated_targets_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Vec<GeneratedTarget>> {
let path = path.as_ref();
let surface = surface_for_heml_source(path, source.into())?;
Ok(generated_targets(&surface))
}
pub fn template_context_facts_for_heml_file(
path: impl AsRef<Path>,
) -> io::Result<Option<TemplateContextFacts>> {
let path = path.as_ref();
let source = std::fs::read_to_string(path)?;
template_context_facts_for_heml_source(path, source)
}
pub fn template_context_facts_for_heml_source(
path: impl AsRef<Path>,
source: impl Into<String>,
) -> io::Result<Option<TemplateContextFacts>> {
let path = path.as_ref();
let source = source.into();
let Some(context_type) = context_type_for_heml_path(path) else {
return Ok(None);
};
let Some(root) = nearest_dir_with(path, "Cargo.toml") else {
return Ok(None);
};
let structs = rust_struct_facts_in(&root)?;
let Some(context) = structs.get(&context_type) else {
return Ok(None);
};
if !context.derives_hemplate {
return Ok(None);
}
let surface = surface_for_heml_source(path, source)?;
let locals = loop_locals_for_surface(&surface, &context.fields, &structs);
Ok(Some(TemplateContextFacts {
context_type,
self_fields: context.fields.clone(),
locals,
}))
}
fn surface_for_heml_source(path: &Path, source: String) -> io::Result<SurfaceDocument> {
let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(path, err))?;
let Some(doc) = doc else {
return Ok(SurfaceDocument {
nodes: Vec::new(),
scopes: vec![SurfaceScope {
parent: None,
kind: ScopeKind::Root,
}],
forms: Vec::new(),
});
};
Ok(extract_surface(&doc))
}
impl AppBuilder {
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
self.out_dir = Some(out_dir.into());
@@ -1575,28 +1684,285 @@ fn invalid_hemx_value(path: &Path, attr: &str, value: &str, expectation: &str) -
fn reject_unkeyed_loop(
surface: &SurfaceDocument,
mut scope: ScopeId,
scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> io::Result<()> {
if let Some(diagnostic) =
unkeyed_generated_target_diagnostic_for_scope(surface, scope, path, kind, name)
{
Err(diagnostic.to_io_error())
} else {
Ok(())
}
}
fn context_type_for_heml_path(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let mut out = String::new();
let mut upper_next = true;
for ch in stem.chars() {
if ch == '_' || ch == '-' {
upper_next = true;
continue;
}
if upper_next {
out.extend(ch.to_uppercase());
upper_next = false;
} else {
out.push(ch);
}
}
(!out.is_empty()).then_some(out)
}
fn nearest_dir_with(path: &Path, file_name: &str) -> Option<PathBuf> {
for dir in path.ancestors().filter(|path| path.is_dir()) {
if dir.join(file_name).is_file() {
return Some(dir.to_path_buf());
}
}
None
}
fn rust_struct_facts_in(root: &Path) -> io::Result<HashMap<String, RustStructFact>> {
let mut facts = HashMap::new();
collect_rust_struct_facts(&root.join("src"), &mut facts)?;
Ok(facts)
}
fn collect_rust_struct_facts(
dir: &Path,
facts: &mut HashMap<String, RustStructFact>,
) -> io::Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_rust_struct_facts(&path, facts)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
collect_rust_struct_facts_from_file(&path, facts)?;
}
}
Ok(())
}
fn collect_rust_struct_facts_from_file(
path: &Path,
facts: &mut HashMap<String, RustStructFact>,
) -> io::Result<()> {
let source = std::fs::read_to_string(path)?;
let file =
syn::parse_file(&source).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
collect_rust_struct_facts_from_items(&file.items, facts);
Ok(())
}
fn collect_rust_struct_facts_from_items(
items: &[syn::Item],
facts: &mut HashMap<String, RustStructFact>,
) {
for item in items {
match item {
syn::Item::Struct(item) => {
if let syn::Fields::Named(fields) = &item.fields {
facts.insert(
item.ident.to_string(),
RustStructFact {
fields: fields
.named
.iter()
.filter_map(|field| {
Some(TemplateFieldFact {
name: field.ident.as_ref()?.to_string(),
type_name: compact_tokens(&field.ty),
})
})
.collect(),
derives_hemplate: derives_hemplate(&item.attrs),
},
);
}
}
syn::Item::Mod(item) => {
if let Some((_, items)) = &item.content {
collect_rust_struct_facts_from_items(items, facts);
}
}
_ => {}
}
}
}
fn derives_hemplate(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "derive")
&& attr
.meta
.require_list()
.ok()
.is_some_and(|list| list.tokens.to_string().contains("Hemplate"))
})
}
fn compact_tokens(tokens: &impl ToTokens) -> String {
tokens
.to_token_stream()
.to_string()
.replace(" :: ", "::")
.replace(" < ", "<")
.replace(" >", ">")
.replace(" ,", ",")
.replace(" & ", "&")
.replace("& ", "&")
}
fn loop_locals_for_surface(
surface: &SurfaceDocument,
self_fields: &[TemplateFieldFact],
structs: &HashMap<String, RustStructFact>,
) -> Vec<TemplateLocalFact> {
let mut locals = Vec::new();
for node in &surface.nodes {
for attr in &node.attrs {
if attr.name != "h-for" {
continue;
}
let Some(value) = &attr.value else {
continue;
};
let Some((local, field)) = h_for_local_and_self_field(value) else {
continue;
};
let Some(self_field) = self_fields.iter().find(|candidate| candidate.name == field)
else {
continue;
};
let Some(type_name) = vec_element_type(&self_field.type_name) else {
continue;
};
let fields = structs
.get(&type_name)
.map(|fact| fact.fields.clone())
.unwrap_or_default();
if !locals
.iter()
.any(|existing: &TemplateLocalFact| existing.name == local)
{
locals.push(TemplateLocalFact {
name: local,
type_name,
fields,
});
}
}
}
locals
}
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 vec_element_type(type_name: &str) -> Option<String> {
let inner = type_name
.strip_prefix("Vec<")
.or_else(|| type_name.strip_prefix("std::vec::Vec<"))?
.strip_suffix('>')?;
Some(inner.trim().to_owned())
}
fn unkeyed_generated_target_diagnostics(path: &Path, surface: &SurfaceDocument) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for (node_scope, target) in generated_targets_with_scope(surface) {
if let Some(diagnostic) = unkeyed_generated_target_diagnostic_for_scope(
surface,
node_scope,
path,
&target.kind,
&target.name,
) {
diagnostics.push(diagnostic);
}
}
diagnostics
}
fn generated_targets(surface: &SurfaceDocument) -> Vec<GeneratedTarget> {
let mut targets = Vec::new();
for (_, target) in generated_targets_with_scope(surface) {
if !targets.iter().any(|existing: &GeneratedTarget| {
existing.kind == target.kind && existing.name == target.name
}) {
targets.push(target);
}
}
targets
}
fn generated_targets_with_scope(surface: &SurfaceDocument) -> Vec<(ScopeId, GeneratedTarget)> {
let mut targets = Vec::new();
for node in &surface.nodes {
for attr in &node.attrs {
let Some(kind) = attr.name.strip_prefix("data-hemx-") else {
continue;
};
if !matches!(kind, "slot" | "form" | "handle") {
continue;
}
let Some(name) = &attr.value else {
continue;
};
targets.push((
node.scope,
GeneratedTarget {
kind: kind.to_owned(),
name: name.to_owned(),
},
));
}
}
targets
}
fn unkeyed_generated_target_diagnostic_for_scope(
surface: &SurfaceDocument,
mut scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> Option<Diagnostic> {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
return Ok(());
};
let current = surface.scopes.get(scope.0 as usize)?;
if let ScopeKind::For {
pattern,
expr,
key_expr: None,
} = &current.kind
{
return Err(
unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(),
);
return Some(unkeyed_generated_target_diagnostic(
path, kind, name, pattern, expr,
));
}
let Some(parent) = current.parent else {
return Ok(());
};
let parent = current.parent?;
scope = parent;
}
}
@@ -2117,6 +2483,94 @@ fn main() {{
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn diagnostics_for_heml_file_reports_unkeyed_generated_target() {
// req: diagnostics/002 req: diagnostics/004
let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target");
std::fs::create_dir_all(&dir).expect("create test dir");
let template = dir.join("todo.heml");
std::fs::write(
&template,
r#"<main data-hemx-root="todos"><template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template></main>"#,
)
.expect("write template");
let diagnostics = diagnostics_for_heml_file(&template).expect("diagnostics");
assert_eq!(diagnostics.len(), 1);
let diagnostic = &diagnostics[0];
assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget);
assert_eq!(diagnostic.directive, "data-hemx-slot");
assert_eq!(diagnostic.target, "todo_row");
assert!(diagnostic.repair.contains("h-key=\"todo.id\""));
}
#[test]
fn generated_targets_for_heml_source_uses_surface_facts() {
// req: diagnostics/004 req: diagnostics/005
let targets = generated_targets_for_heml_source(
"inline.heml",
r#"<main data-hemx-root="todos"><section data-hemx-slot="summary"></section><form data-hemx-form="save"><button data-hemx-handle="submit">Save</button></form></main>"#,
)
.expect("generated targets");
assert_eq!(targets.len(), 3);
assert!(targets
.iter()
.any(|target| target.kind == "slot" && target.name == "summary"));
assert!(targets
.iter()
.any(|target| target.kind == "form" && target.name == "save"));
assert!(targets
.iter()
.any(|target| target.kind == "handle" && target.name == "submit"));
}
#[test]
fn template_context_facts_include_self_fields_and_h_for_local_fields() {
// req: diagnostics/006
let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields");
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#"
#[derive(Clone)]
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 source = r#"<main><p>{+ self.progress +}</p><li h-for="exercise in &self.plan">{+ exercise.name +}</li></main>"#;
std::fs::write(&template, source).expect("write heml");
let facts = template_context_facts_for_heml_source(&template, source)
.expect("facts")
.expect("derive facts");
assert_eq!(facts.context_type, "Workout");
assert!(facts
.self_fields
.iter()
.any(|field| field.name == "progress" && field.type_name == "String"));
let local = facts
.locals
.iter()
.find(|local| local.name == "exercise")
.expect("exercise local");
assert_eq!(local.type_name, "ExercisePlan");
assert!(local
.fields
.iter()
.any(|field| field.name == "name" && field.type_name == "String"));
}
#[test]
fn unkeyed_generated_target_diagnostic_is_structured() {
// req: diagnostics/002