fix(build): fail closed on invalid loop locals

Recognize only one valid Rust identifier bound directly to a self vector field as a template local; reject malformed and non-vector bindings, preserve unknown element locals without fabricated fields, and deduplicate repeated locals.

req: diagnostics/006

req: surface/008

req: test/021
This commit is contained in:
slhx agent
2026-07-17 05:57:29 +02:00
parent 2e39fb17a1
commit a322e519d3
4 changed files with 106 additions and 43 deletions
+103 -40
View File
@@ -1994,37 +1994,36 @@ fn loop_locals_for_surface(
) -> 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,
});
}
let Some(value) = node
.attrs
.iter()
.find(|attr| attr.name == "h-for")
.and_then(|attr| attr.value.as_deref())
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
@@ -2032,17 +2031,14 @@ fn loop_locals_for_surface(
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 local = rust_ident(local.trim())?;
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()))
.next()
.filter(|field| !field.is_empty())?;
Some((local, field.to_owned()))
}
fn vec_element_type(type_name: &str) -> Option<String> {
@@ -3500,12 +3496,28 @@ fn main() {{
pub struct ExercisePlan { pub name: String, pub kg: f32 }
#[derive(Hemplate)]
pub struct Workout { pub plan: Vec<ExercisePlan>, pub progress: String }
pub struct Workout {
pub plan: Vec<ExercisePlan>,
pub alternate: std::vec::Vec<ExercisePlan>,
pub mysteries: Vec<MissingFact>,
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>"#;
let source = r#"
<main title="ghost in self.plan">
<p h-for="malformed">{+ self.progress +}</p>
<p h-for="(tuple, item) in self.plan">ignored</p>
<p h-for="unknown in self.unknown">ignored</p>
<p h-for="scalar in self.progress">ignored</p>
<li h-for="exercise in &self.plan">{+ exercise.name +}</li>
<li h-for="exercise in self.alternate">{+ exercise.kg +}</li>
<li h-for="mystery in self.mysteries">{+ mystery.anything +}</li>
<li h-for="later in self.plan[0]">{+ later.name +}</li>
</main>
"#;
std::fs::write(&template, source).expect("write heml");
let facts = template_context_facts_for_heml_source(&template, source)
@@ -3527,6 +3539,57 @@ fn main() {{
.fields
.iter()
.any(|field| field.name == "name" && field.type_name == "String"));
assert_eq!(
facts
.locals
.iter()
.filter(|local| local.name == "exercise")
.count(),
1
);
let mystery = facts
.locals
.iter()
.find(|local| local.name == "mystery")
.expect("unknown element type still exposes the local");
assert_eq!(mystery.type_name, "MissingFact");
assert!(mystery.fields.is_empty());
assert!(facts.locals.iter().any(|local| {
local.name == "later"
&& local.type_name == "ExercisePlan"
&& local.fields.iter().any(|field| field.name == "kg")
}));
for rejected in ["ghost", "tuple", "item", "unknown", "scalar"] {
assert!(
facts.locals.iter().all(|local| local.name != rejected),
"unexpected loop local {rejected}"
);
}
assert_eq!(
h_for_local_and_self_field(" item in &self.plan "),
Some(("item".into(), "plan".into()))
);
assert_eq!(
h_for_local_and_self_field("item in self.plan.iter()"),
Some(("item".into(), "plan".into()))
);
for invalid in [
"item:self.plan",
" in self.plan",
"1item in self.plan",
"item-name in self.plan",
"(item, index) in self.plan",
"item in plan",
"item in &plan",
"item in self.",
] {
assert_eq!(
h_for_local_and_self_field(invalid),
None,
"accepted invalid h-for {invalid:?}"
);
}
}
#[test]