//! Validation functions for template (Liquid) and script (Lua/Python) content. //! Per template.allium and script.allium, these are pre-publish gates. //! //! Current implementation: basic structural checks. //! When a full Liquid parser crate is added, upgrade `validate_liquid` to //! attempt a real parse. Similarly for `validate_script` with a Lua parser. /// Result of a validation check. #[derive(Debug, Clone)] pub struct ValidationResult { pub valid: bool, pub errors: Vec, } impl ValidationResult { pub fn ok() -> Self { Self { valid: true, errors: Vec::new(), } } pub fn fail(errors: Vec) -> Self { Self { valid: false, errors, } } } /// Validate Liquid template content. /// Per template.allium: "LiquidJS parser must accept the template". /// Currently performs structural tag-matching. Upgrade to full liquid crate parse later. pub fn validate_liquid(content: &str) -> ValidationResult { let mut errors = Vec::new(); // Check for unmatched Liquid block tags let block_tags = [ "if", "unless", "for", "case", "capture", "comment", "raw", "paginate", "tablerow", "block", "schema", ]; for tag in &block_tags { let open_pattern = format!("{{% {tag}"); let close_pattern = format!("{{% end{tag}"); let opens = content.matches(&open_pattern).count(); let closes = content.matches(&close_pattern).count(); if opens != closes { errors.push(format!( "Unmatched {{% {tag} %}}: {opens} opens, {closes} closes" )); } } // Check for unclosed {{ }} let double_opens = content.matches("{{").count(); let double_closes = content.matches("}}").count(); if double_opens != double_closes { errors.push(format!( "Unmatched {{}}: {double_opens} opens, {double_closes} closes" )); } // Check for unclosed {% %} let tag_opens = content.matches("{%").count(); let tag_closes = content.matches("%}").count(); if tag_opens != tag_closes { errors.push(format!( "Unmatched {{% %}}: {tag_opens} opens, {tag_closes} closes" )); } if errors.is_empty() { ValidationResult::ok() } else { ValidationResult::fail(errors) } } /// Validate script content (Lua syntax). /// Per script.allium: "AST parsing must succeed". /// Currently performs basic bracket-matching. Upgrade to full Lua parser later. pub fn validate_script(content: &str) -> ValidationResult { let mut errors = Vec::new(); // Basic bracket matching let mut paren_depth: i32 = 0; let mut brace_depth: i32 = 0; let mut bracket_depth: i32 = 0; for (line_num, line) in content.lines().enumerate() { // Skip comment lines let trimmed = line.trim(); if trimmed.starts_with("--") { continue; } for ch in line.chars() { match ch { '(' => paren_depth += 1, ')' => paren_depth -= 1, '{' => brace_depth += 1, '}' => brace_depth -= 1, '[' => bracket_depth += 1, ']' => bracket_depth -= 1, _ => {} } if paren_depth < 0 { errors.push(format!("Unexpected ')' at line {}", line_num + 1)); paren_depth = 0; } if brace_depth < 0 { errors.push(format!("Unexpected '}}' at line {}", line_num + 1)); brace_depth = 0; } if bracket_depth < 0 { errors.push(format!("Unexpected ']' at line {}", line_num + 1)); bracket_depth = 0; } } } if paren_depth != 0 { errors.push(format!("Unclosed parentheses: depth {paren_depth}")); } if brace_depth != 0 { errors.push(format!("Unclosed braces: depth {brace_depth}")); } if bracket_depth != 0 { errors.push(format!("Unclosed brackets: depth {bracket_depth}")); } // Check for unmatched Lua block keywords. // In Lua, `for ... do ... end` and `while ... do ... end` each form a // single block, so `do` on such lines must NOT be counted as a separate // opener. let mut block_opens: usize = 0; let mut block_closes: usize = 0; for line in content.lines() { let trimmed = line.trim(); if trimmed.starts_with("--") { continue; } let words: Vec<&str> = trimmed.split_whitespace().collect(); let has_for_or_while = words.iter().any(|w| matches!(*w, "for" | "while")); for w in &words { match *w { "function" | "if" | "for" | "while" | "repeat" => block_opens += 1, "do" if !has_for_or_while => block_opens += 1, "end" | "until" => block_closes += 1, _ => {} } } } if block_opens > block_closes { errors.push(format!( "Possible unclosed blocks: {block_opens} openers, {block_closes} closers" )); } if errors.is_empty() { ValidationResult::ok() } else { ValidationResult::fail(errors) } } #[cfg(test)] mod tests { use super::*; #[test] fn valid_liquid_template() { let content = r#" {% if user %}

{{ user.name }}

{% endif %} "#; let result = validate_liquid(content); assert!(result.valid, "Errors: {:?}", result.errors); } #[test] fn unmatched_liquid_if() { let content = "{% if true %}

Hello

"; let result = validate_liquid(content); assert!(!result.valid); assert!(result.errors.iter().any(|e| e.contains("if"))); } #[test] fn unmatched_liquid_output() { let content = "{{ user.name"; let result = validate_liquid(content); assert!(!result.valid); } #[test] fn valid_lua_script() { let content = r#" function render(ctx) local result = {} for i = 1, 10 do result[i] = i * 2 end return result end "#; let result = validate_script(content); assert!(result.valid, "Errors: {:?}", result.errors); } #[test] fn unmatched_lua_parens() { let content = "function test(\n local x = 1\nend"; let result = validate_script(content); assert!(!result.valid); } }