fix: even more closing of M0/M1/M2 gaps against the spec

This commit is contained in:
2026-04-05 14:26:26 +02:00
parent 6e34f5de1c
commit 0cf59da467
24 changed files with 1979 additions and 29 deletions

View File

@@ -0,0 +1,218 @@
/// 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<String>,
}
impl ValidationResult {
pub fn ok() -> Self {
Self {
valid: true,
errors: Vec::new(),
}
}
pub fn fail(errors: Vec<String>) -> 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#"
<html>
{% if user %}
<p>{{ user.name }}</p>
{% endif %}
</html>"#;
let result = validate_liquid(content);
assert!(result.valid, "Errors: {:?}", result.errors);
}
#[test]
fn unmatched_liquid_if() {
let content = "{% if true %}<p>Hello</p>";
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);
}
}