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:
@@ -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
@@ -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,
|
||||
} = ¤t.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
|
||||
|
||||
Reference in New Issue
Block a user