PureXML/Sources/Schema/DecimalValue.swift
Mihaela Mihaljevic e862f364aa feat(schema): XSD simple-type datatype library with facets
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
2026-06-08 06:29:37 +02:00

93 lines
4.2 KiB
Swift

extension PureXML.Schema {
/// An exact decimal value, parsed from an XSD `decimal`/`integer` lexical form
/// and compared without floating-point loss. Holds the sign and the
/// significant integer and fraction digits (leading and trailing zeros
/// removed), which is also what `totalDigits` and `fractionDigits` count.
struct DecimalValue: Equatable, Comparable, Sendable {
let negative: Bool
let integerDigits: [Character]
let fractionDigits: [Character]
/// Parses a decimal lexical form. With `allowFraction` false the value must
/// be an integer (no decimal point).
init?(_ lexical: String, allowFraction: Bool) {
var characters = Array(lexical)
guard !characters.isEmpty else { return nil }
var negative = false
if characters.first == "+" || characters.first == "-" {
negative = characters.first == "-"
characters.removeFirst()
}
guard !characters.isEmpty else { return nil }
let parts = Self.split(characters)
guard let (whole, fraction) = parts, allowFraction || fraction == nil else { return nil }
guard whole.allSatisfy(\.isNumber), (fraction ?? []).allSatisfy(\.isNumber) else { return nil }
guard !whole.isEmpty || !(fraction ?? []).isEmpty else { return nil }
let integerDigits = Self.dropLeadingZeros(whole)
let fractionDigits = Self.dropTrailingZeros(fraction ?? [])
self.integerDigits = integerDigits
self.fractionDigits = fractionDigits
// Negative zero is the same value as zero.
self.negative = (integerDigits.isEmpty && fractionDigits.isEmpty) ? false : negative
}
/// Splits the digits on a single decimal point, rejecting a second point.
/// Returns nil when malformed.
private static func split(_ characters: [Character]) -> (whole: [Character], fraction: [Character]?)? {
guard let dot = characters.firstIndex(of: ".") else { return (characters, nil) }
let rest = characters[(dot + 1)...]
guard !rest.contains(".") else { return nil }
return (Array(characters[..<dot]), Array(rest))
}
private static func dropLeadingZeros(_ digits: [Character]) -> [Character] {
Array(digits.drop { $0 == "0" })
}
private static func dropTrailingZeros(_ digits: [Character]) -> [Character] {
var digits = digits
while digits.last == "0" {
digits.removeLast()
}
return digits
}
var isZero: Bool {
integerDigits.isEmpty && fractionDigits.isEmpty
}
/// The XSD `totalDigits` of the value: every significant digit, at least one.
var totalDigits: Int {
Swift.max(1, integerDigits.count + fractionDigits.count)
}
static func < (lhs: DecimalValue, rhs: DecimalValue) -> Bool {
if lhs.negative != rhs.negative { return lhs.negative }
let magnitude = compareMagnitude(lhs, rhs)
return lhs.negative ? magnitude > 0 : magnitude < 0
}
/// Negative when `lhs` has the smaller magnitude, positive when larger.
private static func compareMagnitude(_ lhs: DecimalValue, _ rhs: DecimalValue) -> Int {
if lhs.integerDigits.count != rhs.integerDigits.count {
return lhs.integerDigits.count < rhs.integerDigits.count ? -1 : 1
}
if let order = lexicographic(lhs.integerDigits, rhs.integerDigits) { return order }
return lexicographic(lhs.fractionDigits, rhs.fractionDigits) ?? 0
}
/// Digit-by-digit comparison; a proper prefix is the smaller value. Returns
/// nil when the sequences are identical.
private static func lexicographic(_ lhs: [Character], _ rhs: [Character]) -> Int? {
for (left, right) in zip(lhs, rhs) where left != right {
return left < right ? -1 : 1
}
if lhs.count == rhs.count { return nil }
return lhs.count < rhs.count ? -1 : 1
}
}
}