1use exprimo::{CustomFuncError, CustomFunction, Evaluator};
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use super::{Binding, BindingSource};
14use crate::error::EngineError;
15
16#[derive(Debug)]
27struct LengthFn;
28
29impl CustomFunction for LengthFn {
30 fn call(&self, args: &[Value]) -> Result<Value, CustomFuncError> {
31 if args.len() != 1 {
32 return Err(CustomFuncError::ArityError {
33 expected: 1,
34 got: args.len(),
35 });
36 }
37 let n = match &args[0] {
38 Value::String(s) => s.chars().count(),
43 Value::Array(a) => a.len(),
44 Value::Object(o) => o.len(),
45 Value::Null => 0,
46 other => {
47 return Err(CustomFuncError::ArgumentError(format!(
48 "length() expects string, array, object, or null; got {}",
49 other
50 )));
51 }
52 };
53 Ok(Value::Number(serde_json::Number::from(n as u64)))
56 }
57}
58
59fn builtin_functions() -> HashMap<String, Arc<dyn CustomFunction>> {
64 let mut funcs: HashMap<String, Arc<dyn CustomFunction>> = HashMap::new();
65 funcs.insert("length".to_string(), Arc::new(LengthFn));
66 funcs
67}
68
69pub fn evaluate_expression(
88 expr: &str,
89 context: &HashMap<String, Value>,
90) -> Result<Value, EngineError> {
91 let evaluator = Evaluator::new(context.clone(), builtin_functions());
92 evaluator
93 .evaluate(expr)
94 .map_err(|e| EngineError::ExpressionError(e.to_string()))
95}
96
97pub fn build_expression_context(
104 state: &Value,
105 item: Option<&Value>,
106 data_sources: Option<&indexmap::IndexMap<String, Value>>,
107) -> HashMap<String, Value> {
108 let mut context = HashMap::new();
109
110 context.insert("state".to_string(), state.clone());
112
113 if let Some(item_value) = item {
115 context.insert("item".to_string(), item_value.clone());
116 }
117
118 if let Some(ds_map) = data_sources {
120 for (provider, ds_state) in ds_map {
121 context.insert(provider.clone(), ds_state.clone());
122 }
123 }
124
125 context
126}
127
128pub fn extract_bindings_from_expression(expr: &str) -> Vec<Binding> {
144 let mut bindings = Vec::new();
145 let mut seen_paths: HashSet<String> = HashSet::new();
146
147 for prefix in &["state.", "item."] {
149 let source = if *prefix == "state." {
150 BindingSource::State
151 } else {
152 BindingSource::Item
153 };
154
155 let mut search_pos = 0;
156 while let Some(start) = expr[search_pos..].find(prefix) {
157 let abs_start = search_pos + start;
158
159 if abs_start > 0 {
162 let prev_char = expr.chars().nth(abs_start - 1).unwrap_or(' ');
163 if prev_char.is_ascii_alphanumeric() || prev_char == '_' {
164 search_pos = abs_start + prefix.len();
165 continue;
166 }
167 }
168
169 let path_start = abs_start + prefix.len();
171 let mut path_end = path_start;
172
173 let chars: Vec<char> = expr.chars().collect();
175 while path_end < chars.len() {
176 let c = chars[path_end];
177 if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
178 path_end += 1;
179 } else {
180 break;
181 }
182 }
183
184 if path_end > path_start {
185 let path_str: String = chars[path_start..path_end].iter().collect();
186 let path_str = path_str.trim_end_matches('.');
188
189 if !path_str.is_empty() {
190 let full_path = format!("{}{}", prefix, path_str);
191 if !seen_paths.contains(&full_path) {
192 seen_paths.insert(full_path);
193 let path: Vec<String> =
194 path_str.split('.').map(|s| s.to_string()).collect();
195 bindings.push(Binding::new(source.clone(), path));
196 }
197 }
198 }
199
200 search_pos = path_end.max(abs_start + prefix.len());
201 }
202 }
203
204 extract_data_source_bindings_from_expression(expr, &mut bindings, &mut seen_paths);
207
208 bindings
209}
210
211fn extract_data_source_bindings_from_expression(
213 expr: &str,
214 bindings: &mut Vec<Binding>,
215 seen_paths: &mut HashSet<String>,
216) {
217 let chars: Vec<char> = expr.chars().collect();
218 let len = chars.len();
219 let mut pos = 0;
220
221 let reserved = ["state", "item", "true", "false", "null"];
223
224 while pos < len {
225 if !chars[pos].is_ascii_alphabetic() && chars[pos] != '_' {
227 pos += 1;
228 continue;
229 }
230
231 if pos > 0 && (chars[pos - 1].is_ascii_alphanumeric() || chars[pos - 1] == '_') {
233 pos += 1;
234 continue;
235 }
236
237 let ident_start = pos;
239 while pos < len && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_') {
240 pos += 1;
241 }
242 let ident: String = chars[ident_start..pos].iter().collect();
243
244 if pos >= len || chars[pos] != '.' {
246 continue;
247 }
248
249 if reserved.contains(&ident.as_str()) {
251 continue;
252 }
253
254 let path_start = pos + 1; let mut path_end = path_start;
257 while path_end < len
258 && (chars[path_end].is_ascii_alphanumeric()
259 || chars[path_end] == '_'
260 || chars[path_end] == '.')
261 {
262 path_end += 1;
263 }
264
265 if path_end > path_start {
266 let path_str: String = chars[path_start..path_end].iter().collect();
267 let path_str = path_str.trim_end_matches('.');
268 if !path_str.is_empty() {
269 let full_path = format!("{}.{}", ident, path_str);
270 if !seen_paths.contains(&full_path) {
271 seen_paths.insert(full_path);
272 let path: Vec<String> = path_str.split('.').map(|s| s.to_string()).collect();
273 bindings.push(Binding::data_source(&ident, path));
274 }
275 }
276 }
277
278 pos = path_end;
279 }
280}
281
282pub fn build_evaluator(
288 state: &Value,
289 item: Option<&Value>,
290 data_sources: Option<&indexmap::IndexMap<String, Value>>,
291) -> Evaluator {
292 let context = build_expression_context(state, item, data_sources);
293 Evaluator::new(context, builtin_functions())
294}
295
296pub fn evaluate_template_string(
302 template: &str,
303 evaluator: &Evaluator,
304) -> Result<String, EngineError> {
305 let mut result = template.to_string();
306 let mut pos = 0;
307
308 while let Some(rel_start) = result[pos..].find("@{") {
309 let abs_start = pos + rel_start;
310 let body_start = abs_start + 2;
311
312 let mut depth: i32 = 1;
318 let mut close_byte: Option<usize> = None;
319 for (off, ch) in result[body_start..].char_indices() {
320 match ch {
321 '{' => depth += 1,
322 '}' => {
323 depth -= 1;
324 if depth == 0 {
325 close_byte = Some(body_start + off);
326 break;
327 }
328 }
329 _ => {}
330 }
331 }
332
333 let close_byte = match close_byte {
334 Some(b) => b,
335 None => {
336 return Err(EngineError::ExpressionError(
337 "Unclosed expression in template".to_string(),
338 ));
339 }
340 };
341
342 let expr_content = result[body_start..close_byte].to_string();
344
345 let value = evaluator
347 .evaluate(&expr_content)
348 .map_err(|e| EngineError::ExpressionError(e.to_string()))?;
349
350 let replacement = match &value {
352 Value::String(s) => s.clone(),
353 Value::Number(n) => n.to_string(),
354 Value::Bool(b) => b.to_string(),
355 Value::Null => "null".to_string(),
356 _ => serde_json::to_string(&value).unwrap_or_default(),
357 };
358
359 let end_byte = close_byte + 1;
361 result.replace_range(abs_start..end_byte, &replacement);
362
363 pos = abs_start + replacement.len();
365 }
366
367 Ok(result)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use serde_json::json;
374
375 #[test]
376 fn test_simple_expression() {
377 let mut context = HashMap::new();
378 context.insert("x".to_string(), json!(5));
379 context.insert("y".to_string(), json!(3));
380
381 let result = evaluate_expression("x + y", &context).unwrap();
382 assert_eq!(result.as_f64().unwrap(), 8.0);
384 }
385
386 #[test]
387 fn test_ternary_expression() {
388 let mut context = HashMap::new();
389 context.insert("selected".to_string(), json!(true));
390
391 let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
392 assert_eq!(result, json!("yes"));
393 }
394
395 #[test]
396 fn test_ternary_with_colors() {
397 let mut context = HashMap::new();
398 context.insert("selected".to_string(), json!(true));
399
400 let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
401 assert_eq!(result, json!("#FFA7E1"));
402 }
403
404 #[test]
405 fn test_comparison_expression() {
406 let mut context = HashMap::new();
407 context.insert("count".to_string(), json!(15));
408
409 let result = evaluate_expression("count > 10", &context).unwrap();
410 assert_eq!(result, json!(true));
411 }
412
413 #[test]
414 fn test_state_object_access() {
415 let context =
416 build_expression_context(&json!({"user": {"name": "Alice", "age": 30}}), None, None);
417
418 let result = evaluate_expression("state.user.name", &context).unwrap();
419 assert_eq!(result, json!("Alice"));
420 }
421
422 #[test]
423 fn test_item_object_access() {
424 let context = build_expression_context(
425 &json!({}),
426 Some(&json!({"name": "Item 1", "selected": true})),
427 None,
428 );
429
430 let result = evaluate_expression("item.name", &context).unwrap();
431 assert_eq!(result, json!("Item 1"));
432 }
433
434 #[test]
435 fn test_item_ternary() {
436 let context = build_expression_context(&json!({}), Some(&json!({"selected": true})), None);
437
438 let result =
439 evaluate_expression("item.selected ? '#FFA7E1' : '#374151'", &context).unwrap();
440 assert_eq!(result, json!("#FFA7E1"));
441 }
442
443 #[test]
444 fn test_template_string_simple() {
445 let state = json!({"user": {"name": "Alice"}});
446 let evaluator = build_evaluator(&state, None, None);
447 let result = evaluate_template_string("Hello @{state.user.name}!", &evaluator).unwrap();
448 assert_eq!(result, "Hello Alice!");
449 }
450
451 #[test]
452 fn test_template_string_with_expression() {
453 let state = json!({"selected": true});
454 let evaluator = build_evaluator(&state, None, None);
455 let result = evaluate_template_string(
456 "Color: @{state.selected ? '#FFA7E1' : '#374151'}",
457 &evaluator,
458 )
459 .unwrap();
460 assert_eq!(result, "Color: #FFA7E1");
461 }
462
463 #[test]
464 fn test_template_string_multiple_expressions() {
465 let state = json!({"name": "Alice", "count": 5});
466 let evaluator = build_evaluator(&state, None, None);
467 let result =
468 evaluate_template_string("@{state.name} has @{state.count} items", &evaluator).unwrap();
469 assert_eq!(result, "Alice has 5 items");
470 }
471
472 #[test]
473 fn test_template_with_item() {
474 let state = json!({});
475 let item = json!({"name": "Product", "price": 99});
476 let evaluator = build_evaluator(&state, Some(&item), None);
477 let result = evaluate_template_string("@{item.name}: $@{item.price}", &evaluator).unwrap();
478 assert_eq!(result, "Product: $99");
479 }
480
481 #[test]
482 fn test_string_concatenation() {
483 let mut context = HashMap::new();
484 context.insert("first".to_string(), json!("Hello"));
485 context.insert("second".to_string(), json!("World"));
486
487 let result = evaluate_expression("first + ' ' + second", &context).unwrap();
488 assert_eq!(result, json!("Hello World"));
489 }
490
491 #[test]
492 fn test_logical_and() {
493 let mut context = HashMap::new();
494 context.insert("a".to_string(), json!(true));
495 context.insert("b".to_string(), json!(false));
496
497 let result = evaluate_expression("a && b", &context).unwrap();
498 assert_eq!(result, json!(false));
499 }
500
501 #[test]
502 fn test_logical_or() {
503 let mut context = HashMap::new();
504 context.insert("a".to_string(), json!(false));
505 context.insert("b".to_string(), json!(true));
506
507 let result = evaluate_expression("a || b", &context).unwrap();
508 assert_eq!(result, json!(true));
509 }
510
511 #[test]
512 fn test_template_string_multibyte_before_expression() {
513 let state = json!({"a": "ALPHA", "b": "BETA"});
518 let evaluator = build_evaluator(&state, None, None);
519
520 let result = evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
522 assert_eq!(result, "ALPHA · BETA");
523
524 let result = evaluate_template_string("prefix · @{state.a}", &evaluator).unwrap();
525 assert_eq!(result, "prefix · ALPHA");
526
527 let result = evaluate_template_string("@{state.a} — @{state.b}", &evaluator).unwrap();
529 assert_eq!(result, "ALPHA — BETA");
530
531 let result = evaluate_template_string("🍕 @{state.a}", &evaluator).unwrap();
533 assert_eq!(result, "🍕 ALPHA");
534
535 let result = evaluate_template_string("你好 @{state.a}", &evaluator).unwrap();
537 assert_eq!(result, "你好 ALPHA");
538
539 let result = evaluate_template_string("café @{state.a}", &evaluator).unwrap();
541 assert_eq!(result, "café ALPHA");
542 }
543
544 #[test]
545 fn test_template_string_multibyte_inside_replacement() {
546 let state = json!({"a": "café", "b": "naïve"});
549 let evaluator = build_evaluator(&state, None, None);
550
551 let result = evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
552 assert_eq!(result, "café · naïve");
553 }
554
555 #[test]
556 fn test_template_string_realistic_restaurant_example() {
557 let state = json!({
561 "restaurant": {
562 "cuisine": "Italian",
563 "description": "Wood-fired pizza"
564 }
565 });
566 let evaluator = build_evaluator(&state, None, None);
567 let result = evaluate_template_string(
568 "@{state.restaurant.cuisine} · @{state.restaurant.description}",
569 &evaluator,
570 )
571 .unwrap();
572 assert_eq!(result, "Italian · Wood-fired pizza");
573 }
574
575 #[test]
576 fn test_length_function_strings_arrays_objects() {
577 let state = json!({
582 "empty": "",
583 "hello": "hello",
584 "unicode": "café", "items_empty": [],
586 "items": [1, 2, 3],
587 "obj": { "a": 1, "b": 2 },
588 });
589 let ev = build_evaluator(&state, None, None);
590
591 assert_eq!(
593 evaluate_template_string("@{length(state.empty)}", &ev).unwrap(),
594 "0"
595 );
596 assert_eq!(
597 evaluate_template_string("@{length(state.hello)}", &ev).unwrap(),
598 "5"
599 );
600 assert_eq!(
602 evaluate_template_string("@{length(state.unicode)}", &ev).unwrap(),
603 "4"
604 );
605
606 assert_eq!(
608 evaluate_template_string("@{length(state.items_empty)}", &ev).unwrap(),
609 "0"
610 );
611 assert_eq!(
612 evaluate_template_string("@{length(state.items)}", &ev).unwrap(),
613 "3"
614 );
615
616 assert_eq!(
618 evaluate_template_string("@{length(state.obj)}", &ev).unwrap(),
619 "2"
620 );
621 }
622
623 #[test]
624 fn test_length_function_empty_search_query_condition() {
625 let state = json!({ "searchQuery": "" });
630 let ev = build_evaluator(&state, None, None);
631
632 let mut ctx = HashMap::new();
633 ctx.insert("state".to_string(), state.clone());
634
635 let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
636 assert_eq!(result, json!(true));
637
638 let populated = json!({ "searchQuery": "pizza" });
639 let mut ctx = HashMap::new();
640 ctx.insert("state".to_string(), populated);
641 let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
642 assert_eq!(result, json!(false));
643
644 let _ = ev; }
649
650 #[test]
651 fn test_length_function_null_and_errors() {
652 let state = json!({ "missing": null });
653 let ev = build_evaluator(&state, None, None);
654
655 assert_eq!(
658 evaluate_template_string("@{length(state.missing)}", &ev).unwrap(),
659 "0"
660 );
661
662 let err = evaluate_template_string("@{length()}", &ev).unwrap_err();
664 match err {
665 EngineError::ExpressionError(msg) => {
666 assert!(
667 msg.contains("expected 1") || msg.contains("arg"),
668 "expected arity error, got: {}",
669 msg
670 );
671 }
672 _ => panic!("expected ExpressionError"),
673 }
674 }
675
676 #[test]
677 fn test_template_string_unclosed_expression() {
678 let state = json!({});
679 let evaluator = build_evaluator(&state, None, None);
680 let err = evaluate_template_string("prefix @{state.a", &evaluator).unwrap_err();
681 match err {
682 EngineError::ExpressionError(msg) => {
683 assert!(msg.contains("Unclosed"));
684 }
685 _ => panic!("expected ExpressionError"),
686 }
687 }
688
689 #[test]
690 fn test_complex_expression() {
691 let context = build_expression_context(
692 &json!({
693 "user": {
694 "premium": true,
695 "age": 25
696 }
697 }),
698 None,
699 None,
700 );
701
702 let result = evaluate_expression(
703 "state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
704 &context,
705 )
706 .unwrap();
707 assert_eq!(result, json!("VIP Adult"));
708 }
709}