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

kaidokert / xacro / 20874766435

10 Jan 2026 07:13AM UTC coverage: 88.154%. First build
20874766435

Pull #37

github

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

140 of 157 new or added lines in 1 file covered. (89.17%)

2292 of 2600 relevant lines covered (88.15%)

177.09 hits per line

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

89.19
/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(
51✔
20
    text: &str,
51✔
21
    start: usize,
51✔
22
) -> Option<usize> {
51✔
23
    let bytes = text.as_bytes();
51✔
24
    if start >= bytes.len() || bytes[start] != b'(' {
51✔
NEW
25
        return None;
×
26
    }
51✔
27

28
    let mut depth = 0;
51✔
29
    for (i, &ch) in bytes.iter().enumerate().skip(start) {
219✔
30
        match ch {
219✔
31
            b'(' => depth += 1,
54✔
32
            b')' => {
33
                depth -= 1;
54✔
34
                if depth == 0 {
54✔
35
                    return Some(i);
51✔
36
                }
3✔
37
            }
38
            _ => {}
111✔
39
        }
40
    }
NEW
41
    None
×
42
}
51✔
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 {
403✔
63
    MATH_FUNCS_REGEX.get_or_init(|| {
403✔
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
}
403✔
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
/// In practice, this is handled gracefully: if argument evaluation fails, the original
81
/// text is preserved and passed to pyisheval.
82
///
83
/// # Arguments
84
/// * `expr` - Expression that may contain math function calls
85
/// * `interp` - Interpreter for evaluating function arguments
86
///
87
/// # Returns
88
/// Expression with math function calls replaced by their computed values
89
fn preprocess_math_functions(
354✔
90
    expr: &str,
354✔
91
    interp: &mut Interpreter,
354✔
92
) -> Result<String, EvalError> {
354✔
93
    let mut result = expr.to_string();
354✔
94

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

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

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

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

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

132
            let arg = &result[paren_pos + 1..close_pos];
51✔
133

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

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

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

174
        if !made_replacement {
51✔
175
            // No successful replacements possible, done processing
176
            break;
2✔
177
        }
49✔
178
    }
179

180
    Ok(result)
354✔
181
}
354✔
182

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

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

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

235
    interp
405✔
236
}
405✔
237

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

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

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

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

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

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

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

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

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

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

417
                return Ok((name.clone(), lambda_value));
9✔
418
            }
92✔
419

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

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

432
    Ok(context)
510✔
433
}
510✔
434

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

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

475
    interp.eval_with_context(&preprocessed, context).map(Some)
354✔
476
}
359✔
477

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

494
    // Tokenize the input text
495
    let lexer = Lexer::new(text);
163✔
496
    let mut result = Vec::new();
163✔
497

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

539
    Ok(result.join(""))
159✔
540
}
163✔
541

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

798
    // ===== NEW TESTS FOR eval_boolean =====
799

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1210
    // ===== Math Function Tests =====
1211

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10✔
1322
            // Test passes if no panic occurs - the unreachable!() would panic if function is missing
10✔
1323
            // Just verify we get some result (reaching this point proves no panic occurred)
10✔
1324
            let _ = result;
10✔
1325
        }
10✔
1326
    }
1✔
1327
}
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