use exprimo::{CustomFuncError, CustomFunction, Evaluator};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use super::{Binding, BindingSource};
use crate::error::EngineError;
#[derive(Debug)]
struct LengthFn;
impl CustomFunction for LengthFn {
fn call(&self, args: &[Value]) -> Result<Value, CustomFuncError> {
if args.len() != 1 {
return Err(CustomFuncError::ArityError {
expected: 1,
got: args.len(),
});
}
let n = match &args[0] {
Value::String(s) => s.chars().count(),
Value::Array(a) => a.len(),
Value::Object(o) => o.len(),
Value::Null => 0,
other => {
return Err(CustomFuncError::ArgumentError(format!(
"length() expects string, array, object, or null; got {}",
other
)));
}
};
Ok(Value::Number(serde_json::Number::from(n as u64)))
}
}
fn builtin_functions() -> HashMap<String, Arc<dyn CustomFunction>> {
let mut funcs: HashMap<String, Arc<dyn CustomFunction>> = HashMap::new();
funcs.insert("length".to_string(), Arc::new(LengthFn));
funcs
}
pub fn evaluate_expression(
expr: &str,
context: &HashMap<String, Value>,
) -> Result<Value, EngineError> {
let evaluator = Evaluator::new(context.clone(), builtin_functions());
evaluator
.evaluate(expr)
.map_err(|e| EngineError::ExpressionError(e.to_string()))
}
pub fn build_expression_context(
state: &Value,
item: Option<&Value>,
data_sources: Option<&indexmap::IndexMap<String, Value>>,
) -> HashMap<String, Value> {
let mut context = HashMap::new();
context.insert("state".to_string(), state.clone());
if let Some(item_value) = item {
context.insert("item".to_string(), item_value.clone());
}
if let Some(ds_map) = data_sources {
for (provider, ds_state) in ds_map {
context.insert(provider.clone(), ds_state.clone());
}
}
context
}
pub fn extract_bindings_from_expression(expr: &str) -> Vec<Binding> {
let mut bindings = Vec::new();
let mut seen_paths: HashSet<String> = HashSet::new();
for prefix in &["state.", "item."] {
let source = if *prefix == "state." {
BindingSource::State
} else {
BindingSource::Item
};
let mut search_pos = 0;
while let Some(start) = expr[search_pos..].find(prefix) {
let abs_start = search_pos + start;
if abs_start > 0 {
let prev_char = expr.chars().nth(abs_start - 1).unwrap_or(' ');
if prev_char.is_ascii_alphanumeric() || prev_char == '_' {
search_pos = abs_start + prefix.len();
continue;
}
}
let path_start = abs_start + prefix.len();
let mut path_end = path_start;
let chars: Vec<char> = expr.chars().collect();
while path_end < chars.len() {
let c = chars[path_end];
if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
path_end += 1;
} else {
break;
}
}
if path_end > path_start {
let path_str: String = chars[path_start..path_end].iter().collect();
let path_str = path_str.trim_end_matches('.');
if !path_str.is_empty() {
let full_path = format!("{}{}", prefix, path_str);
if !seen_paths.contains(&full_path) {
seen_paths.insert(full_path);
let path: Vec<String> =
path_str.split('.').map(|s| s.to_string()).collect();
bindings.push(Binding::new(source.clone(), path));
}
}
}
search_pos = path_end.max(abs_start + prefix.len());
}
}
extract_data_source_bindings_from_expression(expr, &mut bindings, &mut seen_paths);
bindings
}
fn extract_data_source_bindings_from_expression(
expr: &str,
bindings: &mut Vec<Binding>,
seen_paths: &mut HashSet<String>,
) {
let chars: Vec<char> = expr.chars().collect();
let len = chars.len();
let mut pos = 0;
let reserved = ["state", "item", "true", "false", "null"];
while pos < len {
if !chars[pos].is_ascii_alphabetic() && chars[pos] != '_' {
pos += 1;
continue;
}
if pos > 0 && (chars[pos - 1].is_ascii_alphanumeric() || chars[pos - 1] == '_') {
pos += 1;
continue;
}
let ident_start = pos;
while pos < len && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_') {
pos += 1;
}
let ident: String = chars[ident_start..pos].iter().collect();
if pos >= len || chars[pos] != '.' {
continue;
}
if reserved.contains(&ident.as_str()) {
continue;
}
let path_start = pos + 1; let mut path_end = path_start;
while path_end < len
&& (chars[path_end].is_ascii_alphanumeric()
|| chars[path_end] == '_'
|| chars[path_end] == '.')
{
path_end += 1;
}
if path_end > path_start {
let path_str: String = chars[path_start..path_end].iter().collect();
let path_str = path_str.trim_end_matches('.');
if !path_str.is_empty() {
let full_path = format!("{}.{}", ident, path_str);
if !seen_paths.contains(&full_path) {
seen_paths.insert(full_path);
let path: Vec<String> = path_str.split('.').map(|s| s.to_string()).collect();
bindings.push(Binding::data_source(&ident, path));
}
}
}
pos = path_end;
}
}
pub fn build_evaluator(
state: &Value,
item: Option<&Value>,
data_sources: Option<&indexmap::IndexMap<String, Value>>,
) -> Evaluator {
let context = build_expression_context(state, item, data_sources);
Evaluator::new(context, builtin_functions())
}
pub fn evaluate_template_string(
template: &str,
evaluator: &Evaluator,
) -> Result<String, EngineError> {
let mut result = template.to_string();
let mut pos = 0;
while let Some(rel_start) = result[pos..].find("@{") {
let abs_start = pos + rel_start;
let body_start = abs_start + 2;
let mut depth: i32 = 1;
let mut close_byte: Option<usize> = None;
for (off, ch) in result[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close_byte = Some(body_start + off);
break;
}
}
_ => {}
}
}
let close_byte = match close_byte {
Some(b) => b,
None => {
return Err(EngineError::ExpressionError(
"Unclosed expression in template".to_string(),
));
}
};
let expr_content = result[body_start..close_byte].to_string();
let value = evaluator
.evaluate(&expr_content)
.map_err(|e| EngineError::ExpressionError(e.to_string()))?;
let replacement = match &value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
_ => serde_json::to_string(&value).unwrap_or_default(),
};
let end_byte = close_byte + 1;
result.replace_range(abs_start..end_byte, &replacement);
pos = abs_start + replacement.len();
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_simple_expression() {
let mut context = HashMap::new();
context.insert("x".to_string(), json!(5));
context.insert("y".to_string(), json!(3));
let result = evaluate_expression("x + y", &context).unwrap();
assert_eq!(result.as_f64().unwrap(), 8.0);
}
#[test]
fn test_ternary_expression() {
let mut context = HashMap::new();
context.insert("selected".to_string(), json!(true));
let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
assert_eq!(result, json!("yes"));
}
#[test]
fn test_ternary_with_colors() {
let mut context = HashMap::new();
context.insert("selected".to_string(), json!(true));
let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
assert_eq!(result, json!("#FFA7E1"));
}
#[test]
fn test_comparison_expression() {
let mut context = HashMap::new();
context.insert("count".to_string(), json!(15));
let result = evaluate_expression("count > 10", &context).unwrap();
assert_eq!(result, json!(true));
}
#[test]
fn test_state_object_access() {
let context =
build_expression_context(&json!({"user": {"name": "Alice", "age": 30}}), None, None);
let result = evaluate_expression("state.user.name", &context).unwrap();
assert_eq!(result, json!("Alice"));
}
#[test]
fn test_item_object_access() {
let context = build_expression_context(
&json!({}),
Some(&json!({"name": "Item 1", "selected": true})),
None,
);
let result = evaluate_expression("item.name", &context).unwrap();
assert_eq!(result, json!("Item 1"));
}
#[test]
fn test_item_ternary() {
let context = build_expression_context(&json!({}), Some(&json!({"selected": true})), None);
let result =
evaluate_expression("item.selected ? '#FFA7E1' : '#374151'", &context).unwrap();
assert_eq!(result, json!("#FFA7E1"));
}
#[test]
fn test_template_string_simple() {
let state = json!({"user": {"name": "Alice"}});
let evaluator = build_evaluator(&state, None, None);
let result = evaluate_template_string("Hello @{state.user.name}!", &evaluator).unwrap();
assert_eq!(result, "Hello Alice!");
}
#[test]
fn test_template_string_with_expression() {
let state = json!({"selected": true});
let evaluator = build_evaluator(&state, None, None);
let result = evaluate_template_string(
"Color: @{state.selected ? '#FFA7E1' : '#374151'}",
&evaluator,
)
.unwrap();
assert_eq!(result, "Color: #FFA7E1");
}
#[test]
fn test_template_string_multiple_expressions() {
let state = json!({"name": "Alice", "count": 5});
let evaluator = build_evaluator(&state, None, None);
let result =
evaluate_template_string("@{state.name} has @{state.count} items", &evaluator).unwrap();
assert_eq!(result, "Alice has 5 items");
}
#[test]
fn test_template_with_item() {
let state = json!({});
let item = json!({"name": "Product", "price": 99});
let evaluator = build_evaluator(&state, Some(&item), None);
let result = evaluate_template_string("@{item.name}: $@{item.price}", &evaluator).unwrap();
assert_eq!(result, "Product: $99");
}
#[test]
fn test_string_concatenation() {
let mut context = HashMap::new();
context.insert("first".to_string(), json!("Hello"));
context.insert("second".to_string(), json!("World"));
let result = evaluate_expression("first + ' ' + second", &context).unwrap();
assert_eq!(result, json!("Hello World"));
}
#[test]
fn test_logical_and() {
let mut context = HashMap::new();
context.insert("a".to_string(), json!(true));
context.insert("b".to_string(), json!(false));
let result = evaluate_expression("a && b", &context).unwrap();
assert_eq!(result, json!(false));
}
#[test]
fn test_logical_or() {
let mut context = HashMap::new();
context.insert("a".to_string(), json!(false));
context.insert("b".to_string(), json!(true));
let result = evaluate_expression("a || b", &context).unwrap();
assert_eq!(result, json!(true));
}
#[test]
fn test_template_string_multibyte_before_expression() {
let state = json!({"a": "ALPHA", "b": "BETA"});
let evaluator = build_evaluator(&state, None, None);
let result = evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
assert_eq!(result, "ALPHA · BETA");
let result = evaluate_template_string("prefix · @{state.a}", &evaluator).unwrap();
assert_eq!(result, "prefix · ALPHA");
let result = evaluate_template_string("@{state.a} — @{state.b}", &evaluator).unwrap();
assert_eq!(result, "ALPHA — BETA");
let result = evaluate_template_string("🍕 @{state.a}", &evaluator).unwrap();
assert_eq!(result, "🍕 ALPHA");
let result = evaluate_template_string("你好 @{state.a}", &evaluator).unwrap();
assert_eq!(result, "你好 ALPHA");
let result = evaluate_template_string("café @{state.a}", &evaluator).unwrap();
assert_eq!(result, "café ALPHA");
}
#[test]
fn test_template_string_multibyte_inside_replacement() {
let state = json!({"a": "café", "b": "naïve"});
let evaluator = build_evaluator(&state, None, None);
let result = evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
assert_eq!(result, "café · naïve");
}
#[test]
fn test_template_string_realistic_restaurant_example() {
let state = json!({
"restaurant": {
"cuisine": "Italian",
"description": "Wood-fired pizza"
}
});
let evaluator = build_evaluator(&state, None, None);
let result = evaluate_template_string(
"@{state.restaurant.cuisine} · @{state.restaurant.description}",
&evaluator,
)
.unwrap();
assert_eq!(result, "Italian · Wood-fired pizza");
}
#[test]
fn test_length_function_strings_arrays_objects() {
let state = json!({
"empty": "",
"hello": "hello",
"unicode": "café", "items_empty": [],
"items": [1, 2, 3],
"obj": { "a": 1, "b": 2 },
});
let ev = build_evaluator(&state, None, None);
assert_eq!(
evaluate_template_string("@{length(state.empty)}", &ev).unwrap(),
"0"
);
assert_eq!(
evaluate_template_string("@{length(state.hello)}", &ev).unwrap(),
"5"
);
assert_eq!(
evaluate_template_string("@{length(state.unicode)}", &ev).unwrap(),
"4"
);
assert_eq!(
evaluate_template_string("@{length(state.items_empty)}", &ev).unwrap(),
"0"
);
assert_eq!(
evaluate_template_string("@{length(state.items)}", &ev).unwrap(),
"3"
);
assert_eq!(
evaluate_template_string("@{length(state.obj)}", &ev).unwrap(),
"2"
);
}
#[test]
fn test_length_function_empty_search_query_condition() {
let state = json!({ "searchQuery": "" });
let ev = build_evaluator(&state, None, None);
let mut ctx = HashMap::new();
ctx.insert("state".to_string(), state.clone());
let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
assert_eq!(result, json!(true));
let populated = json!({ "searchQuery": "pizza" });
let mut ctx = HashMap::new();
ctx.insert("state".to_string(), populated);
let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
assert_eq!(result, json!(false));
let _ = ev; }
#[test]
fn test_length_function_null_and_errors() {
let state = json!({ "missing": null });
let ev = build_evaluator(&state, None, None);
assert_eq!(
evaluate_template_string("@{length(state.missing)}", &ev).unwrap(),
"0"
);
let err = evaluate_template_string("@{length()}", &ev).unwrap_err();
match err {
EngineError::ExpressionError(msg) => {
assert!(
msg.contains("expected 1") || msg.contains("arg"),
"expected arity error, got: {}",
msg
);
}
_ => panic!("expected ExpressionError"),
}
}
#[test]
fn test_template_string_unclosed_expression() {
let state = json!({});
let evaluator = build_evaluator(&state, None, None);
let err = evaluate_template_string("prefix @{state.a", &evaluator).unwrap_err();
match err {
EngineError::ExpressionError(msg) => {
assert!(msg.contains("Unclosed"));
}
_ => panic!("expected ExpressionError"),
}
}
#[test]
fn test_complex_expression() {
let context = build_expression_context(
&json!({
"user": {
"premium": true,
"age": 25
}
}),
None,
None,
);
let result = evaluate_expression(
"state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
&context,
)
.unwrap();
assert_eq!(result, json!("VIP Adult"));
}
}