Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/fhirpath/src/collection_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
123 changes: 78 additions & 45 deletions crates/fhirpath/src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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('\\');
Expand Down Expand Up @@ -6960,37 +6969,40 @@ fn add_duration_to_datetime(
value: Decimal,
unit: &str,
) -> Result<EvaluationResult, EvaluationError> {
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);
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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<char> {
fn hex4(chars: &mut std::str::Chars<'_>) -> Option<u32> {
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
Expand All @@ -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, _, _) => {
Expand Down
33 changes: 4 additions & 29 deletions crates/fhirpath/src/extension_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions crates/fhirpath/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading