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(e.to_string()))
41}
42
43/// Build a context for expression evaluation from state, optional item, and data sources
44///
45/// Flattens `state`, `item`, and data source objects so that expressions can access:
46/// - `state.user.name` via path traversal
47/// - `item.selected` via path traversal
48/// - `spacetime.messages` via data source path traversal
49pub fn build_expression_context(state: &Value, item: Option<&Value>) -> HashMap<String, Value> {
50    build_expression_context_with_data_sources(state, item, None)
51}
52
53/// Build a context for expression evaluation with optional data sources
54pub fn build_expression_context_with_data_sources(
55    state: &Value,
56    item: Option<&Value>,
57    data_sources: Option<&indexmap::IndexMap<String, Value>>,
58) -> HashMap<String, Value> {
59    let mut context = HashMap::new();
60
61    // Add the full state object
62    context.insert("state".to_string(), state.clone());
63
64    // Add the full item object if present
65    if let Some(item_value) = item {
66        context.insert("item".to_string(), item_value.clone());
67    }
68
69    // Add data source states as top-level context entries
70    if let Some(ds_map) = data_sources {
71        for (provider, ds_state) in ds_map {
72            context.insert(provider.clone(), ds_state.clone());
73        }
74    }
75
76    context
77}
78
79/// Extract all state, item, and data source bindings from an expression string
80///
81/// This scans the expression for `state.xxx`, `item.xxx`, and `provider.xxx`
82/// patterns and returns Binding objects for dependency tracking. Any
83/// `identifier.path` that isn't `state.` or `item.` is treated as a potential
84/// data source binding (validated at the dependency graph level).
85///
86/// # Example
87/// ```
88/// use hypen_engine::reactive::extract_bindings_from_expression;
89///
90/// let bindings = extract_bindings_from_expression("state.selected ? '#FFA7E1' : '#374151'");
91/// assert_eq!(bindings.len(), 1);
92/// assert_eq!(bindings[0].full_path(), "selected");
93/// ```
94pub fn extract_bindings_from_expression(expr: &str) -> Vec<Binding> {
95    let mut bindings = Vec::new();
96    let mut seen_paths: HashSet<String> = HashSet::new();
97
98    // Find all state.xxx and item.xxx patterns
99    for prefix in &["state.", "item."] {
100        let source = if *prefix == "state." {
101            BindingSource::State
102        } else {
103            BindingSource::Item
104        };
105
106        let mut search_pos = 0;
107        while let Some(start) = expr[search_pos..].find(prefix) {
108            let abs_start = search_pos + start;
109
110            // Check that this isn't in the middle of another identifier
111            // (e.g., "mystate.x" should not match)
112            if abs_start > 0 {
113                let prev_char = expr.chars().nth(abs_start - 1).unwrap_or(' ');
114                if prev_char.is_ascii_alphanumeric() || prev_char == '_' {
115                    search_pos = abs_start + prefix.len();
116                    continue;
117                }
118            }
119
120            // Extract the path after the prefix
121            let path_start = abs_start + prefix.len();
122            let mut path_end = path_start;
123
124            // Consume valid path characters (alphanumeric, underscore, and dots)
125            let chars: Vec<char> = expr.chars().collect();
126            while path_end < chars.len() {
127                let c = chars[path_end];
128                if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
129                    path_end += 1;
130                } else {
131                    break;
132                }
133            }
134
135            if path_end > path_start {
136                let path_str: String = chars[path_start..path_end].iter().collect();
137                // Remove trailing dots
138                let path_str = path_str.trim_end_matches('.');
139
140                if !path_str.is_empty() {
141                    let full_path = format!("{}{}", prefix, path_str);
142                    if !seen_paths.contains(&full_path) {
143                        seen_paths.insert(full_path);
144                        let path: Vec<String> =
145                            path_str.split('.').map(|s| s.to_string()).collect();
146                        bindings.push(Binding::new(source.clone(), path));
147                    }
148                }
149            }
150
151            search_pos = path_end.max(abs_start + prefix.len());
152        }
153    }
154
155    // Also find potential data source references: identifier.path patterns that
156    // aren't state.* or item.*. E.g., `spacetime.status == 'connected'`
157    extract_data_source_bindings_from_expression(expr, &mut bindings, &mut seen_paths);
158
159    bindings
160}
161
162/// Extract data source bindings (identifier.path patterns not starting with state/item)
163fn extract_data_source_bindings_from_expression(
164    expr: &str,
165    bindings: &mut Vec<Binding>,
166    seen_paths: &mut HashSet<String>,
167) {
168    let chars: Vec<char> = expr.chars().collect();
169    let len = chars.len();
170    let mut pos = 0;
171
172    // Reserved identifiers that are NOT data source providers
173    let reserved = ["state", "item", "true", "false", "null"];
174
175    while pos < len {
176        // Find the start of an identifier
177        if !chars[pos].is_ascii_alphabetic() && chars[pos] != '_' {
178            pos += 1;
179            continue;
180        }
181
182        // Check not preceded by alphanumeric (part of another identifier)
183        if pos > 0 && (chars[pos - 1].is_ascii_alphanumeric() || chars[pos - 1] == '_') {
184            pos += 1;
185            continue;
186        }
187
188        // Consume the identifier
189        let ident_start = pos;
190        while pos < len && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_') {
191            pos += 1;
192        }
193        let ident: String = chars[ident_start..pos].iter().collect();
194
195        // Must be followed by a dot and more path segments
196        if pos >= len || chars[pos] != '.' {
197            continue;
198        }
199
200        // Skip reserved words
201        if reserved.contains(&ident.as_str()) {
202            continue;
203        }
204
205        // Consume the dot and path segments
206        let path_start = pos + 1; // skip the dot
207        let mut path_end = path_start;
208        while path_end < len
209            && (chars[path_end].is_ascii_alphanumeric()
210                || chars[path_end] == '_'
211                || chars[path_end] == '.')
212        {
213            path_end += 1;
214        }
215
216        if path_end > path_start {
217            let path_str: String = chars[path_start..path_end].iter().collect();
218            let path_str = path_str.trim_end_matches('.');
219            if !path_str.is_empty() {
220                let full_path = format!("{}.{}", ident, path_str);
221                if !seen_paths.contains(&full_path) {
222                    seen_paths.insert(full_path);
223                    let path: Vec<String> =
224                        path_str.split('.').map(|s| s.to_string()).collect();
225                    bindings.push(Binding::data_source(&ident, path));
226                }
227            }
228        }
229
230        pos = path_end;
231    }
232}
233
234/// Check if a string contains an expression (not just a simple path)
235///
236/// Simple paths: `state.user.name`, `item.selected`
237/// Expressions: `state.selected ? 'a' : 'b'`, `item.count > 10`
238pub fn is_expression(s: &str) -> bool {
239    // Look for operators that indicate an expression
240    s.contains('?')
241        || s.contains("&&")
242        || s.contains("||")
243        || s.contains("==")
244        || s.contains("!=")
245        || s.contains(">=")
246        || s.contains("<=")
247        || s.contains('>') && !s.contains("->") // avoid matching arrow functions
248        || s.contains('<') && !s.contains("<-")
249        || s.contains('+')
250        || s.contains('-') && !s.starts_with('-') // avoid negative numbers
251        || s.contains('*')
252        || s.contains('/')
253        || s.contains('%')
254        || s.contains('!')
255}
256
257/// Evaluate a template string that may contain expressions
258///
259/// Handles strings like:
260/// - `"Hello ${state.user.name}"` - simple binding
261/// - `"Color: ${state.selected ? '#FFA7E1' : '#374151'}"` - expression
262/// - `"${state.count} items"` - mixed
263/// - `"${spacetime.messages.length} messages"` - data source binding
264pub fn evaluate_template_string(
265    template: &str,
266    state: &Value,
267    item: Option<&Value>,
268) -> Result<String, EngineError> {
269    evaluate_template_string_full(template, state, item, None)
270}
271
272/// Evaluate a template string with full context including data sources
273pub fn evaluate_template_string_full(
274    template: &str,
275    state: &Value,
276    item: Option<&Value>,
277    data_sources: Option<&indexmap::IndexMap<String, Value>>,
278) -> Result<String, EngineError> {
279    let context = build_expression_context_with_data_sources(state, item, data_sources);
280    let mut result = template.to_string();
281    let mut pos = 0;
282
283    while let Some(start) = result[pos..].find("${") {
284        let abs_start = pos + start;
285
286        // Find matching closing brace, handling nested braces
287        let mut depth = 1;
288        let mut end_pos = abs_start + 2;
289        let chars: Vec<char> = result.chars().collect();
290
291        while end_pos < chars.len() && depth > 0 {
292            match chars[end_pos] {
293                '{' => depth += 1,
294                '}' => depth -= 1,
295                _ => {}
296            }
297            if depth > 0 {
298                end_pos += 1;
299            }
300        }
301
302        if depth != 0 {
303            return Err(EngineError::ExpressionError(
304                "Unclosed expression in template".to_string(),
305            ));
306        }
307
308        // Extract the expression content
309        let expr_content: String = chars[abs_start + 2..end_pos].iter().collect();
310
311        // Evaluate the expression
312        let value = evaluate_expression(&expr_content, &context)?;
313
314        // Convert to string
315        let replacement = match &value {
316            Value::String(s) => s.clone(),
317            Value::Number(n) => n.to_string(),
318            Value::Bool(b) => b.to_string(),
319            Value::Null => "null".to_string(),
320            _ => serde_json::to_string(&value).unwrap_or_default(),
321        };
322
323        // Replace in result
324        let pattern: String = chars[abs_start..=end_pos].iter().collect();
325        result = result.replacen(&pattern, &replacement, 1);
326
327        // Reset position to search from beginning (replacement might be shorter/longer)
328        pos = 0;
329    }
330
331    Ok(result)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn test_simple_expression() {
341        let mut context = HashMap::new();
342        context.insert("x".to_string(), json!(5));
343        context.insert("y".to_string(), json!(3));
344
345        let result = evaluate_expression("x + y", &context).unwrap();
346        // exprimo returns floats for arithmetic, so compare as f64
347        assert_eq!(result.as_f64().unwrap(), 8.0);
348    }
349
350    #[test]
351    fn test_ternary_expression() {
352        let mut context = HashMap::new();
353        context.insert("selected".to_string(), json!(true));
354
355        let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
356        assert_eq!(result, json!("yes"));
357    }
358
359    #[test]
360    fn test_ternary_with_colors() {
361        let mut context = HashMap::new();
362        context.insert("selected".to_string(), json!(true));
363
364        let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
365        assert_eq!(result, json!("#FFA7E1"));
366    }
367
368    #[test]
369    fn test_comparison_expression() {
370        let mut context = HashMap::new();
371        context.insert("count".to_string(), json!(15));
372
373        let result = evaluate_expression("count > 10", &context).unwrap();
374        assert_eq!(result, json!(true));
375    }
376
377    #[test]
378    fn test_state_object_access() {
379        let context =
380            build_expression_context(&json!({"user": {"name": "Alice", "age": 30}}), None);
381
382        let result = evaluate_expression("state.user.name", &context).unwrap();
383        assert_eq!(result, json!("Alice"));
384    }
385
386    #[test]
387    fn test_item_object_access() {
388        let context = build_expression_context(
389            &json!({}),
390            Some(&json!({"name": "Item 1", "selected": true})),
391        );
392
393        let result = evaluate_expression("item.name", &context).unwrap();
394        assert_eq!(result, json!("Item 1"));
395    }
396
397    #[test]
398    fn test_item_ternary() {
399        let context = build_expression_context(&json!({}), Some(&json!({"selected": true})));
400
401        let result =
402            evaluate_expression("item.selected ? '#FFA7E1' : '#374151'", &context).unwrap();
403        assert_eq!(result, json!("#FFA7E1"));
404    }
405
406    #[test]
407    fn test_template_string_simple() {
408        let state = json!({"user": {"name": "Alice"}});
409        let result = evaluate_template_string("Hello ${state.user.name}!", &state, None).unwrap();
410        assert_eq!(result, "Hello Alice!");
411    }
412
413    #[test]
414    fn test_template_string_with_expression() {
415        let state = json!({"selected": true});
416        let result = evaluate_template_string(
417            "Color: ${state.selected ? '#FFA7E1' : '#374151'}",
418            &state,
419            None,
420        )
421        .unwrap();
422        assert_eq!(result, "Color: #FFA7E1");
423    }
424
425    #[test]
426    fn test_template_string_multiple_expressions() {
427        let state = json!({"name": "Alice", "count": 5});
428        let result =
429            evaluate_template_string("${state.name} has ${state.count} items", &state, None)
430                .unwrap();
431        assert_eq!(result, "Alice has 5 items");
432    }
433
434    #[test]
435    fn test_template_with_item() {
436        let state = json!({});
437        let item = json!({"name": "Product", "price": 99});
438        let result =
439            evaluate_template_string("${item.name}: $${item.price}", &state, Some(&item)).unwrap();
440        assert_eq!(result, "Product: $99");
441    }
442
443    #[test]
444    fn test_is_expression() {
445        // Simple paths - not expressions
446        assert!(!is_expression("state.user.name"));
447        assert!(!is_expression("item.selected"));
448
449        // Expressions
450        assert!(is_expression("selected ? 'a' : 'b'"));
451        assert!(is_expression("count > 10"));
452        assert!(is_expression("a && b"));
453        assert!(is_expression("a || b"));
454        assert!(is_expression("a == b"));
455        assert!(is_expression("a + b"));
456    }
457
458    #[test]
459    fn test_string_concatenation() {
460        let mut context = HashMap::new();
461        context.insert("first".to_string(), json!("Hello"));
462        context.insert("second".to_string(), json!("World"));
463
464        let result = evaluate_expression("first + ' ' + second", &context).unwrap();
465        assert_eq!(result, json!("Hello World"));
466    }
467
468    #[test]
469    fn test_logical_and() {
470        let mut context = HashMap::new();
471        context.insert("a".to_string(), json!(true));
472        context.insert("b".to_string(), json!(false));
473
474        let result = evaluate_expression("a && b", &context).unwrap();
475        assert_eq!(result, json!(false));
476    }
477
478    #[test]
479    fn test_logical_or() {
480        let mut context = HashMap::new();
481        context.insert("a".to_string(), json!(false));
482        context.insert("b".to_string(), json!(true));
483
484        let result = evaluate_expression("a || b", &context).unwrap();
485        assert_eq!(result, json!(true));
486    }
487
488    #[test]
489    fn test_complex_expression() {
490        let context = build_expression_context(
491            &json!({
492                "user": {
493                    "premium": true,
494                    "age": 25
495                }
496            }),
497            None,
498        );
499
500        let result = evaluate_expression(
501            "state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
502            &context,
503        )
504        .unwrap();
505        assert_eq!(result, json!("VIP Adult"));
506    }
507}