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

kaidokert / xacro / 20873336116

10 Jan 2026 05:11AM UTC coverage: 87.885%. First build
20873336116

Pull #37

github

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

133 of 157 new or added lines in 1 file covered. (84.71%)

2285 of 2600 relevant lines covered (87.88%)

176.17 hits per line

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

88.15
/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 std::collections::HashMap;
5

6
/// Find matching closing parenthesis, handling nested parentheses
7
///
8
/// # Arguments
9
/// * `text` - String starting with '('
10
/// * `start` - Index of opening '('
11
///
12
/// # Returns
13
/// Index of matching ')', or None if not found
14
fn find_matching_paren(
27✔
15
    text: &str,
27✔
16
    start: usize,
27✔
17
) -> Option<usize> {
27✔
18
    let chars: Vec<char> = text.chars().collect();
27✔
19
    if start >= chars.len() || chars[start] != '(' {
27✔
NEW
20
        return None;
×
21
    }
27✔
22

23
    let mut depth = 0;
27✔
24
    for (i, &ch) in chars.iter().enumerate().skip(start) {
138✔
25
        match ch {
138✔
26
            '(' => depth += 1,
30✔
27
            ')' => {
28
                depth -= 1;
30✔
29
                if depth == 0 {
30✔
30
                    return Some(i);
27✔
31
                }
3✔
32
            }
33
            _ => {}
78✔
34
        }
35
    }
NEW
36
    None
×
37
}
27✔
38

39
/// Preprocess an expression to evaluate native math functions
40
///
41
/// pyisheval doesn't support native math functions like cos(), sin(), tan().
42
/// This function finds math function calls, evaluates them using Rust's f64 methods,
43
/// and substitutes the results back into the expression.
44
///
45
/// Supported functions: cos, sin, tan, acos, asin, atan, sqrt, abs, floor, ceil, radians (override)
46
///
47
/// # Arguments
48
/// * `expr` - Expression that may contain math function calls
49
/// * `interp` - Interpreter for evaluating function arguments
50
///
51
/// # Returns
52
/// Expression with math function calls replaced by their computed values
53
fn preprocess_math_functions(
326✔
54
    expr: &str,
326✔
55
    interp: &mut Interpreter,
326✔
56
) -> Result<String, EvalError> {
326✔
57
    // Math functions that take one argument
58
    // IMPORTANT: Sort by length (longest first) to match acos before cos, asin before sin, etc.
59
    let single_arg_funcs = [
326✔
60
        "acos", "asin", "atan", "floor", "ceil", "sqrt", "cos", "sin", "tan", "abs",
326✔
61
    ];
326✔
62

63
    let mut result = expr.to_string();
326✔
64

65
    // Keep replacing until no more matches (handle nested calls from inside out)
66
    let mut iteration = 0;
326✔
67
    const MAX_ITERATIONS: usize = 100;
68

69
    loop {
70
        iteration += 1;
353✔
71
        if iteration > MAX_ITERATIONS {
353✔
NEW
72
            return Err(EvalError::PyishEval {
×
NEW
73
                expr: expr.to_string(),
×
NEW
74
                source: pyisheval::EvalError::ParseError(
×
NEW
75
                    "Too many nested math function calls (possible infinite loop)".to_string(),
×
NEW
76
                ),
×
NEW
77
            });
×
78
        }
353✔
79

80
        let mut found_match = false;
353✔
81
        let mut new_result = result.clone();
353✔
82

83
        // Try each function type
84
        for func_name in &single_arg_funcs {
3,761✔
85
            // Find function_name( patterns
86
            let mut search_pos = 0;
3,435✔
87
            while let Some(pos) = result[search_pos..].find(func_name) {
3,435✔
88
                let abs_pos = search_pos + pos;
27✔
89
                let after_name = abs_pos + func_name.len();
27✔
90

91
                // Check word boundary before function name (not alphanumeric or underscore)
92
                let is_word_start = abs_pos == 0 || {
27✔
93
                    let prev_char = result.as_bytes()[abs_pos - 1];
4✔
94
                    !prev_char.is_ascii_alphanumeric() && prev_char != b'_'
4✔
95
                };
96

97
                // Check if followed by '(' and is at word boundary
98
                if is_word_start
27✔
99
                    && after_name < result.len()
27✔
100
                    && result.as_bytes()[after_name] == b'('
27✔
101
                {
102
                    // Find matching closing parenthesis
103
                    if let Some(close_pos) = find_matching_paren(&result, after_name) {
27✔
104
                        found_match = true;
27✔
105

106
                        // Extract argument (without parentheses)
107
                        let arg = &result[after_name + 1..close_pos];
27✔
108

109
                        // Evaluate argument
110
                        let replacement = match interp.eval(arg) {
27✔
111
                            Ok(Value::Number(n)) => {
27✔
112
                                // Call the appropriate Rust math function
113
                                let computed = match *func_name {
27✔
114
                                    "cos" => n.cos(),
27✔
115
                                    "sin" => n.sin(),
20✔
116
                                    "tan" => n.tan(),
19✔
117
                                    "acos" => n.acos(),
18✔
118
                                    "asin" => n.asin(),
16✔
119
                                    "atan" => n.atan(),
15✔
120
                                    "sqrt" => n.sqrt(),
14✔
121
                                    "abs" => n.abs(),
10✔
122
                                    "floor" => n.floor(),
4✔
123
                                    "ceil" => n.ceil(),
2✔
NEW
124
                                    _ => n, // Shouldn't happen
×
125
                                };
126

127
                                // Format result
128
                                format!("{}", computed)
27✔
129
                            }
NEW
130
                            Ok(other) => {
×
NEW
131
                                log::warn!(
×
NEW
132
                                    "Math function '{}' expected number, got: {:?}",
×
133
                                    func_name,
134
                                    other
135
                                );
136
                                // Keep original
NEW
137
                                result[abs_pos..=close_pos].to_string()
×
138
                            }
NEW
139
                            Err(e) => {
×
NEW
140
                                log::warn!(
×
NEW
141
                                    "Failed to evaluate argument for '{}({})'': {}",
×
142
                                    func_name,
143
                                    arg,
144
                                    e
145
                                );
146
                                // Keep original
NEW
147
                                result[abs_pos..=close_pos].to_string()
×
148
                            }
149
                        };
150

151
                        // Build new result with replacement
152
                        new_result = format!(
27✔
153
                            "{}{}{}",
27✔
154
                            &result[..abs_pos],
27✔
155
                            replacement,
156
                            &result[close_pos + 1..]
27✔
157
                        );
158

159
                        // Start over from the beginning with the new result
160
                        break;
27✔
NEW
161
                    }
×
NEW
162
                }
×
163

NEW
164
                search_pos = after_name;
×
165
            }
166

167
            if found_match {
3,435✔
168
                result = new_result;
27✔
169
                break;
27✔
170
            }
3,408✔
171
        }
172

173
        if !found_match {
353✔
174
            break;
326✔
175
        }
27✔
176
    }
177

178
    Ok(result)
326✔
179
}
326✔
180

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

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

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

233
    interp
385✔
234
}
385✔
235

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

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

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

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

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

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

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

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

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

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

415
                return Ok((name.clone(), lambda_value));
9✔
416
            }
92✔
417

418
            // Default: store as string literal
419
            Ok((name.clone(), Value::StringLit(value.clone())))
92✔
420
        })
357✔
421
        .collect::<Result<HashMap<_, _>, _>>()?;
482✔
422

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

430
    Ok(context)
482✔
431
}
482✔
432

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

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

473
    interp.eval_with_context(&preprocessed, context).map(Some)
326✔
474
}
331✔
475

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

492
    // Tokenize the input text
493
    let lexer = Lexer::new(text);
143✔
494
    let mut result = Vec::new();
143✔
495

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

537
    Ok(result.join(""))
141✔
538
}
143✔
539

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

796
    // ===== NEW TESTS FOR eval_boolean =====
797

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1208
    // ===== Math Function Tests =====
1209

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1302
        // sqrt(x^2 + y^2) = sqrt(9 + 16) = sqrt(25) = 5
1303
        let result = eval_text("${sqrt(x**2 + y**2)}", &props).unwrap();
1✔
1304
        assert_eq!(result, "5");
1✔
1305
    }
1✔
1306
}
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