@@ -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();