Continue the facet-validity work. Per XSD Part 2 Datatypes 4.3, each minInclusive/maxInclusive/minExclusive/maxExclusive and enumeration value must be a valid value of the base type; minInclusive excludes minExclusive and maxInclusive excludes maxExclusive; and the lower bound may not exceed the upper bound (nor equal it when either side is exclusive, an empty value space), compared in the base primitive's value space. Bound and enumeration values were previously unchecked, so a malformed or out-of-space value (maxInclusive="", enumeration="" on float) was accepted. The bound-value check (which validates a bound against its base type) exposed a gMonth lexical gap: only --MM was recognized, not the XSD 1.0 form --MM-- used by valid schemas across the corpus. parseMonthOnly now accepts an optional trailing -- (the --MM erratum form still works), with an optional timezone, which removes 26 false schema rejections the bound check would otherwise have caused and also fixes instance-side gMonth validation. XSTS: invalid schemas accepted 2082 to 1789; valid instances rejected 233 to 214 (the gMonth fix); valid schemas rejected held at 72; invalid instances accepted held at 165.
178 lines
6.9 KiB
Swift
178 lines
6.9 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 }
|
|
let 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 }
|
|
// XSD 1.0 gMonth is `--MM--`; the later erratum/1.1 form `--MM` is also
|
|
// accepted, so the trailing `--` is optional. A timezone may follow either.
|
|
_ = consume("--")
|
|
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]))
|
|
}
|
|
}
|
|
}
|