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