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