on master help sign in
Add form elements, conditionals, loops, accessibility, error handling, caching, and LSP improvements
commit15d080a4d8fae696811cab4dc5dbb58279fe9c3b authoriko <mail@iko.soy> dateTue 14 Apr 2026, 15:24 (6 months ago) parent1d60aee
10 files changed+724−42
src/ast.rs+8 src/bin/htmlang-lsp.rs+60 src/codegen.rs+88−15 src/main.rs+55 src/parser.rs+279−27 tests/snapshots.rs+187 tests/snapshots/accessibility.hl+6 tests/snapshots/conditionals.hl+21 tests/snapshots/form_elements.hl+12 tests/snapshots/loops.hl+8
src/ast.rs+8
@@ -30,6 +30,7 @@ pub struct Element {
3030 pub attrs: Vec<Attribute>,
3131 pub argument: Option<String>,
3232 pub children: Vec<Node>,
33+ pub line_num: usize,
3334}
3435
3536#[derive(Debug, Clone, PartialEq)]
@@ -42,6 +43,13 @@ pub enum ElementKind {
4243 Image,
4344 Link,
4445 Children,
46+ // Form elements
47+ Input,
48+ Button,
49+ Select,
50+ Textarea,
51+ Option,
52+ Label,
4553}
4654
4755#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+60
@@ -301,6 +301,14 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
301301 ("@image", "Image element"),
302302 ("@img", "Image element (short for @image)"),
303303 ("@link", "Anchor/link element"),
304+ ("@input", "Form input element (self-closing)"),
305+ ("@button", "Button element"),
306+ ("@btn", "Button element (short for @button)"),
307+ ("@select", "Select dropdown element"),
308+ ("@textarea", "Multi-line text input"),
309+ ("@option", "Option inside @select"),
310+ ("@opt", "Option (short for @option)"),
311+ ("@label", "Label element"),
304312 ("@raw", "Raw HTML escape hatch"),
305313 ("@children", "Slot for caller's children (inside @fn)"),
306314 ]
@@ -316,6 +324,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
316324 ("@define", "Define an attribute bundle", "@define "),
317325 ("@fn", "Define a reusable function", "@fn "),
318326 ("@keyframes", "Define a CSS animation", "@keyframes "),
327+ ("@if", "Conditional rendering", "@if "),
328+ ("@each", "Loop over comma-separated values", "@each "),
329+ ("@include", "Include another .hl file", "@include "),
319330 ]
320331 .iter()
321332 .map(|(name, detail, insert)| {
@@ -376,6 +387,29 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
376387 ("class", "HTML class attribute", true),
377388 // Animation
378389 ("animation", "CSS animation (e.g., name 0.3s ease)", true),
390+ // Form
391+ ("type", "Input type (text/email/password/submit/...)", true),
392+ ("placeholder", "Placeholder text", true),
393+ ("name", "Form field name", true),
394+ ("value", "Form field value", true),
395+ ("disabled", "Disable the element", false),
396+ ("required", "Mark field as required", false),
397+ ("checked", "Checkbox/radio checked state", false),
398+ ("for", "Label target (id of associated input)", true),
399+ ("action", "Form action URL", true),
400+ ("method", "Form method (get/post)", true),
401+ ("rows", "Textarea rows", true),
402+ ("cols", "Textarea columns", true),
403+ ("maxlength", "Maximum input length", true),
404+ ("multiple", "Allow multiple selections", false),
405+ // Accessibility
406+ ("alt", "Alternative text (for images)", true),
407+ ("role", "ARIA role", true),
408+ ("tabindex", "Tab order", true),
409+ ("title", "Tooltip text", true),
410+ ("aria-label", "Accessible label", true),
411+ ("aria-hidden", "Hide from assistive tech (true/false)", true),
412+ ("data-", "Custom data attribute", true),
379413 // State prefixes
380414 ("hover:", "Style on hover", false),
381415 ("active:", "Style on active/click", false),
@@ -630,6 +664,15 @@ fn hover_builtin(word: &str) -> Option<String> {
630664 "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
631665 "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
632666 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
667+ "@input" => "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`",
668+ "@button" | "@btn" => "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`",
669+ "@select" => "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`",
670+ "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
671+ "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
672+ "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
673+ "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else\n @el [background white]\n```",
674+ "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\n\n```\n@each $color in red,green,blue\n @el [background $color]\n```",
675+ "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file.\n\nUsage: `@include header.hl`",
633676 // Attributes
634677 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
635678 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, 3, or 4 values.",
@@ -671,6 +714,23 @@ fn hover_builtin(word: &str) -> Option<String> {
671714 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
672715 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
673716 "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
717+ // Form attributes
718+ "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
719+ "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
720+ "name" => "**name** `<value>` \u{2014} Form field name.",
721+ "value" => "**value** `<value>` \u{2014} Form field value.",
722+ "disabled" => "**disabled** \u{2014} Disable the element.",
723+ "required" => "**required** \u{2014} Mark field as required.",
724+ "checked" => "**checked** \u{2014} Checkbox/radio checked state.",
725+ "for" => "**for** `<id>` \u{2014} Label target (id of the associated input).",
726+ "rows" => "**rows** `<value>` \u{2014} Number of visible rows for textarea.",
727+ "cols" => "**cols** `<value>` \u{2014} Number of visible columns for textarea.",
728+ "maxlength" => "**maxlength** `<value>` \u{2014} Maximum input length.",
729+ // Accessibility
730+ "alt" => "**alt** `<value>` \u{2014} Alternative text for images.",
731+ "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
732+ "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
733+ "title" => "**title** `<value>` \u{2014} Tooltip text.",
674734 _ => return None,
675735 };
676736
src/codegen.rs+88−15
@@ -289,6 +289,39 @@ fn generate_node(
289289 }
290290}
291291
292+/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
293+const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
294+ "type", "placeholder", "name", "value", "disabled", "required", "checked",
295+ "for", "action", "method", "autocomplete",
296+ "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
297+ "alt", "role", "tabindex", "title",
298+];
299+
300+/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
301+const BOOLEAN_HTML_ATTRS: &[&str] = &["disabled", "required", "checked", "multiple"];
302+
303+fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
304+ for attr in attrs {
305+ let key = attr.key.as_str();
306+ let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
307+ || key.starts_with("aria-")
308+ || key.starts_with("data-");
309+ if !is_passthrough {
310+ continue;
311+ }
312+ if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
313+ out.push(' ');
314+ out.push_str(key);
315+ } else if let Some(val) = &attr.value {
316+ out.push(' ');
317+ out.push_str(key);
318+ out.push_str("=\"");
319+ out.push_str(&html_escape(val));
320+ out.push('"');
321+ }
322+ }
323+}
324+
292325fn generate_element(
293326 elem: &Element,
294327 parent_kind: Option<&ElementKind>,
@@ -296,8 +329,9 @@ fn generate_element(
296329 styles: &mut StyleCollector,
297330 ctx: &mut GenContext,
298331) {
299− if elem.kind == ElementKind::Image {
300− generate_image(elem, parent_kind, out, styles, ctx);
332+ // Self-closing elements
333+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input) {
334+ generate_self_closing(elem, parent_kind, out, styles, ctx);
301335 return;
302336 }
303337 if elem.kind == ElementKind::Children {
@@ -309,7 +343,12 @@ fn generate_element(
309343 ElementKind::Text => "span",
310344 ElementKind::Paragraph => "p",
311345 ElementKind::Link => "a",
312− ElementKind::Image | ElementKind::Children => unreachable!(),
346+ ElementKind::Button => "button",
347+ ElementKind::Select => "select",
348+ ElementKind::Textarea => "textarea",
349+ ElementKind::Option => "option",
350+ ElementKind::Label => "label",
351+ ElementKind::Image | ElementKind::Input | ElementKind::Children => unreachable!(),
313352 };
314353
315354 let kind_label = match elem.kind {
@@ -319,6 +358,11 @@ fn generate_element(
319358 ElementKind::Text => "text",
320359 ElementKind::Paragraph => "paragraph",
321360 ElementKind::Link => "link",
361+ ElementKind::Button => "button",
362+ ElementKind::Select => "select",
363+ ElementKind::Textarea => "textarea",
364+ ElementKind::Option => "option",
365+ ElementKind::Label => "label",
322366 _ => "",
323367 };
324368
@@ -326,9 +370,9 @@ fn generate_element(
326370 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
327371 let (id, user_class) = extract_id_class(&elem.attrs);
328372
329− if ctx.dev {
373+ if ctx.dev && elem.line_num > 0 {
330374 out.push_str(&ctx.indent());
331− out.push_str(&format!("<!-- @{} -->\n", kind_label));
375+ out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
332376 }
333377 out.push_str(&ctx.indent());
334378 out.push('<');
@@ -349,11 +393,21 @@ fn generate_element(
349393 out.push_str(&html_escape(&id));
350394 out.push('"');
351395 }
396+
397+ emit_html_passthrough_attrs(out, &elem.attrs);
398+
352399 out.push('>');
353400 out.push_str(ctx.nl());
354401
355− // Inline text argument (@text content)
356− if elem.kind == ElementKind::Text {
402+ // Inline text argument
403+ if matches!(
404+ elem.kind,
405+ ElementKind::Text
406+ | ElementKind::Button
407+ | ElementKind::Label
408+ | ElementKind::Option
409+ | ElementKind::Textarea
410+ ) {
357411 if let Some(text) = &elem.argument {
358412 out.push_str(&html_escape(text));
359413 }
@@ -377,25 +431,37 @@ fn generate_element(
377431 out.push_str(ctx.nl());
378432}
379433
380−fn generate_image(
434+fn generate_self_closing(
381435 elem: &Element,
382436 parent_kind: Option<&ElementKind>,
383437 out: &mut String,
384438 styles: &mut StyleCollector,
385439 ctx: &GenContext,
386440) {
387− let src = elem.argument.as_deref().unwrap_or("");
388441 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
389442 let (id, user_class) = extract_id_class(&elem.attrs);
390443
391− if ctx.dev {
444+ let (tag, kind_label) = match elem.kind {
445+ ElementKind::Image => ("img", "image"),
446+ ElementKind::Input => ("input", "input"),
447+ _ => unreachable!(),
448+ };
449+
450+ if ctx.dev && elem.line_num > 0 {
392451 out.push_str(&ctx.indent());
393− out.push_str("<!-- @image -->\n");
452+ out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
394453 }
395454 out.push_str(&ctx.indent());
396− out.push_str("<img src=\"");
397− out.push_str(&html_escape(src));
398− out.push('"');
455+ out.push('<');
456+ out.push_str(tag);
457+
458+ // Image src
459+ if elem.kind == ElementKind::Image {
460+ let src = elem.argument.as_deref().unwrap_or("");
461+ out.push_str(" src=\"");
462+ out.push_str(&html_escape(src));
463+ out.push('"');
464+ }
399465
400466 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
401467
@@ -404,6 +470,9 @@ fn generate_image(
404470 out.push_str(&html_escape(&id));
405471 out.push('"');
406472 }
473+
474+ emit_html_passthrough_attrs(out, &elem.attrs);
475+
407476 out.push('>');
408477 out.push_str(ctx.nl());
409478}
@@ -783,8 +852,12 @@ fn attrs_to_css(
783852 }
784853 }
785854
786− // Identity — not CSS
855+ // Identity and HTML passthrough — not CSS
787856 "id" | "class" => {}
857+ "type" | "placeholder" | "name" | "value" | "disabled" | "required"
858+ | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
859+ | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
860+ | "multiple" | "alt" | "role" | "tabindex" | "title" => {}
788861
789862 _ => {}
790863 }
src/main.rs+55
@@ -1,5 +1,7 @@
1+use std::collections::HashMap;
12use std::env;
23use std::fs;
4+use std::hash::{Hash, Hasher};
35use std::path::{Path, PathBuf};
46use std::process;
57
@@ -21,6 +23,9 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
2123 htmlang::parser::Severity::Warning => "warning",
2224 };
2325 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
26+ if let Some(ref src) = d.source_line {
27+ eprintln!(" | {}", src);
28+ }
2429 }
2530
2631 let has_errors = result
@@ -143,17 +148,67 @@ fn main() {
143148
144149 eprintln!("watching for changes...");
145150
151+ // Track content hashes for incremental rebuilds
152+ let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
153+
154+ fn hash_file(path: &Path) -> Option<u64> {
155+ let content = fs::read(path).ok()?;
156+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
157+ content.hash(&mut hasher);
158+ Some(hasher.finish())
159+ }
160+
161+ // Seed initial hashes
162+ if let Some(h) = hash_file(&input_canonical) {
163+ content_hashes.insert(input_canonical.clone(), h);
164+ }
165+ for inc in &included_files {
166+ if let Some(h) = hash_file(inc) {
167+ content_hashes.insert(inc.clone(), h);
168+ }
169+ }
170+
146171 loop {
147172 match rx.recv() {
148173 Ok(_) => {
149174 // Drain additional events (debounce)
150175 while rx.try_recv().is_ok() {}
151176
177+ // Check if any file content actually changed
178+ let mut any_changed = false;
179+ let check_path = |path: &Path,
180+ hashes: &mut HashMap<PathBuf, u64>|
181+ -> bool {
182+ if let Some(h) = hash_file(path) {
183+ if hashes.get(path) != Some(&h) {
184+ hashes.insert(path.to_path_buf(), h);
185+ return true;
186+ }
187+ }
188+ false
189+ };
190+
191+ if check_path(&input_canonical, &mut content_hashes) {
192+ any_changed = true;
193+ }
194+ for inc in &included_files {
195+ if check_path(inc, &mut content_hashes) {
196+ any_changed = true;
197+ }
198+ }
199+
200+ if !any_changed {
201+ continue;
202+ }
203+
152204 eprintln!("\nrecompiling...");
153205 let (_, new_includes) = compile(&input_path, dev);
154206
155207 for inc in &new_includes {
156208 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
209+ if let Some(h) = hash_file(inc) {
210+ content_hashes.insert(inc.clone(), h);
211+ }
157212 }
158213
159214 if let Some(ref tx) = reload_tx {
src/parser.rs+279−27
@@ -19,6 +19,7 @@ pub struct Diagnostic {
1919 pub line: usize,
2020 pub message: String,
2121 pub severity: Severity,
22+ pub source_line: Option<String>,
2223}
2324
2425pub struct ParseResult {
@@ -73,6 +74,8 @@ struct ParseContext {
7374 base_path: Option<PathBuf>,
7475 included_files: Vec<PathBuf>,
7576 include_stack: Vec<PathBuf>,
77+ fn_call_stack: Vec<String>,
78+ file_cache: HashMap<PathBuf, String>,
7679}
7780
7881struct Parser {
@@ -98,8 +101,11 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
98101 base_path: base_path.map(|p| p.to_path_buf()),
99102 included_files: Vec::new(),
100103 include_stack: Vec::new(),
104+ fn_call_stack: Vec::new(),
105+ file_cache: HashMap::new(),
101106 };
102107 let nodes = parser.parse_children(0, &mut ctx);
108+ validate_tree(&nodes, None, &mut ctx.diagnostics);
103109 ParseResult {
104110 document: Document {
105111 page_title: ctx.page_title,
@@ -234,6 +240,7 @@ impl Parser {
234240 line: e.line,
235241 message: e.message,
236242 severity: Severity::Error,
243+ source_line: None,
237244 });
238245 // Skip forward past any deeper-indented children of the errored line
239246 while self.pos < self.lines.len()
@@ -313,37 +320,164 @@ impl Parser {
313320 line: line_num,
314321 message: format!("circular include '{}'", filename),
315322 severity: Severity::Error,
323+ source_line: Some(content.clone()),
316324 });
317325 return Ok(None);
318326 }
319327
320− match std::fs::read_to_string(&resolved) {
321− Ok(included_text) => {
322− ctx.included_files.push(resolved.clone());
323− ctx.include_stack.push(resolved.clone());
324− let saved_base = ctx.base_path.clone();
325− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
326−
327− let included_lines = preprocess(&included_text);
328− let mut included_parser = Parser {
329− lines: included_lines,
330− pos: 0,
331− };
332− let nodes = included_parser.parse_children(0, ctx);
333−
334− ctx.base_path = saved_base;
335− ctx.include_stack.pop();
336− return Ok(Some(nodes));
328+ // Use file cache to avoid redundant reads
329+ let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
330+ cached.clone()
331+ } else {
332+ match std::fs::read_to_string(&resolved) {
333+ Ok(text) => {
334+ ctx.file_cache.insert(resolved.clone(), text.clone());
335+ text
336+ }
337+ Err(e) => {
338+ ctx.diagnostics.push(Diagnostic {
339+ line: line_num,
340+ message: format!("cannot include '{}': {}", filename, e),
341+ severity: Severity::Error,
342+ source_line: Some(content.clone()),
343+ });
344+ return Ok(None);
345+ }
337346 }
338− Err(e) => {
339− ctx.diagnostics.push(Diagnostic {
340− line: line_num,
341− message: format!("cannot include '{}': {}", filename, e),
342− severity: Severity::Error,
343− });
344− return Ok(None);
347+ };
348+
349+ ctx.included_files.push(resolved.clone());
350+ ctx.include_stack.push(resolved.clone());
351+ let saved_base = ctx.base_path.clone();
352+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
353+
354+ let included_lines = preprocess(&included_text);
355+ let mut included_parser = Parser {
356+ lines: included_lines,
357+ pos: 0,
358+ };
359+ let nodes = included_parser.parse_children(0, ctx);
360+
361+ ctx.base_path = saved_base;
362+ ctx.include_stack.pop();
363+ return Ok(Some(nodes));
364+ }
365+
366+ // --- @if / @else ---
367+
368+ if let Some(rest) = content.strip_prefix("@if ") {
369+ let rest = rest.trim();
370+ let condition = substitute_vars(rest, &ctx.variables);
371+ let result = evaluate_condition(&condition);
372+
373+ // Collect then-body lines
374+ let mut then_lines = Vec::new();
375+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
376+ then_lines.push(self.lines[self.pos].clone());
377+ self.pos += 1;
378+ }
379+
380+ // Check for @else at same indent
381+ let mut else_lines = Vec::new();
382+ if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
383+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
384+ if s.trim() == "@else" {
385+ self.pos += 1; // consume @else
386+ while self.pos < self.lines.len()
387+ && self.lines[self.pos].indent > current_indent
388+ {
389+ else_lines.push(self.lines[self.pos].clone());
390+ self.pos += 1;
391+ }
392+ }
345393 }
346394 }
395+
396+ let body_lines = if result { then_lines } else { else_lines };
397+ if body_lines.is_empty() {
398+ return Ok(None);
399+ }
400+
401+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
402+ let adjusted: Vec<Line> = body_lines
403+ .iter()
404+ .map(|l| Line {
405+ indent: l.indent - min_indent,
406+ content: l.content.clone(),
407+ line_num: l.line_num,
408+ })
409+ .collect();
410+
411+ let mut body_parser = Parser {
412+ lines: adjusted,
413+ pos: 0,
414+ };
415+ let nodes = body_parser.parse_children(0, ctx);
416+ return Ok(Some(nodes));
417+ }
418+
419+ if content.trim() == "@else" {
420+ return Err(ParseError {
421+ line: line_num,
422+ message: "@else without matching @if".to_string(),
423+ });
424+ }
425+
426+ // --- @each loop ---
427+
428+ if let Some(rest) = content.strip_prefix("@each ") {
429+ let rest = rest.trim();
430+ let parts: Vec<&str> = rest.splitn(3, ' ').collect();
431+ if parts.len() < 3 || parts[1] != "in" {
432+ return Err(ParseError {
433+ line: line_num,
434+ message: "@each requires: @each $var in list".to_string(),
435+ });
436+ }
437+ let var_name = parts[0].strip_prefix('$').unwrap_or(parts[0]).to_string();
438+ let list_str = substitute_vars(parts[2], &ctx.variables);
439+ let items: Vec<String> = list_str
440+ .split(',')
441+ .map(|s| s.trim().to_string())
442+ .filter(|s| !s.is_empty())
443+ .collect();
444+
445+ // Collect body lines
446+ let mut body_lines = Vec::new();
447+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
448+ body_lines.push(self.lines[self.pos].clone());
449+ self.pos += 1;
450+ }
451+
452+ if body_lines.is_empty() || items.is_empty() {
453+ return Ok(None);
454+ }
455+
456+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
457+ let adjusted: Vec<Line> = body_lines
458+ .iter()
459+ .map(|l| Line {
460+ indent: l.indent - min_indent,
461+ content: l.content.clone(),
462+ line_num: l.line_num,
463+ })
464+ .collect();
465+
466+ let saved_vars = ctx.variables.clone();
467+ let mut all_nodes = Vec::new();
468+
469+ for item in &items {
470+ ctx.variables.insert(var_name.clone(), item.clone());
471+ let mut body_parser = Parser {
472+ lines: adjusted.clone(),
473+ pos: 0,
474+ };
475+ let nodes = body_parser.parse_children(0, ctx);
476+ all_nodes.extend(nodes);
477+ }
478+
479+ ctx.variables = saved_vars;
480+ return Ok(Some(all_nodes));
347481 }
348482
349483 // --- @keyframes directive ---
@@ -428,6 +562,19 @@ impl Parser {
428562 line_num: usize,
429563 ctx: &mut ParseContext,
430564 ) -> Result<Vec<Node>, ParseError> {
565+ // Recursive function cycle detection
566+ if ctx.fn_call_stack.contains(&name.to_string()) {
567+ return Err(ParseError {
568+ line: line_num,
569+ message: format!(
570+ "recursive function call to @{} (call stack: {})",
571+ name,
572+ ctx.fn_call_stack.join(" -> ")
573+ ),
574+ });
575+ }
576+ ctx.fn_call_stack.push(name.to_string());
577+
431578 // Parse [param value, ...] arguments
432579 let rest = &content[1 + name.len()..];
433580 let rest = rest.trim_start();
@@ -481,8 +628,9 @@ impl Parser {
481628 };
482629 let body_nodes = body_parser.parse_children(0, ctx);
483630
484− // Restore variables
631+ // Restore variables and call stack
485632 ctx.variables = saved_vars;
633+ ctx.fn_call_stack.pop();
486634
487635 // Replace @children with caller's children
488636 Ok(replace_children_nodes(body_nodes, &caller_children))
@@ -613,14 +761,18 @@ fn parse_single_element(
613761 attrs,
614762 argument,
615763 children,
764+ line_num,
616765 })
617766}
618767
619768const KNOWN_ELEMENTS: &[&str] = &[
620769 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
770+ "input", "button", "select", "textarea", "option", "label",
621771];
622772
623−const KNOWN_DIRECTIVES: &[&str] = &["page", "let", "define", "fn", "include", "raw", "keyframes"];
773+const KNOWN_DIRECTIVES: &[&str] = &[
774+ "page", "let", "define", "fn", "include", "raw", "keyframes", "if", "else", "each",
775+];
624776
625777fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
626778 match s {
@@ -632,6 +784,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
632784 "image" | "img" => Ok(ElementKind::Image),
633785 "link" => Ok(ElementKind::Link),
634786 "children" => Ok(ElementKind::Children),
787+ "input" => Ok(ElementKind::Input),
788+ "button" | "btn" => Ok(ElementKind::Button),
789+ "select" => Ok(ElementKind::Select),
790+ "textarea" => Ok(ElementKind::Textarea),
791+ "option" | "opt" => Ok(ElementKind::Option),
792+ "label" => Ok(ElementKind::Label),
635793 _ => {
636794 let all_known: Vec<&str> = KNOWN_ELEMENTS
637795 .iter()
@@ -690,16 +848,26 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
690848// ---------------------------------------------------------------------------
691849
692850const KNOWN_ATTRS: &[&str] = &[
851+ // Layout
693852 "spacing", "padding", "padding-x", "padding-y",
694853 "width", "height", "min-width", "max-width", "min-height", "max-height",
695854 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
855+ // Style
696856 "background", "color", "border", "rounded",
697857 "bold", "italic", "underline",
698858 "size", "font", "transition", "cursor", "opacity",
699859 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
700860 "wrap", "gap-x", "gap-y",
861+ // Identity
701862 "id", "class",
863+ // Animation
702864 "animation",
865+ // Form
866+ "type", "placeholder", "name", "value", "disabled", "required", "checked",
867+ "for", "action", "method", "autocomplete",
868+ "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
869+ // Accessibility
870+ "alt", "role", "tabindex", "title",
703871];
704872
705873/// Attributes that expect purely numeric values (px-based).
@@ -739,6 +907,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
739907 attr.key, val
740908 ),
741909 severity: Severity::Warning,
910+ source_line: None,
742911 });
743912 return;
744913 }
@@ -756,6 +925,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
756925 val
757926 ),
758927 severity: Severity::Warning,
928+ source_line: None,
759929 });
760930 }
761931 } else if base_key == "opacity" {
@@ -765,6 +935,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
765935 line: line_num,
766936 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
767937 severity: Severity::Warning,
938+ source_line: None,
768939 });
769940 }
770941 } else {
@@ -772,6 +943,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
772943 line: line_num,
773944 message: format!("'opacity' expects a numeric value, got '{}'", val),
774945 severity: Severity::Warning,
946+ source_line: None,
775947 });
776948 }
777949 } else if base_key == "z-index" {
@@ -780,6 +952,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
780952 line: line_num,
781953 message: format!("'z-index' expects an integer, got '{}'", val),
782954 severity: Severity::Warning,
955+ source_line: None,
783956 });
784957 }
785958 }
@@ -888,7 +1061,10 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
8881061 .or_else(|| base_key.strip_prefix("lg:"))
8891062 .or_else(|| base_key.strip_prefix("xl:"))
8901063 .unwrap_or(base_key);
891− if !KNOWN_ATTRS.contains(&base_key) {
1064+ if !KNOWN_ATTRS.contains(&base_key)
1065+ && !base_key.starts_with("aria-")
1066+ && !base_key.starts_with("data-")
1067+ {
8921068 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
8931069 let msg = match suggestion {
8941070 Some(closest) => {
@@ -900,6 +1076,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
9001076 line: line_num,
9011077 message: msg,
9021078 severity: Severity::Warning,
1079+ source_line: None,
9031080 });
9041081 } else {
9051082 validate_attr_value(&attr, line_num, ctx);
@@ -1049,6 +1226,81 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
10491226// Variable substitution
10501227// ---------------------------------------------------------------------------
10511228
1229+// ---------------------------------------------------------------------------
1230+// Condition evaluation for @if
1231+// ---------------------------------------------------------------------------
1232+
1233+fn evaluate_condition(condition: &str) -> bool {
1234+ if let Some((left, right)) = condition.split_once("!=") {
1235+ left.trim() != right.trim()
1236+ } else if let Some((left, right)) = condition.split_once("==") {
1237+ left.trim() == right.trim()
1238+ } else {
1239+ // Truthy check: non-empty, not "false", not "0"
1240+ let trimmed = condition.trim();
1241+ !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
1242+ }
1243+}
1244+
1245+// ---------------------------------------------------------------------------
1246+// Post-parse validation (context-dependent warnings)
1247+// ---------------------------------------------------------------------------
1248+
1249+fn strip_all_prefixes(key: &str) -> &str {
1250+ let key = key
1251+ .strip_prefix("hover:")
1252+ .or_else(|| key.strip_prefix("active:"))
1253+ .or_else(|| key.strip_prefix("focus:"))
1254+ .unwrap_or(key);
1255+ key.strip_prefix("sm:")
1256+ .or_else(|| key.strip_prefix("md:"))
1257+ .or_else(|| key.strip_prefix("lg:"))
1258+ .or_else(|| key.strip_prefix("xl:"))
1259+ .unwrap_or(key)
1260+}
1261+
1262+fn validate_tree(
1263+ nodes: &[Node],
1264+ parent_kind: Option<&ElementKind>,
1265+ diagnostics: &mut Vec<Diagnostic>,
1266+) {
1267+ for node in nodes {
1268+ if let Node::Element(elem) = node {
1269+ for attr in &elem.attrs {
1270+ let base = strip_all_prefixes(&attr.key);
1271+ if base == "width" && attr.value.as_deref() == Some("fill") {
1272+ if !matches!(parent_kind, Some(ElementKind::Row)) {
1273+ diagnostics.push(Diagnostic {
1274+ line: elem.line_num,
1275+ message: "'width fill' works best inside @row; using 100% as fallback"
1276+ .to_string(),
1277+ severity: Severity::Warning,
1278+ source_line: None,
1279+ });
1280+ }
1281+ }
1282+ if base == "height" && attr.value.as_deref() == Some("fill") {
1283+ if !matches!(parent_kind, Some(ElementKind::Column)) {
1284+ diagnostics.push(Diagnostic {
1285+ line: elem.line_num,
1286+ message:
1287+ "'height fill' works best inside @column; using 100% as fallback"
1288+ .to_string(),
1289+ severity: Severity::Warning,
1290+ source_line: None,
1291+ });
1292+ }
1293+ }
1294+ }
1295+ validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1296+ }
1297+ }
1298+}
1299+
1300+// ---------------------------------------------------------------------------
1301+// Variable substitution
1302+// ---------------------------------------------------------------------------
1303+
10521304fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
10531305 if !input.contains('$') {
10541306 return input.to_string();
tests/snapshots.rs+187
@@ -14,6 +14,10 @@ fn compile(input: &str) -> String {
1414 htmlang::codegen::generate(&result.document)
1515}
1616
17+fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
18+ htmlang::parser::parse(input).diagnostics
19+}
20+
1721fn snapshot_test(name: &str) {
1822 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
1923 let hl_path = dir.join(format!("{}.hl", name));
@@ -112,3 +116,186 @@ fn snapshot_animations() {
112116fn snapshot_css_vars() {
113117 snapshot_test("css_vars");
114118}
119+
120+#[test]
121+fn snapshot_form_elements() {
122+ snapshot_test("form_elements");
123+}
124+
125+#[test]
126+fn snapshot_conditionals() {
127+ snapshot_test("conditionals");
128+}
129+
130+#[test]
131+fn snapshot_loops() {
132+ snapshot_test("loops");
133+}
134+
135+#[test]
136+fn snapshot_accessibility() {
137+ snapshot_test("accessibility");
138+}
139+
140+// ---------------------------------------------------------------------------
141+// Error case tests
142+// ---------------------------------------------------------------------------
143+
144+#[test]
145+fn error_unknown_element() {
146+ let diags = parse_diagnostics("@unknown");
147+ assert!(
148+ diags
149+ .iter()
150+ .any(|d| d.message.contains("unknown element @unknown")),
151+ "expected unknown element error, got: {:?}",
152+ diags
153+ );
154+}
155+
156+#[test]
157+fn error_unknown_element_suggestion() {
158+ let diags = parse_diagnostics("@ro");
159+ assert!(
160+ diags
161+ .iter()
162+ .any(|d| d.message.contains("did you mean @row")),
163+ "expected suggestion, got: {:?}",
164+ diags
165+ );
166+}
167+
168+#[test]
169+fn error_unknown_attribute() {
170+ let diags = parse_diagnostics("@el [bakground red]");
171+ assert!(
172+ diags
173+ .iter()
174+ .any(|d| d.message.contains("unknown attribute") && d.message.contains("background")),
175+ "expected unknown attribute with suggestion, got: {:?}",
176+ diags
177+ );
178+}
179+
180+#[test]
181+fn error_unclosed_bracket() {
182+ let diags = parse_diagnostics("@el [padding 10");
183+ assert!(
184+ diags.iter().any(|d| d.message.contains("unclosed")),
185+ "expected unclosed bracket error, got: {:?}",
186+ diags
187+ );
188+}
189+
190+#[test]
191+fn error_recursive_function() {
192+ let input = "@fn loop\n @loop\n@loop";
193+ let diags = parse_diagnostics(input);
194+ assert!(
195+ diags
196+ .iter()
197+ .any(|d| d.message.contains("recursive function call")),
198+ "expected recursive function error, got: {:?}",
199+ diags
200+ );
201+}
202+
203+#[test]
204+fn error_else_without_if() {
205+ let diags = parse_diagnostics("@else");
206+ assert!(
207+ diags
208+ .iter()
209+ .any(|d| d.message.contains("@else without matching @if")),
210+ "expected @else error, got: {:?}",
211+ diags
212+ );
213+}
214+
215+#[test]
216+fn error_each_bad_syntax() {
217+ let diags = parse_diagnostics("@each $x");
218+ assert!(
219+ diags
220+ .iter()
221+ .any(|d| d.message.contains("@each requires")),
222+ "expected @each syntax error, got: {:?}",
223+ diags
224+ );
225+}
226+
227+#[test]
228+fn error_numeric_validation() {
229+ let diags = parse_diagnostics("@el [padding abc]");
230+ assert!(
231+ diags
232+ .iter()
233+ .any(|d| d.message.contains("expects a numeric value")),
234+ "expected numeric validation warning, got: {:?}",
235+ diags
236+ );
237+}
238+
239+#[test]
240+fn error_opacity_range() {
241+ let diags = parse_diagnostics("@el [opacity 2.0]");
242+ assert!(
243+ diags
244+ .iter()
245+ .any(|d| d.message.contains("between 0 and 1")),
246+ "expected opacity range warning, got: {:?}",
247+ diags
248+ );
249+}
250+
251+#[test]
252+fn warning_fill_outside_row() {
253+ let diags = parse_diagnostics("@column\n @el [width fill]");
254+ assert!(
255+ diags
256+ .iter()
257+ .any(|d| d.message.contains("width fill") && d.message.contains("@row")),
258+ "expected fill context warning, got: {:?}",
259+ diags
260+ );
261+}
262+
263+#[test]
264+fn warning_fill_inside_row_ok() {
265+ let diags = parse_diagnostics("@row\n @el [width fill]");
266+ assert!(
267+ !diags.iter().any(|d| d.message.contains("width fill")),
268+ "should not warn about width fill inside @row, got: {:?}",
269+ diags
270+ );
271+}
272+
273+#[test]
274+fn if_condition_truthy() {
275+ let output = compile("@let x hello\n@if $x\n @text visible");
276+ assert!(output.contains("visible"));
277+}
278+
279+#[test]
280+fn if_condition_falsy() {
281+ let output = compile("@let x false\n@if $x\n @text hidden");
282+ assert!(!output.contains("hidden"));
283+}
284+
285+#[test]
286+fn each_loop_expansion() {
287+ let output = compile("@each $n in a,b,c\n @text $n");
288+ assert!(output.contains("a"));
289+ assert!(output.contains("b"));
290+ assert!(output.contains("c"));
291+}
292+
293+#[test]
294+fn aria_data_attrs_accepted() {
295+ let diags = parse_diagnostics("@el [aria-label Test, data-id 42]");
296+ assert!(
297+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
298+ "aria-*/data-* should not produce warnings, got: {:?}",
299+ diags
300+ );
301+}
tests/snapshots/accessibility.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Accessibility
2+@image [alt A photo of sunset, width 200] https://example.com/sunset.jpg
3+@el [role navigation, aria-label Main menu]
4+ @link [tabindex 0, aria-current page] / Home
5+ @link [tabindex 0] /about About
6+@button [aria-pressed false, data-action toggle] Toggle
tests/snapshots/conditionals.hlnew file+21
@@ -0,0 +1,21 @@
1+@page Conditionals
2+@let theme dark
3+@if $theme == dark
4+ @el [background #333, color white, padding 20]
5+ @text Dark mode
6+@else
7+ @el [background white, color #333, padding 20]
8+ @text Light mode
9+
10+@let show true
11+@if $show
12+ @text Visible
13+
14+@let hidden false
15+@if $hidden
16+ @text Should not appear
17+
18+@let a hello
19+@let b world
20+@if $a != $b
21+ @text They differ
tests/snapshots/form_elements.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Form Elements
2+@row [spacing 10]
3+ @button [type submit] Click me
4+ @input [type text, placeholder Enter name, name username]
5+ @input [type email, required, disabled]
6+@column [spacing 5]
7+ @label [for bio] Biography
8+ @textarea [name bio, rows 4] Default text
9+@select [name color]
10+ @option [value red] Red
11+ @option [value green] Green
12+ @option Blue
tests/snapshots/loops.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Loops
2+@let colors red,green,blue
3+@row [spacing 10]
4+ @each $color in $colors
5+ @el [background $color, width 50, height 50]
6+
7+@each $item in apple,banana,cherry
8+ @text $item