Skip to main content

fraiseql_core/validation/
js_codegen.rs

1//! `JavaScript` code generation from ELO validation expressions.
2//!
3//! Translates ELO expressions into `JavaScript` validator functions that can be
4//! used for client-side validation, ensuring the same rules apply on both the
5//! server (Rust) and the client (browser/`Node.js`).
6//!
7//! # Example
8//!
9//! ```
10//! use fraiseql_core::validation::js_codegen::JsCodegen;
11//!
12//! let codegen = JsCodegen::new();
13//! let js = codegen.emit_validator("User", "age >= 18 && length(email) > 0");
14//! assert!(js.contains("function validate_User"));
15//! ```
16
17/// `JavaScript` code generator for ELO expressions.
18#[derive(Debug, Clone, Default)]
19pub struct JsCodegen {
20    _private: (),
21}
22
23impl JsCodegen {
24    /// Create a new `JavaScript` code generator.
25    #[must_use]
26    pub const fn new() -> Self {
27        Self { _private: () }
28    }
29
30    /// Emit a `JavaScript` validator function for a single type + expression.
31    ///
32    /// Returns a self-contained ES module `export function validate_<Type>(data)`.
33    #[must_use]
34    pub fn emit_validator(&self, type_name: &str, expression: &str) -> String {
35        let js_expr = self.elo_to_js(expression);
36        format!(
37            "export function validate_{type_name}(data) {{\n\
38             \x20 const errors = [];\n\
39             \x20 if (!({js_expr})) errors.push({{ field: null, rule: {rule_str} }});\n\
40             \x20 return {{ valid: errors.length === 0, errors }};\n\
41             }}",
42            rule_str = Self::js_string_literal(expression),
43        )
44    }
45
46    /// Emit a `JavaScript` module with validators for multiple types.
47    ///
48    /// Each entry is `(type_name, elo_expression)`.
49    #[must_use]
50    pub fn emit_module(&self, validators: &[(&str, &str)]) -> String {
51        let mut parts = Vec::with_capacity(validators.len() + 1);
52        parts.push("// Generated by FraiseQL — do not edit manually.".to_string());
53
54        for (type_name, expression) in validators {
55            parts.push(String::new());
56            parts.push(self.emit_validator(type_name, expression));
57        }
58
59        parts.join("\n")
60    }
61
62    /// Translate an ELO expression to a `JavaScript` boolean expression.
63    #[must_use]
64    pub fn elo_to_js(&self, expression: &str) -> String {
65        let expr = expression.trim();
66
67        // Handle logical AND
68        if let Some(idx) = find_op_outside_parens(expr, "&&") {
69            let left = self.elo_to_js(&expr[..idx]);
70            let right = self.elo_to_js(&expr[idx + 2..]);
71            return format!("({left} && {right})");
72        }
73
74        // Handle logical OR
75        if let Some(idx) = find_op_outside_parens(expr, "||") {
76            let left = self.elo_to_js(&expr[..idx]);
77            let right = self.elo_to_js(&expr[idx + 2..]);
78            return format!("({left} || {right})");
79        }
80
81        // Handle negation
82        if let Some(inner) = expr.strip_prefix('!') {
83            let inner_js = self.elo_to_js(inner.trim());
84            return format!("!{inner_js}");
85        }
86
87        // Strip matching outer parentheses
88        if expr.starts_with('(') && expr.ends_with(')') && has_matching_parens(expr) {
89            let inner = &expr[1..expr.len() - 1];
90            let inner_js = self.elo_to_js(inner);
91            return format!("({inner_js})");
92        }
93
94        // Handle comparison operators
95        for op in &["<=", ">=", "!=", "==", "<", ">"] {
96            if let Some(idx) = expr.find(op) {
97                let left = self.elo_operand_to_js(expr[..idx].trim());
98                let right = self.elo_operand_to_js(expr[idx + op.len()..].trim());
99                let js_op = match *op {
100                    "==" => "===",
101                    "!=" => "!==",
102                    other => other,
103                };
104                return format!("{left} {js_op} {right}");
105            }
106        }
107
108        // Standalone function call or value
109        self.elo_operand_to_js(expr)
110    }
111
112    /// Translate an operand (field, literal, function) to JS.
113    fn elo_operand_to_js(&self, operand: &str) -> String {
114        let operand = operand.trim();
115
116        // Numeric literal
117        if operand.parse::<f64>().is_ok() {
118            return operand.to_string();
119        }
120
121        // Boolean literal
122        if operand == "true" || operand == "false" {
123            return operand.to_string();
124        }
125
126        // Null literal
127        if operand == "null" {
128            return "null".to_string();
129        }
130
131        // String literal — pass through
132        if (operand.starts_with('"') && operand.ends_with('"'))
133            || (operand.starts_with('\'') && operand.ends_with('\''))
134        {
135            return operand.to_string();
136        }
137
138        // Function calls
139        if let Some(rest) = operand.strip_prefix("length(") {
140            if let Some(field) = rest.strip_suffix(')') {
141                let field = field.trim();
142                return format!("(data.{field} || '').length");
143            }
144        }
145
146        if let Some(rest) = operand.strip_prefix("age(") {
147            if let Some(field) = rest.strip_suffix(')') {
148                let field = field.trim();
149                // Inline age calculation in JS
150                return format!(
151                    "(() => {{ \
152                        const b = new Date(data.{field}); \
153                        const t = new Date(); \
154                        let a = t.getFullYear() - b.getFullYear(); \
155                        if (t.getMonth() < b.getMonth() || \
156                            (t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--; \
157                        return a; \
158                    }})()"
159                );
160            }
161        }
162
163        if let Some(rest) = operand.strip_prefix("matches(") {
164            if let Some(args) = rest.strip_suffix(')') {
165                let parts: Vec<&str> = args.splitn(2, ',').collect();
166                if parts.len() == 2 {
167                    let field = parts[0].trim();
168                    let pattern = parts[1].trim();
169                    return format!("new RegExp({pattern}).test(data.{field})");
170                }
171            }
172        }
173
174        if let Some(rest) = operand.strip_prefix("contains(") {
175            if let Some(args) = rest.strip_suffix(')') {
176                let parts: Vec<&str> = args.splitn(2, ',').collect();
177                if parts.len() == 2 {
178                    let field = parts[0].trim();
179                    let needle = parts[1].trim();
180                    return format!("(data.{field} || '').includes({needle})");
181                }
182            }
183        }
184
185        // Field reference (dot notation supported)
186        format!("data.{operand}")
187    }
188
189    /// Produce a JS string literal (double-quoted, escaped).
190    fn js_string_literal(s: &str) -> String {
191        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
192        format!("\"{escaped}\"")
193    }
194}
195
196/// Find a two-char operator outside of parentheses and quotes.
197fn find_op_outside_parens(expr: &str, op: &str) -> Option<usize> {
198    let mut depth = 0i32;
199    let mut in_string = false;
200    let bytes = expr.as_bytes();
201
202    for i in 0..bytes.len() {
203        let ch = bytes[i];
204        if ch == b'"' || ch == b'\'' {
205            in_string = !in_string;
206            continue;
207        }
208        if in_string {
209            continue;
210        }
211        if ch == b'(' {
212            depth += 1;
213        } else if ch == b')' {
214            depth -= 1;
215        } else if depth == 0 && i + op.len() <= bytes.len() && &expr[i..i + op.len()] == op {
216            return Some(i);
217        }
218    }
219
220    None
221}
222
223/// Check if the outer parens of `expr` match (i.e. `(...)` not `(...) op (...)`).
224fn has_matching_parens(expr: &str) -> bool {
225    let mut depth = 0i32;
226    for (i, ch) in expr.chars().enumerate() {
227        match ch {
228            '(' => depth += 1,
229            ')' => {
230                depth -= 1;
231                if depth == 0 && i < expr.len() - 1 {
232                    return false;
233                }
234            },
235            _ => {},
236        }
237    }
238    depth == 0
239}