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(
34    expr: &str,
35    context: &HashMap<String, Value>,
36) -> Result<Value, EngineError> {
37    let evaluator = Evaluator::new(context.clone(), HashMap::new());
38    evaluator
39        .evaluate(expr)
40        .map_err(|e| EngineError::ExpressionError(format!("{:?}", e)))
41}
42
43/// Build a context for expression evaluation from state and optional item
44///
45/// Flattens `state` and `item` objects so that expressions can access:
46/// - `state.user.name` via path traversal
47/// - `item.selected` via path traversal
48/// - Direct access like `selected` if we inject item properties directly
49pub fn build_expression_context(state: &Value, item: Option<&Value>) -> 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> =
127                            path_str.split('.').map(|s| s.to_string()).collect();
128                        bindings.push(Binding::new(source.clone(), path));
129                    }
130                }
131            }
132
133            search_pos = path_end.max(abs_start + prefix.len());
134        }
135    }
136
137    bindings
138}
139
140/// Check if a string contains an expression (not just a simple path)
141///
142/// Simple paths: `state.user.name`, `item.selected`
143/// Expressions: `state.selected ? 'a' : 'b'`, `item.count > 10`
144pub fn is_expression(s: &str) -> bool {
145    // Look for operators that indicate an expression
146    s.contains('?')
147        || s.contains("&&")
148        || s.contains("||")
149        || s.contains("==")
150        || s.contains("!=")
151        || s.contains(">=")
152        || s.contains("<=")
153        || s.contains('>') && !s.contains("->") // avoid matching arrow functions
154        || s.contains('<') && !s.contains("<-")
155        || s.contains('+')
156        || s.contains('-') && !s.starts_with('-') // avoid negative numbers
157        || s.contains('*')
158        || s.contains('/')
159        || s.contains('%')
160        || s.contains('!')
161}
162
163/// Evaluate a template string that may contain expressions
164///
165/// Handles strings like:
166/// - `"Hello ${state.user.name}"` - simple binding
167/// - `"Color: ${state.selected ? '#FFA7E1' : '#374151'}"` - expression
168/// - `"${state.count} items"` - mixed
169pub fn evaluate_template_string(
170    template: &str,
171    state: &Value,
172    item: Option<&Value>,
173) -> Result<String, EngineError> {
174    let context = build_expression_context(state, item);
175    let mut result = template.to_string();
176    let mut pos = 0;
177
178    while let Some(start) = result[pos..].find("${") {
179        let abs_start = pos + start;
180
181        // Find matching closing brace, handling nested braces
182        let mut depth = 1;
183        let mut end_pos = abs_start + 2;
184        let chars: Vec<char> = result.chars().collect();
185
186        while end_pos < chars.len() && depth > 0 {
187            match chars[end_pos] {
188                '{' => depth += 1,
189                '}' => depth -= 1,
190                _ => {}
191            }
192            if depth > 0 {
193                end_pos += 1;
194            }
195        }
196
197        if depth != 0 {
198            return Err(EngineError::ExpressionError(
199                "Unclosed expression in template".to_string(),
200            ));
201        }
202
203        // Extract the expression content
204        let expr_content: String = chars[abs_start + 2..end_pos].iter().collect();
205
206        // Evaluate the expression
207        let value = evaluate_expression(&expr_content, &context)?;
208
209        // Convert to string
210        let replacement = match &value {
211            Value::String(s) => s.clone(),
212            Value::Number(n) => n.to_string(),
213            Value::Bool(b) => b.to_string(),
214            Value::Null => "null".to_string(),
215            _ => serde_json::to_string(&value).unwrap_or_default(),
216        };
217
218        // Replace in result
219        let pattern: String = chars[abs_start..=end_pos].iter().collect();
220        result = result.replacen(&pattern, &replacement, 1);
221
222        // Reset position to search from beginning (replacement might be shorter/longer)
223        pos = 0;
224    }
225
226    Ok(result)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use serde_json::json;
233
234    #[test]
235    fn test_simple_expression() {
236        let mut context = HashMap::new();
237        context.insert("x".to_string(), json!(5));
238        context.insert("y".to_string(), json!(3));
239
240        let result = evaluate_expression("x + y", &context).unwrap();
241        // exprimo returns floats for arithmetic, so compare as f64
242        assert_eq!(result.as_f64().unwrap(), 8.0);
243    }
244
245    #[test]
246    fn test_ternary_expression() {
247        let mut context = HashMap::new();
248        context.insert("selected".to_string(), json!(true));
249
250        let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
251        assert_eq!(result, json!("yes"));
252    }
253
254    #[test]
255    fn test_ternary_with_colors() {
256        let mut context = HashMap::new();
257        context.insert("selected".to_string(), json!(true));
258
259        let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
260        assert_eq!(result, json!("#FFA7E1"));
261    }
262
263    #[test]
264    fn test_comparison_expression() {
265        let mut context = HashMap::new();
266        context.insert("count".to_string(), json!(15));
267
268        let result = evaluate_expression("count > 10", &context).unwrap();
269        assert_eq!(result, json!(true));
270    }
271
272    #[test]
273    fn test_state_object_access() {
274        let context =
275            build_expression_context(&json!({"user": {"name": "Alice", "age": 30}}), None);
276
277        let result = evaluate_expression("state.user.name", &context).unwrap();
278        assert_eq!(result, json!("Alice"));
279    }
280
281    #[test]
282    fn test_item_object_access() {
283        let context = build_expression_context(
284            &json!({}),
285            Some(&json!({"name": "Item 1", "selected": true})),
286        );
287
288        let result = evaluate_expression("item.name", &context).unwrap();
289        assert_eq!(result, json!("Item 1"));
290    }
291
292    #[test]
293    fn test_item_ternary() {
294        let context = build_expression_context(&json!({}), Some(&json!({"selected": true})));
295
296        let result =
297            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 =
324            evaluate_template_string("${state.name} has ${state.count} items", &state, None)
325                .unwrap();
326        assert_eq!(result, "Alice has 5 items");
327    }
328
329    #[test]
330    fn test_template_with_item() {
331        let state = json!({});
332        let item = json!({"name": "Product", "price": 99});
333        let result =
334            evaluate_template_string("${item.name}: $${item.price}", &state, Some(&item)).unwrap();
335        assert_eq!(result, "Product: $99");
336    }
337
338    #[test]
339    fn test_is_expression() {
340        // Simple paths - not expressions
341        assert!(!is_expression("state.user.name"));
342        assert!(!is_expression("item.selected"));
343
344        // Expressions
345        assert!(is_expression("selected ? 'a' : 'b'"));
346        assert!(is_expression("count > 10"));
347        assert!(is_expression("a && b"));
348        assert!(is_expression("a || b"));
349        assert!(is_expression("a == b"));
350        assert!(is_expression("a + b"));
351    }
352
353    #[test]
354    fn test_string_concatenation() {
355        let mut context = HashMap::new();
356        context.insert("first".to_string(), json!("Hello"));
357        context.insert("second".to_string(), json!("World"));
358
359        let result = evaluate_expression("first + ' ' + second", &context).unwrap();
360        assert_eq!(result, json!("Hello World"));
361    }
362
363    #[test]
364    fn test_logical_and() {
365        let mut context = HashMap::new();
366        context.insert("a".to_string(), json!(true));
367        context.insert("b".to_string(), json!(false));
368
369        let result = evaluate_expression("a && b", &context).unwrap();
370        assert_eq!(result, json!(false));
371    }
372
373    #[test]
374    fn test_logical_or() {
375        let mut context = HashMap::new();
376        context.insert("a".to_string(), json!(false));
377        context.insert("b".to_string(), json!(true));
378
379        let result = evaluate_expression("a || b", &context).unwrap();
380        assert_eq!(result, json!(true));
381    }
382
383    #[test]
384    fn test_complex_expression() {
385        let context = build_expression_context(
386            &json!({
387                "user": {
388                    "premium": true,
389                    "age": 25
390                }
391            }),
392            None,
393        );
394
395        let result = evaluate_expression(
396            "state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
397            &context,
398        )
399        .unwrap();
400        assert_eq!(result, json!("VIP Adult"));
401    }
402}