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

kaidokert / xacro / 20874241031

10 Jan 2026 06:28AM UTC coverage: 88.111%. First build
20874241031

Pull #37

github

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

138 of 156 new or added lines in 1 file covered. (88.46%)

2290 of 2599 relevant lines covered (88.11%)

174.59 hits per line

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

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

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

44
/// Regex pattern for matching math function calls with word boundaries
45
static MATH_FUNCS_REGEX: OnceLock<Regex> = OnceLock::new();
46

47
/// Get the math functions regex, initializing it on first access
48
///
49
/// Matches function names at word boundaries followed by optional whitespace and '('.
50
fn get_math_funcs_regex() -> &'static Regex {
395✔
51
    MATH_FUNCS_REGEX.get_or_init(|| {
395✔
52
        // Order by length (longest first) for defensive regex alternation
53
        let funcs = [
12✔
54
            "floor", "acos", "asin", "atan", "ceil", "sqrt", "cos", "sin", "tan", "abs",
12✔
55
        ];
12✔
56
        // Use \b for word boundaries, capture function name, allow optional whitespace before '('
57
        let pattern = format!(r"\b({})\s*\(", funcs.join("|"));
12✔
58
        Regex::new(&pattern).expect("Math functions regex should be valid")
12✔
59
    })
12✔
60
}
395✔
61

62
/// Preprocess an expression to evaluate native math functions
63
///
64
/// pyisheval doesn't support native math functions like cos(), sin(), tan().
65
/// This function finds math function calls, evaluates them using Rust's f64 methods,
66
/// and substitutes the results back into the expression.
67
///
68
/// Supported functions: cos, sin, tan, acos, asin, atan, sqrt, abs, floor, ceil
69
///
70
/// # Limitations
71
/// Does not distinguish function calls inside string literals (e.g., `"cos(0)"`).
72
/// In practice, this is handled gracefully: if argument evaluation fails, the original
73
/// text is preserved and passed to pyisheval.
74
///
75
/// # Arguments
76
/// * `expr` - Expression that may contain math function calls
77
/// * `interp` - Interpreter for evaluating function arguments
78
///
79
/// # Returns
80
/// Expression with math function calls replaced by their computed values
81
fn preprocess_math_functions(
348✔
82
    expr: &str,
348✔
83
    interp: &mut Interpreter,
348✔
84
) -> Result<String, EvalError> {
348✔
85
    let mut result = expr.to_string();
348✔
86

87
    // Keep replacing until no more matches (handle nested calls from inside out)
88
    let mut iteration = 0;
348✔
89
    const MAX_ITERATIONS: usize = 100;
90

91
    loop {
92
        iteration += 1;
395✔
93
        if iteration > MAX_ITERATIONS {
395✔
NEW
94
            return Err(EvalError::PyishEval {
×
NEW
95
                expr: expr.to_string(),
×
NEW
96
                source: pyisheval::EvalError::ParseError(
×
NEW
97
                    "Too many nested math function calls (possible infinite loop)".to_string(),
×
NEW
98
                ),
×
NEW
99
            });
×
100
        }
395✔
101

102
        // Collect all function matches to iterate right-to-left (innermost first)
103
        let captures: Vec<_> = get_math_funcs_regex().captures_iter(&result).collect();
395✔
104
        if captures.is_empty() {
395✔
105
            break;
348✔
106
        }
47✔
107

108
        let mut made_replacement = false;
47✔
109
        // Iterate from right to left to find the innermost, evaluatable function call
110
        for caps in captures.iter().rev() {
47✔
111
            // Safe extraction of capture groups (should always succeed due to regex structure)
112
            let (whole_match, func_name) = match (caps.get(0), caps.get(1)) {
47✔
113
                (Some(m), Some(f)) => (m, f.as_str()),
47✔
NEW
114
                _ => continue, // Skip malformed captures
×
115
            };
116
            let paren_pos = whole_match.end() - 1; // Position of '(' after optional whitespace
47✔
117

118
            // Find matching closing parenthesis
119
            let close_pos = match find_matching_paren(&result, paren_pos) {
47✔
120
                Some(pos) => pos,
47✔
NEW
121
                None => continue, // Skip if no matching paren
×
122
            };
123

124
            let arg = &result[paren_pos + 1..close_pos];
47✔
125

126
            // Try to evaluate the argument - only replace if successful
127
            if let Ok(Value::Number(n)) = interp.eval(arg) {
47✔
128
                // Call the appropriate Rust math function
129
                let computed = match func_name {
47✔
130
                    "cos" => n.cos(),
47✔
131
                    "sin" => n.sin(),
35✔
132
                    "tan" => n.tan(),
32✔
133
                    "acos" => n.acos(),
30✔
134
                    "asin" => n.asin(),
27✔
135
                    "atan" => n.atan(),
25✔
136
                    "sqrt" => n.sqrt(),
23✔
137
                    "abs" => n.abs(),
17✔
138
                    "floor" => n.floor(),
9✔
139
                    "ceil" => n.ceil(),
4✔
NEW
140
                    _ => unreachable!(
×
141
                        "Function '{}' matched regex but not in match statement",
142
                        func_name
143
                    ),
144
                };
145

146
                let replacement = format!("{}", computed);
47✔
147
                result.replace_range(whole_match.start()..=close_pos, &replacement);
47✔
148
                made_replacement = true;
47✔
149
                // A replacement was made, restart loop to rescan the string
150
                break;
47✔
NEW
151
            }
×
152
            // If eval fails or returns non-number, continue to next match
153
        }
154

155
        if !made_replacement {
47✔
156
            // No successful replacements possible, done processing
NEW
157
            break;
×
158
        }
47✔
159
    }
160

161
    Ok(result)
348✔
162
}
348✔
163

164
/// Initialize an Interpreter with builtin constants and math functions
165
///
166
/// This ensures all interpreters have access to:
167
/// - Math constants: pi, e, tau, M_PI
168
/// - Math functions: radians(), degrees()
169
///
170
/// Note: inf and nan are NOT initialized here - they are injected directly into
171
/// the context HashMap in build_pyisheval_context() to bypass parsing issues.
172
///
173
/// Note: Native math functions (cos, sin, tan, etc.) are handled via preprocessing
174
/// in evaluate_expression() rather than as lambda functions, because pyisheval
175
/// cannot call native Rust functions.
176
///
177
/// # Returns
178
/// A fully initialized Interpreter ready for expression evaluation
179
pub fn init_interpreter() -> Interpreter {
401✔
180
    let mut interp = Interpreter::new();
401✔
181

182
    // Initialize math constants in the interpreter
183
    // These are loaded directly into the interpreter's environment for use in expressions
184
    // Note: inf and nan are NOT in BUILTIN_CONSTANTS (pyisheval can't parse them as literals)
185
    // They are injected directly into the context map in build_pyisheval_context()
186
    for (name, value) in BUILTIN_CONSTANTS {
2,005✔
187
        if let Err(e) = interp.eval(&format!("{} = {}", name, value)) {
1,604✔
188
            log::warn!(
×
189
                "Could not initialize built-in constant '{}': {}. \
×
190
                 This constant will not be available in expressions.",
×
191
                name,
192
                e
193
            );
194
        }
1,604✔
195
    }
196

197
    // Add math conversion functions as lambda expressions directly in the interpreter
198
    // This makes them available as callable functions in all expressions
199
    if let Err(e) = interp.eval("radians = lambda x: x * pi / 180") {
401✔
200
        log::warn!(
×
201
            "Could not define built-in function 'radians': {}. \
×
202
             This function will not be available in expressions. \
×
203
             (May be due to missing 'pi' constant)",
×
204
            e
205
        );
206
    }
401✔
207
    if let Err(e) = interp.eval("degrees = lambda x: x * 180 / pi") {
401✔
208
        log::warn!(
×
209
            "Could not define built-in function 'degrees': {}. \
×
210
             This function will not be available in expressions. \
×
211
             (May be due to missing 'pi' constant)",
×
212
            e
213
        );
214
    }
401✔
215

216
    interp
401✔
217
}
401✔
218

219
#[derive(Debug, thiserror::Error)]
220
pub enum EvalError {
221
    #[error("Failed to evaluate expression '{expr}': {source}")]
222
    PyishEval {
223
        expr: String,
224
        #[source]
225
        source: pyisheval::EvalError,
226
    },
227

228
    #[error("Xacro conditional \"{condition}\" evaluated to \"{evaluated}\", which is not a boolean expression.")]
229
    InvalidBoolean {
230
        condition: String,
231
        evaluated: String,
232
    },
233
}
234

235
/// Format a pyisheval Value to match Python xacro's output format
236
///
237
/// Python xacro uses Python's int/float distinction for formatting:
238
/// - Integer arithmetic (2+3) produces int → formats as "5" (no decimal)
239
/// - Float arithmetic (2.5*2) produces float → formats as "5.0" (with decimal)
240
///
241
/// Since pyisheval only has f64 (no int type), we approximate this by checking
242
/// if the f64 value is mathematically a whole number using fract() == 0.0.
243
///
244
/// # Arguments
245
/// * `value` - The pyisheval Value to format
246
/// * `force_float` - Whether to keep .0 for whole numbers (true for float context)
247
pub fn format_value_python_style(
349✔
248
    value: &Value,
349✔
249
    force_float: bool,
349✔
250
) -> String {
349✔
251
    match value {
272✔
252
        Value::Number(n) if n.is_finite() => {
272✔
253
            // Python's str() for floats switches to scientific notation at 1e16
254
            const PYTHON_SCIENTIFIC_THRESHOLD: f64 = 1e16;
255

256
            if n.fract() == 0.0 && n.abs() < PYTHON_SCIENTIFIC_THRESHOLD {
246✔
257
                // Whole number
258
                if force_float {
185✔
259
                    // Float context: keep .0 for whole numbers
260
                    format!("{:.1}", n) // "1.0" not "1"
18✔
261
                } else {
262
                    // Int context: strip .0
263
                    format!("{:.0}", n) // "1" not "1.0"
167✔
264
                }
265
            } else {
266
                // Has fractional part or is a large number: use default formatting
267
                n.to_string()
61✔
268
            }
269
        }
270
        _ => value.to_string(),
103✔
271
    }
272
}
349✔
273

274
/// Remove quotes from string values (handles both single and double quotes)
275
pub fn remove_quotes(s: &str) -> &str {
338✔
276
    // pyisheval's StringLit to_string() returns strings with single quotes
277
    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
338✔
278
        if s.len() >= 2 {
×
279
            &s[1..s.len() - 1]
×
280
        } else {
281
            s
×
282
        }
283
    } else {
284
        s
338✔
285
    }
286
}
338✔
287

288
/// Evaluate text containing ${...} expressions
289
///
290
/// Examples:
291
///   "hello ${name}" with {name: "world"} → "hello world"
292
///   "${2 + 3}" → "5"
293
///   "${width * 2}" with {width: "0.5"} → "1"
294
pub fn eval_text(
58✔
295
    text: &str,
58✔
296
    properties: &HashMap<String, String>,
58✔
297
) -> Result<String, EvalError> {
58✔
298
    let mut interp = init_interpreter();
58✔
299
    eval_text_with_interpreter(text, properties, &mut interp)
58✔
300
}
58✔
301

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

372
    // Second pass: Build the actual context, evaluating lambdas
373
    let mut context: HashMap<String, Value> = properties
504✔
374
        .iter()
504✔
375
        .map(|(name, value)| -> Result<(String, Value), EvalError> {
504✔
376
            // Try to parse as number first
377
            if let Ok(num) = value.parse::<f64>() {
363✔
378
                return Ok((name.clone(), Value::Number(num)));
262✔
379
            }
101✔
380

381
            // Check if it's a lambda expression
382
            let trimmed = value.trim();
101✔
383
            if trimmed.starts_with("lambda ") {
101✔
384
                // Evaluate and assign the lambda expression to the variable name
385
                // The interpreter now has all constants and properties loaded from first pass
386
                let assignment = format!("{} = {}", name, trimmed);
9✔
387
                interp.eval(&assignment).map_err(|e| EvalError::PyishEval {
9✔
388
                    expr: assignment.clone(),
×
389
                    source: e,
×
390
                })?;
×
391

392
                // Retrieve the lambda value to store in context
393
                let lambda_value = interp.eval(name).map_err(|e| EvalError::PyishEval {
9✔
394
                    expr: name.clone(),
×
395
                    source: e,
×
396
                })?;
×
397

398
                return Ok((name.clone(), lambda_value));
9✔
399
            }
92✔
400

401
            // Default: store as string literal
402
            Ok((name.clone(), Value::StringLit(value.clone())))
92✔
403
        })
363✔
404
        .collect::<Result<HashMap<_, _>, _>>()?;
504✔
405

406
    // Manually inject inf and nan constants (Strategy 3: bypass parsing)
407
    // Python xacro provides these via float('inf') and math.inf, but they're also
408
    // used as bare identifiers in expressions. Pyisheval cannot parse these as
409
    // literals, so we inject them directly into the context.
410
    context.insert("inf".to_string(), Value::Number(f64::INFINITY));
504✔
411
    context.insert("nan".to_string(), Value::Number(f64::NAN));
504✔
412

413
    Ok(context)
504✔
414
}
504✔
415

416
/// Evaluate a single expression string, handling Xacro-specific special cases
417
///
418
/// This centralizes handling of special functions like xacro.print_location()
419
/// that don't fit into the generic pyisheval evaluation model.
420
///
421
/// # Arguments
422
/// * `interp` - The pyisheval interpreter to use
423
/// * `expr` - The expression string to evaluate
424
/// * `context` - The variable context for evaluation
425
///
426
/// # Returns
427
/// * `Ok(Some(value))` - Normal expression evaluated successfully
428
/// * `Ok(None)` - Special case that produces no output (e.g., xacro.print_location())
429
/// * `Err(e)` - Evaluation error
430
///
431
/// # Special Cases
432
/// * `xacro.print_location()` - Debug function that prints stack trace to stderr in Python.
433
///   We stub it out by returning None (no output). This is handled as a special case because:
434
///   1. pyisheval doesn't support object.method syntax
435
///   2. This is a debug-only function with no production use
436
///   3. We're not implementing the full debug functionality
437
pub fn evaluate_expression(
353✔
438
    interp: &mut Interpreter,
353✔
439
    expr: &str,
353✔
440
    context: &HashMap<String, Value>,
353✔
441
) -> Result<Option<Value>, pyisheval::EvalError> {
353✔
442
    let trimmed_expr = expr.trim();
353✔
443
    if trimmed_expr == "xacro.print_location()" {
353✔
444
        // Special case: stub debug function returns no output
445
        return Ok(None);
5✔
446
    }
348✔
447

448
    // Preprocess math functions (cos, sin, tan, etc.) before evaluation
449
    // This converts native math calls into computed values since pyisheval
450
    // doesn't support calling native Rust functions
451
    let preprocessed = preprocess_math_functions(expr, interp).map_err(|e| match e {
348✔
NEW
452
        EvalError::PyishEval { source, .. } => source,
×
NEW
453
        _ => pyisheval::EvalError::ParseError(e.to_string()),
×
NEW
454
    })?;
×
455

456
    interp.eval_with_context(&preprocessed, context).map(Some)
348✔
457
}
353✔
458

459
/// Evaluate text containing ${...} expressions using a provided interpreter
460
///
461
/// This version allows reusing an Interpreter instance for better performance
462
/// when processing multiple text blocks with the same properties context.
463
///
464
/// Takes a mutable reference to ensure lambdas are created in the same
465
/// interpreter context where they'll be evaluated.
466
pub fn eval_text_with_interpreter(
159✔
467
    text: &str,
159✔
468
    properties: &HashMap<String, String>,
159✔
469
    interp: &mut Interpreter,
159✔
470
) -> Result<String, EvalError> {
159✔
471
    // Build context for pyisheval (may fail if lambdas have errors)
472
    // This loads properties into the interpreter and evaluates lambda expressions
473
    let context = build_pyisheval_context(properties, interp)?;
159✔
474

475
    // Tokenize the input text
476
    let lexer = Lexer::new(text);
159✔
477
    let mut result = Vec::new();
159✔
478

479
    // Process each token
480
    for (token_type, token_value) in lexer {
338✔
481
        match token_type {
181✔
482
            TokenType::Text => {
66✔
483
                // Plain text, keep as-is
66✔
484
                result.push(token_value);
66✔
485
            }
66✔
486
            TokenType::Expr => {
487
                // Evaluate expression using centralized helper
488
                match evaluate_expression(interp, &token_value, &context) {
112✔
489
                    Ok(Some(value)) => {
107✔
490
                        #[cfg(feature = "compat")]
107✔
491
                        let value_str = format_value_python_style(&value, false);
107✔
492
                        #[cfg(not(feature = "compat"))]
107✔
493
                        let value_str = format_value_python_style(&value, true);
107✔
494
                        result.push(remove_quotes(&value_str).to_string());
107✔
495
                    }
107✔
496
                    Ok(None) => {
497
                        // Special case returned no output (e.g., xacro.print_location())
498
                        continue;
3✔
499
                    }
500
                    Err(e) => {
2✔
501
                        return Err(EvalError::PyishEval {
2✔
502
                            expr: token_value.clone(),
2✔
503
                            source: e,
2✔
504
                        });
2✔
505
                    }
506
                }
507
            }
508
            TokenType::Extension => {
×
509
                // $(extension) - handle later (Phase 6)
×
510
                // For now, just keep the original text
×
511
                result.push(format!("$({})", token_value));
×
512
            }
×
513
            TokenType::DollarDollarBrace => {
3✔
514
                // $$ escape - output $ followed by the delimiter ({ or ()
3✔
515
                result.push(format!("${}", token_value));
3✔
516
            }
3✔
517
        }
518
    }
519

520
    Ok(result.join(""))
157✔
521
}
159✔
522

523
/// Apply Python xacro's STRICT string truthiness rules
524
///
525
/// Accepts: "true", "True", "false", "False", or parseable integers
526
/// Rejects: Everything else (including "nonsense", empty string, floats as strings)
527
fn apply_string_truthiness(
50✔
528
    s: &str,
50✔
529
    original: &str,
50✔
530
) -> Result<bool, EvalError> {
50✔
531
    let trimmed = s.trim();
50✔
532

533
    // Exact string matches for boolean literals
534
    if trimmed == "true" || trimmed == "True" {
50✔
535
        return Ok(true);
18✔
536
    }
32✔
537
    if trimmed == "false" || trimmed == "False" {
32✔
538
        return Ok(false);
11✔
539
    }
21✔
540

541
    // Try integer conversion (Python's bool(int(value)))
542
    if let Ok(i) = trimmed.parse::<i64>() {
21✔
543
        return Ok(i != 0);
14✔
544
    }
7✔
545

546
    // Try float conversion (for values like "1.0")
547
    if let Ok(f) = trimmed.parse::<f64>() {
7✔
548
        return Ok(f != 0.0);
×
549
    }
7✔
550

551
    // Everything else is an error (STRICT mode)
552
    Err(EvalError::InvalidBoolean {
7✔
553
        condition: original.to_string(),
7✔
554
        evaluated: s.to_string(),
7✔
555
    })
7✔
556
}
50✔
557

558
/// Evaluate expression as boolean following Python xacro's STRICT rules
559
///
560
/// Python xacro's get_boolean_value() logic (ref/xacro/src/xacro/__init__.py:856):
561
/// - Accepts: "true", "True", "false", "False"
562
/// - Accepts: Any string convertible to int: "1", "0", "42", "-5"
563
/// - REJECTS: "nonsense", empty string, anything else → Error
564
///
565
/// CRITICAL: This preserves type information from pyisheval!
566
/// ${3*0.1} evaluates to float 0.3 (truthy), NOT string "0.3" (would error)
567
///
568
/// Examples:
569
///   eval_boolean("true", &props) → Ok(true)
570
///   eval_boolean("${3*0.1}", &props) → Ok(true)  // Float 0.3 != 0.0
571
///   eval_boolean("${0}", &props) → Ok(false)     // Integer 0
572
///   eval_boolean("nonsense", &props) → Err(InvalidBoolean)
573
pub fn eval_boolean(
108✔
574
    text: &str,
108✔
575
    properties: &HashMap<String, String>,
108✔
576
) -> Result<bool, EvalError> {
108✔
577
    let mut interp = init_interpreter();
108✔
578

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

582
    // Tokenize input to detect structure
583
    let lexer = Lexer::new(text);
108✔
584
    let tokens: Vec<_> = lexer.collect();
108✔
585

586
    // CASE 1: Single ${expr} token → Preserve type, apply truthiness on Value
587
    // This is CRITICAL for float truthiness: ${3*0.1} → float 0.3 → true
588
    if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
108✔
589
        let value = interp
62✔
590
            .eval_with_context(&tokens[0].1, &context)
62✔
591
            .map_err(|e| EvalError::PyishEval {
62✔
592
                expr: text.to_string(),
×
593
                source: e,
×
594
            })?;
×
595

596
        // Apply Python truthiness based on Value type
597
        return match value {
62✔
598
            Value::Number(n) => Ok(n != 0.0), // Float/int truthiness (includes bools: True=1.0, False=0.0)
58✔
599
            Value::StringLit(s) => {
4✔
600
                // String: must be "true"/"false" or parseable as int
601
                apply_string_truthiness(&s, text)
4✔
602
            }
603
            // Other types (Lambda, List, etc.) - error for now
604
            _ => Err(EvalError::InvalidBoolean {
×
605
                condition: text.to_string(),
×
606
                evaluated: format!("{:?}", value),
×
607
            }),
×
608
        };
609
    }
46✔
610

611
    // CASE 2: Multiple tokens or plain text → Evaluate to string, then parse
612
    // Example: "text ${expr} more" or just "true"
613
    let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
46✔
614
    apply_string_truthiness(&evaluated, text)
46✔
615
}
108✔
616

617
#[cfg(test)]
618
mod tests {
619
    use super::*;
620

621
    // TEST 1: Backward compatibility - simple property substitution
622
    #[test]
623
    fn test_simple_property_substitution() {
1✔
624
        let mut props = HashMap::new();
1✔
625
        props.insert("width".to_string(), "0.5".to_string());
1✔
626

627
        let result = eval_text("${width}", &props).unwrap();
1✔
628
        assert_eq!(result, "0.5");
1✔
629
    }
1✔
630

631
    // TEST 2: Property in text
632
    #[test]
633
    fn test_property_in_text() {
1✔
634
        let mut props = HashMap::new();
1✔
635
        props.insert("width".to_string(), "0.5".to_string());
1✔
636

637
        let result = eval_text("The width is ${width} meters", &props).unwrap();
1✔
638
        assert_eq!(result, "The width is 0.5 meters");
1✔
639
    }
1✔
640

641
    // TEST 3: Multiple properties
642
    #[test]
643
    fn test_multiple_properties() {
1✔
644
        let mut props = HashMap::new();
1✔
645
        props.insert("width".to_string(), "0.5".to_string());
1✔
646
        props.insert("height".to_string(), "1.0".to_string());
1✔
647

648
        let result = eval_text("${width} x ${height}", &props).unwrap();
1✔
649
        // Whole numbers format without .0 (Python int behavior)
650
        assert_eq!(result, "0.5 x 1");
1✔
651
    }
1✔
652

653
    // TEST 4: NEW - Simple arithmetic
654
    #[test]
655
    fn test_arithmetic_expression() {
1✔
656
        let mut props = HashMap::new();
1✔
657
        props.insert("width".to_string(), "0.5".to_string());
1✔
658

659
        let result = eval_text("${width * 2}", &props).unwrap();
1✔
660
        assert_eq!(result, "1");
1✔
661
    }
1✔
662

663
    // TEST 5: NEW - Arithmetic without properties
664
    #[test]
665
    fn test_pure_arithmetic() {
1✔
666
        let props = HashMap::new();
1✔
667

668
        let result = eval_text("${2 + 3}", &props).unwrap();
1✔
669
        assert_eq!(result, "5");
1✔
670
    }
1✔
671

672
    // TEST 6: NEW - Complex expression
673
    #[test]
674
    fn test_complex_expression() {
1✔
675
        let mut props = HashMap::new();
1✔
676
        props.insert("width".to_string(), "0.5".to_string());
1✔
677
        props.insert("height".to_string(), "2.0".to_string());
1✔
678

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

683
    // TEST 7: NEW - String concatenation with literals
684
    // Note: pyisheval doesn't currently support string concatenation with +
685
    // This is documented as a known limitation. Use property substitution instead.
686
    #[test]
687
    #[ignore]
688
    fn test_string_concatenation() {
×
689
        let props = HashMap::new();
×
690

691
        // String concatenation with string literals (quoted in expression)
692
        let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
×
693
        assert_eq!(result, "link_base");
×
694
    }
×
695

696
    // TEST 8: NEW - Built-in functions
697
    #[test]
698
    fn test_builtin_functions() {
1✔
699
        let props = HashMap::new();
1✔
700

701
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
702
        assert_eq!(result, "5");
1✔
703

704
        let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
1✔
705
        assert_eq!(result, "5");
1✔
706
    }
1✔
707

708
    // TEST 9: NEW - Conditional expressions
709
    #[test]
710
    fn test_conditional_expression() {
1✔
711
        let mut props = HashMap::new();
1✔
712
        props.insert("width".to_string(), "0.5".to_string());
1✔
713

714
        let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
1✔
715
        assert_eq!(result, "0.5");
1✔
716
    }
1✔
717

718
    // TEST 10: Text without expressions (pass through)
719
    #[test]
720
    fn test_no_expressions() {
1✔
721
        let props = HashMap::new();
1✔
722
        let result = eval_text("hello world", &props).unwrap();
1✔
723
        assert_eq!(result, "hello world");
1✔
724
    }
1✔
725

726
    // TEST 11: Empty string
727
    #[test]
728
    fn test_empty_string() {
1✔
729
        let props = HashMap::new();
1✔
730
        let result = eval_text("", &props).unwrap();
1✔
731
        assert_eq!(result, "");
1✔
732
    }
1✔
733

734
    // TEST 12: Error case - undefined property
735
    #[test]
736
    fn test_undefined_property() {
1✔
737
        let props = HashMap::new();
1✔
738
        let result = eval_text("${undefined}", &props);
1✔
739
        assert!(result.is_err());
1✔
740
    }
1✔
741

742
    // TEST 13: String property substitution (non-numeric values)
743
    #[test]
744
    fn test_string_property() {
1✔
745
        let mut props = HashMap::new();
1✔
746
        props.insert("link_name".to_string(), "base_link".to_string());
1✔
747
        props.insert("joint_type".to_string(), "revolute".to_string());
1✔
748

749
        // Test single property
750
        let result = eval_text("${link_name}", &props).unwrap();
1✔
751
        assert_eq!(result, "base_link");
1✔
752

753
        // Test property in text
754
        let result = eval_text("name_${link_name}_suffix", &props).unwrap();
1✔
755
        assert_eq!(result, "name_base_link_suffix");
1✔
756

757
        // Test multiple string properties
758
        let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
1✔
759
        assert_eq!(result, "base_link revolute");
1✔
760
    }
1✔
761

762
    #[test]
763
    fn test_double_dollar_escape() {
1✔
764
        let props = HashMap::new();
1✔
765

766
        // Test $$ escape with brace - should produce literal ${
767
        let result = eval_text("$${expr}", &props).unwrap();
1✔
768
        assert_eq!(result, "${expr}");
1✔
769

770
        // Test $$ escape with paren - should produce literal $(
771
        let result = eval_text("$$(command)", &props).unwrap();
1✔
772
        assert_eq!(result, "$(command)");
1✔
773

774
        // Test $$ escape in context
775
        let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
1✔
776
        assert_eq!(result, "prefix_${literal}_suffix");
1✔
777
    }
1✔
778

779
    // ===== NEW TESTS FOR eval_boolean =====
780

781
    // Test from Python xacro: test_boolean_if_statement (line 715)
782
    #[test]
783
    fn test_eval_boolean_literals() {
1✔
784
        let props = HashMap::new();
1✔
785

786
        // Boolean string literals
787
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
788
        assert_eq!(eval_boolean("false", &props).unwrap(), false);
1✔
789
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
790
        assert_eq!(eval_boolean("False", &props).unwrap(), false);
1✔
791
    }
1✔
792

793
    // Test from Python xacro: test_integer_if_statement (line 735)
794
    #[test]
795
    fn test_eval_boolean_integer_truthiness() {
1✔
796
        let props = HashMap::new();
1✔
797

798
        // Integer literals as strings
799
        assert_eq!(eval_boolean("0", &props).unwrap(), false);
1✔
800
        assert_eq!(eval_boolean("1", &props).unwrap(), true);
1✔
801
        assert_eq!(eval_boolean("42", &props).unwrap(), true);
1✔
802
        assert_eq!(eval_boolean("-5", &props).unwrap(), true);
1✔
803

804
        // Integer expressions
805
        assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); // 0
1✔
806
        assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
1✔
807
        assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); // 5
1✔
808
    }
1✔
809

810
    // Test from Python xacro: test_float_if_statement (line 755)
811
    #[test]
812
    fn test_eval_boolean_float_truthiness() {
1✔
813
        let props = HashMap::new();
1✔
814

815
        // CRITICAL: Float expressions must preserve type
816
        assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); // 0.0
1✔
817
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); // 0.3 (non-zero float)
1✔
818
        assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
1✔
819
        assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
1✔
820
    }
1✔
821

822
    // Test from Python xacro: test_property_if_statement (line 769)
823
    #[test]
824
    fn test_eval_boolean_with_properties() {
1✔
825
        let mut props = HashMap::new();
1✔
826
        props.insert("condT".to_string(), "1".to_string()); // True as number
1✔
827
        props.insert("condF".to_string(), "0".to_string()); // False as number
1✔
828
        props.insert("num".to_string(), "5".to_string());
1✔
829

830
        assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
1✔
831
        assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
1✔
832
        assert_eq!(eval_boolean("${num}", &props).unwrap(), true); // 5 != 0
1✔
833

834
        // Note: pyisheval doesn't have True/False as built-in constants
835
        // They would need to be defined as properties with value 1/0
836
    }
1✔
837

838
    // Test from Python xacro: test_equality_expression_in_if_statement (line 788)
839
    #[test]
840
    fn test_eval_boolean_expressions() {
1✔
841
        let mut props = HashMap::new();
1✔
842
        props.insert("var".to_string(), "useit".to_string());
1✔
843

844
        // Equality
845
        assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
1✔
846
        assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
1✔
847

848
        // Comparison
849
        props.insert("x".to_string(), "5".to_string());
1✔
850
        assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
1✔
851
        assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
1✔
852

853
        // Note: pyisheval doesn't support 'in' operator for strings yet
854
        // That would require extending pyisheval or using a different evaluator
855
    }
1✔
856

857
    /// Test that pyisheval returns Value::Number for boolean expressions
858
    ///
859
    /// CRITICAL: This test documents that pyisheval v0.9.0 does NOT have Value::Bool.
860
    /// Boolean comparison expressions like ${1 == 1} return Value::Number(1.0), not Value::Bool(true).
861
    /// This is similar to Python where bool is a subclass of int (True == 1, False == 0).
862
    ///
863
    /// This test exists to:
864
    /// 1. Verify our Number-based truthiness handling works for comparisons
865
    /// 2. Document pyisheval's current behavior
866
    /// 3. Catch if pyisheval adds Value::Bool in future (this would fail, prompting us to update)
867
    #[test]
868
    fn test_eval_boolean_comparison_expressions() {
1✔
869
        let mut props = HashMap::new();
1✔
870
        props.insert("x".to_string(), "5".to_string());
1✔
871
        props.insert("y".to_string(), "10".to_string());
1✔
872

873
        // Equality comparisons
874
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
875
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
876
        assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
1✔
877
        assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
1✔
878

879
        // Inequality comparisons
880
        assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
1✔
881
        assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
1✔
882

883
        // Less than / greater than
884
        assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
1✔
885
        assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
1✔
886
        assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
1✔
887
        assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
1✔
888

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

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

897
    // Test from Python xacro: test_invalid_if_statement (line 729)
898
    #[test]
899
    fn test_eval_boolean_invalid_values() {
1✔
900
        let props = HashMap::new();
1✔
901

902
        // STRICT mode: "nonsense" should error
903
        let result = eval_boolean("nonsense", &props);
1✔
904
        assert!(result.is_err());
1✔
905
        assert!(result
1✔
906
            .unwrap_err()
1✔
907
            .to_string()
1✔
908
            .contains("not a boolean expression"));
1✔
909

910
        // Empty string should error
911
        let result = eval_boolean("", &props);
1✔
912
        assert!(result.is_err());
1✔
913

914
        // Random text should error
915
        let result = eval_boolean("random text", &props);
1✔
916
        assert!(result.is_err());
1✔
917
    }
1✔
918

919
    // Test edge case: whitespace handling
920
    #[test]
921
    fn test_eval_boolean_whitespace() {
1✔
922
        let props = HashMap::new();
1✔
923

924
        // Should trim whitespace
925
        assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
1✔
926
        assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
1✔
927
        assert_eq!(eval_boolean("  0  ", &props).unwrap(), false);
1✔
928
        assert_eq!(eval_boolean("  1  ", &props).unwrap(), true);
1✔
929
    }
1✔
930

931
    // Test case sensitivity
932
    #[test]
933
    fn test_eval_boolean_case_sensitivity() {
1✔
934
        let props = HashMap::new();
1✔
935

936
        // "true" and "True" are accepted
937
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
938
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
939

940
        // But not other cases (should error)
941
        assert!(eval_boolean("TRUE", &props).is_err());
1✔
942
        assert!(eval_boolean("tRuE", &props).is_err());
1✔
943
    }
1✔
944

945
    // Test evaluate_expression special case handling directly
946
    #[test]
947
    fn test_evaluate_expression_special_cases() {
1✔
948
        let mut interp = init_interpreter();
1✔
949
        let context = HashMap::new();
1✔
950

951
        // Test xacro.print_location() special case
952
        let result = evaluate_expression(&mut interp, "xacro.print_location()", &context).unwrap();
1✔
953
        assert!(
1✔
954
            result.is_none(),
1✔
955
            "xacro.print_location() should return None"
×
956
        );
957

958
        // Test with surrounding whitespace
959
        let result =
1✔
960
            evaluate_expression(&mut interp, "  xacro.print_location()  ", &context).unwrap();
1✔
961
        assert!(
1✔
962
            result.is_none(),
1✔
963
            "xacro.print_location() with whitespace should return None"
×
964
        );
965

966
        // Test a normal expression to ensure it's not affected
967
        let result = evaluate_expression(&mut interp, "1 + 1", &context).unwrap();
1✔
968
        assert!(
1✔
969
            matches!(result, Some(Value::Number(n)) if n == 2.0),
1✔
970
            "Normal expression should evaluate correctly"
×
971
        );
972
    }
1✔
973

974
    // Test xacro.print_location() stub function via integration
975
    #[test]
976
    fn test_xacro_print_location_stub() {
1✔
977
        let props = HashMap::new();
1✔
978

979
        // xacro.print_location() should return empty string
980
        let result = eval_text("${xacro.print_location()}", &props).unwrap();
1✔
981
        assert_eq!(result, "");
1✔
982

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

987
        // With whitespace in expression
988
        let result = eval_text("${ xacro.print_location() }", &props).unwrap();
1✔
989
        assert_eq!(result, "");
1✔
990
    }
1✔
991

992
    // Test that inf and nan are available via direct context injection
993
    #[test]
994
    fn test_inf_nan_direct_injection() {
1✔
995
        let props = HashMap::new();
1✔
996
        let mut interp = init_interpreter();
1✔
997

998
        // Build context with direct inf/nan injection
999
        let context = build_pyisheval_context(&props, &mut interp).unwrap();
1✔
1000

1001
        // Verify inf and nan are in the context
1002
        assert!(
1✔
1003
            context.contains_key("inf"),
1✔
1004
            "Context should contain 'inf' key"
×
1005
        );
1006
        assert!(
1✔
1007
            context.contains_key("nan"),
1✔
1008
            "Context should contain 'nan' key"
×
1009
        );
1010

1011
        // Test 1: inf should be positive infinity
1012
        if let Some(Value::Number(n)) = context.get("inf") {
1✔
1013
            assert!(
1✔
1014
                n.is_infinite() && n.is_sign_positive(),
1✔
1015
                "inf should be positive infinity, got: {}",
×
1016
                n
1017
            );
1018
        } else {
1019
            panic!("inf should be a Number value");
×
1020
        }
1021

1022
        // Test 2: nan should be NaN
1023
        if let Some(Value::Number(n)) = context.get("nan") {
1✔
1024
            assert!(n.is_nan(), "nan should be NaN, got: {}", n);
1✔
1025
        } else {
1026
            panic!("nan should be a Number value");
×
1027
        }
1028

1029
        // Test 3: inf should be usable in expressions
1030
        let result = interp.eval_with_context("inf * 2", &context);
1✔
1031
        assert!(
1✔
1032
            matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
1✔
1033
            "inf * 2 should return positive infinity, got: {:?}",
×
1034
            result
1035
        );
1036

1037
        // Test 4: nan should be usable in expressions
1038
        let result = interp.eval_with_context("nan + 1", &context);
1✔
1039
        assert!(
1✔
1040
            matches!(result, Ok(Value::Number(n)) if n.is_nan()),
1✔
1041
            "nan + 1 should return NaN, got: {:?}",
×
1042
            result
1043
        );
1044
    }
1✔
1045

1046
    // Test type preservation: the key feature!
1047
    #[test]
1048
    fn test_eval_boolean_type_preservation() {
1✔
1049
        let props = HashMap::new();
1✔
1050

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

1055
        // Multiple tokens: becomes string
1056
        // "result: ${3*0.1}" → "result: 0.3" → can't parse as int → error
1057
        let result = eval_boolean("result: ${3*0.1}", &props);
1✔
1058
        assert!(result.is_err());
1✔
1059
    }
1✔
1060

1061
    // Test Boolean value type from pyisheval
1062
    #[test]
1063
    fn test_eval_boolean_bool_values() {
1✔
1064
        let props = HashMap::new();
1✔
1065

1066
        // pyisheval returns Value::Bool directly
1067
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
1068
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
1069
        assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
1✔
1070
    }
1✔
1071

1072
    // Lambda expression tests
1073
    #[test]
1074
    fn test_basic_lambda_works() {
1✔
1075
        let mut props = HashMap::new();
1✔
1076
        props.insert("f".to_string(), "lambda x: x * 2".to_string());
1✔
1077
        assert_eq!(eval_text("${f(5)}", &props).unwrap(), "10");
1✔
1078
    }
1✔
1079

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

1084
    // Python-style number formatting tests
1085
    #[test]
1086
    fn test_format_value_python_style_whole_numbers() {
1✔
1087
        use pyisheval::Value;
1088

1089
        // Whole numbers format without .0 (Python int behavior)
1090
        assert_eq!(format_value_python_style(&Value::Number(0.0), false), "0");
1✔
1091
        assert_eq!(format_value_python_style(&Value::Number(1.0), false), "1");
1✔
1092
        assert_eq!(format_value_python_style(&Value::Number(2.0), false), "2");
1✔
1093
        assert_eq!(format_value_python_style(&Value::Number(-1.0), false), "-1");
1✔
1094
        assert_eq!(
1✔
1095
            format_value_python_style(&Value::Number(100.0), false),
1✔
1096
            "100"
1097
        );
1098
    }
1✔
1099

1100
    #[test]
1101
    fn test_format_value_python_style_fractional() {
1✔
1102
        use pyisheval::Value;
1103

1104
        // Fractional numbers use default formatting (no trailing zeros)
1105
        assert_eq!(format_value_python_style(&Value::Number(1.5), false), "1.5");
1✔
1106
        assert_eq!(format_value_python_style(&Value::Number(0.5), false), "0.5");
1✔
1107
        assert_eq!(
1✔
1108
            format_value_python_style(&Value::Number(0.4235294117647059), false),
1✔
1109
            "0.4235294117647059"
1110
        );
1111
    }
1✔
1112

1113
    #[test]
1114
    fn test_format_value_python_style_special() {
1✔
1115
        use pyisheval::Value;
1116

1117
        // Special values
1118
        assert_eq!(
1✔
1119
            format_value_python_style(&Value::Number(f64::INFINITY), false),
1✔
1120
            "inf"
1121
        );
1122
        assert_eq!(
1✔
1123
            format_value_python_style(&Value::Number(f64::NEG_INFINITY), false),
1✔
1124
            "-inf"
1125
        );
1126
        assert_eq!(
1✔
1127
            format_value_python_style(&Value::Number(f64::NAN), false),
1✔
1128
            "NaN"
1129
        );
1130
    }
1✔
1131

1132
    #[test]
1133
    fn test_eval_with_python_number_formatting() {
1✔
1134
        let mut props = HashMap::new();
1✔
1135
        props.insert("height".to_string(), "1.0".to_string());
1✔
1136

1137
        // Whole numbers format without .0 (mimics Python int behavior)
1138
        assert_eq!(eval_text("${height}", &props).unwrap(), "1");
1✔
1139
        assert_eq!(eval_text("${1.0 + 0.0}", &props).unwrap(), "1");
1✔
1140
        assert_eq!(eval_text("${2.0 * 1.0}", &props).unwrap(), "2");
1✔
1141
    }
1✔
1142

1143
    #[test]
1144
    fn test_lambda_referencing_property() {
1✔
1145
        let mut props = HashMap::new();
1✔
1146
        props.insert("offset".to_string(), "10".to_string());
1✔
1147
        props.insert("add_offset".to_string(), "lambda x: x + offset".to_string());
1✔
1148
        assert_eq!(eval_text("${add_offset(5)}", &props).unwrap(), "15");
1✔
1149
    }
1✔
1150

1151
    #[test]
1152
    fn test_lambda_referencing_multiple_properties() {
1✔
1153
        let mut props = HashMap::new();
1✔
1154
        props.insert("a".to_string(), "2".to_string());
1✔
1155
        props.insert("b".to_string(), "3".to_string());
1✔
1156
        props.insert("scale".to_string(), "lambda x: x * a + b".to_string());
1✔
1157
        assert_eq!(eval_text("${scale(5)}", &props).unwrap(), "13");
1✔
1158
    }
1✔
1159

1160
    #[test]
1161
    fn test_lambda_with_conditional() {
1✔
1162
        let mut props = HashMap::new();
1✔
1163
        props.insert(
1✔
1164
            "sign".to_string(),
1✔
1165
            "lambda x: 1 if x > 0 else -1".to_string(),
1✔
1166
        );
1167
        assert_eq!(eval_text("${sign(5)}", &props).unwrap(), "1");
1✔
1168
        assert_eq!(eval_text("${sign(-3)}", &props).unwrap(), "-1");
1✔
1169
    }
1✔
1170

1171
    #[test]
1172
    fn test_multiple_lambdas() {
1✔
1173
        let mut props = HashMap::new();
1✔
1174
        props.insert("double".to_string(), "lambda x: x * 2".to_string());
1✔
1175
        props.insert("triple".to_string(), "lambda x: x * 3".to_string());
1✔
1176
        assert_eq!(
1✔
1177
            eval_text("${double(5)} ${triple(5)}", &props).unwrap(),
1✔
1178
            "10 15"
1179
        );
1180
    }
1✔
1181

1182
    #[test]
1183
    fn test_lambda_referencing_inf_property() {
1✔
1184
        let mut props = HashMap::new();
1✔
1185
        props.insert("my_inf".to_string(), "inf".to_string());
1✔
1186
        props.insert("is_inf".to_string(), "lambda x: x == my_inf".to_string());
1✔
1187
        // inf == inf should be true (1)
1188
        assert_eq!(eval_text("${is_inf(inf)}", &props).unwrap(), "1");
1✔
1189
    }
1✔
1190

1191
    // ===== Math Function Tests =====
1192

1193
    #[test]
1194
    fn test_math_functions_cos_sin() {
1✔
1195
        let mut props = HashMap::new();
1✔
1196
        props.insert("pi".to_string(), "3.141592653589793".to_string());
1✔
1197

1198
        let result = eval_text("${cos(0)}", &props).unwrap();
1✔
1199
        assert_eq!(result, "1");
1✔
1200

1201
        let result = eval_text("${sin(0)}", &props).unwrap();
1✔
1202
        assert_eq!(result, "0");
1✔
1203

1204
        let result = eval_text("${cos(pi)}", &props).unwrap();
1✔
1205
        assert_eq!(result, "-1");
1✔
1206
    }
1✔
1207

1208
    #[test]
1209
    fn test_math_functions_nested() {
1✔
1210
        let mut props = HashMap::new();
1✔
1211
        props.insert("radius".to_string(), "0.5".to_string());
1✔
1212

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

1217
        let result = eval_text("${radius*cos(radians(60))}", &props).unwrap();
1✔
1218
        // cos(60°) = 0.5, so 0.5 * 0.5 = 0.25 (with floating point rounding)
1219
        let value: f64 = result.parse().unwrap();
1✔
1220
        assert!(
1✔
1221
            (value - 0.25).abs() < 1e-10,
1✔
NEW
1222
            "Expected ~0.25, got {}",
×
1223
            value
1224
        );
1225
    }
1✔
1226

1227
    #[test]
1228
    fn test_math_functions_sqrt_abs() {
1✔
1229
        let props = HashMap::new();
1✔
1230

1231
        let result = eval_text("${sqrt(16)}", &props).unwrap();
1✔
1232
        assert_eq!(result, "4");
1✔
1233

1234
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
1235
        assert_eq!(result, "5");
1✔
1236

1237
        let result = eval_text("${abs(5)}", &props).unwrap();
1✔
1238
        assert_eq!(result, "5");
1✔
1239
    }
1✔
1240

1241
    #[test]
1242
    fn test_math_functions_floor_ceil() {
1✔
1243
        let props = HashMap::new();
1✔
1244

1245
        let result = eval_text("${floor(3.7)}", &props).unwrap();
1✔
1246
        assert_eq!(result, "3");
1✔
1247

1248
        let result = eval_text("${ceil(3.2)}", &props).unwrap();
1✔
1249
        assert_eq!(result, "4");
1✔
1250

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

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

1258
    #[test]
1259
    fn test_math_functions_trig() {
1✔
1260
        let props = HashMap::new();
1✔
1261

1262
        // tan(0) = 0
1263
        let result = eval_text("${tan(0)}", &props).unwrap();
1✔
1264
        assert_eq!(result, "0");
1✔
1265

1266
        // asin(0) = 0
1267
        let result = eval_text("${asin(0)}", &props).unwrap();
1✔
1268
        assert_eq!(result, "0");
1✔
1269

1270
        // acos(1) = 0
1271
        let result = eval_text("${acos(1)}", &props).unwrap();
1✔
1272
        assert_eq!(result, "0");
1✔
1273

1274
        // atan(0) = 0
1275
        let result = eval_text("${atan(0)}", &props).unwrap();
1✔
1276
        assert_eq!(result, "0");
1✔
1277
    }
1✔
1278

1279
    #[test]
1280
    fn test_math_functions_multiple_in_expression() {
1✔
1281
        let mut props = HashMap::new();
1✔
1282
        props.insert("x".to_string(), "3".to_string());
1✔
1283
        props.insert("y".to_string(), "4".to_string());
1✔
1284

1285
        // sqrt(x^2 + y^2) = sqrt(9 + 16) = sqrt(25) = 5
1286
        let result = eval_text("${sqrt(x**2 + y**2)}", &props).unwrap();
1✔
1287
        assert_eq!(result, "5");
1✔
1288
    }
1✔
1289

1290
    /// Test to prevent divergence between regex pattern and match statement
1291
    ///
1292
    /// This ensures all functions in the regex have corresponding implementations,
1293
    /// catching bugs at test time rather than runtime.
1294
    #[test]
1295
    fn test_math_functions_regex_match_consistency() {
1✔
1296
        let props = HashMap::new();
1✔
1297

1298
        // List of all functions that should be in both regex and match statement
1299
        let expected_functions = [
1✔
1300
            "floor", "acos", "asin", "atan", "ceil", "sqrt", "cos", "sin", "tan", "abs",
1✔
1301
        ];
1✔
1302

1303
        // Test each function to ensure it's implemented
1304
        for func in &expected_functions {
11✔
1305
            let expr = format!("${{{}(0)}}", func);
10✔
1306
            let result = eval_text(&expr, &props);
10✔
1307

1308
            // Should either succeed or fail with a valid error, but NOT panic with unreachable!()
1309
            // If we get here without panic, the function is properly implemented
1310
            assert!(
10✔
1311
                result.is_ok() || result.is_err(),
10✔
NEW
1312
                "Function '{}' should have an implementation (got panic instead)",
×
1313
                func
1314
            );
1315
        }
1316
    }
1✔
1317
}
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