#[derive(Debug, Clone, Default)]
pub struct JsCodegen {
_private: (),
}
impl JsCodegen {
#[must_use]
pub const fn new() -> Self {
Self { _private: () }
}
#[must_use]
pub fn emit_validator(&self, type_name: &str, expression: &str) -> String {
let js_expr = self.elo_to_js(expression);
format!(
"export function validate_{type_name}(data) {{\n\
\x20 const errors = [];\n\
\x20 if (!({js_expr})) errors.push({{ field: null, rule: {rule_str} }});\n\
\x20 return {{ valid: errors.length === 0, errors }};\n\
}}",
rule_str = Self::js_string_literal(expression),
)
}
#[must_use]
pub fn emit_module(&self, validators: &[(&str, &str)]) -> String {
let mut parts = Vec::with_capacity(validators.len() + 1);
parts.push("// Generated by FraiseQL — do not edit manually.".to_string());
for (type_name, expression) in validators {
parts.push(String::new());
parts.push(self.emit_validator(type_name, expression));
}
parts.join("\n")
}
#[must_use]
pub fn elo_to_js(&self, expression: &str) -> String {
let expr = expression.trim();
if let Some(idx) = find_op_outside_parens(expr, "&&") {
let left = self.elo_to_js(&expr[..idx]);
let right = self.elo_to_js(&expr[idx + 2..]);
return format!("({left} && {right})");
}
if let Some(idx) = find_op_outside_parens(expr, "||") {
let left = self.elo_to_js(&expr[..idx]);
let right = self.elo_to_js(&expr[idx + 2..]);
return format!("({left} || {right})");
}
if let Some(inner) = expr.strip_prefix('!') {
let inner_js = self.elo_to_js(inner.trim());
return format!("!{inner_js}");
}
if expr.starts_with('(') && expr.ends_with(')') && has_matching_parens(expr) {
let inner = &expr[1..expr.len() - 1];
let inner_js = self.elo_to_js(inner);
return format!("({inner_js})");
}
for op in &["<=", ">=", "!=", "==", "<", ">"] {
if let Some(idx) = expr.find(op) {
let left = self.elo_operand_to_js(expr[..idx].trim());
let right = self.elo_operand_to_js(expr[idx + op.len()..].trim());
let js_op = match *op {
"==" => "===",
"!=" => "!==",
other => other,
};
return format!("{left} {js_op} {right}");
}
}
self.elo_operand_to_js(expr)
}
fn elo_operand_to_js(&self, operand: &str) -> String {
let operand = operand.trim();
if operand.parse::<f64>().is_ok() {
return operand.to_string();
}
if operand == "true" || operand == "false" {
return operand.to_string();
}
if operand == "null" {
return "null".to_string();
}
if (operand.starts_with('"') && operand.ends_with('"'))
|| (operand.starts_with('\'') && operand.ends_with('\''))
{
return operand.to_string();
}
if let Some(rest) = operand.strip_prefix("length(") {
if let Some(field) = rest.strip_suffix(')') {
let field = field.trim();
return format!("(data.{field} || '').length");
}
}
if let Some(rest) = operand.strip_prefix("age(") {
if let Some(field) = rest.strip_suffix(')') {
let field = field.trim();
return format!(
"(() => {{ \
const b = new Date(data.{field}); \
const t = new Date(); \
let a = t.getFullYear() - b.getFullYear(); \
if (t.getMonth() < b.getMonth() || \
(t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--; \
return a; \
}})()"
);
}
}
if let Some(rest) = operand.strip_prefix("matches(") {
if let Some(args) = rest.strip_suffix(')') {
let parts: Vec<&str> = args.splitn(2, ',').collect();
if parts.len() == 2 {
let field = parts[0].trim();
let pattern = parts[1].trim();
return format!("new RegExp({pattern}).test(data.{field})");
}
}
}
if let Some(rest) = operand.strip_prefix("contains(") {
if let Some(args) = rest.strip_suffix(')') {
let parts: Vec<&str> = args.splitn(2, ',').collect();
if parts.len() == 2 {
let field = parts[0].trim();
let needle = parts[1].trim();
return format!("(data.{field} || '').includes({needle})");
}
}
}
format!("data.{operand}")
}
fn js_string_literal(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
format!("\"{escaped}\"")
}
}
fn find_op_outside_parens(expr: &str, op: &str) -> Option<usize> {
let mut depth = 0i32;
let mut in_string = false;
let bytes = expr.as_bytes();
for i in 0..bytes.len() {
let ch = bytes[i];
if ch == b'"' || ch == b'\'' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
if ch == b'(' {
depth += 1;
} else if ch == b')' {
depth -= 1;
} else if depth == 0 && i + op.len() <= bytes.len() && &expr[i..i + op.len()] == op {
return Some(i);
}
}
None
}
fn has_matching_parens(expr: &str) -> bool {
let mut depth = 0i32;
for (i, ch) in expr.chars().enumerate() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 && i < expr.len() - 1 {
return false;
}
},
_ => {},
}
}
depth == 0
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_simple_comparison() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("age >= 18");
assert_eq!(js, "data.age >= 18");
}
#[test]
fn test_equality_uses_strict() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("role == \"admin\"");
assert_eq!(js, "data.role === \"admin\"");
}
#[test]
fn test_inequality_uses_strict() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("role != \"banned\"");
assert_eq!(js, "data.role !== \"banned\"");
}
#[test]
fn test_and_operator() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("age >= 18 && verified == true");
assert_eq!(js, "(data.age >= 18 && data.verified === true)");
}
#[test]
fn test_or_operator() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("role == \"admin\" || role == \"mod\"");
assert_eq!(js, "(data.role === \"admin\" || data.role === \"mod\")");
}
#[test]
fn test_length_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("length(name) <= 255");
assert_eq!(js, "(data.name || '').length <= 255");
}
#[test]
fn test_contains_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("contains(email, \"@\")");
assert_eq!(js, "(data.email || '').includes(\"@\")");
}
#[test]
fn test_matches_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("matches(email, \"^.+@.+$\")");
assert_eq!(js, "new RegExp(\"^.+@.+$\").test(data.email)");
}
#[test]
fn test_age_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("age(birthDate) >= 18");
assert!(js.contains("getFullYear"));
assert!(js.contains(">= 18"));
}
#[test]
fn test_emit_validator() {
let cg = JsCodegen::new();
let js = cg.emit_validator("User", "age >= 18");
assert!(js.contains("export function validate_User(data)"));
assert!(js.contains("data.age >= 18"));
assert!(js.contains("errors.push"));
}
#[test]
fn test_emit_module() {
let cg = JsCodegen::new();
let js = cg.emit_module(&[("User", "age >= 18"), ("Order", "total > 0")]);
assert!(js.contains("validate_User"));
assert!(js.contains("validate_Order"));
assert!(js.contains("// Generated by FraiseQL"));
}
#[test]
fn test_negation() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("!(verified == false)");
assert!(js.contains('!'));
}
#[test]
fn test_boolean_literal() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("verified == true");
assert_eq!(js, "data.verified === true");
}
}