fix: even more closing of M0/M1/M2 gaps against the spec
This commit is contained in:
218
crates/bds-core/src/engine/validate_content.rs
Normal file
218
crates/bds-core/src/engine/validate_content.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user