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

kaidokert / xacro / 20746387393

06 Jan 2026 11:05AM UTC coverage: 86.749%. First build
20746387393

Pull #25

github

web-flow
Merge 78347c1a0 into 16f367cd8
Pull Request #25: Add inf/nan support via direct context injection

33 of 42 new or added lines in 1 file covered. (78.57%)

1545 of 1781 relevant lines covered (86.75%)

176.82 hits per line

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

86.01
/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
/// Initialize an Interpreter with builtin constants and math functions
7
///
8
/// This ensures all interpreters have access to:
9
/// - Math constants: pi, e, tau, M_PI
10
/// - Math functions: radians(), degrees()
11
///
12
/// Note: inf and nan are NOT initialized here - they are injected directly into
13
/// the context HashMap in build_pyisheval_context() to bypass parsing issues.
14
///
15
/// # Returns
16
/// A fully initialized Interpreter ready for expression evaluation
17
pub fn init_interpreter() -> Interpreter {
300✔
18
    let mut interp = Interpreter::new();
300✔
19

20
    // Initialize math constants in the interpreter
21
    // These are loaded directly into the interpreter's environment for use in expressions
22
    // Note: inf and nan are skipped here (pyisheval can't parse them as literals)
23
    // They are injected directly into the context map in build_pyisheval_context()
24
    for (name, value) in BUILTIN_CONSTANTS {
1,500✔
25
        // Skip inf and nan - they're handled by direct HashMap injection
26
        if *name == "inf" || *name == "nan" {
1,200✔
NEW
27
            continue;
×
28
        }
1,200✔
29

30
        if let Err(e) = interp.eval(&format!("{} = {}", name, value)) {
1,200✔
31
            log::warn!(
×
32
                "Could not initialize built-in constant '{}': {}. \
×
33
                 This constant will not be available in expressions.",
×
34
                name,
35
                e
36
            );
37
        }
1,200✔
38
    }
39

40
    // Add math conversion functions as lambda expressions directly in the interpreter
41
    // This makes them available as callable functions in all expressions
42
    if let Err(e) = interp.eval("radians = lambda x: x * pi / 180") {
300✔
43
        log::warn!(
×
44
            "Could not define built-in function 'radians': {}. \
×
45
             This function will not be available in expressions. \
×
46
             (May be due to missing 'pi' constant)",
×
47
            e
48
        );
49
    }
300✔
50
    if let Err(e) = interp.eval("degrees = lambda x: x * 180 / pi") {
300✔
51
        log::warn!(
×
52
            "Could not define built-in function 'degrees': {}. \
×
53
             This function will not be available in expressions. \
×
54
             (May be due to missing 'pi' constant)",
×
55
            e
56
        );
57
    }
300✔
58

59
    interp
300✔
60
}
300✔
61

62
#[derive(Debug, thiserror::Error)]
63
pub enum EvalError {
64
    #[error("Failed to evaluate expression '{expr}': {source}")]
65
    PyishEval {
66
        expr: String,
67
        #[source]
68
        source: pyisheval::EvalError,
69
    },
70

71
    #[error("Xacro conditional \"{condition}\" evaluated to \"{evaluated}\", which is not a boolean expression.")]
72
    InvalidBoolean {
73
        condition: String,
74
        evaluated: String,
75
    },
76
}
77

78
/// Remove quotes from string values (handles both single and double quotes)
79
fn remove_quotes(s: &str) -> &str {
239✔
80
    // pyisheval's StringLit to_string() returns strings with single quotes
81
    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
239✔
82
        if s.len() >= 2 {
×
83
            &s[1..s.len() - 1]
×
84
        } else {
85
            s
×
86
        }
87
    } else {
88
        s
239✔
89
    }
90
}
239✔
91

92
/// Evaluate text containing ${...} expressions
93
///
94
/// Examples:
95
///   "hello ${name}" with {name: "world"} → "hello world"
96
///   "${2 + 3}" → "5"
97
///   "${width * 2}" with {width: "0.5"} → "1"
98
pub fn eval_text(
18✔
99
    text: &str,
18✔
100
    properties: &HashMap<String, String>,
18✔
101
) -> Result<String, EvalError> {
18✔
102
    let mut interp = init_interpreter();
18✔
103
    eval_text_with_interpreter(text, properties, &mut interp)
18✔
104
}
18✔
105

106
/// Build a pyisheval context HashMap from properties
107
///
108
/// Converts string properties to pyisheval Values, parsing numbers when possible.
109
/// For lambda expressions, evaluates them to callable lambda values using the
110
/// provided interpreter. This ensures lambdas capture the correct environment.
111
///
112
/// # Arguments
113
/// * `properties` - Property name-value pairs to convert to pyisheval Values
114
/// * `interp` - The interpreter to use for evaluating lambda expressions
115
///
116
/// # Errors
117
/// Returns `EvalError` if a lambda expression fails to evaluate.
118
fn build_pyisheval_context(
401✔
119
    properties: &HashMap<String, String>,
401✔
120
    interp: &mut Interpreter,
401✔
121
) -> Result<HashMap<String, Value>, EvalError> {
401✔
122
    // First pass: Load all constants and non-lambda properties into the interpreter
123
    // This ensures that lambda expressions can reference them during evaluation
124
    // Note: We skip inf/nan/NaN as they can't be created via arithmetic in pyisheval
125
    // (10**400 creates inf but 0.0/0.0 fails with DivisionByZero)
126
    for (name, value) in properties.iter() {
401✔
127
        let trimmed = value.trim();
291✔
128
        if !trimmed.starts_with("lambda ") {
291✔
129
            // Load both numeric and string properties into interpreter
130
            if let Ok(num) = value.parse::<f64>() {
291✔
131
                // Skip inf and nan - they'll be injected into context HashMap instead
132
                if num.is_infinite() || num.is_nan() {
202✔
133
                    continue;
11✔
134
                }
191✔
135
                // Numeric property: load as number
136
                interp
191✔
137
                    .eval(&format!("{} = {}", name, num))
191✔
138
                    .map_err(|e| EvalError::PyishEval {
191✔
139
                        expr: format!("{} = {}", name, num),
×
140
                        source: e,
×
141
                    })?;
×
142
            } else if !value.is_empty() {
89✔
143
                // Skip string values that are inf/nan identifiers
144
                // These will be available via context HashMap injection
145
                if value == "inf" || value == "nan" || value == "NaN" {
88✔
NEW
146
                    continue;
×
147
                }
88✔
148
                // String property: load as quoted string literal
149
                // Skip empty strings as pyisheval can't parse ''
150
                // Escape backslashes first, then single quotes (order matters!)
151
                // This handles Windows paths (C:\Users), regex patterns, etc.
152
                let escaped_value = value.replace('\\', "\\\\").replace('\'', "\\'");
88✔
153
                interp
88✔
154
                    .eval(&format!("{} = '{}'", name, escaped_value))
88✔
155
                    .map_err(|e| EvalError::PyishEval {
88✔
156
                        expr: format!("{} = '{}'", name, escaped_value),
×
157
                        source: e,
×
158
                    })?;
×
159
            }
1✔
160
            // Empty strings are skipped in first pass (pyisheval can't handle '')
161
            // They'll be stored as Value::StringLit("") in the second pass
162
        }
×
163
    }
164

165
    // Second pass: Build the actual context, evaluating lambdas
166
    let mut context: HashMap<String, Value> = properties
401✔
167
        .iter()
401✔
168
        .map(|(name, value)| -> Result<(String, Value), EvalError> {
401✔
169
            // Try to parse as number first
170
            if let Ok(num) = value.parse::<f64>() {
291✔
171
                return Ok((name.clone(), Value::Number(num)));
202✔
172
            }
89✔
173

174
            // Check if it's a lambda expression
175
            let trimmed = value.trim();
89✔
176
            if trimmed.starts_with("lambda ") {
89✔
177
                // Evaluate the lambda expression to get a callable lambda value
178
                // The interpreter now has all constants and properties loaded from first pass
179
                return interp
×
180
                    .eval(trimmed)
×
181
                    .map(|lambda_value| (name.clone(), lambda_value))
×
182
                    .map_err(|e| EvalError::PyishEval {
×
183
                        expr: trimmed.to_string(),
×
184
                        source: e,
×
185
                    });
×
186
            }
89✔
187

188
            // Default: store as string literal
189
            Ok((name.clone(), Value::StringLit(value.clone())))
89✔
190
        })
291✔
191
        .collect::<Result<HashMap<_, _>, _>>()?;
401✔
192

193
    // Manually inject inf and nan constants (Strategy 3: bypass parsing)
194
    // Python xacro provides these via float('inf') and math.inf, but they're also
195
    // used as bare identifiers in expressions. Pyisheval cannot parse these as
196
    // literals, so we inject them directly into the context.
197
    context.insert("inf".to_string(), Value::Number(f64::INFINITY));
401✔
198
    context.insert("nan".to_string(), Value::Number(f64::NAN));
401✔
199

200
    Ok(context)
401✔
201
}
401✔
202

203
/// Evaluate text containing ${...} expressions using a provided interpreter
204
///
205
/// This version allows reusing an Interpreter instance for better performance
206
/// when processing multiple text blocks with the same properties context.
207
///
208
/// Takes a mutable reference to ensure lambdas are created in the same
209
/// interpreter context where they'll be evaluated.
210
pub fn eval_text_with_interpreter(
293✔
211
    text: &str,
293✔
212
    properties: &HashMap<String, String>,
293✔
213
    interp: &mut Interpreter,
293✔
214
) -> Result<String, EvalError> {
293✔
215
    // Build context for pyisheval (may fail if lambdas have errors)
216
    // This loads properties into the interpreter and evaluates lambda expressions
217
    let context = build_pyisheval_context(properties, interp)?;
293✔
218

219
    // Tokenize the input text
220
    let lexer = Lexer::new(text);
293✔
221
    let mut result = Vec::new();
293✔
222

223
    // Process each token
224
    for (token_type, token_value) in lexer {
603✔
225
        match token_type {
318✔
226
            TokenType::Text => {
68✔
227
                // Plain text, keep as-is
68✔
228
                result.push(token_value);
68✔
229
            }
68✔
230
            TokenType::Expr => {
231
                // ${expr} - evaluate using pyisheval
232
                match interp.eval_with_context(&token_value, &context) {
247✔
233
                    Ok(value) => {
239✔
234
                        let value_str = value.to_string();
239✔
235
                        result.push(remove_quotes(&value_str).to_string());
239✔
236
                    }
239✔
237
                    Err(e) => {
8✔
238
                        return Err(EvalError::PyishEval {
8✔
239
                            expr: token_value.clone(),
8✔
240
                            source: e,
8✔
241
                        });
8✔
242
                    }
243
                }
244
            }
245
            TokenType::Extension => {
×
246
                // $(extension) - handle later (Phase 6)
×
247
                // For now, just keep the original text
×
248
                result.push(format!("$({})", token_value));
×
249
            }
×
250
            TokenType::DollarDollarBrace => {
3✔
251
                // $$ escape - output $ followed by the delimiter ({ or ()
3✔
252
                result.push(format!("${}", token_value));
3✔
253
            }
3✔
254
        }
255
    }
256

257
    Ok(result.join(""))
285✔
258
}
293✔
259

260
/// Apply Python xacro's STRICT string truthiness rules
261
///
262
/// Accepts: "true", "True", "false", "False", or parseable integers
263
/// Rejects: Everything else (including "nonsense", empty string, floats as strings)
264
fn apply_string_truthiness(
50✔
265
    s: &str,
50✔
266
    original: &str,
50✔
267
) -> Result<bool, EvalError> {
50✔
268
    let trimmed = s.trim();
50✔
269

270
    // Exact string matches for boolean literals
271
    if trimmed == "true" || trimmed == "True" {
50✔
272
        return Ok(true);
18✔
273
    }
32✔
274
    if trimmed == "false" || trimmed == "False" {
32✔
275
        return Ok(false);
11✔
276
    }
21✔
277

278
    // Try integer conversion (Python's bool(int(value)))
279
    if let Ok(i) = trimmed.parse::<i64>() {
21✔
280
        return Ok(i != 0);
14✔
281
    }
7✔
282

283
    // Everything else is an error (STRICT mode)
284
    Err(EvalError::InvalidBoolean {
7✔
285
        condition: original.to_string(),
7✔
286
        evaluated: s.to_string(),
7✔
287
    })
7✔
288
}
50✔
289

290
/// Evaluate expression as boolean following Python xacro's STRICT rules
291
///
292
/// Python xacro's get_boolean_value() logic (ref/xacro/src/xacro/__init__.py:856):
293
/// - Accepts: "true", "True", "false", "False"
294
/// - Accepts: Any string convertible to int: "1", "0", "42", "-5"
295
/// - REJECTS: "nonsense", empty string, anything else → Error
296
///
297
/// CRITICAL: This preserves type information from pyisheval!
298
/// ${3*0.1} evaluates to float 0.3 (truthy), NOT string "0.3" (would error)
299
///
300
/// Examples:
301
///   eval_boolean("true", &props) → Ok(true)
302
///   eval_boolean("${3*0.1}", &props) → Ok(true)  // Float 0.3 != 0.0
303
///   eval_boolean("${0}", &props) → Ok(false)     // Integer 0
304
///   eval_boolean("nonsense", &props) → Err(InvalidBoolean)
305
pub fn eval_boolean(
107✔
306
    text: &str,
107✔
307
    properties: &HashMap<String, String>,
107✔
308
) -> Result<bool, EvalError> {
107✔
309
    let mut interp = init_interpreter();
107✔
310

311
    // Build context for pyisheval (may fail if lambdas have errors)
312
    let context = build_pyisheval_context(properties, &mut interp)?;
107✔
313

314
    // Tokenize input to detect structure
315
    let lexer = Lexer::new(text);
107✔
316
    let tokens: Vec<_> = lexer.collect();
107✔
317

318
    // CASE 1: Single ${expr} token → Preserve type, apply truthiness on Value
319
    // This is CRITICAL for float truthiness: ${3*0.1} → float 0.3 → true
320
    if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
107✔
321
        let value = interp
61✔
322
            .eval_with_context(&tokens[0].1, &context)
61✔
323
            .map_err(|e| EvalError::PyishEval {
61✔
324
                expr: text.to_string(),
×
325
                source: e,
×
326
            })?;
×
327

328
        // Apply Python truthiness based on Value type
329
        return match value {
61✔
330
            Value::Number(n) => Ok(n != 0.0), // Float/int truthiness (includes bools: True=1.0, False=0.0)
57✔
331
            Value::StringLit(s) => {
4✔
332
                // String: must be "true"/"false" or parseable as int
333
                apply_string_truthiness(&s, text)
4✔
334
            }
335
            // Other types (Lambda, List, etc.) - error for now
336
            _ => Err(EvalError::InvalidBoolean {
×
337
                condition: text.to_string(),
×
338
                evaluated: format!("{:?}", value),
×
339
            }),
×
340
        };
341
    }
46✔
342

343
    // CASE 2: Multiple tokens or plain text → Evaluate to string, then parse
344
    // Example: "text ${expr} more" or just "true"
345
    let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
46✔
346
    apply_string_truthiness(&evaluated, text)
46✔
347
}
107✔
348

349
#[cfg(test)]
350
mod tests {
351
    use super::*;
352

353
    // TEST 1: Backward compatibility - simple property substitution
354
    #[test]
355
    fn test_simple_property_substitution() {
1✔
356
        let mut props = HashMap::new();
1✔
357
        props.insert("width".to_string(), "0.5".to_string());
1✔
358

359
        let result = eval_text("${width}", &props).unwrap();
1✔
360
        assert_eq!(result, "0.5");
1✔
361
    }
1✔
362

363
    // TEST 2: Property in text
364
    #[test]
365
    fn test_property_in_text() {
1✔
366
        let mut props = HashMap::new();
1✔
367
        props.insert("width".to_string(), "0.5".to_string());
1✔
368

369
        let result = eval_text("The width is ${width} meters", &props).unwrap();
1✔
370
        assert_eq!(result, "The width is 0.5 meters");
1✔
371
    }
1✔
372

373
    // TEST 3: Multiple properties
374
    #[test]
375
    fn test_multiple_properties() {
1✔
376
        let mut props = HashMap::new();
1✔
377
        props.insert("width".to_string(), "0.5".to_string());
1✔
378
        props.insert("height".to_string(), "1.0".to_string());
1✔
379

380
        let result = eval_text("${width} x ${height}", &props).unwrap();
1✔
381
        // Note: pyisheval formats 1.0 as "1" since it's an integer value
382
        assert_eq!(result, "0.5 x 1");
1✔
383
    }
1✔
384

385
    // TEST 4: NEW - Simple arithmetic
386
    #[test]
387
    fn test_arithmetic_expression() {
1✔
388
        let mut props = HashMap::new();
1✔
389
        props.insert("width".to_string(), "0.5".to_string());
1✔
390

391
        let result = eval_text("${width * 2}", &props).unwrap();
1✔
392
        assert_eq!(result, "1");
1✔
393
    }
1✔
394

395
    // TEST 5: NEW - Arithmetic without properties
396
    #[test]
397
    fn test_pure_arithmetic() {
1✔
398
        let props = HashMap::new();
1✔
399

400
        let result = eval_text("${2 + 3}", &props).unwrap();
1✔
401
        assert_eq!(result, "5");
1✔
402
    }
1✔
403

404
    // TEST 6: NEW - Complex expression
405
    #[test]
406
    fn test_complex_expression() {
1✔
407
        let mut props = HashMap::new();
1✔
408
        props.insert("width".to_string(), "0.5".to_string());
1✔
409
        props.insert("height".to_string(), "2.0".to_string());
1✔
410

411
        let result = eval_text("${width * height + 1}", &props).unwrap();
1✔
412
        assert_eq!(result, "2");
1✔
413
    }
1✔
414

415
    // TEST 7: NEW - String concatenation with literals
416
    // Note: pyisheval doesn't currently support string concatenation with +
417
    // This is documented as a known limitation. Use property substitution instead.
418
    #[test]
419
    #[ignore]
420
    fn test_string_concatenation() {
×
421
        let props = HashMap::new();
×
422

423
        // String concatenation with string literals (quoted in expression)
424
        let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
×
425
        assert_eq!(result, "link_base");
×
426
    }
×
427

428
    // TEST 8: NEW - Built-in functions
429
    #[test]
430
    fn test_builtin_functions() {
1✔
431
        let props = HashMap::new();
1✔
432

433
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
434
        assert_eq!(result, "5");
1✔
435

436
        let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
1✔
437
        assert_eq!(result, "5");
1✔
438
    }
1✔
439

440
    // TEST 9: NEW - Conditional expressions
441
    #[test]
442
    fn test_conditional_expression() {
1✔
443
        let mut props = HashMap::new();
1✔
444
        props.insert("width".to_string(), "0.5".to_string());
1✔
445

446
        let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
1✔
447
        assert_eq!(result, "0.5");
1✔
448
    }
1✔
449

450
    // TEST 10: Text without expressions (pass through)
451
    #[test]
452
    fn test_no_expressions() {
1✔
453
        let props = HashMap::new();
1✔
454
        let result = eval_text("hello world", &props).unwrap();
1✔
455
        assert_eq!(result, "hello world");
1✔
456
    }
1✔
457

458
    // TEST 11: Empty string
459
    #[test]
460
    fn test_empty_string() {
1✔
461
        let props = HashMap::new();
1✔
462
        let result = eval_text("", &props).unwrap();
1✔
463
        assert_eq!(result, "");
1✔
464
    }
1✔
465

466
    // TEST 12: Error case - undefined property
467
    #[test]
468
    fn test_undefined_property() {
1✔
469
        let props = HashMap::new();
1✔
470
        let result = eval_text("${undefined}", &props);
1✔
471
        assert!(result.is_err());
1✔
472
    }
1✔
473

474
    // TEST 13: String property substitution (non-numeric values)
475
    #[test]
476
    fn test_string_property() {
1✔
477
        let mut props = HashMap::new();
1✔
478
        props.insert("link_name".to_string(), "base_link".to_string());
1✔
479
        props.insert("joint_type".to_string(), "revolute".to_string());
1✔
480

481
        // Test single property
482
        let result = eval_text("${link_name}", &props).unwrap();
1✔
483
        assert_eq!(result, "base_link");
1✔
484

485
        // Test property in text
486
        let result = eval_text("name_${link_name}_suffix", &props).unwrap();
1✔
487
        assert_eq!(result, "name_base_link_suffix");
1✔
488

489
        // Test multiple string properties
490
        let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
1✔
491
        assert_eq!(result, "base_link revolute");
1✔
492
    }
1✔
493

494
    #[test]
495
    fn test_double_dollar_escape() {
1✔
496
        let props = HashMap::new();
1✔
497

498
        // Test $$ escape with brace - should produce literal ${
499
        let result = eval_text("$${expr}", &props).unwrap();
1✔
500
        assert_eq!(result, "${expr}");
1✔
501

502
        // Test $$ escape with paren - should produce literal $(
503
        let result = eval_text("$$(command)", &props).unwrap();
1✔
504
        assert_eq!(result, "$(command)");
1✔
505

506
        // Test $$ escape in context
507
        let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
1✔
508
        assert_eq!(result, "prefix_${literal}_suffix");
1✔
509
    }
1✔
510

511
    // ===== NEW TESTS FOR eval_boolean =====
512

513
    // Test from Python xacro: test_boolean_if_statement (line 715)
514
    #[test]
515
    fn test_eval_boolean_literals() {
1✔
516
        let props = HashMap::new();
1✔
517

518
        // Boolean string literals
519
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
520
        assert_eq!(eval_boolean("false", &props).unwrap(), false);
1✔
521
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
522
        assert_eq!(eval_boolean("False", &props).unwrap(), false);
1✔
523
    }
1✔
524

525
    // Test from Python xacro: test_integer_if_statement (line 735)
526
    #[test]
527
    fn test_eval_boolean_integer_truthiness() {
1✔
528
        let props = HashMap::new();
1✔
529

530
        // Integer literals as strings
531
        assert_eq!(eval_boolean("0", &props).unwrap(), false);
1✔
532
        assert_eq!(eval_boolean("1", &props).unwrap(), true);
1✔
533
        assert_eq!(eval_boolean("42", &props).unwrap(), true);
1✔
534
        assert_eq!(eval_boolean("-5", &props).unwrap(), true);
1✔
535

536
        // Integer expressions
537
        assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); // 0
1✔
538
        assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
1✔
539
        assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); // 5
1✔
540
    }
1✔
541

542
    // Test from Python xacro: test_float_if_statement (line 755)
543
    #[test]
544
    fn test_eval_boolean_float_truthiness() {
1✔
545
        let props = HashMap::new();
1✔
546

547
        // CRITICAL: Float expressions must preserve type
548
        assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); // 0.0
1✔
549
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); // 0.3 (non-zero float)
1✔
550
        assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
1✔
551
        assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
1✔
552
    }
1✔
553

554
    // Test from Python xacro: test_property_if_statement (line 769)
555
    #[test]
556
    fn test_eval_boolean_with_properties() {
1✔
557
        let mut props = HashMap::new();
1✔
558
        props.insert("condT".to_string(), "1".to_string()); // True as number
1✔
559
        props.insert("condF".to_string(), "0".to_string()); // False as number
1✔
560
        props.insert("num".to_string(), "5".to_string());
1✔
561

562
        assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
1✔
563
        assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
1✔
564
        assert_eq!(eval_boolean("${num}", &props).unwrap(), true); // 5 != 0
1✔
565

566
        // Note: pyisheval doesn't have True/False as built-in constants
567
        // They would need to be defined as properties with value 1/0
568
    }
1✔
569

570
    // Test from Python xacro: test_equality_expression_in_if_statement (line 788)
571
    #[test]
572
    fn test_eval_boolean_expressions() {
1✔
573
        let mut props = HashMap::new();
1✔
574
        props.insert("var".to_string(), "useit".to_string());
1✔
575

576
        // Equality
577
        assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
1✔
578
        assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
1✔
579

580
        // Comparison
581
        props.insert("x".to_string(), "5".to_string());
1✔
582
        assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
1✔
583
        assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
1✔
584

585
        // Note: pyisheval doesn't support 'in' operator for strings yet
586
        // That would require extending pyisheval or using a different evaluator
587
    }
1✔
588

589
    /// Test that pyisheval returns Value::Number for boolean expressions
590
    ///
591
    /// CRITICAL: This test documents that pyisheval v0.9.0 does NOT have Value::Bool.
592
    /// Boolean comparison expressions like ${1 == 1} return Value::Number(1.0), not Value::Bool(true).
593
    /// This is similar to Python where bool is a subclass of int (True == 1, False == 0).
594
    ///
595
    /// This test exists to:
596
    /// 1. Verify our Number-based truthiness handling works for comparisons
597
    /// 2. Document pyisheval's current behavior (see notes/PYISHEVAL_ISSUES.md)
598
    /// 3. Catch if pyisheval adds Value::Bool in future (this would fail, prompting us to update)
599
    #[test]
600
    fn test_eval_boolean_comparison_expressions() {
1✔
601
        let mut props = HashMap::new();
1✔
602
        props.insert("x".to_string(), "5".to_string());
1✔
603
        props.insert("y".to_string(), "10".to_string());
1✔
604

605
        // Equality comparisons
606
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
607
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
608
        assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
1✔
609
        assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
1✔
610

611
        // Inequality comparisons
612
        assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
1✔
613
        assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
1✔
614

615
        // Less than / greater than
616
        assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
1✔
617
        assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
1✔
618
        assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
1✔
619
        assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
1✔
620

621
        // NOTE: pyisheval v0.9.0 does NOT support `and`/`or` operators
622
        // These would fail: ${1 and 1}, ${x > 3 and y < 15}
623
        // Fortunately, real xacro files don't use these - they use simpler expressions
624
        // See notes/PYISHEVAL_ISSUES.md for details
625

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

630
    // Test from Python xacro: test_invalid_if_statement (line 729)
631
    #[test]
632
    fn test_eval_boolean_invalid_values() {
1✔
633
        let props = HashMap::new();
1✔
634

635
        // STRICT mode: "nonsense" should error
636
        let result = eval_boolean("nonsense", &props);
1✔
637
        assert!(result.is_err());
1✔
638
        assert!(result
1✔
639
            .unwrap_err()
1✔
640
            .to_string()
1✔
641
            .contains("not a boolean expression"));
1✔
642

643
        // Empty string should error
644
        let result = eval_boolean("", &props);
1✔
645
        assert!(result.is_err());
1✔
646

647
        // Random text should error
648
        let result = eval_boolean("random text", &props);
1✔
649
        assert!(result.is_err());
1✔
650
    }
1✔
651

652
    // Test edge case: whitespace handling
653
    #[test]
654
    fn test_eval_boolean_whitespace() {
1✔
655
        let props = HashMap::new();
1✔
656

657
        // Should trim whitespace
658
        assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
1✔
659
        assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
1✔
660
        assert_eq!(eval_boolean("  0  ", &props).unwrap(), false);
1✔
661
        assert_eq!(eval_boolean("  1  ", &props).unwrap(), true);
1✔
662
    }
1✔
663

664
    // Test case sensitivity
665
    #[test]
666
    fn test_eval_boolean_case_sensitivity() {
1✔
667
        let props = HashMap::new();
1✔
668

669
        // "true" and "True" are accepted
670
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
671
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
672

673
        // But not other cases (should error)
674
        assert!(eval_boolean("TRUE", &props).is_err());
1✔
675
        assert!(eval_boolean("tRuE", &props).is_err());
1✔
676
    }
1✔
677

678
    // Test that inf and nan are available via direct context injection
679
    #[test]
680
    fn test_inf_nan_direct_injection() {
1✔
681
        let props = HashMap::new();
1✔
682
        let mut interp = init_interpreter();
1✔
683

684
        // Build context with direct inf/nan injection
685
        let context = build_pyisheval_context(&props, &mut interp).unwrap();
1✔
686

687
        // Verify inf and nan are in the context
688
        assert!(
1✔
689
            context.contains_key("inf"),
1✔
NEW
690
            "Context should contain 'inf' key"
×
691
        );
692
        assert!(
1✔
693
            context.contains_key("nan"),
1✔
NEW
694
            "Context should contain 'nan' key"
×
695
        );
696

697
        // Test 1: inf should be positive infinity
698
        if let Some(Value::Number(n)) = context.get("inf") {
1✔
699
            assert!(
1✔
700
                n.is_infinite() && n.is_sign_positive(),
1✔
NEW
701
                "inf should be positive infinity, got: {}",
×
702
                n
703
            );
704
        } else {
NEW
705
            panic!("inf should be a Number value");
×
706
        }
707

708
        // Test 2: nan should be NaN
709
        if let Some(Value::Number(n)) = context.get("nan") {
1✔
710
            assert!(n.is_nan(), "nan should be NaN, got: {}", n);
1✔
711
        } else {
NEW
712
            panic!("nan should be a Number value");
×
713
        }
714

715
        // Test 3: inf should be usable in expressions
716
        let result = interp.eval_with_context("inf * 2", &context);
1✔
717
        assert!(
1✔
718
            matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
1✔
NEW
719
            "inf * 2 should return positive infinity, got: {:?}",
×
720
            result
721
        );
722

723
        // Test 4: nan should be usable in expressions
724
        let result = interp.eval_with_context("nan + 1", &context);
1✔
725
        assert!(
1✔
726
            matches!(result, Ok(Value::Number(n)) if n.is_nan()),
1✔
NEW
727
            "nan + 1 should return NaN, got: {:?}",
×
728
            result
729
        );
730
    }
1✔
731

732
    // Test type preservation: the key feature!
733
    #[test]
734
    fn test_eval_boolean_type_preservation() {
1✔
735
        let props = HashMap::new();
1✔
736

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

741
        // Multiple tokens: becomes string
742
        // "result: ${3*0.1}" → "result: 0.3" → can't parse as int → error
743
        let result = eval_boolean("result: ${3*0.1}", &props);
1✔
744
        assert!(result.is_err());
1✔
745
    }
1✔
746

747
    // Test Boolean value type from pyisheval
748
    #[test]
749
    fn test_eval_boolean_bool_values() {
1✔
750
        let props = HashMap::new();
1✔
751

752
        // pyisheval returns Value::Bool directly
753
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
754
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
755
        assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
1✔
756
    }
1✔
757
}
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