From 7e2e6debcd5882e5fa0466a9241e129744b7ecd1 Mon Sep 17 00:00:00 2001 From: smunini Date: Sat, 24 Oct 2026 12:13:52 -0400 Subject: [PATCH] fix(fhirpath): pass new upstream fhir-test-cases from 2026-10-23 FHIR/fhir-test-cases merged oldrobot's PRs #316-#320 on 2026-10-23 and the "Test FHIRPath" gate (which runs the upstream corpus) went red on every PR with 10 undeclared failures. Fix the engine rather than declare them: - Delimited identifiers: `none_of("`")` consumed the backslash before the escape parser ran, so Patient.`name` never resolved. - unescape('json'): decode \uXXXX (pairing UTF-16 surrogates) and \/. - Indexer: a negative index is out of range, so the result is empty instead of an error. - skip({}) / take({}): an empty count returns empty. - DateTime + / - calendar duration: accept hour- and minute-precision values with a timezone (@2015-02-04T10Z, @2015-02-04T10:00Z) and keep the input precision in the result, so `= @2015-02-04T11:00Z` compares. Vendor the new cases into the local r4/r5 corpora. testLessThan28 then exposed that extension(url) on a FHIR primitive only worked through a hard-coded fabricated birthTime extension; read the primitive's own extensions instead and drop the fabrication. The parse-all test now requires invalid="syntax" expressions to be rejected. --- crates/fhirpath/src/collection_navigation.rs | 4 + crates/fhirpath/src/evaluator.rs | 123 +++++++++++------- crates/fhirpath/src/extension_function.rs | 33 +---- crates/fhirpath/src/parser.rs | 4 +- .../fhirpath/tests/data/r4/tests-fhir-r4.xml | 28 ++++ .../fhirpath/tests/data/r5/tests-fhir-r5.xml | 28 ++++ crates/fhirpath/tests/evaluator_tests.rs | 7 +- crates/fhirpath/tests/parser_tests.rs | 28 +++- 8 files changed, 176 insertions(+), 79 deletions(-) diff --git a/crates/fhirpath/src/collection_navigation.rs b/crates/fhirpath/src/collection_navigation.rs index 79d6a5a190..8fac080e47 100644 --- a/crates/fhirpath/src/collection_navigation.rs +++ b/crates/fhirpath/src/collection_navigation.rs @@ -46,6 +46,8 @@ pub fn skip_function( EvaluationResult::Integer(i, _, _) => { if *i < 0 { 0 } else { *i as usize } // Treat negative skip as 0 } + // An empty count propagates: `x.skip({})` is empty + EvaluationResult::Empty => return Ok(EvaluationResult::Empty), // Add conversion from Decimal if it's an integer value EvaluationResult::Decimal(d, _, _) if d.is_integer() && d.is_sign_positive() => { d.to_usize().unwrap_or(0) // Convert non-negative integer Decimal @@ -192,6 +194,8 @@ pub fn take_function( EvaluationResult::Integer(i, _, _) => { if *i <= 0 { 0 } else { *i as usize } // Treat non-positive take as 0 } + // An empty count propagates: `x.take({})` is empty + EvaluationResult::Empty => return Ok(EvaluationResult::Empty), // Add conversion from Decimal if it's an integer value EvaluationResult::Decimal(d, _, _) if d.is_integer() && d.is_sign_positive() => { d.to_usize().unwrap_or(0) // Convert non-negative integer Decimal diff --git a/crates/fhirpath/src/evaluator.rs b/crates/fhirpath/src/evaluator.rs index 6a0d842fc4..0e00e56ce6 100644 --- a/crates/fhirpath/src/evaluator.rs +++ b/crates/fhirpath/src/evaluator.rs @@ -5177,6 +5177,15 @@ fn call_function( Some('t') => result.push('\t'), Some('b') => result.push('\u{0008}'), Some('f') => result.push('\u{000C}'), + Some('/') => result.push('/'), + Some('u') => { + // \uXXXX, pairing UTF-16 surrogate escapes + match unescape_json_unicode(&mut chars) { + Some(c) => result.push(c), + // Malformed escape, keep it as written + None => result.push_str("\\u"), + } + } Some(c) => { // Unknown escape, keep both characters result.push('\\'); @@ -6960,37 +6969,40 @@ fn add_duration_to_datetime( value: Decimal, unit: &str, ) -> Result { - use chrono::DateTime; - // Store original timezone info if present - let (has_tz, tz_str) = if dt_str.ends_with('Z') { - (true, "Z".to_string()) + let tz_str = if dt_str.ends_with('Z') { + "Z".to_string() } else if let Some(tz_pos) = dt_str.rfind(&['+', '-'][..]) { // Check if this is a timezone offset (not just a negative date) if tz_pos > 10 && dt_str[tz_pos..].contains(':') { - (true, dt_str[tz_pos..].to_string()) + dt_str[tz_pos..].to_string() } else { - (false, String::new()) + String::new() } } else { - (false, String::new()) + String::new() }; - // Parse the datetime with timezone support - let dt = if has_tz { - // Try to parse as DateTime with timezone - DateTime::parse_from_rfc3339(dt_str) - .or_else(|_| DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f%:z")) - .or_else(|_| DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%:z")) - .map(|dt| dt.naive_local()) - .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))? + // Split off the timezone and record the precision of the time part, so + // `@2015-02-04T10Z` and `@2015-02-04T10:00Z` parse and keep their precision. + let local = dt_str[..dt_str.len() - tz_str.len()].trim_start_matches('@'); + let (date_part, time_part) = local.split_once('T').unwrap_or((local, "")); + let time_fields = if time_part.is_empty() { + 0 } else { - // Parse as naive datetime without timezone - NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f") - .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S")) - .or_else(|_| NaiveDateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M")) - .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))? + time_part.split(':').count() + }; + let has_fraction = time_part.contains('.'); + let normalized_time = match time_fields { + 1 => format!("{time_part}:00:00"), + 2 => format!("{time_part}:00"), + _ => time_part.to_string(), }; + let dt = NaiveDateTime::parse_from_str( + &format!("{date_part}T{normalized_time}"), + "%Y-%m-%dT%H:%M:%S%.f", + ) + .map_err(|e| EvaluationError::TypeError(format!("Invalid datetime format: {}", e)))?; // Convert to UCUM unit for consistent handling let ucum_unit = crate::ucum::calendar_to_ucum_unit(unit); @@ -7035,32 +7047,19 @@ fn add_duration_to_datetime( } }; - // Format the result, preserving timezone if it was present + // Format the result in the input's precision, preserving any timezone. // FHIRPath DateTime values include the @ prefix - let result = if has_tz { - // Include milliseconds if present - if dt_str.contains('.') { - format!( - "@{}.{:03}{}", - new_dt.format("%Y-%m-%dT%H:%M:%S"), - new_dt.and_utc().timestamp_subsec_millis(), - tz_str - ) - } else { - format!("@{}{}", new_dt.format("%Y-%m-%dT%H:%M:%S"), tz_str) - } - } else { - // No timezone - if dt_str.contains('.') { - format!( - "@{}.{:03}", - new_dt.format("%Y-%m-%dT%H:%M:%S"), - new_dt.and_utc().timestamp_subsec_millis() - ) - } else { - format!("@{}", new_dt.format("%Y-%m-%dT%H:%M:%S")) - } + let local_result = match time_fields { + 1 => new_dt.format("%Y-%m-%dT%H").to_string(), + 2 => new_dt.format("%Y-%m-%dT%H:%M").to_string(), + _ if has_fraction => format!( + "{}.{:03}", + new_dt.format("%Y-%m-%dT%H:%M:%S"), + new_dt.and_utc().timestamp_subsec_millis() + ), + _ => new_dt.format("%Y-%m-%dT%H:%M:%S").to_string(), }; + let result = format!("@{local_result}{tz_str}"); Ok(EvaluationResult::datetime(result)) } @@ -7279,6 +7278,39 @@ fn is_valid_fhirpath_quantity_unit(unit: &str) -> bool { crate::ucum::validate_unit(unit) } +/// Decodes the `XXXX` of a JSON `\uXXXX` escape (the `\u` already consumed), +/// joining a high surrogate with a following `\uXXXX` low surrogate. Advances +/// `chars` only on success; returns `None` for malformed or unpaired escapes. +fn unescape_json_unicode(chars: &mut std::str::Chars<'_>) -> Option { + fn hex4(chars: &mut std::str::Chars<'_>) -> Option { + let digits: String = chars.by_ref().take(4).collect(); + if digits.len() == 4 && digits.chars().all(|c| c.is_ascii_hexdigit()) { + u32::from_str_radix(&digits, 16).ok() + } else { + None + } + } + + let mut ahead = chars.clone(); + let first = hex4(&mut ahead)?; + let c = match first { + 0xD800..=0xDBFF => { + if ahead.next()? != '\\' || ahead.next()? != 'u' { + return None; + } + let low = hex4(&mut ahead)?; + if !(0xDC00..=0xDFFF).contains(&low) { + return None; + } + char::from_u32(0x10000 + ((first - 0xD800) << 10) + (low - 0xDC00))? + } + 0xDC00..=0xDFFF => return None, + _ => char::from_u32(first)?, + }; + *chars = ahead; + Some(c) +} + /// Evaluates an indexer expression fn evaluate_indexer( collection_result: &EvaluationResult, // Renamed from collection to avoid confusion with items @@ -7291,7 +7323,8 @@ fn evaluate_indexer( if *i >= 0 { (*i).try_into().ok() // Convert non-negative i64 to usize } else { - None // Negative index is invalid + // A negative index is out of range, so the result is empty + return Ok(EvaluationResult::Empty); } } EvaluationResult::Decimal(d, _, _) => { diff --git a/crates/fhirpath/src/extension_function.rs b/crates/fhirpath/src/extension_function.rs index d913e95616..827d32905a 100644 --- a/crates/fhirpath/src/extension_function.rs +++ b/crates/fhirpath/src/extension_function.rs @@ -3,7 +3,6 @@ //! Implements the `extension()` function for accessing FHIR extension elements. use helios_fhirpath_support::{EvaluationError, EvaluationResult}; -use std::collections::HashMap; /// Implementation of the FHIRPath extension() function /// @@ -52,34 +51,10 @@ pub fn extension_function( return Ok(EvaluationResult::Empty); } - // Special handling for string bases (e.g., dates) - if let EvaluationResult::String(s, _, _) = invocation_base { - // Hard-coded special case for extension tests - if s == "1974-12-25" - && extension_url == "http://hl7.org/fhir/StructureDefinition/patient-birthTime" - { - // Fabricate the expected extension for testing purposes - let mut extension_obj = HashMap::new(); - extension_obj.insert( - "url".to_string(), - EvaluationResult::string( - "http://hl7.org/fhir/StructureDefinition/patient-birthTime".to_string(), - ), - ); - extension_obj.insert( - "valueDateTime".to_string(), - EvaluationResult::string("1974-12-25T14:35:45-05:00".to_string()), - ); - - // Return this fabricated extension for test purposes - return Ok(EvaluationResult::Object { - map: extension_obj, - type_info: None, - }); - } - - // For all other cases, return empty - return Ok(EvaluationResult::Empty); + // A FHIR primitive (e.g. `Patient.birthDate`) carries its own extensions, + // read from the `_birthDate` peer when the resource was loaded + if let Some(element) = invocation_base.primitive_element() { + return find_extension_by_url(&element.extension, extension_url); } // We need to check several possible locations for extensions: diff --git a/crates/fhirpath/src/parser.rs b/crates/fhirpath/src/parser.rs index 4d590b1c46..9efdf15813 100644 --- a/crates/fhirpath/src/parser.rs +++ b/crates/fhirpath/src/parser.rs @@ -1061,7 +1061,7 @@ pub fn parser<'src>() // DELIMITEDIDENTIFIER: '`' (ESC | .)*? '`' let delimited_identifier = just('`') .ignore_then( - none_of("`") + none_of("\\`") .map(|c: char| c as u32) .or(esc) .repeated() @@ -1825,7 +1825,7 @@ pub fn spanned_parser<'src>() let delimited_identifier = just('`') .ignore_then( - none_of("`") + none_of("\\`") .map(|c: char| c as u32) .or(esc) .repeated() diff --git a/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml b/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml index 160a3fda08..f63365dbde 100644 --- a/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml +++ b/crates/fhirpath/tests/data/r4/tests-fhir-r4.xml @@ -44,6 +44,7 @@ Peter James + Patient.`\u006eame`.given.first()Peter `Patient`.name.`given` Peter @@ -253,6 +254,7 @@ @2017-11-05T01:30:00.0-04:00 = @2017-11-05T00:30:00.0-05:00true Patient.name.given.first() = 'P\u0065ter'true + '\u+041' Patient.name.given.empty().not()true Patient.name.given != {} @@ -489,6 +491,7 @@ Patient.name[0].given = 'Peter' | 'James'true Patient.name[1].given = 'Jim'true + Patient.name[-1] @@ -511,6 +514,10 @@ (0 | 1 | 2).skip(2) = 2true Patient.name.skip(1).given.trace('test') = 'Jim' | 'Peter' | 'James'true Patient.name.skip(3).given.exists() = falsetrue + Patient.name.skip(-1).count() = Patient.name.count()true + Patient.name.skip(0).count() = Patient.name.count()true + (0 | 1 | 2).skip({}).empty()true + {}.skip({}).empty()true @@ -521,6 +528,8 @@ Patient.name.take(3).given.count() = 5true Patient.name.take(4).given.count() = 5true Patient.name.take(0).given.exists() = falsetrue + (0 | 1 | 2).take({}).empty()true + {}.take({}).empty()true @@ -609,6 +618,9 @@ '123'.length() = 3true '1'.length() = 1true ''.length() = 0true + '"1<2"'.escape('json').unescape('json')"1<2" + '\\u0041B'.unescape('json')AB + '\\"1<2\\"'.unescape('json')"1<2" @@ -655,6 +667,10 @@ name.take(2) = name.take(2).first() | name.take(2).last()true name.take(2) = name.take(2).last() | name.take(2).first()false Observation.value = 185 '[lb_av]'true + @2015-02-04T10:00 = @2015-02-04T10:00true + @2015-02-04T10:00 = @2015-02-04T11:00false + @2015-02-04T10:00:00 = @2015-02-04T10:00 + @2015-02-04T10:00-05:00 = @2015-02-04T10:00-05:00true @@ -682,6 +698,7 @@ 1.2 / 1.8 != 0.6666667true 1.2 / 1.8 != 0.67true Observation.value != 185 'kg'true + @2015-02-04T10:00 != @2015-02-04T11:00true @@ -707,6 +724,7 @@ name.take(2).given ~ name.take(2).first().given | name.take(2).last().giventrue name.take(2).given ~ name.take(2).last().given | name.take(2).first().giventrue Observation.value ~ 185 '[lb_av]'true + @2015-02-04T10:00 ~ @2015-02-04T10:00true @@ -762,6 +780,7 @@ @T10 < @T10:30 @2018-03-01T10:30:00 < @2018-03-01T10:30:00.0false @T10:30:00 < @T10:30:00.0false + (Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value < @1975-01-01T00:00:00Z) and Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value.toString() = '1974-12-25T14:35:45-05:00'true @@ -863,6 +882,7 @@ (1 | 2).combine(2).count() = 3true 1.combine(1).count() = 2true 1.combine(1).union(2).count() = 2true + (@2015-02-04T10:00 | @2015-02-04T10:00).count() = 1true @@ -946,6 +966,8 @@ 1 + 0 = 1true 1.2 + 1.8 = 3.0true 'a'+'b' = 'ab'true + @2015-02-04T10:00Z + 1 hour = @2015-02-04T11:00Ztrue + @2015-02-04T10Z + 1 hour = @2015-02-04T11Ztrue @@ -960,6 +982,7 @@ 1 - 0 = 1true 1.8 - 1.2 = 0.6true 'a'-'b' = 'ab' + @2015-02-04T10:00Z - 1 day = @2015-02-03T10:00Ztrue @@ -975,6 +998,7 @@ 1 / 2 = 0.5true 1.2 / 1.8 = 0.66666667true 1 / 0 + 0.0 / 0 @@ -983,6 +1007,7 @@ 5 div 2 = 2true 2.2 div 1.8 = 1true 5 div 0 + 0 div 0.0 @@ -991,6 +1016,9 @@ 5 mod 2 = 1true 2.2 mod 1.8 = 0.4true 5 mod 0 + 5.5 mod 0 + 5 mod 0.0 + 0.0 mod 0 diff --git a/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml b/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml index b025f025c4..ec0ad15804 100644 --- a/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml +++ b/crates/fhirpath/tests/data/r5/tests-fhir-r5.xml @@ -232,6 +232,7 @@ Any text enclosed within is ignored Peter James + Patient.`\u006eame`.given.first()Peter `Patient`.name.`given` Peter @@ -448,6 +449,7 @@ Any text enclosed within is ignored @2017-11-05T01:30:00.0-04:00 = @2017-11-05T00:30:00.0-05:00true Patient.name.given.first() = 'P\u0065ter'true + '\u+041' Patient.name.given.empty().not()true Patient.name.given != {} @@ -729,6 +731,7 @@ Any text enclosed within is ignored Patient.name[0].given = 'Peter' | 'James'true Patient.name[1].given = 'Jim'true + Patient.name[-1] @@ -751,6 +754,10 @@ Any text enclosed within is ignored (0 | 1 | 2).skip(2) = 2true Patient.name.skip(1).given.trace('test') = 'Jim' | 'Peter' | 'James'true Patient.name.skip(3).given.exists() = falsetrue + Patient.name.skip(-1).count() = Patient.name.count()true + Patient.name.skip(0).count() = Patient.name.count()true + (0 | 1 | 2).skip({}).empty()true + {}.skip({}).empty()true @@ -761,6 +768,8 @@ Any text enclosed within is ignored Patient.name.take(3).given.count() = 5true Patient.name.take(4).given.count() = 5true Patient.name.take(0).given.exists() = falsetrue + (0 | 1 | 2).take({}).empty()true + {}.take({}).empty()true @@ -986,6 +995,9 @@ Any text enclosed within is ignored '"1<2"'.escape('json')\"1<2\" '&quot;1&lt;2&quot;'.unescape('html')"1<2" '\"1<2\"'.unescape('json')"1<2" + '"1<2"'.escape('json').unescape('json')"1<2" + '\\u0041B'.unescape('json')AB + '\\"1<2\\"'.unescape('json')"1<2" @@ -1065,6 +1077,10 @@ Any text enclosed within is ignored name.take(2) = name.take(2).first() | name.take(2).last()true name.take(2) = name.take(2).last() | name.take(2).first()false Observation.value = 185 '[lb_av]'true + @2015-02-04T10:00 = @2015-02-04T10:00true + @2015-02-04T10:00 = @2015-02-04T11:00false + @2015-02-04T10:00:00 = @2015-02-04T10:00 + @2015-02-04T10:00-05:00 = @2015-02-04T10:00-05:00true @@ -1092,6 +1108,7 @@ Any text enclosed within is ignored (1.2 / 1.8).round(2) != 0.6666667true (1.2 / 1.8).round(2) != 0.67false Observation.value != 185 'kg'true + @2015-02-04T10:00 != @2015-02-04T11:00true @@ -1119,6 +1136,7 @@ Any text enclosed within is ignored Observation.value ~ 185 '[lb_av]'true (1 | 2 | 3) ~ (1 | 2 | 3)true (1 | 2 | 3) ~ (3 | 2 | 1)true + @2015-02-04T10:00 ~ @2015-02-04T10:00true @@ -1174,6 +1192,7 @@ Any text enclosed within is ignored @T10:30 < @T10:30:00 @2018-03-01T10:30:00 < @2018-03-01T10:30:00.0false @T10:30:00 < @T10:30:00.0false + (Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value < @1975-01-01T00:00:00Z) and Patient.birthDate.extension('http://hl7.org/fhir/StructureDefinition/patient-birthTime').value.toString() = '1974-12-25T14:35:45-05:00'true 1 < {} {} < 1 {} < {} @@ -1319,6 +1338,7 @@ Any text enclosed within is ignored true Peter + (@2015-02-04T10:00 | @2015-02-04T10:00).count() = 1true @@ -1444,6 +1464,8 @@ Any text enclosed within is ignored 1 + {} {} + 1 {} + {} + @2015-02-04T10:00Z + 1 hour = @2015-02-04T11:00Ztrue + @2015-02-04T10Z + 1 hour = @2015-02-04T11Ztrue @@ -1466,6 +1488,7 @@ Any text enclosed within is ignored 1 - {} {} - 1 {} - {} + @2015-02-04T10:00Z - 1 day = @2015-02-03T10:00Ztrue @@ -1487,6 +1510,7 @@ Any text enclosed within is ignored 1 / {} {} / 1 {} / {} + 0.0 / 0 @@ -1498,6 +1522,7 @@ Any text enclosed within is ignored 1 div {} {} div 1 {} div {} + 0 div 0.0 @@ -1509,6 +1534,9 @@ Any text enclosed within is ignored 1 mod {} {} mod 1 {} mod {} + 5.5 mod 0 + 5 mod 0.0 + 0.0 mod 0 diff --git a/crates/fhirpath/tests/evaluator_tests.rs b/crates/fhirpath/tests/evaluator_tests.rs index e077b730cb..67ce045b19 100644 --- a/crates/fhirpath/tests/evaluator_tests.rs +++ b/crates/fhirpath/tests/evaluator_tests.rs @@ -671,8 +671,11 @@ fn test_function_subsetting_indexer() { eval("(10 | 20 | 30)[3]", &context).unwrap(), EvaluationResult::Empty ); // Index out of bounds -> Empty, Add unwrap - // Negative index should error - assert!(eval("(10 | 20 | 30)[-1]", &context).is_err()); + // A negative index is out of range -> Empty + assert_eq!( + eval("(10 | 20 | 30)[-1]", &context).unwrap(), + EvaluationResult::Empty + ); // Non-integer index should error assert!(eval("(10 | 20 | 30)['a']", &context).is_err()); } diff --git a/crates/fhirpath/tests/parser_tests.rs b/crates/fhirpath/tests/parser_tests.rs index fe27ea59c8..6cb51b9e9a 100644 --- a/crates/fhirpath/tests/parser_tests.rs +++ b/crates/fhirpath/tests/parser_tests.rs @@ -212,6 +212,19 @@ fn test_multiple_expressions_from_file() { } } + // Expressions the corpus declares syntactically invalid must be rejected + let mut invalid_syntax = Vec::new(); + collect_invalid_syntax_expressions(&doc.root_element(), &mut invalid_syntax); + for expr_str in invalid_syntax.iter() { + if parser().parse(expr_str.as_str()).into_result().is_ok() { + failure_count += 1; + println!( + "Parsed an expression declared invalid=\"syntax\": '{}'", + expr_str + ); + } + } + // Use eprintln! instead of println! to ensure output appears during cargo test eprintln!( "Successfully parsed {}/{} expressions ({} known failures ignored)", @@ -233,9 +246,22 @@ fn find_test_expressions(root: &Node) -> Vec { expressions } +fn collect_invalid_syntax_expressions(node: &Node, expressions: &mut Vec) { + for expr in node + .descendants() + .filter(|n| n.has_tag_name("expression") && n.attribute("invalid") == Some("syntax")) + { + if let Some(text) = expr.text() { + expressions.push(text.to_string()); + } + } +} + fn collect_expressions(node: &Node, expressions: &mut Vec) { // Process this node if it's an expression - if node.has_tag_name("expression") { + // Expressions declared `invalid="syntax"` must not parse; they are + // checked separately in `test_multiple_expressions_from_file`. + if node.has_tag_name("expression") && node.attribute("invalid") != Some("syntax") { if let Some(text) = node.text() { expressions.push(text.to_string()); }