PureXML/Sources/Schema/DurationValue.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

120 lines
5.1 KiB
Swift

extension PureXML.Schema {
/// The outcome of comparing two durations, which form a partial order.
enum DurationOrder { case lessThan, equal, greaterThan, incomparable }
/// An `xs:duration` as its two independent signed components, months and
/// seconds, with the XSD 1.0 order relation. Because a month is 28 to 31
/// days, the order is partial: P and Q compare by adding each to the four
/// reference dateTimes the spec fixes, and durations whose sign disagrees
/// across those references are incomparable (for example `P1M` and `P30D`).
/// See XSD 1.0 Datatypes 3.2.6.2. Used by the ordering facets on duration,
/// which the generic total-order path cannot represent.
struct DurationValue {
let months: Int
let seconds: Double
/// The four reference points (year, month); each is the first of its
/// month at T00:00:00Z, so the day is always 1 and need not be carried.
private static let references: [(year: Int, month: Int)] = [
(1696, 9), (1697, 2), (1903, 3), (1903, 7),
]
func compare(to other: DurationValue) -> DurationOrder {
var sawLess = false
var sawGreater = false
var sawEqual = false
for reference in Self.references {
let here = Self.instant(reference, addingMonths: months, seconds: seconds)
let there = Self.instant(reference, addingMonths: other.months, seconds: other.seconds)
if here < there {
sawLess = true
} else if here > there {
sawGreater = true
} else {
sawEqual = true
}
}
// P < Q only when s+P < s+Q at every reference; > likewise; equal
// when all four coincide (which holds iff the components are equal).
if sawLess, !sawGreater, !sawEqual {
return .lessThan
}
if sawGreater, !sawLess, !sawEqual {
return .greaterThan
}
if sawEqual, !sawLess, !sawGreater {
return .equal
}
return .incomparable
}
/// The instant, in seconds, of `reference` + (months, seconds): add the
/// months with calendar rollover, then add the seconds. The reference
/// day is always 1, which is valid in every month, so no day clamping is
/// needed (a duration's day component lives in `seconds`, not here).
private static func instant(
_ reference: (year: Int, month: Int),
addingMonths addMonths: Int,
seconds: Double,
) -> Double {
let total = (reference.month - 1) + addMonths
let yearsCarried = Int((Double(total) / 12.0).rounded(.down))
let newYear = reference.year + yearsCarried
let newMonth = total - yearsCarried * 12 + 1
let days = DateTimeValue.daysFromCivil(year: newYear, month: newMonth, day: 1)
return Double(days) * 86400 + seconds
}
/// Parses the lexical form `-?P(nY)?(nM)?(nD)?(T(nH)?(nM)?(nS)?)?`. The
/// lexical recognizer has already accepted the value, so this trusts the
/// shape and only reads out the components.
init?(_ string: String) {
var characters = Substring(string)
var sign = 1.0
if characters.first == "-" {
sign = -1
characters = characters.dropFirst()
}
guard characters.first == "P" else { return nil }
characters = characters.dropFirst()
var inTime = false
var years = 0
var monthsPart = 0
var days = 0
var secondsPart = 0.0
var sawComponent = false
var number = ""
func consume(_ designator: Character) -> Bool {
guard !number.isEmpty, let magnitude = Double(number) else { return false }
switch (inTime, designator) {
case (false, "Y"): years = Int(magnitude)
case (false, "M"): monthsPart = Int(magnitude)
case (false, "D"): days = Int(magnitude)
case (true, "H"): secondsPart += magnitude * 3600
case (true, "M"): secondsPart += magnitude * 60
case (true, "S"): secondsPart += magnitude
default: return false
}
number = ""
sawComponent = true
return true
}
for character in characters {
if character == "T" {
inTime = true
} else if character.isNumber || character == "." {
number.append(character)
} else if !consume(character) {
return nil
}
}
guard sawComponent, number.isEmpty else { return nil }
months = Int(sign) * (years * 12 + monthsPart)
seconds = sign * (Double(days) * 86400 + secondsPart)
}
}
}