Add the PureXML.Schema namespace validating XSD Part 2 built-in datatypes and constraining facets. Built-ins span string/boolean/decimal, the bounded integer family with intrinsic bounds, float/double, duration, the eight date/time types, hexBinary, base64Binary, anyURI, QName, and the Name/NCName/NMTOKEN/language family. Facets: length/minLength/maxLength, pattern (via the regex engine), enumeration (value-space comparison), whiteSpace, min/maxInclusive/Exclusive, totalDigits, fractionDigits. Decimals compare exactly with no floating-point loss; date/time fields are range-validated with leap years, 24:00:00, and timezone bounds. Closes #8
175 lines
6.7 KiB
Swift
175 lines
6.7 KiB
Swift
extension PureXML.Schema {
|
|
/// Parses an XSD date/time lexical form for a given ``DateKind``, validating
|
|
/// field ranges (including leap years and the `24:00:00` midnight form) and
|
|
/// the timezone. Returns nil when the form is invalid.
|
|
struct DateTimeParser {
|
|
private let chars: [Character]
|
|
private var index = 0
|
|
|
|
private init(_ lexical: String) {
|
|
chars = Array(lexical)
|
|
}
|
|
|
|
static func parse(_ lexical: String, kind: DateKind) -> DateTimeValue? {
|
|
var parser = DateTimeParser(lexical)
|
|
guard var value = parser.parseFields(kind) else { return nil }
|
|
guard parser.parseTimezone(into: &value) else { return nil }
|
|
return parser.isAtEnd ? value : nil
|
|
}
|
|
|
|
private mutating func parseFields(_ kind: DateKind) -> DateTimeValue? {
|
|
switch kind {
|
|
case .dateTime: parseDateTime()
|
|
case .date: parseDate()
|
|
case .time: parseTimeOnly()
|
|
case .gYearMonth: parseYearMonth()
|
|
case .gYear: parseYearOnly()
|
|
case .gMonthDay: parseMonthDay()
|
|
case .gDay: parseDayOnly()
|
|
case .gMonth: parseMonthOnly()
|
|
}
|
|
}
|
|
|
|
// MARK: Composite forms
|
|
|
|
private mutating func parseDateTime() -> DateTimeValue? {
|
|
guard var value = parseDate(), consume("T") else { return nil }
|
|
return parseTime(into: &value) ? value : nil
|
|
}
|
|
|
|
private mutating func parseDate() -> DateTimeValue? {
|
|
guard var value = parseYearMonth(), consume("-"), let day = readFixed(2) else { return nil }
|
|
value.day = day
|
|
return dayInRange(value) ? value : nil
|
|
}
|
|
|
|
private mutating func parseYearMonth() -> DateTimeValue? {
|
|
guard var value = parseYearOnly(), consume("-"), let month = readFixed(2), (1 ... 12).contains(month) else {
|
|
return nil
|
|
}
|
|
value.month = month
|
|
return value
|
|
}
|
|
|
|
private mutating func parseYearOnly() -> DateTimeValue? {
|
|
var value = DateTimeValue()
|
|
let negative = consume("-")
|
|
guard let digits = readDigits(minimum: 4) else { return nil }
|
|
value.year = (negative ? -1 : 1) * digits
|
|
return value
|
|
}
|
|
|
|
private mutating func parseTimeOnly() -> DateTimeValue? {
|
|
var value = DateTimeValue()
|
|
return parseTime(into: &value) ? value : nil
|
|
}
|
|
|
|
// MARK: g* forms
|
|
|
|
private mutating func parseMonthDay() -> DateTimeValue? {
|
|
guard consume("--"), let month = readFixed(2), (1 ... 12).contains(month), consume("-"),
|
|
let day = readFixed(2) else { return nil }
|
|
var value = DateTimeValue(year: 2000, month: month, day: day)
|
|
return dayInRange(value) ? value : nil
|
|
}
|
|
|
|
private mutating func parseMonthOnly() -> DateTimeValue? {
|
|
guard consume("--"), let month = readFixed(2), (1 ... 12).contains(month) else { return nil }
|
|
return DateTimeValue(month: month)
|
|
}
|
|
|
|
private mutating func parseDayOnly() -> DateTimeValue? {
|
|
guard consume("---"), let day = readFixed(2), (1 ... 31).contains(day) else { return nil }
|
|
return DateTimeValue(day: day)
|
|
}
|
|
|
|
// MARK: Time and timezone
|
|
|
|
private mutating func parseTime(into value: inout DateTimeValue) -> Bool {
|
|
guard let hour = readFixed(2), consume(":"), let minute = readFixed(2), consume(":"),
|
|
let second = readFixed(2) else { return false }
|
|
guard (0 ... 24).contains(hour), (0 ... 59).contains(minute), (0 ... 59).contains(second) else { return false }
|
|
if hour == 24, minute != 0 || second != 0 { return false }
|
|
value.hour = hour
|
|
value.minute = minute
|
|
value.second = second
|
|
if consume(".") {
|
|
guard let fraction = readFraction() else { return false }
|
|
value.fraction = fraction
|
|
}
|
|
return true
|
|
}
|
|
|
|
private mutating func parseTimezone(into value: inout DateTimeValue) -> Bool {
|
|
if consume("Z") { value.offsetMinutes = 0
|
|
return true
|
|
}
|
|
guard peek() == "+" || peek() == "-" else { return true }
|
|
let negative = consume("-")
|
|
if !negative { advance() }
|
|
guard let hour = readFixed(2), consume(":"), let minute = readFixed(2) else { return false }
|
|
guard (0 ... 14).contains(hour), (0 ... 59).contains(minute), hour * 60 + minute <= 14 * 60 else {
|
|
return false
|
|
}
|
|
value.offsetMinutes = (negative ? -1 : 1) * (hour * 60 + minute)
|
|
return true
|
|
}
|
|
|
|
private func dayInRange(_ value: DateTimeValue) -> Bool {
|
|
(1 ... DateTimeValue.daysInMonth(year: value.year, month: value.month)).contains(value.day)
|
|
}
|
|
|
|
// MARK: Cursor
|
|
|
|
private var isAtEnd: Bool {
|
|
index >= chars.count
|
|
}
|
|
|
|
private func peek() -> Character? {
|
|
index < chars.count ? chars[index] : nil
|
|
}
|
|
|
|
private mutating func advance() {
|
|
if index < chars.count { index += 1 }
|
|
}
|
|
|
|
private mutating func consume(_ literal: String) -> Bool {
|
|
let target = Array(literal)
|
|
guard index + target.count <= chars.count, Array(chars[index ..< index + target.count]) == target else {
|
|
return false
|
|
}
|
|
index += target.count
|
|
return true
|
|
}
|
|
|
|
/// Reads exactly `count` digits as an integer, or nil.
|
|
private mutating func readFixed(_ count: Int) -> Int? {
|
|
guard index + count <= chars.count else { return nil }
|
|
let slice = chars[index ..< index + count]
|
|
guard slice.allSatisfy(\.isNumber) else { return nil }
|
|
index += count
|
|
return Int(String(slice))
|
|
}
|
|
|
|
/// Reads at least `minimum` digits as an integer.
|
|
private mutating func readDigits(minimum: Int) -> Int? {
|
|
let start = index
|
|
while let character = peek(), character.isNumber {
|
|
advance()
|
|
}
|
|
guard index - start >= minimum else { return nil }
|
|
return Int(String(chars[start ..< index]))
|
|
}
|
|
|
|
/// Reads the fractional seconds after the decimal point.
|
|
private mutating func readFraction() -> Double? {
|
|
let start = index
|
|
while let character = peek(), character.isNumber {
|
|
advance()
|
|
}
|
|
guard index > start else { return nil }
|
|
return Double("0." + String(chars[start ..< index]))
|
|
}
|
|
}
|
|
}
|