use super::init::{format_value_python_style, init_interpreter, EvalError};
use super::math::preprocess_math_functions;
use super::parsing::{escape_python_string, eval_literal, remove_quotes};
use super::yaml_utils::preprocess_load_yaml;
use crate::eval::lexer::{Lexer, TokenType};
use log;
use pyisheval::{EvalError as PyEvalError, Interpreter, Value};
use std::collections::HashMap;
pub(crate) fn build_pyisheval_context(
properties: &HashMap<String, String>,
interp: &mut Interpreter,
) -> Result<HashMap<String, Value>, EvalError> {
interp.eval("None = 0").map_err(|e| EvalError::PyishEval {
expr: "None = 0".to_string(),
source: e,
})?;
for (name, value) in properties.iter() {
let trimmed = value.trim();
if !trimmed.starts_with("lambda ") {
if trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('(') {
if let Err(e) = interp.eval(&format!("{} = {}", name, trimmed)) {
log::warn!(
"Could not load property '{}' with value '{}' into interpreter as Python literal: {}. \
Loading as string fallback to prevent 'Undefined variable' in lambdas.",
name, value, e
);
let escaped = escape_python_string(value);
let _ = interp.eval(&format!("{} = '{}'", name, escaped));
}
continue;
}
match eval_literal(value) {
Value::Number(num) => {
if num.is_infinite() {
let sign = if num.is_sign_negative() { "-" } else { "" };
let expr = format!("{} = {}10 ** 400", name, sign);
interp
.eval(&expr)
.map_err(|e| EvalError::PyishEval { expr, source: e })?;
continue;
}
if num.is_nan() {
log::warn!(
"Property '{}' has NaN value, which cannot be loaded into interpreter. \
Lambda expressions referencing this property will fail.",
name
);
continue;
}
interp.eval(&format!("{} = {}", name, num)).map_err(|e| {
EvalError::PyishEval {
expr: format!("{} = {}", name, num),
source: e,
}
})?;
}
Value::StringLit(s) if !s.is_empty() => {
let escaped_value = escape_python_string(&s);
interp
.eval(&format!("{} = '{}'", name, escaped_value))
.map_err(|e| EvalError::PyishEval {
expr: format!("{} = '{}'", name, escaped_value),
source: e,
})?;
}
Value::StringLit(_) => {
}
_ => {
unreachable!(
"eval_literal returned unexpected value type for property '{}': {:?}",
name, value
);
}
}
}
}
let mut context: HashMap<String, Value> = properties
.iter()
.map(|(name, value)| -> Result<(String, Value), EvalError> {
let trimmed = value.trim();
if trimmed.starts_with("lambda ") {
let assignment = format!("{} = {}", name, trimmed);
interp.eval(&assignment).map_err(|e| EvalError::PyishEval {
expr: assignment.clone(),
source: e,
})?;
let lambda_value = interp.eval(name).map_err(|e| EvalError::PyishEval {
expr: name.clone(),
source: e,
})?;
return Ok((name.clone(), lambda_value));
}
if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.starts_with('(') {
match interp.eval(trimmed) {
Ok(evaluated_value) => {
Ok((name.clone(), evaluated_value))
}
Err(e) => {
match &e {
PyEvalError::TypeError
| PyEvalError::DivisionByZero
| PyEvalError::LambdaCallError
| PyEvalError::ArgError(_)
| PyEvalError::DictKeyError => {
log::warn!(
"Property '{}' with value '{}' failed evaluation: {}. Treating as string literal.",
name, value, e
);
}
PyEvalError::ParseError(_) | PyEvalError::UndefinedVar(_) => {}
}
Ok((name.clone(), Value::StringLit(value.clone())))
}
}
} else {
let literal_value = eval_literal(value);
Ok((name.clone(), literal_value))
}
})
.collect::<Result<HashMap<_, _>, _>>()?;
context.insert("inf".to_string(), Value::Number(f64::INFINITY));
context.insert("nan".to_string(), Value::Number(f64::NAN));
context.insert("None".to_string(), Value::Number(0.0));
Ok(context)
}
fn print_location(location_ctx: Option<&crate::eval::LocationContext>) {
let ctx = match location_ctx {
Some(c) => c,
None => {
log::info!("(unknown location)");
return;
}
};
if !ctx.macro_stack.is_empty() {
let mut msg = "when instantiating macro:";
for macro_name in ctx.macro_stack.iter().rev() {
if let Some(file) = &ctx.file {
log::info!("{} {} ({})", msg, macro_name, file.display());
} else {
log::info!("{} {} (???)", msg, macro_name);
}
msg = "instantiated from:";
}
}
let file_msg = if ctx.macro_stack.is_empty() {
"when processing file:"
} else {
"in file:"
};
if !ctx.include_stack.is_empty() {
let mut first = true;
for file_path in ctx.include_stack.iter().rev() {
let msg = if first { file_msg } else { "included from:" };
log::info!("{} {}", msg, file_path.display());
first = false;
}
} else if let Some(file) = &ctx.file {
log::info!("{} {}", file_msg, file.display());
}
}
pub(crate) fn evaluate_expression_impl(
interp: &mut Interpreter,
expr: &str,
context: &HashMap<String, Value>,
#[cfg(feature = "yaml")] yaml_tag_handler_registry: Option<
&crate::eval::yaml_tag_handler::YamlTagHandlerRegistry,
>,
location_ctx: Option<&crate::eval::LocationContext>,
) -> Result<Option<Value>, pyisheval::EvalError> {
let trimmed_expr = expr.trim();
if trimmed_expr == "xacro.print_location()" {
print_location(location_ctx);
return Ok(None);
}
let preprocessed = preprocess_math_functions(expr, interp, context).map_err(|e| match e {
EvalError::PyishEval { source, .. } => source,
_ => pyisheval::EvalError::ParseError(e.to_string()),
})?;
let preprocessed = preprocess_load_yaml(
&preprocessed,
interp,
context,
#[cfg(feature = "yaml")]
yaml_tag_handler_registry,
)
.map_err(|e| match e {
EvalError::PyishEval { source, .. } => source,
_ => pyisheval::EvalError::ParseError(e.to_string()),
})?;
interp.eval_with_context(&preprocessed, context).map(Some)
}
fn eval_text_with_interpreter_impl(
text: &str,
properties: &HashMap<String, String>,
interp: &mut Interpreter,
#[cfg(feature = "yaml")] yaml_tag_handler_registry: Option<
&crate::eval::yaml_tag_handler::YamlTagHandlerRegistry,
>,
) -> Result<String, EvalError> {
let context = build_pyisheval_context(properties, interp)?;
let lexer = Lexer::new(text);
let mut result = Vec::new();
for (token_type, token_value) in lexer {
match token_type {
TokenType::Text => {
result.push(token_value);
}
TokenType::Expr => {
match evaluate_expression_impl(
interp,
&token_value,
&context,
#[cfg(feature = "yaml")]
yaml_tag_handler_registry,
None, ) {
Ok(Some(value)) => {
#[cfg(feature = "compat")]
let value_str = format_value_python_style(&value, false);
#[cfg(not(feature = "compat"))]
let value_str = format_value_python_style(&value, true);
result.push(remove_quotes(&value_str).to_string());
}
Ok(None) => {
continue;
}
Err(e) => {
return Err(EvalError::PyishEval {
expr: token_value.clone(),
source: e,
});
}
}
}
TokenType::Extension => {
result.push(format!("$({})", token_value));
}
TokenType::DollarDollarBrace => {
result.push(format!("${}", token_value));
}
}
}
Ok(result.join(""))
}
pub(crate) fn eval_text_with_interpreter(
text: &str,
properties: &HashMap<String, String>,
interp: &mut Interpreter,
) -> Result<String, EvalError> {
eval_text_with_interpreter_impl(
text,
properties,
interp,
#[cfg(feature = "yaml")]
None,
)
}
fn apply_string_truthiness(
s: &str,
original: &str,
) -> Result<bool, EvalError> {
let trimmed = s.trim();
if trimmed == "true" || trimmed == "True" {
return Ok(true);
}
if trimmed == "false" || trimmed == "False" {
return Ok(false);
}
if let Ok(i) = trimmed.parse::<i64>() {
return Ok(i != 0);
}
if let Ok(f) = trimmed.parse::<f64>() {
return Ok(f != 0.0);
}
Err(EvalError::InvalidBoolean {
condition: original.to_string(),
evaluated: s.to_string(),
})
}
pub(crate) fn eval_boolean(
text: &str,
properties: &HashMap<String, String>,
) -> Result<bool, EvalError> {
let mut interp = init_interpreter();
let context = build_pyisheval_context(properties, &mut interp)?;
let lexer = Lexer::new(text);
let tokens: Vec<_> = lexer.collect();
if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
let value = interp
.eval_with_context(&tokens[0].1, &context)
.map_err(|e| EvalError::PyishEval {
expr: text.to_string(),
source: e,
})?;
return match value {
Value::Number(n) => Ok(n != 0.0), Value::StringLit(s) => {
apply_string_truthiness(&s, text)
}
_ => Err(EvalError::InvalidBoolean {
condition: text.to_string(),
evaluated: format!("{:?}", value),
}),
};
}
let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
apply_string_truthiness(&evaluated, text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eval::interpreter::parsing::{
find_matching_paren, split_args_balanced, SUPPORTED_MATH_FUNCS,
};
fn eval_text(
text: &str,
properties: &HashMap<String, String>,
) -> Result<String, EvalError> {
let mut interp = init_interpreter();
eval_text_with_interpreter(text, properties, &mut interp)
}
fn evaluate_expression(
interp: &mut Interpreter,
expr: &str,
context: &HashMap<String, Value>,
) -> Result<Option<Value>, pyisheval::EvalError> {
evaluate_expression_impl(
interp,
expr,
context,
#[cfg(feature = "yaml")]
None,
None, )
}
#[test]
fn test_simple_property_substitution() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
let result = eval_text("${width}", &props).unwrap();
assert_eq!(result, "0.5");
}
#[test]
fn test_property_in_text() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
let result = eval_text("The width is ${width} meters", &props).unwrap();
assert_eq!(result, "The width is 0.5 meters");
}
#[test]
fn test_multiple_properties() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
props.insert("height".to_string(), "1.0".to_string());
let result = eval_text("${width} x ${height}", &props).unwrap();
assert_eq!(result, "0.5 x 1");
}
#[test]
fn test_arithmetic_expression() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
let result = eval_text("${width * 2}", &props).unwrap();
assert_eq!(result, "1");
}
#[test]
fn test_pure_arithmetic() {
let props = HashMap::new();
let result = eval_text("${2 + 3}", &props).unwrap();
assert_eq!(result, "5");
}
#[test]
fn test_complex_expression() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
props.insert("height".to_string(), "2.0".to_string());
let result = eval_text("${width * height + 1}", &props).unwrap();
assert_eq!(result, "2");
}
#[test]
#[ignore]
fn test_string_concatenation() {
let props = HashMap::new();
let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
assert_eq!(result, "link_base");
}
#[test]
fn test_builtin_functions() {
let props = HashMap::new();
let result = eval_text("${abs(-5)}", &props).unwrap();
assert_eq!(result, "5");
let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
assert_eq!(result, "5");
}
#[test]
fn test_conditional_expression() {
let mut props = HashMap::new();
props.insert("width".to_string(), "0.5".to_string());
let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
assert_eq!(result, "0.5");
}
#[test]
fn test_no_expressions() {
let props = HashMap::new();
let result = eval_text("hello world", &props).unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn test_empty_string() {
let props = HashMap::new();
let result = eval_text("", &props).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_undefined_property() {
let props = HashMap::new();
let result = eval_text("${undefined}", &props);
assert!(result.is_err());
}
#[test]
fn test_string_property() {
let mut props = HashMap::new();
props.insert("link_name".to_string(), "base_link".to_string());
props.insert("joint_type".to_string(), "revolute".to_string());
let result = eval_text("${link_name}", &props).unwrap();
assert_eq!(result, "base_link");
let result = eval_text("name_${link_name}_suffix", &props).unwrap();
assert_eq!(result, "name_base_link_suffix");
let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
assert_eq!(result, "base_link revolute");
}
#[test]
fn test_double_dollar_escape() {
let props = HashMap::new();
let result = eval_text("$${expr}", &props).unwrap();
assert_eq!(result, "${expr}");
let result = eval_text("$$(command)", &props).unwrap();
assert_eq!(result, "$(command)");
let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
assert_eq!(result, "prefix_${literal}_suffix");
}
#[test]
fn test_eval_boolean_literals() {
let props = HashMap::new();
assert_eq!(eval_boolean("true", &props).unwrap(), true);
assert_eq!(eval_boolean("false", &props).unwrap(), false);
assert_eq!(eval_boolean("True", &props).unwrap(), true);
assert_eq!(eval_boolean("False", &props).unwrap(), false);
}
#[test]
fn test_eval_boolean_integer_truthiness() {
let props = HashMap::new();
assert_eq!(eval_boolean("0", &props).unwrap(), false);
assert_eq!(eval_boolean("1", &props).unwrap(), true);
assert_eq!(eval_boolean("42", &props).unwrap(), true);
assert_eq!(eval_boolean("-5", &props).unwrap(), true);
assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); }
#[test]
fn test_eval_boolean_float_truthiness() {
let props = HashMap::new();
assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
}
#[test]
fn test_eval_boolean_with_properties() {
let mut props = HashMap::new();
props.insert("condT".to_string(), "1".to_string()); props.insert("condF".to_string(), "0".to_string()); props.insert("num".to_string(), "5".to_string());
assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
assert_eq!(eval_boolean("${num}", &props).unwrap(), true);
}
#[test]
fn test_eval_boolean_expressions() {
let mut props = HashMap::new();
props.insert("var".to_string(), "useit".to_string());
assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
props.insert("x".to_string(), "5".to_string());
assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
}
#[test]
fn test_eval_boolean_comparison_expressions() {
let mut props = HashMap::new();
props.insert("x".to_string(), "5".to_string());
props.insert("y".to_string(), "10".to_string());
assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
}
#[test]
fn test_eval_boolean_invalid_values() {
let props = HashMap::new();
let result = eval_boolean("nonsense", &props);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not a boolean expression"));
let result = eval_boolean("", &props);
assert!(result.is_err());
let result = eval_boolean("random text", &props);
assert!(result.is_err());
}
#[test]
fn test_eval_boolean_whitespace() {
let props = HashMap::new();
assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
assert_eq!(eval_boolean(" 0 ", &props).unwrap(), false);
assert_eq!(eval_boolean(" 1 ", &props).unwrap(), true);
}
#[test]
fn test_eval_boolean_case_sensitivity() {
let props = HashMap::new();
assert_eq!(eval_boolean("true", &props).unwrap(), true);
assert_eq!(eval_boolean("True", &props).unwrap(), true);
assert!(eval_boolean("TRUE", &props).is_err());
assert!(eval_boolean("tRuE", &props).is_err());
}
#[test]
fn test_evaluate_expression_special_cases() {
let mut interp = init_interpreter();
let context = HashMap::new();
let result = evaluate_expression(&mut interp, "xacro.print_location()", &context).unwrap();
assert!(
result.is_none(),
"xacro.print_location() should return None"
);
let result =
evaluate_expression(&mut interp, " xacro.print_location() ", &context).unwrap();
assert!(
result.is_none(),
"xacro.print_location() with whitespace should return None"
);
let result = evaluate_expression(&mut interp, "1 + 1", &context).unwrap();
assert!(
matches!(result, Some(Value::Number(n)) if n == 2.0),
"Normal expression should evaluate correctly"
);
}
#[test]
fn test_xacro_print_location_stub() {
let props = HashMap::new();
let result = eval_text("${xacro.print_location()}", &props).unwrap();
assert_eq!(result, "");
let result = eval_text("before${xacro.print_location()}after", &props).unwrap();
assert_eq!(result, "beforeafter");
let result = eval_text("${ xacro.print_location() }", &props).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_inf_nan_direct_injection() {
let props = HashMap::new();
let mut interp = init_interpreter();
let context = build_pyisheval_context(&props, &mut interp).unwrap();
assert!(
context.contains_key("inf"),
"Context should contain 'inf' key"
);
assert!(
context.contains_key("nan"),
"Context should contain 'nan' key"
);
if let Some(Value::Number(n)) = context.get("inf") {
assert!(
n.is_infinite() && n.is_sign_positive(),
"inf should be positive infinity, got: {}",
n
);
} else {
panic!("inf should be a Number value");
}
if let Some(Value::Number(n)) = context.get("nan") {
assert!(n.is_nan(), "nan should be NaN, got: {}", n);
} else {
panic!("nan should be a Number value");
}
let result = interp.eval_with_context("inf * 2", &context);
assert!(
matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
"inf * 2 should return positive infinity, got: {:?}",
result
);
let result = interp.eval_with_context("nan + 1", &context);
assert!(
matches!(result, Ok(Value::Number(n)) if n.is_nan()),
"nan + 1 should return NaN, got: {:?}",
result
);
}
#[test]
fn test_eval_boolean_type_preservation() {
let props = HashMap::new();
assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true);
let result = eval_boolean("result: ${3*0.1}", &props);
assert!(result.is_err());
}
#[test]
fn test_eval_boolean_bool_values() {
let props = HashMap::new();
assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
}
#[test]
fn test_basic_lambda_works() {
let mut props = HashMap::new();
props.insert("f".to_string(), "lambda x: x * 2".to_string());
assert_eq!(eval_text("${f(5)}", &props).unwrap(), "10");
}
#[test]
fn test_format_value_python_style_whole_numbers() {
assert_eq!(format_value_python_style(&Value::Number(0.0), false), "0");
assert_eq!(format_value_python_style(&Value::Number(1.0), false), "1");
assert_eq!(format_value_python_style(&Value::Number(2.0), false), "2");
assert_eq!(format_value_python_style(&Value::Number(-1.0), false), "-1");
assert_eq!(
format_value_python_style(&Value::Number(100.0), false),
"100"
);
}
#[test]
fn test_format_value_python_style_fractional() {
assert_eq!(format_value_python_style(&Value::Number(1.5), false), "1.5");
assert_eq!(format_value_python_style(&Value::Number(0.5), false), "0.5");
assert_eq!(
format_value_python_style(&Value::Number(0.4235294117647059), false),
"0.4235294117647059"
);
}
#[test]
fn test_format_value_python_style_special() {
assert_eq!(
format_value_python_style(&Value::Number(f64::INFINITY), false),
"inf"
);
assert_eq!(
format_value_python_style(&Value::Number(f64::NEG_INFINITY), false),
"-inf"
);
assert_eq!(
format_value_python_style(&Value::Number(f64::NAN), false),
"NaN"
);
}
#[test]
fn test_eval_with_python_number_formatting() {
let mut props = HashMap::new();
props.insert("height".to_string(), "1.0".to_string());
assert_eq!(eval_text("${height}", &props).unwrap(), "1");
assert_eq!(eval_text("${1.0 + 0.0}", &props).unwrap(), "1");
assert_eq!(eval_text("${2.0 * 1.0}", &props).unwrap(), "2");
}
#[test]
fn test_lambda_referencing_property() {
let mut props = HashMap::new();
props.insert("offset".to_string(), "10".to_string());
props.insert("add_offset".to_string(), "lambda x: x + offset".to_string());
assert_eq!(eval_text("${add_offset(5)}", &props).unwrap(), "15");
}
#[test]
fn test_lambda_referencing_multiple_properties() {
let mut props = HashMap::new();
props.insert("a".to_string(), "2".to_string());
props.insert("b".to_string(), "3".to_string());
props.insert("scale".to_string(), "lambda x: x * a + b".to_string());
assert_eq!(eval_text("${scale(5)}", &props).unwrap(), "13");
}
#[test]
fn test_lambda_with_conditional() {
let mut props = HashMap::new();
props.insert(
"sign".to_string(),
"lambda x: 1 if x > 0 else -1".to_string(),
);
assert_eq!(eval_text("${sign(5)}", &props).unwrap(), "1");
assert_eq!(eval_text("${sign(-3)}", &props).unwrap(), "-1");
}
#[test]
fn test_multiple_lambdas() {
let mut props = HashMap::new();
props.insert("double".to_string(), "lambda x: x * 2".to_string());
props.insert("triple".to_string(), "lambda x: x * 3".to_string());
assert_eq!(
eval_text("${double(5)} ${triple(5)}", &props).unwrap(),
"10 15"
);
}
#[test]
fn test_lambda_referencing_inf_property() {
let mut props = HashMap::new();
props.insert("my_inf".to_string(), "inf".to_string());
props.insert("is_inf".to_string(), "lambda x: x == my_inf".to_string());
assert_eq!(eval_text("${is_inf(inf)}", &props).unwrap(), "1");
}
#[test]
fn test_math_functions_cos_sin() {
let mut props = HashMap::new();
props.insert("pi".to_string(), "3.141592653589793".to_string());
let result = eval_text("${cos(0)}", &props).unwrap();
assert_eq!(result, "1");
let result = eval_text("${sin(0)}", &props).unwrap();
assert_eq!(result, "0");
let result = eval_text("${cos(pi)}", &props).unwrap();
assert_eq!(result, "-1");
}
#[test]
fn test_math_functions_nested() {
let mut props = HashMap::new();
props.insert("radius".to_string(), "0.5".to_string());
let result = eval_text("${radius*cos(radians(0))}", &props).unwrap();
assert_eq!(result, "0.5");
let result = eval_text("${radius*cos(radians(60))}", &props).unwrap();
let value: f64 = result.parse().unwrap();
assert!(
(value - 0.25).abs() < 1e-10,
"Expected ~0.25, got {}",
value
);
}
#[test]
fn test_math_functions_sqrt_abs() {
let props = HashMap::new();
let result = eval_text("${sqrt(16)}", &props).unwrap();
assert_eq!(result, "4");
let result = eval_text("${abs(-5)}", &props).unwrap();
assert_eq!(result, "5");
let result = eval_text("${abs(5)}", &props).unwrap();
assert_eq!(result, "5");
}
#[test]
fn test_math_functions_floor_ceil() {
let props = HashMap::new();
let result = eval_text("${floor(3.7)}", &props).unwrap();
assert_eq!(result, "3");
let result = eval_text("${ceil(3.2)}", &props).unwrap();
assert_eq!(result, "4");
let result = eval_text("${floor(-2.3)}", &props).unwrap();
assert_eq!(result, "-3");
let result = eval_text("${ceil(-2.3)}", &props).unwrap();
assert_eq!(result, "-2");
}
#[test]
fn test_math_functions_trig() {
let props = HashMap::new();
let result = eval_text("${tan(0)}", &props).unwrap();
assert_eq!(result, "0");
let result = eval_text("${asin(0)}", &props).unwrap();
assert_eq!(result, "0");
let result = eval_text("${acos(1)}", &props).unwrap();
assert_eq!(result, "0");
let result = eval_text("${atan(0)}", &props).unwrap();
assert_eq!(result, "0");
}
#[test]
fn test_math_functions_multiple_in_expression() {
let mut props = HashMap::new();
props.insert("x".to_string(), "3".to_string());
props.insert("y".to_string(), "4".to_string());
let result = eval_text("${sqrt(x**2 + y**2)}", &props).unwrap();
assert_eq!(result, "5");
}
#[test]
fn test_math_functions_regex_match_consistency() {
let props = HashMap::new();
for func in SUPPORTED_MATH_FUNCS {
let expr = if *func == "atan2" || *func == "pow" {
format!("${{{}(0, 1)}}", func)
} else {
format!("${{{}(0)}}", func)
};
let result = eval_text(&expr, &props);
result.expect("Evaluation should succeed for all supported math functions");
}
}
#[test]
fn test_find_matching_paren_with_brackets() {
let text = "pow([1,2][0], 3)";
let result = find_matching_paren(text, 3); assert_eq!(result, Some(15)); }
#[test]
fn test_find_matching_paren_with_braces() {
let text = "func({a:1,b:2}, 3)";
let result = find_matching_paren(text, 4); assert_eq!(result, Some(17)); }
#[test]
fn test_split_args_with_array_literal() {
let args = "[1,2][0], 3";
let result = split_args_balanced(args);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "[1,2][0]");
assert_eq!(result[1], " 3");
}
#[test]
fn test_split_args_with_dict_literal() {
let args = "{a:1,b:2}, 3";
let result = split_args_balanced(args);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "{a:1,b:2}");
assert_eq!(result[1], " 3");
}
#[test]
fn test_split_args_with_nested_structures() {
let args = "[[1,2],[3,4]], {x:[5,6]}, max(7,8)";
let result = split_args_balanced(args);
assert_eq!(result.len(), 3);
assert_eq!(result[0], "[[1,2],[3,4]]");
assert_eq!(result[1], " {x:[5,6]}");
assert_eq!(result[2], " max(7,8)");
}
#[test]
fn test_math_pi_not_substring_match() {
let mut props = HashMap::new();
props.insert("math_pi_value".to_string(), "42".to_string());
let result = eval_text("${math_pi_value}", &props).unwrap();
assert_eq!(result, "42");
let result = eval_text("${math.pi * 2}", &props).unwrap();
let value: f64 = result.parse().unwrap();
assert!((value - (std::f64::consts::PI * 2.0)).abs() < 1e-9);
}
#[test]
fn test_math_functions_not_in_string_literals() {
let props = HashMap::new();
let result = eval_text("${'Print cos(0)'}", &props).unwrap();
assert_eq!(
result, "Print cos(0)",
"cos(0) in single-quoted string should not be evaluated"
);
let result = eval_text("${'The function sin(x) is useful'}", &props).unwrap();
assert_eq!(
result, "The function sin(x) is useful",
"sin(x) in string should not be evaluated"
);
let result = eval_text("${cos(0)}", &props).unwrap();
let value: f64 = result.parse().unwrap();
assert!(
(value - 1.0).abs() < 1e-9,
"cos(0) outside strings should be evaluated to 1"
);
}
#[test]
fn test_math_pi_not_in_string_literals() {
let props = HashMap::new();
let result = eval_text("${'Use math.pi for calculations'}", &props).unwrap();
assert_eq!(
result, "Use math.pi for calculations",
"math.pi in single-quoted string should not be replaced"
);
let result = eval_text("${'The constant math.pi is useful'}", &props).unwrap();
assert_eq!(
result, "The constant math.pi is useful",
"math.pi in string should not be replaced"
);
let result = eval_text("${math.pi}", &props).unwrap();
let value: f64 = result.parse().unwrap();
assert!(
(value - std::f64::consts::PI).abs() < 1e-9,
"math.pi outside strings should be replaced with pi constant"
);
let result = eval_text("${'math.pi' == 'math.pi'}", &props).unwrap();
assert_eq!(result, "1", "String comparison should work");
let result = eval_text("${math.pi > 3}", &props).unwrap();
assert_eq!(
result, "1",
"Numeric math.pi should be replaced and evaluated"
);
}
#[test]
fn test_pow_with_nested_function_args() {
let props = HashMap::new();
let result = eval_text("${pow(max(1, 2), 3)}", &props).unwrap();
assert_eq!(result, "8");
let result_arith = eval_text("${pow((1 + 1), 3)}", &props).unwrap();
assert_eq!(result_arith, "8");
}
#[test]
fn test_pow_with_array_indexing() {
let mut props = HashMap::new();
props.insert("values".to_string(), "[2,3,4]".to_string());
let result = eval_text("${pow([1,2][1], 3)}", &props).unwrap();
assert_eq!(result, "8");
let result = eval_text("${pow(values[0], 3)}", &props).unwrap();
assert_eq!(result, "8"); }
#[test]
fn test_custom_namespace_not_hijacked() {
let mut props = HashMap::new();
props.insert("custom".to_string(), "unused".to_string());
let result = eval_text("${custom.sin(0)}", &props);
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("UndefinedVar") || err_msg.contains("AttributeError"),
"Expected undefined variable error, got: {}",
err_msg
);
}
#[test]
fn test_context_can_shadow_len_builtin() {
let mut interp = Interpreter::new();
let mut properties = HashMap::new();
properties.insert("len".to_string(), "0.2".to_string());
let context = build_pyisheval_context(&properties, &mut interp).unwrap();
assert_eq!(
context.get("len"),
Some(&Value::Number(0.2)),
"len should be 0.2"
);
let result = evaluate_expression(&mut interp, "len", &context).unwrap();
assert_eq!(
result,
Some(Value::Number(0.2)),
"Should return 0.2, not builtin"
);
let result2 = evaluate_expression(&mut interp, "len * 2", &context).unwrap();
assert_eq!(
result2,
Some(Value::Number(0.4)),
"Should be able to use len in expressions"
);
}
#[test]
fn test_context_can_shadow_other_builtins() {
let mut interp = Interpreter::new();
let mut properties = HashMap::new();
properties.insert("min".to_string(), "42".to_string());
properties.insert("max".to_string(), "100".to_string());
let context = build_pyisheval_context(&properties, &mut interp).unwrap();
assert_eq!(
context.get("min"),
Some(&Value::Number(42.0)),
"min should be 42.0"
);
assert_eq!(
context.get("max"),
Some(&Value::Number(100.0)),
"max should be 100.0"
);
let result = evaluate_expression(&mut interp, "min + max", &context).unwrap();
assert_eq!(
result,
Some(Value::Number(142.0)),
"Should be able to use min and max in expressions"
);
}
#[test]
fn test_eval_literal_boolean_true() {
let result = eval_literal("True");
assert!(
matches!(result, Value::Number(n) if (n - 1.0).abs() < 1e-10),
"True should convert to 1.0, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_boolean_false() {
let result = eval_literal("False");
assert!(
matches!(result, Value::Number(n) if n.abs() < 1e-10),
"False should convert to 0.0, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_boolean_lowercase_true() {
let result = eval_literal("true");
assert!(
matches!(result, Value::Number(n) if (n - 1.0).abs() < 1e-10),
"true should convert to 1.0, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_boolean_lowercase_false() {
let result = eval_literal("false");
assert!(
matches!(result, Value::Number(n) if n.abs() < 1e-10),
"false should convert to 0.0, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_int() {
let result = eval_literal("123");
assert!(
matches!(result, Value::Number(n) if (n - 123.0).abs() < 1e-10),
"Integer string should convert to float, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_float() {
let result = eval_literal("3.14");
assert!(
matches!(result, Value::Number(n) if (n - 3.14).abs() < 1e-10),
"Float string should convert to float, got: {:?}",
result
);
}
#[test]
fn test_eval_literal_quoted_string() {
let result = eval_literal("'hello'");
assert_eq!(
result,
Value::StringLit("hello".to_string()),
"Quoted string should strip quotes"
);
}
#[test]
fn test_eval_literal_underscore_string() {
let result = eval_literal("foo_bar");
assert_eq!(
result,
Value::StringLit("foo_bar".to_string()),
"String with underscore should remain string (likely variable name)"
);
}
#[test]
fn test_eval_literal_numeric_looking_underscore_string() {
let result = eval_literal("36_11");
assert_eq!(
result,
Value::StringLit("36_11".to_string()),
"Numeric-looking string with underscore should remain string, not be parsed as number"
);
}
#[test]
fn test_eval_literal_unparseable_string() {
let result = eval_literal("hello");
assert_eq!(
result,
Value::StringLit("hello".to_string()),
"Unparseable string should remain string"
);
}
#[test]
fn test_eval_literal_empty_string() {
let result = eval_literal("");
assert_eq!(
result,
Value::StringLit("".to_string()),
"Empty string should remain empty string"
);
}
#[test]
fn test_true_false_in_properties() {
let mut props = HashMap::new();
props.insert("flag".to_string(), "True".to_string());
props.insert("disabled".to_string(), "False".to_string());
let result = eval_text("${flag}", &props).unwrap();
assert_eq!(result, "1", "True converts to 1");
let result = eval_text("${disabled}", &props).unwrap();
assert_eq!(result, "0", "False converts to 0");
let result = eval_text("${flag == 1}", &props).unwrap();
assert_eq!(result, "1", "True should equal 1 (returns 1 for true)");
let result = eval_text("${disabled == 0}", &props).unwrap();
assert_eq!(result, "1", "False should equal 0 (returns 1 for true)");
let result = eval_text("${1 if flag else 0}", &props).unwrap();
assert_eq!(result, "1", "True (1.0) should evaluate as truthy");
let result = eval_text("${1 if disabled else 0}", &props).unwrap();
assert_eq!(result, "0", "False (0.0) should evaluate as falsy");
}
#[test]
fn test_true_false_property_comparison() {
let mut props = HashMap::new();
props.insert("enabled".to_string(), "True".to_string());
props.insert("also_enabled".to_string(), "True".to_string());
props.insert("disabled".to_string(), "False".to_string());
let result = eval_text("${enabled == also_enabled}", &props).unwrap();
assert_eq!(result, "1", "1.0 should equal 1.0");
let result = eval_text("${enabled == disabled}", &props).unwrap();
assert_eq!(result, "0", "1.0 should not equal 0.0");
}
#[test]
fn test_pow_function() {
let props = HashMap::new();
let result = eval_text("${pow(2, 3)}", &props).expect("pow should work");
assert_eq!(result, "8");
let result = eval_text("${pow(10, 0.5)}", &props).expect("pow with fractional exp");
let value: f64 = result.parse().expect("parse float");
assert!((value - 10.0_f64.sqrt()).abs() < 1e-10, "sqrt(10) mismatch");
}
#[test]
fn test_log_function() {
let props = HashMap::new();
let result = eval_text("${log(1)}", &props).expect("log should work");
assert_eq!(result, "0", "ln(1) = 0");
let result = eval_text("${log(e)}", &props).expect("log(e)");
let value: f64 = result.parse().expect("parse float");
assert!((value - 1.0).abs() < 1e-10, "ln(e) = 1");
let result = eval_text("${log(100, 10)}", &props).expect("log(100, 10)");
let value: f64 = result.parse().expect("parse float");
assert!((value - 2.0).abs() < 1e-10, "log_10(100) = 2");
let result = eval_text("${log(8, 2)}", &props).expect("log(8, 2)");
let value: f64 = result.parse().expect("parse float");
assert!((value - 3.0).abs() < 1e-10, "log_2(8) = 3");
}
#[test]
fn test_math_prefix_functions() {
let props = HashMap::new();
let result = eval_text("${math.pow(2, 3)}", &props).expect("math.pow");
assert_eq!(result, "8");
let result = eval_text("${math.log(1)}", &props).expect("math.log");
assert_eq!(result, "0");
let result = eval_text("${math.atan2(1, 0)}", &props).expect("math.atan2");
let value: f64 = result.parse().expect("parse float");
assert!(
(value - std::f64::consts::FRAC_PI_2).abs() < 1e-10,
"atan2(1,0) = π/2"
);
let result = eval_text("${math.sqrt(4)}", &props).expect("math.sqrt");
assert_eq!(result, "2");
}
#[test]
fn test_math_pi_constant_access() {
let props = HashMap::new();
let result = eval_text("${math.pi}", &props).expect("math.pi");
let value: f64 = result.parse().expect("parse float");
assert!((value - std::f64::consts::PI).abs() < 1e-10, "math.pi = π");
let result = eval_text("${-math.pi / 2}", &props).expect("-math.pi / 2");
let value: f64 = result.parse().expect("parse float");
assert!((value + std::f64::consts::FRAC_PI_2).abs() < 1e-10, "-Ï€/2");
}
#[cfg(feature = "yaml")]
mod yaml_tests {
use super::*;
#[test]
fn test_load_yaml_nested_dict() {
let props = HashMap::new();
let value = eval_text(
"${load_yaml('tests/data/test_config.yaml')['robot']['chassis']['length']}",
&props,
)
.expect("load_yaml nested access should succeed");
assert_eq!(value, "0.5", "chassis length should be 0.5");
}
#[test]
fn test_load_yaml_with_xacro_prefix() {
let props = HashMap::new();
let value = eval_text(
"${xacro.load_yaml('tests/data/test_config.yaml')['count']}",
&props,
)
.expect("xacro.load_yaml should succeed");
assert_eq!(value, "5", "count should be 5");
}
#[test]
fn test_load_yaml_array_access() {
let props = HashMap::new();
let value = eval_text(
"${load_yaml('tests/data/test_config.yaml')['joints'][0]}",
&props,
)
.expect("load_yaml array access should succeed");
assert_eq!(value, "joint1", "first joint should be joint1");
}
#[test]
fn test_load_yaml_deep_nesting() {
let props = HashMap::new();
let value = eval_text(
"${load_yaml('tests/data/test_config.yaml')['nested']['level1']['level2']['value']}",
&props,
)
.expect("load_yaml deep nesting should succeed");
assert_eq!(value, "deep_value", "deep nested value should match");
}
#[test]
fn test_load_yaml_in_arithmetic() {
let props = HashMap::new();
let value = eval_text(
"${load_yaml('tests/data/test_config.yaml')['robot']['wheel']['radius'] * 2}",
&props,
)
.expect("load_yaml in arithmetic should succeed");
assert_eq!(value, "0.2", "radius * 2 should be 0.2");
}
#[test]
fn test_load_yaml_multiple_calls() {
let props = HashMap::new();
let value = eval_text(
"${load_yaml('tests/data/test_config.yaml')['robot']['chassis']['length'] + \
load_yaml('tests/data/test_config.yaml')['robot']['chassis']['width']}",
&props,
)
.expect("multiple load_yaml calls should succeed");
assert_eq!(value, "0.8", "0.5 + 0.3 should be 0.8");
}
#[test]
fn test_load_yaml_extract_and_store() {
let mut props = HashMap::new();
let mut interp = init_interpreter();
let wheel_base = eval_text_with_interpreter(
"${load_yaml('tests/data/test_config.yaml')['robot']['wheel']['base']}",
&props,
&mut interp,
)
.expect("load_yaml should succeed");
props.insert("wheel_base".to_string(), wheel_base);
let value = eval_text_with_interpreter("${wheel_base * 2}", &props, &mut interp)
.expect("stored value calculation should succeed");
assert_eq!(value, "0.8", "wheel_base * 2 should be 0.8");
}
#[test]
fn test_load_yaml_file_not_found() {
let props = HashMap::new();
let result = eval_text("${load_yaml('tests/data/nonexistent.yaml')}", &props);
assert!(result.is_err(), "should error on missing file");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Failed to load YAML") || err_msg.contains("No such file"),
"error should mention file loading failure, got: {}",
err_msg
);
}
#[test]
fn test_load_yaml_invalid_yaml() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("create temp file");
write!(temp_file, "invalid: yaml:\n - bad\n syntax").expect("write temp file");
let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
let props = HashMap::new();
let result = eval_text(&format!("${{load_yaml('{}')}}", temp_path), &props);
assert!(result.is_err(), "should error on invalid YAML");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Failed to parse YAML") || err_msg.contains("parse"),
"error should mention YAML parsing failure, got: {}",
err_msg
);
}
#[test]
fn test_load_yaml_with_property_filename() {
let mut props = HashMap::new();
props.insert(
"config_file".to_string(),
"tests/data/test_config.yaml".to_string(),
);
let value = eval_text("${load_yaml(config_file)['count']}", &props)
.expect("variable filename should work");
assert_eq!(value, "5", "count should be 5");
}
#[test]
fn test_load_yaml_argument_with_parentheses_in_string() {
use std::io::Write;
use tempfile::Builder;
let props = HashMap::new();
let mut temp = Builder::new()
.prefix("config(")
.suffix(").yaml")
.tempfile()
.expect("create temp yaml");
write!(temp, "robot:\n chassis:\n width: 0.3\n").expect("write temp yaml");
let path = temp.path().to_string_lossy().replace('\\', "/");
let expr = format!("${{load_yaml('{}')['robot']['chassis']['width']}}", path);
let value =
eval_text(&expr, &props).expect("load_yaml argument parsing should succeed");
assert_eq!(
value, "0.3",
"should correctly parse load_yaml argument even with potential paren complexity"
);
}
#[test]
fn test_load_yaml_null_value() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("create temp file");
write!(temp_file, "~").expect("write temp file");
let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
let props = HashMap::new();
let value = eval_text(&format!("${{load_yaml('{}') + 5}}", &temp_path), &props)
.expect("load_yaml with null should succeed");
assert_eq!(value, "5", "null (None) + 5 should be 5");
}
#[test]
fn test_load_yaml_null_in_dict() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().expect("create temp file");
write!(temp_file, "value: null\nother: 10").expect("write temp file");
let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
let props = HashMap::new();
let value = eval_text(
&format!("${{load_yaml('{}')['value']}}", &temp_path),
&props,
)
.expect("load_yaml null value access should succeed");
assert_eq!(value, "0", "null value should evaluate to 0 (None)");
}
#[test]
fn test_load_yaml_inf_nan_values() {
let props = HashMap::new();
let result = eval_text(
"${load_yaml('tests/data/test_inf_nan.yaml')['positive_inf']}",
&props,
);
assert!(
result.is_ok(),
"positive_inf should evaluate successfully, got: {:?}",
result
);
let result = eval_text(
"${load_yaml('tests/data/test_inf_nan.yaml')['negative_inf']}",
&props,
);
assert!(result.is_ok(), "negative_inf should evaluate successfully");
let result = eval_text(
"${load_yaml('tests/data/test_inf_nan.yaml')['not_a_number']}",
&props,
);
assert!(result.is_ok(), "not_a_number should evaluate successfully");
let value = eval_text(
"${load_yaml('tests/data/test_inf_nan.yaml')['normal_float']}",
&props,
)
.expect("normal_float should succeed");
assert_eq!(value, "3.14", "normal float should be '3.14'");
}
} }