Skip to main content

hypen_engine/reactive/
expression.rs

1//! Expression evaluation using exprimo
2//!
3//! Supports JavaScript-like expressions in bindings:
4//! - `${state.selected ? '#FFA7E1' : '#374151'}`
5//! - `${item.count > 10 ? 'many' : 'few'}`
6//! - `${state.user.name + ' (' + state.user.role + ')'}`
7
8use exprimo::Evaluator;
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11
12use super::{Binding, BindingSource};
13
14/// Evaluate an expression string with the given context
15///
16/// The context should contain flattened variables like:
17/// - `state` -> the full state object
18/// - `item` -> the current item (in list iteration)
19///
20/// # Example
21/// ```
22/// use serde_json::json;
23/// use std::collections::HashMap;
24/// use hypen_engine::reactive::evaluate_expression;
25///
26/// let mut context = HashMap::new();
27/// context.insert("selected".to_string(), json!(true));
28///
29/// let result = evaluate_expression("selected ? 'yes' : 'no'", &context);
30/// assert_eq!(result, Ok(json!("yes")));
31/// ```
32pub fn evaluate_expression(expr: &str, context: &HashMap<String, Value>) -> Result<Value, String> {
33    let evaluator = Evaluator::new(context.clone(), HashMap::new());
34    evaluator
35        .evaluate(expr)
36        .map_err(|e| format!("Expression error: {:?}", e))
37}
38
39/// Build a context for expression evaluation from state and optional item
40///
41/// Flattens `state` and `item` objects so that expressions can access:
42/// - `state.user.name` via path traversal
43/// - `item.selected` via path traversal
44/// - Direct access like `selected` if we inject item properties directly
45pub fn build_expression_context(
46    state: &Value,
47    item: Option<&Value>,
48) -> HashMap<String, Value> {
49    let mut context = HashMap::new();
50
51    // Add the full state object
52    context.insert("state".to_string(), state.clone());
53
54    // Add the full item object if present
55    if let Some(item_value) = item {
56        context.insert("item".to_string(), item_value.clone());
57    }
58
59    context
60}
61
62/// Extract all state and item bindings from an expression string
63///
64/// This scans the expression for `state.xxx` and `item.xxx` patterns and returns
65/// Binding objects for dependency tracking.
66///
67/// # Example
68/// ```
69/// use hypen_engine::reactive::extract_bindings_from_expression;
70///
71/// let bindings = extract_bindings_from_expression("state.selected ? '#FFA7E1' : '#374151'");
72/// assert_eq!(bindings.len(), 1);
73/// assert_eq!(bindings[0].full_path(), "selected");
74/// ```
75pub fn extract_bindings_from_expression(expr: &str) -> Vec<Binding> {
76    let mut bindings = Vec::new();
77    let mut seen_paths: HashSet<String> = HashSet::new();
78
79    // Find all state.xxx and item.xxx patterns
80    for prefix in &["state.", "item."] {
81        let source = if *prefix == "state." {
82            BindingSource::State
83        } else {
84            BindingSource::Item
85        };
86
87        let mut search_pos = 0;
88        while let Some(start) = expr[search_pos..].find(prefix) {
89            let abs_start = search_pos + start;
90
91            // Check that this isn't in the middle of another identifier
92            // (e.g., "mystate.x" should not match)
93            if abs_start > 0 {
94                let prev_char = expr.chars().nth(abs_start - 1).unwrap_or(' ');
95                if prev_char.is_ascii_alphanumeric() || prev_char == '_' {
96                    search_pos = abs_start + prefix.len();
97                    continue;
98                }
99            }
100
101            // Extract the path after the prefix
102            let path_start = abs_start + prefix.len();
103            let mut path_end = path_start;
104
105            // Consume valid path characters (alphanumeric, underscore, and dots)
106            let chars: Vec<char> = expr.chars().collect();
107            while path_end < chars.len() {
108                let c = chars[path_end];
109                if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
110                    path_end += 1;
111                } else {
112                    break;
113                }
114            }
115
116            if path_end > path_start {
117                let path_str: String = chars[path_start..path_end].iter().collect();
118                // Remove trailing dots
119                let path_str = path_str.trim_end_matches('.');
120
121                if !path_str.is_empty() {
122                    let full_path = format!("{}{}", prefix, path_str);
123                    if !seen_paths.contains(&full_path) {
124                        seen_paths.insert(full_path);
125                        let path: Vec<String> = path_str.split('.').map(|s| s.to_string()).collect();
126                        bindings.push(Binding::new(source.clone(), path));
127                    }
128                }
129            }
130
131            search_pos = path_end.max(abs_start + prefix.len());
132        }
133    }
134
135    bindings
136}
137
138/// Check if a string contains an expression (not just a simple path)
139///
140/// Simple paths: `state.user.name`, `item.selected`
141/// Expressions: `state.selected ? 'a' : 'b'`, `item.count > 10`
142pub fn is_expression(s: &str) -> bool {
143    // Look for operators that indicate an expression
144    s.contains('?')
145        || s.contains("&&")
146        || s.contains("||")
147        || s.contains("==")
148        || s.contains("!=")
149        || s.contains(">=")
150        || s.contains("<=")
151        || s.contains('>') && !s.contains("->") // avoid matching arrow functions
152        || s.contains('<') && !s.contains("<-")
153        || s.contains('+')
154        || s.contains('-') && !s.starts_with('-') // avoid negative numbers
155        || s.contains('*')
156        || s.contains('/')
157        || s.contains('%')
158        || s.contains('!')
159}
160
161/// Evaluate a template string that may contain expressions
162///
163/// Handles strings like:
164/// - `"Hello ${state.user.name}"` - simple binding
165/// - `"Color: ${state.selected ? '#FFA7E1' : '#374151'}"` - expression
166/// - `"${state.count} items"` - mixed
167pub fn evaluate_template_string(
168    template: &str,
169    state: &Value,
170    item: Option<&Value>,
171) -> Result<String, String> {
172    let context = build_expression_context(state, item);
173    let mut result = template.to_string();
174    let mut pos = 0;
175
176    while let Some(start) = result[pos..].find("${") {
177        let abs_start = pos + start;
178
179        // Find matching closing brace, handling nested braces
180        let mut depth = 1;
181        let mut end_pos = abs_start + 2;
182        let chars: Vec<char> = result.chars().collect();
183
184        while end_pos < chars.len() && depth > 0 {
185            match chars[end_pos] {
186                '{' => depth += 1,
187                '}' => depth -= 1,
188                _ => {}
189            }
190            if depth > 0 {
191                end_pos += 1;
192            }
193        }
194
195        if depth != 0 {
196            return Err("Unclosed expression in template".to_string());
197        }
198
199        // Extract the expression content
200        let expr_content: String = chars[abs_start + 2..end_pos].iter().collect();
201
202        // Evaluate the expression
203        let value = evaluate_expression(&expr_content, &context)?;
204
205        // Convert to string
206        let replacement = match &value {
207            Value::String(s) => s.clone(),
208            Value::Number(n) => n.to_string(),
209            Value::Bool(b) => b.to_string(),
210            Value::Null => "null".to_string(),
211            _ => serde_json::to_string(&value).unwrap_or_default(),
212        };
213
214        // Replace in result
215        let pattern: String = chars[abs_start..=end_pos].iter().collect();
216        result = result.replacen(&pattern, &replacement, 1);
217
218        // Reset position to search from beginning (replacement might be shorter/longer)
219        pos = 0;
220    }
221
222    Ok(result)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229
230    #[test]
231    fn test_simple_expression() {
232        let mut context = HashMap::new();
233        context.insert("x".to_string(), json!(5));
234        context.insert("y".to_string(), json!(3));
235
236        let result = evaluate_expression("x + y", &context).unwrap();
237        // exprimo returns floats for arithmetic, so compare as f64
238        assert_eq!(result.as_f64().unwrap(), 8.0);
239    }
240
241    #[test]
242    fn test_ternary_expression() {
243        let mut context = HashMap::new();
244        context.insert("selected".to_string(), json!(true));
245
246        let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
247        assert_eq!(result, json!("yes"));
248    }
249
250    #[test]
251    fn test_ternary_with_colors() {
252        let mut context = HashMap::new();
253        context.insert("selected".to_string(), json!(true));
254
255        let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
256        assert_eq!(result, json!("#FFA7E1"));
257    }
258
259    #[test]
260    fn test_comparison_expression() {
261        let mut context = HashMap::new();
262        context.insert("count".to_string(), json!(15));
263
264        let result = evaluate_expression("count > 10", &context).unwrap();
265        assert_eq!(result, json!(true));
266    }
267
268    #[test]
269    fn test_state_object_access() {
270        let context = build_expression_context(
271            &json!({"user": {"name": "Alice", "age": 30}}),
272            None,
273        );
274
275        let result = evaluate_expression("state.user.name", &context).unwrap();
276        assert_eq!(result, json!("Alice"));
277    }
278
279    #[test]
280    fn test_item_object_access() {
281        let context = build_expression_context(
282            &json!({}),
283            Some(&json!({"name": "Item 1", "selected": true})),
284        );
285
286        let result = evaluate_expression("item.name", &context).unwrap();
287        assert_eq!(result, json!("Item 1"));
288    }
289
290    #[test]
291    fn test_item_ternary() {
292        let context = build_expression_context(
293            &json!({}),
294            Some(&json!({"selected": true})),
295        );
296
297        let result = evaluate_expression("item.selected ? '#FFA7E1' : '#374151'", &context).unwrap();
298        assert_eq!(result, json!("#FFA7E1"));
299    }
300
301    #[test]
302    fn test_template_string_simple() {
303        let state = json!({"user": {"name": "Alice"}});
304        let result = evaluate_template_string("Hello ${state.user.name}!", &state, None).unwrap();
305        assert_eq!(result, "Hello Alice!");
306    }
307
308    #[test]
309    fn test_template_string_with_expression() {
310        let state = json!({"selected": true});
311        let result = evaluate_template_string(
312            "Color: ${state.selected ? '#FFA7E1' : '#374151'}",
313            &state,
314            None,
315        )
316        .unwrap();
317        assert_eq!(result, "Color: #FFA7E1");
318    }
319
320    #[test]
321    fn test_template_string_multiple_expressions() {
322        let state = json!({"name": "Alice", "count": 5});
323        let result = evaluate_template_string(
324            "${state.name} has ${state.count} items",
325            &state,
326            None,
327        )
328        .unwrap();
329        assert_eq!(result, "Alice has 5 items");
330    }
331
332    #[test]
333    fn test_template_with_item() {
334        let state = json!({});
335        let item = json!({"name": "Product", "price": 99});
336        let result = evaluate_template_string(
337            "${item.name}: $${item.price}",
338            &state,
339            Some(&item),
340        )
341        .unwrap();
342        assert_eq!(result, "Product: $99");
343    }
344
345    #[test]
346    fn test_is_expression() {
347        // Simple paths - not expressions
348        assert!(!is_expression("state.user.name"));
349        assert!(!is_expression("item.selected"));
350
351        // Expressions
352        assert!(is_expression("selected ? 'a' : 'b'"));
353        assert!(is_expression("count > 10"));
354        assert!(is_expression("a && b"));
355        assert!(is_expression("a || b"));
356        assert!(is_expression("a == b"));
357        assert!(is_expression("a + b"));
358    }
359
360    #[test]
361    fn test_string_concatenation() {
362        let mut context = HashMap::new();
363        context.insert("first".to_string(), json!("Hello"));
364        context.insert("second".to_string(), json!("World"));
365
366        let result = evaluate_expression("first + ' ' + second", &context).unwrap();
367        assert_eq!(result, json!("Hello World"));
368    }
369
370    #[test]
371    fn test_logical_and() {
372        let mut context = HashMap::new();
373        context.insert("a".to_string(), json!(true));
374        context.insert("b".to_string(), json!(false));
375
376        let result = evaluate_expression("a && b", &context).unwrap();
377        assert_eq!(result, json!(false));
378    }
379
380    #[test]
381    fn test_logical_or() {
382        let mut context = HashMap::new();
383        context.insert("a".to_string(), json!(false));
384        context.insert("b".to_string(), json!(true));
385
386        let result = evaluate_expression("a || b", &context).unwrap();
387        assert_eq!(result, json!(true));
388    }
389
390    #[test]
391    fn test_complex_expression() {
392        let context = build_expression_context(
393            &json!({
394                "user": {
395                    "premium": true,
396                    "age": 25
397                }
398            }),
399            None,
400        );
401
402        let result = evaluate_expression(
403            "state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
404            &context,
405        )
406        .unwrap();
407        assert_eq!(result, json!("VIP Adult"));
408    }
409}