Skip to content

Commit

Permalink
fix & improve FHIR questionnaire → ORKStep translation, question cond…
Browse files Browse the repository at this point in the history
…itions, and skip rule handling (#85)
  • Loading branch information
lukaskollmer authored Dec 8, 2024
1 parent d8d8b0d commit 2c70b9d
Show file tree
Hide file tree
Showing 11 changed files with 790 additions and 212 deletions.
15 changes: 7 additions & 8 deletions Sources/FHIRPathParser/DateComponents+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,33 @@ import Foundation


extension DateComponents {
static var supportedKeyPaths: [WritableKeyPath<DateComponents, Int?>] {
static var supportedKeyPaths: [WritableKeyPath<Self, Int?>] {
[\.year, \.month, \.weekOfYear, \.day, \.hour, \.minute, \.second, \.nanosecond]
}

static prefix func - (components: DateComponents) -> DateComponents {

static prefix func - (components: Self) -> Self {
var components = components
for keyPath in Self.supportedKeyPaths {
// check if the value is set
guard let value = components[keyPath: keyPath] else {
continue
}

components[keyPath: keyPath] = -value
}
return components
}

static func + (lhs: DateComponents, rhs: DateComponents) -> DateComponents {
var result = DateComponents()


static func + (lhs: Self, rhs: Self) -> Self {
var result = Self()
for keyPath in Self.supportedKeyPaths {
guard lhs[keyPath: keyPath] != nil || rhs[keyPath: keyPath] != nil else {
// if the component is nil in both inputs, we keep it nil in the output.
continue
}

result[keyPath: keyPath] = (lhs[keyPath: keyPath] ?? 0) + (rhs[keyPath: keyPath] ?? 0)
}

return result
}
}
39 changes: 22 additions & 17 deletions Sources/FHIRPathParser/DateExpressionEvaluation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,31 @@ final class DateExpressionEvaluation: FHIRPathBaseVisitor<Result<DateEvaluationV
guard let node = ctx.DATETIME() else {
return .failure(ctx.start, .malformedSyntaxTree)
}

// we either have a date like `@2015-02-04`
// or we have a date time like `@2015-02-04T14:34:28+09:00`
// so we need two different parsers

let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withFullDate, .withDashSeparatorInDate]
let dateTimeFormatter = ISO8601DateFormatter()
dateTimeFormatter.formatOptions = .withInternetDateTime

let text = node.getText()
let startIndex = text.index(after: text.startIndex) // skip the `@`

let string = String(text.suffix(from: startIndex))

guard let date = dateTimeFormatter.date(from: string) ?? dateFormatter.date(from: string) else {
do {
let (result, timeZone) = try DateTimeLiteralParser.parse(text)
var dateComponents: DateComponents
switch result {
case .date(let date):
dateComponents = date.components
case .time(let time):
dateComponents = time.components
case .dateTime(let dateTime):
dateComponents = dateTime.components
}
dateComponents.timeZone = timeZone ?? .current
// Setting the components' timeZone to the one we extracted from the parsing (or the current one as a fallback),
// and then calling -date(from:) should always result in a Date with the system's current time zone.
// This way, we can elegantly support parsing dates with different time zones, in a way that when you use them
// the behaviour will be what you expect (the date being interpreted relative to the current time zone, as
// is the case with essentially all Date/Calendar-related APIs).
guard let date = Calendar.current.date(from: dateComponents) else {
return .failure(node.getSymbol(), .invalidLiteral)
}
return .success(.date(date))
} catch {
return .failure(node.getSymbol(), .invalidLiteral)
}

return .success(.date(date))
}

override func visitTimeLiteral(_ ctx: FHIRPathParser.TimeLiteralContext) -> Result<DateEvaluationValue, Error>? {
Expand Down
283 changes: 283 additions & 0 deletions Sources/FHIRPathParser/DateTimeLiteralParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
//
// This source file is part of the ResearchKitOnFHIR open source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation


/// Parser for ISO8601 DateTime literals as used in FHIRPath.
/// Implemented in conformance with the `DATE`, `DATETIME`, and `TIME` rules
/// [in the FHIRPath grammar](https://hl7.org/fhirpath/N1/grammar.html)
struct DateTimeLiteralParser {
enum ParseError: Error {
case unexpectedToken(expected: [Character], found: Character?)
case unsupportedLiteral
}

/// A `Date` as defined by FHIRPath.
/// - Note: FHIRPath allows for partial `Date`s, in which case only some of the components are specified and,
/// starting at the first omitted component, all following components are omitted as well.
/// We currently do not support this; partial `Date`s are treated and represented the same as non-partial `Date`s with the omitted components set to 0.
struct Date {
var year: Int = 0
var month: Int = 0
var day: Int = 0

var components: DateComponents {
DateComponents(year: year, month: month, day: day)
}
}

/// A `Time` as defined by FHIRPath.
/// - Note: FHIRPath allows for partial `Time`s, in which case only some of the components are specified and,
/// starting at the first omitted component, all following components are omitted as well.
/// We currently do not support this; partial `Time`s are treated and represented the same as non-partial `Time`s with the omitted components set to 0.
struct Time {
var hour: Int = 0
var minute: Int = 0
var second: Int = 0

var components: DateComponents {
DateComponents(hour: hour, minute: minute, second: second)
}
}

/// A `DateTime` as defined by FHIRPath.
/// - Note: FHIRPath allows for partial `DateTime`s, in which case the date component is specified, and the time component is omitted.
/// We currently do not support this; partial `DateTime`s are treated and represented the same as non-partial `DateTime`s with the ``Time`` components all set to 0.
struct DateTime {
var date: Date
var time: Time

var components: DateComponents {
date.components + time.components
}
}

enum Result {
case date(Date)
case time(Time)
case dateTime(DateTime)
}

private let input: [Character]
private var position: Int = 0

private var current: Character? { input[safe: position] }
private var next: Character? { input[safe: position + 1] }
private var isAtEnd: Bool { position >= input.endIndex }
private var numRemainingTokens: Int { input.count - position - 1 }


private mutating func consume(_ count: Int = 1) {
position += count
}

/// Checks that the current token is equal to the specified expected value.
/// If yes, the token is consumed (i.e., the position is advanced by 1).
/// - Throws: if the current token is not equal to the specified expected value.
private mutating func expectAndConsume(_ expected: Character) throws(ParseError) {
if current == expected {
consume()
} else {
throw .unexpectedToken(expected: [expected], found: current)
}
}

/// Checks that the current token is equal to one of the specified expected values.
/// If yes, the token is consumed (i.e., the position is advanced by 1).
/// - parameter expected: Non-empty list of tokens we allow to appear at the current position.
/// - Throws: if the current token is not equal to the specified expected value.
/// - Returns: the token that matched.
private mutating func expectAnyOfAndConsume(_ expected: [Character]) throws(ParseError) -> Character {
if let current, expected.contains(current) {
consume()
return current
} else {
throw .unexpectedToken(expected: expected, found: current)
}
}


/// Parses a decimal `Int`, consuming its digits and returning the resulting value.
/// - Note: This function will consume tokens until it reaches the first which is not an ASCII decimal digit character.
/// - Throws: if, when the function is called, the first token is not a decimal digit.
private mutating func parseInt() throws(ParseError) -> Int {
guard !isAtEnd else {
throw .unexpectedToken(expected: Self.asciiDigits, found: nil)
}
if let current, !Self.asciiDigits.contains(current) {
throw .unexpectedToken(expected: Self.asciiDigits, found: current)
}
var value = 0
while let current, Self.asciiDigits.contains(current) {
value *= 10
// Safety: we know that current is an ascii character, and we know that the "0" literal is an ascii character.
// Therefore, we can safely access the asciiValue for both of them.
value += Int(current.asciiValue! - ("0" as Character).asciiValue!) // swiftlint:disable:this force_unwrapping
consume()
}
return value
}
}


extension DateTimeLiteralParser {
// MARK: Date/Time Literal Parsing

/// Parses the provided string into a FHIRPath `Date` or `DateTime` type.
/// - Returns: A tuple of a `Date` object representing the parse result, and the `TimeZone`
/// in which the date should be interpreted, if specified.
/// - Throws: if the input cannot be parsed, e.g. because it is in an invalid format.
static func parse(_ input: String) throws(ParseError) -> (Result, TimeZone?) {
var parser = Self(input: Array(input))
return try parser.run()
}


/// Implements parsing of the `DATE` and `TIME` rules defined in the grammar.
private mutating func run() throws(ParseError) -> (Result, TimeZone?) {
try expectAndConsume("@")
if current == "T" {
let time = try parseTimeFormat()
return (.time(time), nil)
} else {
let date = try parseDateFormat()
if isAtEnd {
// A Date, without any time information.
return (.date(date), nil)
} else if current == "T" {
// Not just a Date, but a DateTime...
consume()
var dateTime = DateTime(date: date, time: .init())
if isAtEnd {
// ...which is partial, and does not have any time information.
return (.dateTime(dateTime), nil)
} else {
// ...which has time information following the date...
dateTime.time = try parseTimeFormat()
if isAtEnd {
// ...but does not specify a time zone offset.
return (.dateTime(dateTime), nil)
} else {
// ...and also specifies a time zone.
let timeZone = try parseTimeZoneOffsetFormat()
return (.dateTime(dateTime), timeZone)
}
}
} else {
// we're not at the end, but the next token after the DATEFORMAT is something other than a 'T'.
// -> this is invalid
throw .unexpectedToken(expected: ["T"], found: current)
}
}
}


/// Implements parsing of the `TIMEFORMAT` rule defined in the grammar.
private mutating func parseTimeFormat() throws(ParseError) -> Time {
// [0-9][0-9] (':'[0-9][0-9] (':'[0-9][0-9] ('.'[0-9]+)?)?)?
let hour = try parseInt()
guard current == ":" else {
return .init(hour: hour)
}
try expectAndConsume(":")
let minute = try parseInt()
guard current == ":" else {
return .init(hour: hour, minute: minute)
}
try expectAndConsume(":")
let second = try parseInt()
switch current {
case ".":
// The time value can optionally have a fractional suffix.
// (In ISO8601, for the last-specified component; in FHIR for the seconds component).
// We currently do not support this.
throw .unsupportedLiteral
default:
return .init(hour: hour, minute: minute, second: second)
}
}


/// Implements parsing of the `DATEFORMAT` rule defined in the grammar.
private mutating func parseDateFormat() throws(ParseError) -> Date {
// [0-9][0-9][0-9][0-9] ('-'[0-9][0-9] ('-'[0-9][0-9])?)?
let year = try parseInt()
guard current == "-" else {
return .init(year: year)
}
try expectAndConsume("-")
let month = try parseInt()
guard current == "-" else {
return .init(year: year, month: month)
}
try expectAndConsume("-")
let day = try parseInt()
return .init(year: year, month: month, day: day)
}


/// Implements parsing of the `TIMEZONEOFFSETFORMAT` rule defined in the grammar.
/// - Throws: if the input tokens are invalid.
/// - Returns: a `TimeZone` matching the specified offset.
/// May return `nil` if the input string specified a valid (w.r.t. the grammar) offset, which however cannot be represented by the `TimeZone` type.
private mutating func parseTimeZoneOffsetFormat() throws(ParseError) -> TimeZone? {
// ('Z' | ('+' | '-') [0-9][0-9]':'[0-9][0-9])
if current == "Z" {
// if the time zone is 'Z', it is interpreted UTC.
consume()
// Safety: it's guaranteed that a TimeZone with this identifier exists.
return TimeZone(identifier: "UTC")! // swiftlint:disable:this force_unwrapping
} else {
let `operator` = try expectAnyOfAndConsume(["+", "-"])
let hours = try parseInt()
try expectAndConsume(":")
let minutes = try parseInt()
var offsetInSeconds = 0
offsetInSeconds += hours * 60 * 60
offsetInSeconds += minutes * 60
offsetInSeconds *= `operator` == "-" ? -1 : 1
return TimeZone(secondsFromGMT: offsetInSeconds)
}
}
}


extension DateTimeLiteralParser {
private static let asciiDigits: [Character] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
}


// MARK: Utilities

extension Collection {
subscript(safe idx: Index) -> Element? {
indices.contains(idx) ? self[idx] : nil
}
}


extension Calendar {
func dateBySetting(timeZone: TimeZone, of date: Date) -> Date? {
var components = dateComponents(in: self.timeZone, from: date)
components.timeZone = timeZone
return self.date(from: components)
}

func convert(
components: DateComponents,
bySettingTimeZoneTo newTimeZone: TimeZone,
componentsToReturn: Set<Component>
) -> DateComponents? {
guard let date = date(from: components),
let adjDate = dateBySetting(timeZone: newTimeZone, of: date) else {
return nil
}
return dateComponents(componentsToReturn, from: adjDate)
}
}
2 changes: 1 addition & 1 deletion Sources/FHIRQuestionnaires/Resources/FormExample.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
}
]
}
},
}
],
"answerOption": [
{
Expand Down
Loading

0 comments on commit 2c70b9d

Please sign in to comment.