@@ -19,6 +19,7 @@ pub struct Diagnostic {
19 pub line: usize,
19 pub line: usize,
20 pub message: String,
20 pub message: String,
21 pub severity: Severity,
21 pub severity: Severity,
22+ pub source_line: Option<String>,
24pub struct ParseResult {
25pub struct ParseResult {
@@ -73,6 +74,8 @@ struct ParseContext {
73 base_path: Option<PathBuf>,
74 base_path: Option<PathBuf>,
74 included_files: Vec<PathBuf>,
75 included_files: Vec<PathBuf>,
75 include_stack: Vec<PathBuf>,
76 include_stack: Vec<PathBuf>,
77+ fn_call_stack: Vec<String>,
78+ file_cache: HashMap<PathBuf, String>,
78struct Parser {
81struct Parser {
@@ -98,8 +101,11 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
98 base_path: base_path.map(|p| p.to_path_buf()),
101 base_path: base_path.map(|p| p.to_path_buf()),
99 included_files: Vec::new(),
102 included_files: Vec::new(),
100 include_stack: Vec::new(),
103 include_stack: Vec::new(),
104+ fn_call_stack: Vec::new(),
105+ file_cache: HashMap::new(),
102 let nodes = parser.parse_children(0, &mut ctx);
107 let nodes = parser.parse_children(0, &mut ctx);
108+ validate_tree(&nodes, None, &mut ctx.diagnostics);
103 ParseResult {
109 ParseResult {
104 document: Document {
110 document: Document {
105 page_title: ctx.page_title,
111 page_title: ctx.page_title,
@@ -234,6 +240,7 @@ impl Parser {
234 line: e.line,
240 line: e.line,
235 message: e.message,
241 message: e.message,
236 severity: Severity::Error,
242 severity: Severity::Error,
238 // Skip forward past any deeper-indented children of the errored line
245 // Skip forward past any deeper-indented children of the errored line
239 while self.pos < self.lines.len()
246 while self.pos < self.lines.len()
@@ -313,37 +320,164 @@ impl Parser {
313 line: line_num,
320 line: line_num,
314 message: format!("circular include '{}'", filename),
321 message: format!("circular include '{}'", filename),
315 severity: Severity::Error,
322 severity: Severity::Error,
323+ source_line: Some(content.clone()),
317 return Ok(None);
325 return Ok(None);
328+ // Use file cache to avoid redundant reads
329+ let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
320− match std::fs::read_to_string(&resolved) {
332+ match std::fs::read_to_string(&resolved) {
321− Ok(included_text) => {
333+ Ok(text) => {
322− ctx.included_files.push(resolved.clone());
334+ ctx.file_cache.insert(resolved.clone(), text.clone());
323− ctx.include_stack.push(resolved.clone());
335+ text
324− let saved_base = ctx.base_path.clone();
336+ }
325− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
337+ Err(e) => {
326−
338+ ctx.diagnostics.push(Diagnostic {
327− let included_lines = preprocess(&included_text);
339+ line: line_num,
328− let mut included_parser = Parser {
340+ message: format!("cannot include '{}': {}", filename, e),
329− lines: included_lines,
341+ severity: Severity::Error,
330− pos: 0,
342+ source_line: Some(content.clone()),
332− let nodes = included_parser.parse_children(0, ctx);
334− ctx.base_path = saved_base;
335− ctx.include_stack.pop();
336− return Ok(Some(nodes));
344+ return Ok(None);
339− ctx.diagnostics.push(Diagnostic {
348+
340− line: line_num,
349+ ctx.included_files.push(resolved.clone());
341− message: format!("cannot include '{}': {}", filename, e),
350+ ctx.include_stack.push(resolved.clone());
342− severity: Severity::Error,
351+ let saved_base = ctx.base_path.clone();
343− });
352+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
354+ let included_lines = preprocess(&included_text);
355+ let mut included_parser = Parser {
356+ lines: included_lines,
359+ let nodes = included_parser.parse_children(0, ctx);
361+ ctx.base_path = saved_base;
362+ ctx.include_stack.pop();
344− return Ok(None);
363+ return Ok(Some(nodes));
366+ // --- @if / @else ---
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);
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());
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
389+ else_lines.push(self.lines[self.pos].clone());
396+ let body_lines = if result { then_lines } else { else_lines };
397+ if body_lines.is_empty() {
401+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
402+ let adjusted: Vec<Line> = body_lines
405+ indent: l.indent - min_indent,
406+ content: l.content.clone(),
407+ line_num: l.line_num,
411+ let mut body_parser = Parser {
415+ let nodes = body_parser.parse_children(0, ctx);
416+ return Ok(Some(nodes));
419+ if content.trim() == "@else" {
420+ return Err(ParseError {
422+ message: "@else without matching @if".to_string(),
426+ // --- @each loop ---
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 {
434+ message: "@each requires: @each $var in list".to_string(),
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
441+ .map(|s| s.trim().to_string())
442+ .filter(|s| !s.is_empty())
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());
452+ if body_lines.is_empty() || items.is_empty() {
456+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
457+ let adjusted: Vec<Line> = body_lines
460+ indent: l.indent - min_indent,
461+ content: l.content.clone(),
462+ line_num: l.line_num,
466+ let saved_vars = ctx.variables.clone();
467+ let mut all_nodes = Vec::new();
469+ for item in &items {
470+ ctx.variables.insert(var_name.clone(), item.clone());
471+ let mut body_parser = Parser {
472+ lines: adjusted.clone(),
475+ let nodes = body_parser.parse_children(0, ctx);
476+ all_nodes.extend(nodes);
479+ ctx.variables = saved_vars;
480+ return Ok(Some(all_nodes));
349 // --- @keyframes directive ---
483 // --- @keyframes directive ---
@@ -428,6 +562,19 @@ impl Parser {
428 line_num: usize,
562 line_num: usize,
429 ctx: &mut ParseContext,
563 ctx: &mut ParseContext,
430 ) -> Result<Vec<Node>, ParseError> {
564 ) -> Result<Vec<Node>, ParseError> {
565+ // Recursive function cycle detection
566+ if ctx.fn_call_stack.contains(&name.to_string()) {
567+ return Err(ParseError {
570+ "recursive function call to @{} (call stack: {})",
572+ ctx.fn_call_stack.join(" -> ")
576+ ctx.fn_call_stack.push(name.to_string());
431 // Parse [param value, ...] arguments
578 // Parse [param value, ...] arguments
432 let rest = &content[1 + name.len()..];
579 let rest = &content[1 + name.len()..];
433 let rest = rest.trim_start();
580 let rest = rest.trim_start();
@@ -481,8 +628,9 @@ impl Parser {
482 let body_nodes = body_parser.parse_children(0, ctx);
629 let body_nodes = body_parser.parse_children(0, ctx);
484− // Restore variables
631+ // Restore variables and call stack
485 ctx.variables = saved_vars;
632 ctx.variables = saved_vars;
633+ ctx.fn_call_stack.pop();
487 // Replace @children with caller's children
635 // Replace @children with caller's children
488 Ok(replace_children_nodes(body_nodes, &caller_children))
636 Ok(replace_children_nodes(body_nodes, &caller_children))
@@ -613,14 +761,18 @@ fn parse_single_element(
614 argument,
762 argument,
615 children,
763 children,
619const KNOWN_ELEMENTS: &[&str] = &[
768const KNOWN_ELEMENTS: &[&str] = &[
620 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
769 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
770+ "input", "button", "select", "textarea", "option", "label",
773+const KNOWN_DIRECTIVES: &[&str] = &[
623−const KNOWN_DIRECTIVES: &[&str] = &["page", "let", "define", "fn", "include", "raw", "keyframes"];
774+ "page", "let", "define", "fn", "include", "raw", "keyframes", "if", "else", "each",
625fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
777fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
626 match s {
778 match s {
@@ -632,6 +784,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
632 "image" | "img" => Ok(ElementKind::Image),
784 "image" | "img" => Ok(ElementKind::Image),
633 "link" => Ok(ElementKind::Link),
785 "link" => Ok(ElementKind::Link),
634 "children" => Ok(ElementKind::Children),
786 "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),
636 let all_known: Vec<&str> = KNOWN_ELEMENTS
794 let all_known: Vec<&str> = KNOWN_ELEMENTS
@@ -690,16 +848,26 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
690// ---------------------------------------------------------------------------
848// ---------------------------------------------------------------------------
692const KNOWN_ATTRS: &[&str] = &[
850const KNOWN_ATTRS: &[&str] = &[
693 "spacing", "padding", "padding-x", "padding-y",
852 "spacing", "padding", "padding-x", "padding-y",
694 "width", "height", "min-width", "max-width", "min-height", "max-height",
853 "width", "height", "min-width", "max-width", "min-height", "max-height",
695 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
854 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
696 "background", "color", "border", "rounded",
856 "background", "color", "border", "rounded",
697 "bold", "italic", "underline",
857 "bold", "italic", "underline",
698 "size", "font", "transition", "cursor", "opacity",
858 "size", "font", "transition", "cursor", "opacity",
699 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
859 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
700 "wrap", "gap-x", "gap-y",
860 "wrap", "gap-x", "gap-y",
701 "id", "class",
862 "id", "class",
702 "animation",
864 "animation",
866+ "type", "placeholder", "name", "value", "disabled", "required", "checked",
867+ "for", "action", "method", "autocomplete",
868+ "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
870+ "alt", "role", "tabindex", "title",
705/// Attributes that expect purely numeric values (px-based).
873/// Attributes that expect purely numeric values (px-based).
@@ -739,6 +907,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
739 attr.key, val
907 attr.key, val
741 severity: Severity::Warning,
909 severity: Severity::Warning,
@@ -756,6 +925,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
758 severity: Severity::Warning,
927 severity: Severity::Warning,
761 } else if base_key == "opacity" {
931 } else if base_key == "opacity" {
@@ -765,6 +935,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
765 line: line_num,
935 line: line_num,
766 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
936 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
767 severity: Severity::Warning,
937 severity: Severity::Warning,
@@ -772,6 +943,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
772 line: line_num,
943 line: line_num,
773 message: format!("'opacity' expects a numeric value, got '{}'", val),
944 message: format!("'opacity' expects a numeric value, got '{}'", val),
774 severity: Severity::Warning,
945 severity: Severity::Warning,
777 } else if base_key == "z-index" {
949 } else if base_key == "z-index" {
@@ -780,6 +952,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
780 line: line_num,
952 line: line_num,
781 message: format!("'z-index' expects an integer, got '{}'", val),
953 message: format!("'z-index' expects an integer, got '{}'", val),
782 severity: Severity::Warning,
954 severity: Severity::Warning,
@@ -888,7 +1061,10 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
888 .or_else(|| base_key.strip_prefix("lg:"))
1061 .or_else(|| base_key.strip_prefix("lg:"))
889 .or_else(|| base_key.strip_prefix("xl:"))
1062 .or_else(|| base_key.strip_prefix("xl:"))
890 .unwrap_or(base_key);
1063 .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-")
892 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
1068 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
893 let msg = match suggestion {
1069 let msg = match suggestion {
894 Some(closest) => {
1070 Some(closest) => {
@@ -900,6 +1076,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
900 line: line_num,
1076 line: line_num,
901 message: msg,
1077 message: msg,
902 severity: Severity::Warning,
1078 severity: Severity::Warning,
904 } else {
1081 } else {
905 validate_attr_value(&attr, line_num, ctx);
1082 validate_attr_value(&attr, line_num, ctx);
@@ -1049,6 +1226,81 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
1049// Variable substitution
1226// Variable substitution
1050// ---------------------------------------------------------------------------
1227// ---------------------------------------------------------------------------
1229+// ---------------------------------------------------------------------------
1230+// Condition evaluation for @if
1231+// ---------------------------------------------------------------------------
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()
1239+ // Truthy check: non-empty, not "false", not "0"
1240+ let trimmed = condition.trim();
1241+ !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
1245+// ---------------------------------------------------------------------------
1246+// Post-parse validation (context-dependent warnings)
1247+// ---------------------------------------------------------------------------
1249+fn strip_all_prefixes(key: &str) -> &str {
1251+ .strip_prefix("hover:")
1252+ .or_else(|| key.strip_prefix("active:"))
1253+ .or_else(|| key.strip_prefix("focus:"))
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:"))
1264+ parent_kind: Option<&ElementKind>,
1265+ diagnostics: &mut Vec<Diagnostic>,
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"
1277+ severity: Severity::Warning,
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,
1287+ "'height fill' works best inside @column; using 100% as fallback"
1289+ severity: Severity::Warning,
1295+ validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1300+// ---------------------------------------------------------------------------
1301+// Variable substitution
1302+// ---------------------------------------------------------------------------
1052fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
1304fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
1053 if !input.contains('$') {
1305 if !input.contains('$') {
1054 return input.to_string();
1306 return input.to_string();