• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kaidokert / xacro / 20874919683

10 Jan 2026 07:26AM UTC coverage: 88.188%. First build
20874919683

Pull #37

github

web-flow
Merge 1e036baa1 into bea9f3bae
Pull Request #37: Add native math function support

140 of 156 new or added lines in 1 file covered. (89.74%)

2292 of 2599 relevant lines covered (88.19%)

178.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.32
/src/utils/eval.rs
1
use crate::features::properties::BUILTIN_CONSTANTS;
2
use crate::utils::lexer::{Lexer, TokenType};
3
use pyisheval::{Interpreter, Value};
4
use regex::Regex;
5
use std::collections::HashMap;
6
use std::sync::OnceLock;
7

8
/// Find matching closing parenthesis, handling nested parentheses
9
///
10
/// # Arguments
11
/// * `text` - String to search
12
/// * `start` - Byte index of opening '('
13
///
14
/// # Returns
15
/// Byte index of matching ')', or None if not found
16
///
17
/// Note: Uses byte-based iteration since parentheses are ASCII characters
18
/// and will never appear as continuation bytes in UTF-8.
19
fn find_matching_paren(
53✔
20
    text: &str,
53✔
21
    start: usize,
53✔
22
) -> Option<usize> {
53✔
23
    let bytes = text.as_bytes();
53✔
24
    if start >= bytes.len() || bytes[start] != b'(' {
53✔
NEW
25
        return None;
×
26
    }
53✔
27

28
    let mut depth = 0;
53✔
29
    for (i, &ch) in bytes.iter().enumerate().skip(start) {
247✔
30
        match ch {
247✔
31
            b'(' => depth += 1,
58✔
32
            b')' => {
33
                depth -= 1;
58✔
34
                if depth == 0 {
58✔
35
                    return Some(i);
53✔
36
                }
5✔
37
            }
38
            _ => {}
131✔
39
        }
40
    }
NEW
41
    None
×
42
}
53✔
43

44
/// List of supported math functions for preprocessing
45
///
46
/// These functions are preprocessed before expression evaluation since pyisheval
47
/// doesn't provide native math functions. Functions are ordered by length (longest first)
48
/// for defensive regex alternation, though with word boundaries this isn't strictly necessary.
49
///
50
/// Note: `radians()` and `degrees()` are NOT in this list because they are implemented as
51
/// lambda functions in pyisheval (see `init_interpreter()`), not as Rust native functions.
52
pub(crate) const SUPPORTED_MATH_FUNCS: &[&str] = &[
53
    "floor", "acos", "asin", "atan", "ceil", "sqrt", "cos", "sin", "tan", "abs",
54
];
55

56
/// Regex pattern for matching math function calls with word boundaries
57
static MATH_FUNCS_REGEX: OnceLock<Regex> = OnceLock::new();
58

59
/// Get the math functions regex, initializing it on first access
60
///
61
/// Matches function names at word boundaries followed by optional whitespace and '('.
62
fn get_math_funcs_regex() -> &'static Regex {
405✔
63
    MATH_FUNCS_REGEX.get_or_init(|| {
405✔
64
        // Use \b for word boundaries, capture function name, allow optional whitespace before '('
65
        let pattern = format!(r"\b({})\s*\(", SUPPORTED_MATH_FUNCS.join("|"));
12✔
66
        Regex::new(&pattern).expect("Math functions regex should be valid")
12✔
67
    })
12✔
68
}
405✔
69

70
/// Preprocess an expression to evaluate native math functions
71
///
72
/// pyisheval doesn't support native math functions like cos(), sin(), tan().
73
/// This function finds math function calls, evaluates them using Rust's f64 methods,
74
/// and substitutes the results back into the expression.
75
///
76
/// Supported functions: cos, sin, tan, acos, asin, atan, sqrt, abs, floor, ceil
77
///
78
/// # Limitations
79
/// **Does not distinguish function calls inside string literals** (e.g., `'cos(0)'`).
80
/// This can cause incorrect evaluation: an expression like `'cos(0)'` will be
81
/// preprocessed to `'1.0'` instead of remaining as the string `"cos(0)"`.
82
/// A full fix would require a proper parser to track string literal context.
83
/// For now, users should avoid math function names inside string literals.
84
///
85
/// # Arguments
86
/// * `expr` - Expression that may contain math function calls
87
/// * `interp` - Interpreter for evaluating function arguments
88
///
89
/// # Returns
90
/// Expression with math function calls replaced by their computed values
91
fn preprocess_math_functions(
356✔
92
    expr: &str,
356✔
93
    interp: &mut Interpreter,
356✔
94
) -> Result<String, EvalError> {
356✔
95
    let mut result = expr.to_string();
356✔
96

97
    // Keep replacing until no more matches (handle nested calls from inside out)
98
    let mut iteration = 0;
356✔
99
    const MAX_ITERATIONS: usize = 100;
100

101
    loop {
102
        iteration += 1;
405✔
103
        if iteration > MAX_ITERATIONS {
405✔
NEW
104
            return Err(EvalError::PyishEval {
×
NEW
105
                expr: expr.to_string(),
×
NEW
106
                source: pyisheval::EvalError::ParseError(
×
NEW
107
                    "Too many nested math function calls (possible infinite loop)".to_string(),
×
NEW
108
                ),
×
NEW
109
            });
×
110
        }
405✔
111

112
        // Collect all function matches to iterate right-to-left (innermost first)
113
        let captures: Vec<_> = get_math_funcs_regex().captures_iter(&result).collect();
405✔
114
        if captures.is_empty() {
405✔
115
            break;
352✔
116
        }
53✔
117

118
        let mut made_replacement = false;
53✔
119
        // Iterate from right to left to find the innermost, evaluatable function call
120
        for caps in captures.iter().rev() {
53✔
121
            // Safe extraction of capture groups (should always succeed due to regex structure)
122
            let (whole_match, func_name) = match (caps.get(0), caps.get(1)) {
53✔
123
                (Some(m), Some(f)) => (m, f.as_str()),
53✔
NEW
124
                _ => continue, // Skip malformed captures
×
125
            };
126
            let paren_pos = whole_match.end() - 1; // Position of '(' after optional whitespace
53✔
127

128
            // Find matching closing parenthesis
129
            let close_pos = match find_matching_paren(&result, paren_pos) {
53✔
130
                Some(pos) => pos,
53✔
NEW
131
                None => continue, // Skip if no matching paren
×
132
            };
133

134
            let arg = &result[paren_pos + 1..close_pos];
53✔
135

136
            // Try to evaluate the argument - only replace if successful
137
            if let Ok(Value::Number(n)) = interp.eval(arg) {
53✔
138
                // Validate domain for inverse trig functions (matches Python xacro behavior)
139
                if (func_name == "acos" || func_name == "asin") && !(-1.0..=1.0).contains(&n) {
51✔
140
                    log::warn!(
2✔
NEW
141
                        "{}({}) domain error: argument must be in [-1, 1], got {}",
×
142
                        func_name,
143
                        arg,
144
                        n
145
                    );
146
                    continue; // Skip this match, try next one
2✔
147
                }
49✔
148

149
                // Call the appropriate Rust math function
150
                let computed = match func_name {
49✔
151
                    "cos" => n.cos(),
49✔
152
                    "sin" => n.sin(),
37✔
153
                    "tan" => n.tan(),
34✔
154
                    "acos" => n.acos(),
32✔
155
                    "asin" => n.asin(),
29✔
156
                    "atan" => n.atan(),
26✔
157
                    "sqrt" => n.sqrt(),
23✔
158
                    "abs" => n.abs(),
17✔
159
                    "floor" => n.floor(),
9✔
160
                    "ceil" => n.ceil(),
4✔
NEW
161
                    _ => unreachable!(
×
162
                        "Function '{}' matched regex but not in match statement",
163
                        func_name
164
                    ),
165
                };
166

167
                let replacement = format!("{}", computed);
49✔
168
                result.replace_range(whole_match.start()..=close_pos, &replacement);
49✔
169
                made_replacement = true;
49✔
170
                // A replacement was made, restart loop to rescan the string
171
                break;
49✔
172
            }
2✔
173
            // If eval fails or returns non-number, continue to next match
174
        }
175

176
        if !made_replacement {
53✔
177
            // No successful replacements possible, done processing
178
            break;
4✔
179
        }
49✔
180
    }
181

182
    Ok(result)
356✔
183
}
356✔
184

185
/// Initialize an Interpreter with builtin constants and math functions
186
///
187
/// This ensures all interpreters have access to:
188
/// - Math constants: pi, e, tau, M_PI
189
/// - Math functions: radians(), degrees()
190
///
191
/// Note: inf and nan are NOT initialized here - they are injected directly into
192
/// the context HashMap in build_pyisheval_context() to bypass parsing issues.
193
///
194
/// Note: Native math functions (cos, sin, tan, etc.) are handled via preprocessing
195
/// in evaluate_expression() rather than as lambda functions, because pyisheval
196
/// cannot call native Rust functions.
197
///
198
/// # Returns
199
/// A fully initialized Interpreter ready for expression evaluation
200
pub fn init_interpreter() -> Interpreter {
407✔
201
    let mut interp = Interpreter::new();
407✔
202

203
    // Initialize math constants in the interpreter
204
    // These are loaded directly into the interpreter's environment for use in expressions
205
    // Note: inf and nan are NOT in BUILTIN_CONSTANTS (pyisheval can't parse them as literals)
206
    // They are injected directly into the context map in build_pyisheval_context()
207
    for (name, value) in BUILTIN_CONSTANTS {
2,035✔
208
        if let Err(e) = interp.eval(&format!("{} = {}", name, value)) {
1,628✔
209
            log::warn!(
×
210
                "Could not initialize built-in constant '{}': {}. \
×
211
                 This constant will not be available in expressions.",
×
212
                name,
213
                e
214
            );
215
        }
1,628✔
216
    }
217

218
    // Add math conversion functions as lambda expressions directly in the interpreter
219
    // This makes them available as callable functions in all expressions
220
    if let Err(e) = interp.eval("radians = lambda x: x * pi / 180") {
407✔
221
        log::warn!(
×
222
            "Could not define built-in function 'radians': {}. \
×
223
             This function will not be available in expressions. \
×
224
             (May be due to missing 'pi' constant)",
×
225
            e
226
        );
227
    }
407✔
228
    if let Err(e) = interp.eval("degrees = lambda x: x * 180 / pi") {
407✔
229
        log::warn!(
×
230
            "Could not define built-in function 'degrees': {}. \
×
231
             This function will not be available in expressions. \
×
232
             (May be due to missing 'pi' constant)",
×
233
            e
234
        );
235
    }
407✔
236

237
    interp
407✔
238
}
407✔
239

240
#[derive(Debug, thiserror::Error)]
241
pub enum EvalError {
242
    #[error("Failed to evaluate expression '{expr}': {source}")]
243
    PyishEval {
244
        expr: String,
245
        #[source]
246
        source: pyisheval::EvalError,
247
    },
248

249
    #[error("Xacro conditional \"{condition}\" evaluated to \"{evaluated}\", which is not a boolean expression.")]
250
    InvalidBoolean {
251
        condition: String,
252
        evaluated: String,
253
    },
254
}
255

256
/// Format a pyisheval Value to match Python xacro's output format
257
///
258
/// Python xacro uses Python's int/float distinction for formatting:
259
/// - Integer arithmetic (2+3) produces int → formats as "5" (no decimal)
260
/// - Float arithmetic (2.5*2) produces float → formats as "5.0" (with decimal)
261
///
262
/// Since pyisheval only has f64 (no int type), we approximate this by checking
263
/// if the f64 value is mathematically a whole number using fract() == 0.0.
264
///
265
/// # Arguments
266
/// * `value` - The pyisheval Value to format
267
/// * `force_float` - Whether to keep .0 for whole numbers (true for float context)
268
pub fn format_value_python_style(
353✔
269
    value: &Value,
353✔
270
    force_float: bool,
353✔
271
) -> String {
353✔
272
    match value {
276✔
273
        Value::Number(n) if n.is_finite() => {
276✔
274
            // Python's str() for floats switches to scientific notation at 1e16
275
            const PYTHON_SCIENTIFIC_THRESHOLD: f64 = 1e16;
276

277
            if n.fract() == 0.0 && n.abs() < PYTHON_SCIENTIFIC_THRESHOLD {
250✔
278
                // Whole number
279
                if force_float {
189✔
280
                    // Float context: keep .0 for whole numbers
281
                    format!("{:.1}", n) // "1.0" not "1"
18✔
282
                } else {
283
                    // Int context: strip .0
284
                    format!("{:.0}", n) // "1" not "1.0"
171✔
285
                }
286
            } else {
287
                // Has fractional part or is a large number: use default formatting
288
                n.to_string()
61✔
289
            }
290
        }
291
        _ => value.to_string(),
103✔
292
    }
293
}
353✔
294

295
/// Remove quotes from string values (handles both single and double quotes)
296
pub fn remove_quotes(s: &str) -> &str {
342✔
297
    // pyisheval's StringLit to_string() returns strings with single quotes
298
    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
342✔
299
        if s.len() >= 2 {
×
300
            &s[1..s.len() - 1]
×
301
        } else {
302
            s
×
303
        }
304
    } else {
305
        s
342✔
306
    }
307
}
342✔
308

309
/// Evaluate text containing ${...} expressions
310
///
311
/// Examples:
312
///   "hello ${name}" with {name: "world"} → "hello world"
313
///   "${2 + 3}" → "5"
314
///   "${width * 2}" with {width: "0.5"} → "1"
315
pub fn eval_text(
58✔
316
    text: &str,
58✔
317
    properties: &HashMap<String, String>,
58✔
318
) -> Result<String, EvalError> {
58✔
319
    let mut interp = init_interpreter();
58✔
320
    eval_text_with_interpreter(text, properties, &mut interp)
58✔
321
}
58✔
322

323
/// Build a pyisheval context HashMap from properties
324
///
325
/// Converts string properties to pyisheval Values, parsing numbers when possible.
326
/// For lambda expressions, evaluates them to callable lambda values using the
327
/// provided interpreter. This ensures lambdas capture the correct environment.
328
///
329
/// # Arguments
330
/// * `properties` - Property name-value pairs to convert to pyisheval Values
331
/// * `interp` - The interpreter to use for evaluating lambda expressions
332
///
333
/// # Errors
334
/// Returns `EvalError` if a lambda expression fails to evaluate.
335
pub fn build_pyisheval_context(
512✔
336
    properties: &HashMap<String, String>,
512✔
337
    interp: &mut Interpreter,
512✔
338
) -> Result<HashMap<String, Value>, EvalError> {
512✔
339
    // First pass: Load all constants and non-lambda properties into the interpreter
340
    // This ensures that lambda expressions can reference them during evaluation
341
    // Note: We skip inf/nan/NaN as they can't be created via arithmetic in pyisheval
342
    // (10**400 creates inf but 0.0/0.0 fails with DivisionByZero)
343
    for (name, value) in properties.iter() {
512✔
344
        let trimmed = value.trim();
365✔
345
        if !trimmed.starts_with("lambda ") {
365✔
346
            // Load both numeric and string properties into interpreter
347
            if let Ok(num) = value.parse::<f64>() {
356✔
348
                // Special handling for inf/nan so lambdas can reference them
349
                if num.is_infinite() {
264✔
350
                    // Use 10**400 to create infinity (pyisheval can't parse "inf" literal)
351
                    let sign = if num.is_sign_negative() { "-" } else { "" };
9✔
352
                    let expr = format!("{} = {}10 ** 400", name, sign);
9✔
353
                    interp
9✔
354
                        .eval(&expr)
9✔
355
                        .map_err(|e| EvalError::PyishEval { expr, source: e })?;
9✔
356
                    continue;
9✔
357
                }
255✔
358
                if num.is_nan() {
255✔
359
                    // LIMITATION: Cannot create NaN in pyisheval (0.0/0.0 triggers DivisionByZero)
360
                    // Lambdas that reference NaN properties will fail with "undefined variable"
361
                    log::warn!(
4✔
362
                        "Property '{}' has NaN value, which cannot be loaded into interpreter. \
×
363
                         Lambda expressions referencing this property will fail.",
×
364
                        name
365
                    );
366
                    continue;
4✔
367
                }
251✔
368
                // Numeric property: load as number
369
                interp
251✔
370
                    .eval(&format!("{} = {}", name, num))
251✔
371
                    .map_err(|e| EvalError::PyishEval {
251✔
372
                        expr: format!("{} = {}", name, num),
×
373
                        source: e,
×
374
                    })?;
×
375
            } else if !value.is_empty() {
92✔
376
                // String property: load as quoted string literal
377
                // Skip empty strings as pyisheval can't parse ''
378
                // Escape backslashes first, then single quotes (order matters!)
379
                // This handles Windows paths (C:\Users), regex patterns, etc.
380
                let escaped_value = value.replace('\\', "\\\\").replace('\'', "\\'");
91✔
381
                interp
91✔
382
                    .eval(&format!("{} = '{}'", name, escaped_value))
91✔
383
                    .map_err(|e| EvalError::PyishEval {
91✔
384
                        expr: format!("{} = '{}'", name, escaped_value),
×
385
                        source: e,
×
386
                    })?;
×
387
            }
1✔
388
            // Empty strings are skipped in first pass (pyisheval can't handle '')
389
            // They'll be stored as Value::StringLit("") in the second pass
390
        }
9✔
391
    }
392

393
    // Second pass: Build the actual context, evaluating lambdas
394
    let mut context: HashMap<String, Value> = properties
512✔
395
        .iter()
512✔
396
        .map(|(name, value)| -> Result<(String, Value), EvalError> {
512✔
397
            // Try to parse as number first
398
            if let Ok(num) = value.parse::<f64>() {
365✔
399
                return Ok((name.clone(), Value::Number(num)));
264✔
400
            }
101✔
401

402
            // Check if it's a lambda expression
403
            let trimmed = value.trim();
101✔
404
            if trimmed.starts_with("lambda ") {
101✔
405
                // Evaluate and assign the lambda expression to the variable name
406
                // The interpreter now has all constants and properties loaded from first pass
407
                let assignment = format!("{} = {}", name, trimmed);
9✔
408
                interp.eval(&assignment).map_err(|e| EvalError::PyishEval {
9✔
409
                    expr: assignment.clone(),
×
410
                    source: e,
×
411
                })?;
×
412

413
                // Retrieve the lambda value to store in context
414
                let lambda_value = interp.eval(name).map_err(|e| EvalError::PyishEval {
9✔
415
                    expr: name.clone(),
×
416
                    source: e,
×
417
                })?;
×
418

419
                return Ok((name.clone(), lambda_value));
9✔
420
            }
92✔
421

422
            // Default: store as string literal
423
            Ok((name.clone(), Value::StringLit(value.clone())))
92✔
424
        })
365✔
425
        .collect::<Result<HashMap<_, _>, _>>()?;
512✔
426

427
    // Manually inject inf and nan constants (Strategy 3: bypass parsing)
428
    // Python xacro provides these via float('inf') and math.inf, but they're also
429
    // used as bare identifiers in expressions. Pyisheval cannot parse these as
430
    // literals, so we inject them directly into the context.
431
    context.insert("inf".to_string(), Value::Number(f64::INFINITY));
512✔
432
    context.insert("nan".to_string(), Value::Number(f64::NAN));
512✔
433

434
    Ok(context)
512✔
435
}
512✔
436

437
/// Evaluate a single expression string, handling Xacro-specific special cases
438
///
439
/// This centralizes handling of special functions like xacro.print_location()
440
/// that don't fit into the generic pyisheval evaluation model.
441
///
442
/// # Arguments
443
/// * `interp` - The pyisheval interpreter to use
444
/// * `expr` - The expression string to evaluate
445
/// * `context` - The variable context for evaluation
446
///
447
/// # Returns
448
/// * `Ok(Some(value))` - Normal expression evaluated successfully
449
/// * `Ok(None)` - Special case that produces no output (e.g., xacro.print_location())
450
/// * `Err(e)` - Evaluation error
451
///
452
/// # Special Cases
453
/// * `xacro.print_location()` - Debug function that prints stack trace to stderr in Python.
454
///   We stub it out by returning None (no output). This is handled as a special case because:
455
///   1. pyisheval doesn't support object.method syntax
456
///   2. This is a debug-only function with no production use
457
///   3. We're not implementing the full debug functionality
458
pub fn evaluate_expression(
361✔
459
    interp: &mut Interpreter,
361✔
460
    expr: &str,
361✔
461
    context: &HashMap<String, Value>,
361✔
462
) -> Result<Option<Value>, pyisheval::EvalError> {
361✔
463
    let trimmed_expr = expr.trim();
361✔
464
    if trimmed_expr == "xacro.print_location()" {
361✔
465
        // Special case: stub debug function returns no output
466
        return Ok(None);
5✔
467
    }
356✔
468

469
    // Preprocess math functions (cos, sin, tan, etc.) before evaluation
470
    // This converts native math calls into computed values since pyisheval
471
    // doesn't support calling native Rust functions
472
    let preprocessed = preprocess_math_functions(expr, interp).map_err(|e| match e {
356✔
NEW
473
        EvalError::PyishEval { source, .. } => source,
×
NEW
474
        _ => pyisheval::EvalError::ParseError(e.to_string()),
×
NEW
475
    })?;
×
476

477
    interp.eval_with_context(&preprocessed, context).map(Some)
356✔
478
}
361✔
479

480
/// Evaluate text containing ${...} expressions using a provided interpreter
481
///
482
/// This version allows reusing an Interpreter instance for better performance
483
/// when processing multiple text blocks with the same properties context.
484
///
485
/// Takes a mutable reference to ensure lambdas are created in the same
486
/// interpreter context where they'll be evaluated.
487
pub fn eval_text_with_interpreter(
165✔
488
    text: &str,
165✔
489
    properties: &HashMap<String, String>,
165✔
490
    interp: &mut Interpreter,
165✔
491
) -> Result<String, EvalError> {
165✔
492
    // Build context for pyisheval (may fail if lambdas have errors)
493
    // This loads properties into the interpreter and evaluates lambda expressions
494
    let context = build_pyisheval_context(properties, interp)?;
165✔
495

496
    // Tokenize the input text
497
    let lexer = Lexer::new(text);
165✔
498
    let mut result = Vec::new();
165✔
499

500
    // Process each token
501
    for (token_type, token_value) in lexer {
346✔
502
        match token_type {
187✔
503
            TokenType::Text => {
66✔
504
                // Plain text, keep as-is
66✔
505
                result.push(token_value);
66✔
506
            }
66✔
507
            TokenType::Expr => {
508
                // Evaluate expression using centralized helper
509
                match evaluate_expression(interp, &token_value, &context) {
118✔
510
                    Ok(Some(value)) => {
109✔
511
                        #[cfg(feature = "compat")]
109✔
512
                        let value_str = format_value_python_style(&value, false);
109✔
513
                        #[cfg(not(feature = "compat"))]
109✔
514
                        let value_str = format_value_python_style(&value, true);
109✔
515
                        result.push(remove_quotes(&value_str).to_string());
109✔
516
                    }
109✔
517
                    Ok(None) => {
518
                        // Special case returned no output (e.g., xacro.print_location())
519
                        continue;
3✔
520
                    }
521
                    Err(e) => {
6✔
522
                        return Err(EvalError::PyishEval {
6✔
523
                            expr: token_value.clone(),
6✔
524
                            source: e,
6✔
525
                        });
6✔
526
                    }
527
                }
528
            }
529
            TokenType::Extension => {
×
530
                // $(extension) - handle later (Phase 6)
×
531
                // For now, just keep the original text
×
532
                result.push(format!("$({})", token_value));
×
533
            }
×
534
            TokenType::DollarDollarBrace => {
3✔
535
                // $$ escape - output $ followed by the delimiter ({ or ()
3✔
536
                result.push(format!("${}", token_value));
3✔
537
            }
3✔
538
        }
539
    }
540

541
    Ok(result.join(""))
159✔
542
}
165✔
543

544
/// Apply Python xacro's STRICT string truthiness rules
545
///
546
/// Accepts: "true", "True", "false", "False", or parseable integers
547
/// Rejects: Everything else (including "nonsense", empty string, floats as strings)
548
fn apply_string_truthiness(
50✔
549
    s: &str,
50✔
550
    original: &str,
50✔
551
) -> Result<bool, EvalError> {
50✔
552
    let trimmed = s.trim();
50✔
553

554
    // Exact string matches for boolean literals
555
    if trimmed == "true" || trimmed == "True" {
50✔
556
        return Ok(true);
18✔
557
    }
32✔
558
    if trimmed == "false" || trimmed == "False" {
32✔
559
        return Ok(false);
11✔
560
    }
21✔
561

562
    // Try integer conversion (Python's bool(int(value)))
563
    if let Ok(i) = trimmed.parse::<i64>() {
21✔
564
        return Ok(i != 0);
14✔
565
    }
7✔
566

567
    // Try float conversion (for values like "1.0")
568
    if let Ok(f) = trimmed.parse::<f64>() {
7✔
569
        return Ok(f != 0.0);
×
570
    }
7✔
571

572
    // Everything else is an error (STRICT mode)
573
    Err(EvalError::InvalidBoolean {
7✔
574
        condition: original.to_string(),
7✔
575
        evaluated: s.to_string(),
7✔
576
    })
7✔
577
}
50✔
578

579
/// Evaluate expression as boolean following Python xacro's STRICT rules
580
///
581
/// Python xacro's get_boolean_value() logic (ref/xacro/src/xacro/__init__.py:856):
582
/// - Accepts: "true", "True", "false", "False"
583
/// - Accepts: Any string convertible to int: "1", "0", "42", "-5"
584
/// - REJECTS: "nonsense", empty string, anything else → Error
585
///
586
/// CRITICAL: This preserves type information from pyisheval!
587
/// ${3*0.1} evaluates to float 0.3 (truthy), NOT string "0.3" (would error)
588
///
589
/// Examples:
590
///   eval_boolean("true", &props) → Ok(true)
591
///   eval_boolean("${3*0.1}", &props) → Ok(true)  // Float 0.3 != 0.0
592
///   eval_boolean("${0}", &props) → Ok(false)     // Integer 0
593
///   eval_boolean("nonsense", &props) → Err(InvalidBoolean)
594
pub fn eval_boolean(
108✔
595
    text: &str,
108✔
596
    properties: &HashMap<String, String>,
108✔
597
) -> Result<bool, EvalError> {
108✔
598
    let mut interp = init_interpreter();
108✔
599

600
    // Build context for pyisheval (may fail if lambdas have errors)
601
    let context = build_pyisheval_context(properties, &mut interp)?;
108✔
602

603
    // Tokenize input to detect structure
604
    let lexer = Lexer::new(text);
108✔
605
    let tokens: Vec<_> = lexer.collect();
108✔
606

607
    // CASE 1: Single ${expr} token → Preserve type, apply truthiness on Value
608
    // This is CRITICAL for float truthiness: ${3*0.1} → float 0.3 → true
609
    if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
108✔
610
        let value = interp
62✔
611
            .eval_with_context(&tokens[0].1, &context)
62✔
612
            .map_err(|e| EvalError::PyishEval {
62✔
613
                expr: text.to_string(),
×
614
                source: e,
×
615
            })?;
×
616

617
        // Apply Python truthiness based on Value type
618
        return match value {
62✔
619
            Value::Number(n) => Ok(n != 0.0), // Float/int truthiness (includes bools: True=1.0, False=0.0)
58✔
620
            Value::StringLit(s) => {
4✔
621
                // String: must be "true"/"false" or parseable as int
622
                apply_string_truthiness(&s, text)
4✔
623
            }
624
            // Other types (Lambda, List, etc.) - error for now
625
            _ => Err(EvalError::InvalidBoolean {
×
626
                condition: text.to_string(),
×
627
                evaluated: format!("{:?}", value),
×
628
            }),
×
629
        };
630
    }
46✔
631

632
    // CASE 2: Multiple tokens or plain text → Evaluate to string, then parse
633
    // Example: "text ${expr} more" or just "true"
634
    let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
46✔
635
    apply_string_truthiness(&evaluated, text)
46✔
636
}
108✔
637

638
#[cfg(test)]
639
mod tests {
640
    use super::*;
641

642
    // TEST 1: Backward compatibility - simple property substitution
643
    #[test]
644
    fn test_simple_property_substitution() {
1✔
645
        let mut props = HashMap::new();
1✔
646
        props.insert("width".to_string(), "0.5".to_string());
1✔
647

648
        let result = eval_text("${width}", &props).unwrap();
1✔
649
        assert_eq!(result, "0.5");
1✔
650
    }
1✔
651

652
    // TEST 2: Property in text
653
    #[test]
654
    fn test_property_in_text() {
1✔
655
        let mut props = HashMap::new();
1✔
656
        props.insert("width".to_string(), "0.5".to_string());
1✔
657

658
        let result = eval_text("The width is ${width} meters", &props).unwrap();
1✔
659
        assert_eq!(result, "The width is 0.5 meters");
1✔
660
    }
1✔
661

662
    // TEST 3: Multiple properties
663
    #[test]
664
    fn test_multiple_properties() {
1✔
665
        let mut props = HashMap::new();
1✔
666
        props.insert("width".to_string(), "0.5".to_string());
1✔
667
        props.insert("height".to_string(), "1.0".to_string());
1✔
668

669
        let result = eval_text("${width} x ${height}", &props).unwrap();
1✔
670
        // Whole numbers format without .0 (Python int behavior)
671
        assert_eq!(result, "0.5 x 1");
1✔
672
    }
1✔
673

674
    // TEST 4: NEW - Simple arithmetic
675
    #[test]
676
    fn test_arithmetic_expression() {
1✔
677
        let mut props = HashMap::new();
1✔
678
        props.insert("width".to_string(), "0.5".to_string());
1✔
679

680
        let result = eval_text("${width * 2}", &props).unwrap();
1✔
681
        assert_eq!(result, "1");
1✔
682
    }
1✔
683

684
    // TEST 5: NEW - Arithmetic without properties
685
    #[test]
686
    fn test_pure_arithmetic() {
1✔
687
        let props = HashMap::new();
1✔
688

689
        let result = eval_text("${2 + 3}", &props).unwrap();
1✔
690
        assert_eq!(result, "5");
1✔
691
    }
1✔
692

693
    // TEST 6: NEW - Complex expression
694
    #[test]
695
    fn test_complex_expression() {
1✔
696
        let mut props = HashMap::new();
1✔
697
        props.insert("width".to_string(), "0.5".to_string());
1✔
698
        props.insert("height".to_string(), "2.0".to_string());
1✔
699

700
        let result = eval_text("${width * height + 1}", &props).unwrap();
1✔
701
        assert_eq!(result, "2");
1✔
702
    }
1✔
703

704
    // TEST 7: NEW - String concatenation with literals
705
    // Note: pyisheval doesn't currently support string concatenation with +
706
    // This is documented as a known limitation. Use property substitution instead.
707
    #[test]
708
    #[ignore]
709
    fn test_string_concatenation() {
×
710
        let props = HashMap::new();
×
711

712
        // String concatenation with string literals (quoted in expression)
713
        let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
×
714
        assert_eq!(result, "link_base");
×
715
    }
×
716

717
    // TEST 8: NEW - Built-in functions
718
    #[test]
719
    fn test_builtin_functions() {
1✔
720
        let props = HashMap::new();
1✔
721

722
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
723
        assert_eq!(result, "5");
1✔
724

725
        let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
1✔
726
        assert_eq!(result, "5");
1✔
727
    }
1✔
728

729
    // TEST 9: NEW - Conditional expressions
730
    #[test]
731
    fn test_conditional_expression() {
1✔
732
        let mut props = HashMap::new();
1✔
733
        props.insert("width".to_string(), "0.5".to_string());
1✔
734

735
        let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
1✔
736
        assert_eq!(result, "0.5");
1✔
737
    }
1✔
738

739
    // TEST 10: Text without expressions (pass through)
740
    #[test]
741
    fn test_no_expressions() {
1✔
742
        let props = HashMap::new();
1✔
743
        let result = eval_text("hello world", &props).unwrap();
1✔
744
        assert_eq!(result, "hello world");
1✔
745
    }
1✔
746

747
    // TEST 11: Empty string
748
    #[test]
749
    fn test_empty_string() {
1✔
750
        let props = HashMap::new();
1✔
751
        let result = eval_text("", &props).unwrap();
1✔
752
        assert_eq!(result, "");
1✔
753
    }
1✔
754

755
    // TEST 12: Error case - undefined property
756
    #[test]
757
    fn test_undefined_property() {
1✔
758
        let props = HashMap::new();
1✔
759
        let result = eval_text("${undefined}", &props);
1✔
760
        assert!(result.is_err());
1✔
761
    }
1✔
762

763
    // TEST 13: String property substitution (non-numeric values)
764
    #[test]
765
    fn test_string_property() {
1✔
766
        let mut props = HashMap::new();
1✔
767
        props.insert("link_name".to_string(), "base_link".to_string());
1✔
768
        props.insert("joint_type".to_string(), "revolute".to_string());
1✔
769

770
        // Test single property
771
        let result = eval_text("${link_name}", &props).unwrap();
1✔
772
        assert_eq!(result, "base_link");
1✔
773

774
        // Test property in text
775
        let result = eval_text("name_${link_name}_suffix", &props).unwrap();
1✔
776
        assert_eq!(result, "name_base_link_suffix");
1✔
777

778
        // Test multiple string properties
779
        let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
1✔
780
        assert_eq!(result, "base_link revolute");
1✔
781
    }
1✔
782

783
    #[test]
784
    fn test_double_dollar_escape() {
1✔
785
        let props = HashMap::new();
1✔
786

787
        // Test $$ escape with brace - should produce literal ${
788
        let result = eval_text("$${expr}", &props).unwrap();
1✔
789
        assert_eq!(result, "${expr}");
1✔
790

791
        // Test $$ escape with paren - should produce literal $(
792
        let result = eval_text("$$(command)", &props).unwrap();
1✔
793
        assert_eq!(result, "$(command)");
1✔
794

795
        // Test $$ escape in context
796
        let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
1✔
797
        assert_eq!(result, "prefix_${literal}_suffix");
1✔
798
    }
1✔
799

800
    // ===== NEW TESTS FOR eval_boolean =====
801

802
    // Test from Python xacro: test_boolean_if_statement (line 715)
803
    #[test]
804
    fn test_eval_boolean_literals() {
1✔
805
        let props = HashMap::new();
1✔
806

807
        // Boolean string literals
808
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
809
        assert_eq!(eval_boolean("false", &props).unwrap(), false);
1✔
810
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
811
        assert_eq!(eval_boolean("False", &props).unwrap(), false);
1✔
812
    }
1✔
813

814
    // Test from Python xacro: test_integer_if_statement (line 735)
815
    #[test]
816
    fn test_eval_boolean_integer_truthiness() {
1✔
817
        let props = HashMap::new();
1✔
818

819
        // Integer literals as strings
820
        assert_eq!(eval_boolean("0", &props).unwrap(), false);
1✔
821
        assert_eq!(eval_boolean("1", &props).unwrap(), true);
1✔
822
        assert_eq!(eval_boolean("42", &props).unwrap(), true);
1✔
823
        assert_eq!(eval_boolean("-5", &props).unwrap(), true);
1✔
824

825
        // Integer expressions
826
        assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); // 0
1✔
827
        assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
1✔
828
        assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); // 5
1✔
829
    }
1✔
830

831
    // Test from Python xacro: test_float_if_statement (line 755)
832
    #[test]
833
    fn test_eval_boolean_float_truthiness() {
1✔
834
        let props = HashMap::new();
1✔
835

836
        // CRITICAL: Float expressions must preserve type
837
        assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); // 0.0
1✔
838
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); // 0.3 (non-zero float)
1✔
839
        assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
1✔
840
        assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
1✔
841
    }
1✔
842

843
    // Test from Python xacro: test_property_if_statement (line 769)
844
    #[test]
845
    fn test_eval_boolean_with_properties() {
1✔
846
        let mut props = HashMap::new();
1✔
847
        props.insert("condT".to_string(), "1".to_string()); // True as number
1✔
848
        props.insert("condF".to_string(), "0".to_string()); // False as number
1✔
849
        props.insert("num".to_string(), "5".to_string());
1✔
850

851
        assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
1✔
852
        assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
1✔
853
        assert_eq!(eval_boolean("${num}", &props).unwrap(), true); // 5 != 0
1✔
854

855
        // Note: pyisheval doesn't have True/False as built-in constants
856
        // They would need to be defined as properties with value 1/0
857
    }
1✔
858

859
    // Test from Python xacro: test_equality_expression_in_if_statement (line 788)
860
    #[test]
861
    fn test_eval_boolean_expressions() {
1✔
862
        let mut props = HashMap::new();
1✔
863
        props.insert("var".to_string(), "useit".to_string());
1✔
864

865
        // Equality
866
        assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
1✔
867
        assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
1✔
868

869
        // Comparison
870
        props.insert("x".to_string(), "5".to_string());
1✔
871
        assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
1✔
872
        assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
1✔
873

874
        // Note: pyisheval doesn't support 'in' operator for strings yet
875
        // That would require extending pyisheval or using a different evaluator
876
    }
1✔
877

878
    /// Test that pyisheval returns Value::Number for boolean expressions
879
    ///
880
    /// CRITICAL: This test documents that pyisheval v0.9.0 does NOT have Value::Bool.
881
    /// Boolean comparison expressions like ${1 == 1} return Value::Number(1.0), not Value::Bool(true).
882
    /// This is similar to Python where bool is a subclass of int (True == 1, False == 0).
883
    ///
884
    /// This test exists to:
885
    /// 1. Verify our Number-based truthiness handling works for comparisons
886
    /// 2. Document pyisheval's current behavior
887
    /// 3. Catch if pyisheval adds Value::Bool in future (this would fail, prompting us to update)
888
    #[test]
889
    fn test_eval_boolean_comparison_expressions() {
1✔
890
        let mut props = HashMap::new();
1✔
891
        props.insert("x".to_string(), "5".to_string());
1✔
892
        props.insert("y".to_string(), "10".to_string());
1✔
893

894
        // Equality comparisons
895
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
896
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
897
        assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
1✔
898
        assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
1✔
899

900
        // Inequality comparisons
901
        assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
1✔
902
        assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
1✔
903

904
        // Less than / greater than
905
        assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
1✔
906
        assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
1✔
907
        assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
1✔
908
        assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
1✔
909

910
        // NOTE: pyisheval v0.9.0 does NOT support `and`/`or` operators
911
        // These would fail: ${1 and 1}, ${x > 3 and y < 15}
912
        // Fortunately, real xacro files don't use these - they use simpler expressions
913

914
        // Note: All these comparison expressions are evaluated by pyisheval as Value::Number(1.0) or Value::Number(0.0)
915
        // Our eval_boolean correctly applies != 0.0 truthiness to convert to bool
916
    }
1✔
917

918
    // Test from Python xacro: test_invalid_if_statement (line 729)
919
    #[test]
920
    fn test_eval_boolean_invalid_values() {
1✔
921
        let props = HashMap::new();
1✔
922

923
        // STRICT mode: "nonsense" should error
924
        let result = eval_boolean("nonsense", &props);
1✔
925
        assert!(result.is_err());
1✔
926
        assert!(result
1✔
927
            .unwrap_err()
1✔
928
            .to_string()
1✔
929
            .contains("not a boolean expression"));
1✔
930

931
        // Empty string should error
932
        let result = eval_boolean("", &props);
1✔
933
        assert!(result.is_err());
1✔
934

935
        // Random text should error
936
        let result = eval_boolean("random text", &props);
1✔
937
        assert!(result.is_err());
1✔
938
    }
1✔
939

940
    // Test edge case: whitespace handling
941
    #[test]
942
    fn test_eval_boolean_whitespace() {
1✔
943
        let props = HashMap::new();
1✔
944

945
        // Should trim whitespace
946
        assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
1✔
947
        assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
1✔
948
        assert_eq!(eval_boolean("  0  ", &props).unwrap(), false);
1✔
949
        assert_eq!(eval_boolean("  1  ", &props).unwrap(), true);
1✔
950
    }
1✔
951

952
    // Test case sensitivity
953
    #[test]
954
    fn test_eval_boolean_case_sensitivity() {
1✔
955
        let props = HashMap::new();
1✔
956

957
        // "true" and "True" are accepted
958
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
959
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
960

961
        // But not other cases (should error)
962
        assert!(eval_boolean("TRUE", &props).is_err());
1✔
963
        assert!(eval_boolean("tRuE", &props).is_err());
1✔
964
    }
1✔
965

966
    // Test evaluate_expression special case handling directly
967
    #[test]
968
    fn test_evaluate_expression_special_cases() {
1✔
969
        let mut interp = init_interpreter();
1✔
970
        let context = HashMap::new();
1✔
971

972
        // Test xacro.print_location() special case
973
        let result = evaluate_expression(&mut interp, "xacro.print_location()", &context).unwrap();
1✔
974
        assert!(
1✔
975
            result.is_none(),
1✔
976
            "xacro.print_location() should return None"
×
977
        );
978

979
        // Test with surrounding whitespace
980
        let result =
1✔
981
            evaluate_expression(&mut interp, "  xacro.print_location()  ", &context).unwrap();
1✔
982
        assert!(
1✔
983
            result.is_none(),
1✔
984
            "xacro.print_location() with whitespace should return None"
×
985
        );
986

987
        // Test a normal expression to ensure it's not affected
988
        let result = evaluate_expression(&mut interp, "1 + 1", &context).unwrap();
1✔
989
        assert!(
1✔
990
            matches!(result, Some(Value::Number(n)) if n == 2.0),
1✔
991
            "Normal expression should evaluate correctly"
×
992
        );
993
    }
1✔
994

995
    // Test xacro.print_location() stub function via integration
996
    #[test]
997
    fn test_xacro_print_location_stub() {
1✔
998
        let props = HashMap::new();
1✔
999

1000
        // xacro.print_location() should return empty string
1001
        let result = eval_text("${xacro.print_location()}", &props).unwrap();
1✔
1002
        assert_eq!(result, "");
1✔
1003

1004
        // Should work in text context too
1005
        let result = eval_text("before${xacro.print_location()}after", &props).unwrap();
1✔
1006
        assert_eq!(result, "beforeafter");
1✔
1007

1008
        // With whitespace in expression
1009
        let result = eval_text("${ xacro.print_location() }", &props).unwrap();
1✔
1010
        assert_eq!(result, "");
1✔
1011
    }
1✔
1012

1013
    // Test that inf and nan are available via direct context injection
1014
    #[test]
1015
    fn test_inf_nan_direct_injection() {
1✔
1016
        let props = HashMap::new();
1✔
1017
        let mut interp = init_interpreter();
1✔
1018

1019
        // Build context with direct inf/nan injection
1020
        let context = build_pyisheval_context(&props, &mut interp).unwrap();
1✔
1021

1022
        // Verify inf and nan are in the context
1023
        assert!(
1✔
1024
            context.contains_key("inf"),
1✔
1025
            "Context should contain 'inf' key"
×
1026
        );
1027
        assert!(
1✔
1028
            context.contains_key("nan"),
1✔
1029
            "Context should contain 'nan' key"
×
1030
        );
1031

1032
        // Test 1: inf should be positive infinity
1033
        if let Some(Value::Number(n)) = context.get("inf") {
1✔
1034
            assert!(
1✔
1035
                n.is_infinite() && n.is_sign_positive(),
1✔
1036
                "inf should be positive infinity, got: {}",
×
1037
                n
1038
            );
1039
        } else {
1040
            panic!("inf should be a Number value");
×
1041
        }
1042

1043
        // Test 2: nan should be NaN
1044
        if let Some(Value::Number(n)) = context.get("nan") {
1✔
1045
            assert!(n.is_nan(), "nan should be NaN, got: {}", n);
1✔
1046
        } else {
1047
            panic!("nan should be a Number value");
×
1048
        }
1049

1050
        // Test 3: inf should be usable in expressions
1051
        let result = interp.eval_with_context("inf * 2", &context);
1✔
1052
        assert!(
1✔
1053
            matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
1✔
1054
            "inf * 2 should return positive infinity, got: {:?}",
×
1055
            result
1056
        );
1057

1058
        // Test 4: nan should be usable in expressions
1059
        let result = interp.eval_with_context("nan + 1", &context);
1✔
1060
        assert!(
1✔
1061
            matches!(result, Ok(Value::Number(n)) if n.is_nan()),
1✔
1062
            "nan + 1 should return NaN, got: {:?}",
×
1063
            result
1064
        );
1065
    }
1✔
1066

1067
    // Test type preservation: the key feature!
1068
    #[test]
1069
    fn test_eval_boolean_type_preservation() {
1✔
1070
        let props = HashMap::new();
1✔
1071

1072
        // Single expression: type preserved
1073
        // ${3*0.1} → Value::Number(0.3) → != 0.0 → true
1074
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true);
1✔
1075

1076
        // Multiple tokens: becomes string
1077
        // "result: ${3*0.1}" → "result: 0.3" → can't parse as int → error
1078
        let result = eval_boolean("result: ${3*0.1}", &props);
1✔
1079
        assert!(result.is_err());
1✔
1080
    }
1✔
1081

1082
    // Test Boolean value type from pyisheval
1083
    #[test]
1084
    fn test_eval_boolean_bool_values() {
1✔
1085
        let props = HashMap::new();
1✔
1086

1087
        // pyisheval returns Value::Bool directly
1088
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
1089
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
1090
        assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
1✔
1091
    }
1✔
1092

1093
    // Lambda expression tests
1094
    #[test]
1095
    fn test_basic_lambda_works() {
1✔
1096
        let mut props = HashMap::new();
1✔
1097
        props.insert("f".to_string(), "lambda x: x * 2".to_string());
1✔
1098
        assert_eq!(eval_text("${f(5)}", &props).unwrap(), "10");
1✔
1099
    }
1✔
1100

1101
    // NOTE: pyisheval doesn't support multi-parameter lambdas (parser limitation)
1102
    // lambda x, y: x + y fails with "Unexpected trailing input"
1103
    // This is a known pyisheval limitation - single parameter lambdas only
1104

1105
    // Python-style number formatting tests
1106
    #[test]
1107
    fn test_format_value_python_style_whole_numbers() {
1✔
1108
        use pyisheval::Value;
1109

1110
        // Whole numbers format without .0 (Python int behavior)
1111
        assert_eq!(format_value_python_style(&Value::Number(0.0), false), "0");
1✔
1112
        assert_eq!(format_value_python_style(&Value::Number(1.0), false), "1");
1✔
1113
        assert_eq!(format_value_python_style(&Value::Number(2.0), false), "2");
1✔
1114
        assert_eq!(format_value_python_style(&Value::Number(-1.0), false), "-1");
1✔
1115
        assert_eq!(
1✔
1116
            format_value_python_style(&Value::Number(100.0), false),
1✔
1117
            "100"
1118
        );
1119
    }
1✔
1120

1121
    #[test]
1122
    fn test_format_value_python_style_fractional() {
1✔
1123
        use pyisheval::Value;
1124

1125
        // Fractional numbers use default formatting (no trailing zeros)
1126
        assert_eq!(format_value_python_style(&Value::Number(1.5), false), "1.5");
1✔
1127
        assert_eq!(format_value_python_style(&Value::Number(0.5), false), "0.5");
1✔
1128
        assert_eq!(
1✔
1129
            format_value_python_style(&Value::Number(0.4235294117647059), false),
1✔
1130
            "0.4235294117647059"
1131
        );
1132
    }
1✔
1133

1134
    #[test]
1135
    fn test_format_value_python_style_special() {
1✔
1136
        use pyisheval::Value;
1137

1138
        // Special values
1139
        assert_eq!(
1✔
1140
            format_value_python_style(&Value::Number(f64::INFINITY), false),
1✔
1141
            "inf"
1142
        );
1143
        assert_eq!(
1✔
1144
            format_value_python_style(&Value::Number(f64::NEG_INFINITY), false),
1✔
1145
            "-inf"
1146
        );
1147
        assert_eq!(
1✔
1148
            format_value_python_style(&Value::Number(f64::NAN), false),
1✔
1149
            "NaN"
1150
        );
1151
    }
1✔
1152

1153
    #[test]
1154
    fn test_eval_with_python_number_formatting() {
1✔
1155
        let mut props = HashMap::new();
1✔
1156
        props.insert("height".to_string(), "1.0".to_string());
1✔
1157

1158
        // Whole numbers format without .0 (mimics Python int behavior)
1159
        assert_eq!(eval_text("${height}", &props).unwrap(), "1");
1✔
1160
        assert_eq!(eval_text("${1.0 + 0.0}", &props).unwrap(), "1");
1✔
1161
        assert_eq!(eval_text("${2.0 * 1.0}", &props).unwrap(), "2");
1✔
1162
    }
1✔
1163

1164
    #[test]
1165
    fn test_lambda_referencing_property() {
1✔
1166
        let mut props = HashMap::new();
1✔
1167
        props.insert("offset".to_string(), "10".to_string());
1✔
1168
        props.insert("add_offset".to_string(), "lambda x: x + offset".to_string());
1✔
1169
        assert_eq!(eval_text("${add_offset(5)}", &props).unwrap(), "15");
1✔
1170
    }
1✔
1171

1172
    #[test]
1173
    fn test_lambda_referencing_multiple_properties() {
1✔
1174
        let mut props = HashMap::new();
1✔
1175
        props.insert("a".to_string(), "2".to_string());
1✔
1176
        props.insert("b".to_string(), "3".to_string());
1✔
1177
        props.insert("scale".to_string(), "lambda x: x * a + b".to_string());
1✔
1178
        assert_eq!(eval_text("${scale(5)}", &props).unwrap(), "13");
1✔
1179
    }
1✔
1180

1181
    #[test]
1182
    fn test_lambda_with_conditional() {
1✔
1183
        let mut props = HashMap::new();
1✔
1184
        props.insert(
1✔
1185
            "sign".to_string(),
1✔
1186
            "lambda x: 1 if x > 0 else -1".to_string(),
1✔
1187
        );
1188
        assert_eq!(eval_text("${sign(5)}", &props).unwrap(), "1");
1✔
1189
        assert_eq!(eval_text("${sign(-3)}", &props).unwrap(), "-1");
1✔
1190
    }
1✔
1191

1192
    #[test]
1193
    fn test_multiple_lambdas() {
1✔
1194
        let mut props = HashMap::new();
1✔
1195
        props.insert("double".to_string(), "lambda x: x * 2".to_string());
1✔
1196
        props.insert("triple".to_string(), "lambda x: x * 3".to_string());
1✔
1197
        assert_eq!(
1✔
1198
            eval_text("${double(5)} ${triple(5)}", &props).unwrap(),
1✔
1199
            "10 15"
1200
        );
1201
    }
1✔
1202

1203
    #[test]
1204
    fn test_lambda_referencing_inf_property() {
1✔
1205
        let mut props = HashMap::new();
1✔
1206
        props.insert("my_inf".to_string(), "inf".to_string());
1✔
1207
        props.insert("is_inf".to_string(), "lambda x: x == my_inf".to_string());
1✔
1208
        // inf == inf should be true (1)
1209
        assert_eq!(eval_text("${is_inf(inf)}", &props).unwrap(), "1");
1✔
1210
    }
1✔
1211

1212
    // ===== Math Function Tests =====
1213

1214
    #[test]
1215
    fn test_math_functions_cos_sin() {
1✔
1216
        let mut props = HashMap::new();
1✔
1217
        props.insert("pi".to_string(), "3.141592653589793".to_string());
1✔
1218

1219
        let result = eval_text("${cos(0)}", &props).unwrap();
1✔
1220
        assert_eq!(result, "1");
1✔
1221

1222
        let result = eval_text("${sin(0)}", &props).unwrap();
1✔
1223
        assert_eq!(result, "0");
1✔
1224

1225
        let result = eval_text("${cos(pi)}", &props).unwrap();
1✔
1226
        assert_eq!(result, "-1");
1✔
1227
    }
1✔
1228

1229
    #[test]
1230
    fn test_math_functions_nested() {
1✔
1231
        let mut props = HashMap::new();
1✔
1232
        props.insert("radius".to_string(), "0.5".to_string());
1✔
1233

1234
        // radians() is a lambda defined in init_interpreter
1235
        let result = eval_text("${radius*cos(radians(0))}", &props).unwrap();
1✔
1236
        assert_eq!(result, "0.5");
1✔
1237

1238
        let result = eval_text("${radius*cos(radians(60))}", &props).unwrap();
1✔
1239
        // cos(60°) = 0.5, so 0.5 * 0.5 = 0.25 (with floating point rounding)
1240
        let value: f64 = result.parse().unwrap();
1✔
1241
        assert!(
1✔
1242
            (value - 0.25).abs() < 1e-10,
1✔
NEW
1243
            "Expected ~0.25, got {}",
×
1244
            value
1245
        );
1246
    }
1✔
1247

1248
    #[test]
1249
    fn test_math_functions_sqrt_abs() {
1✔
1250
        let props = HashMap::new();
1✔
1251

1252
        let result = eval_text("${sqrt(16)}", &props).unwrap();
1✔
1253
        assert_eq!(result, "4");
1✔
1254

1255
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
1256
        assert_eq!(result, "5");
1✔
1257

1258
        let result = eval_text("${abs(5)}", &props).unwrap();
1✔
1259
        assert_eq!(result, "5");
1✔
1260
    }
1✔
1261

1262
    #[test]
1263
    fn test_math_functions_floor_ceil() {
1✔
1264
        let props = HashMap::new();
1✔
1265

1266
        let result = eval_text("${floor(3.7)}", &props).unwrap();
1✔
1267
        assert_eq!(result, "3");
1✔
1268

1269
        let result = eval_text("${ceil(3.2)}", &props).unwrap();
1✔
1270
        assert_eq!(result, "4");
1✔
1271

1272
        let result = eval_text("${floor(-2.3)}", &props).unwrap();
1✔
1273
        assert_eq!(result, "-3");
1✔
1274

1275
        let result = eval_text("${ceil(-2.3)}", &props).unwrap();
1✔
1276
        assert_eq!(result, "-2");
1✔
1277
    }
1✔
1278

1279
    #[test]
1280
    fn test_math_functions_trig() {
1✔
1281
        let props = HashMap::new();
1✔
1282

1283
        // tan(0) = 0
1284
        let result = eval_text("${tan(0)}", &props).unwrap();
1✔
1285
        assert_eq!(result, "0");
1✔
1286

1287
        // asin(0) = 0
1288
        let result = eval_text("${asin(0)}", &props).unwrap();
1✔
1289
        assert_eq!(result, "0");
1✔
1290

1291
        // acos(1) = 0
1292
        let result = eval_text("${acos(1)}", &props).unwrap();
1✔
1293
        assert_eq!(result, "0");
1✔
1294

1295
        // atan(0) = 0
1296
        let result = eval_text("${atan(0)}", &props).unwrap();
1✔
1297
        assert_eq!(result, "0");
1✔
1298
    }
1✔
1299

1300
    #[test]
1301
    fn test_math_functions_multiple_in_expression() {
1✔
1302
        let mut props = HashMap::new();
1✔
1303
        props.insert("x".to_string(), "3".to_string());
1✔
1304
        props.insert("y".to_string(), "4".to_string());
1✔
1305

1306
        // sqrt(x^2 + y^2) = sqrt(9 + 16) = sqrt(25) = 5
1307
        let result = eval_text("${sqrt(x**2 + y**2)}", &props).unwrap();
1✔
1308
        assert_eq!(result, "5");
1✔
1309
    }
1✔
1310

1311
    /// Test to prevent divergence between regex pattern and match statement
1312
    ///
1313
    /// This ensures all functions in SUPPORTED_MATH_FUNCS have corresponding implementations,
1314
    /// catching bugs at test time rather than runtime.
1315
    #[test]
1316
    fn test_math_functions_regex_match_consistency() {
1✔
1317
        let props = HashMap::new();
1✔
1318

1319
        // Test each function in SUPPORTED_MATH_FUNCS to ensure it's implemented
1320
        for func in SUPPORTED_MATH_FUNCS {
11✔
1321
            let expr = format!("${{{}(0)}}", func);
10✔
1322
            let result = eval_text(&expr, &props);
10✔
1323

10✔
1324
            // Ensure evaluation succeeds - unreachable!() would panic if function is missing
10✔
1325
            result.expect("Evaluation should succeed for all supported math functions");
10✔
1326
        }
10✔
1327
    }
1✔
1328
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc