From e862f364aadced2e949ef8c6d5da200e5b085a59 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 06:29:37 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 11 ++ Sources/PureXML.swift | 3 + Sources/Schema/BuiltinType.swift | 127 ++++++++++++++++++++ Sources/Schema/DateTimeParser.swift | 174 ++++++++++++++++++++++++++++ Sources/Schema/DateTimeValue.swift | 62 ++++++++++ Sources/Schema/DecimalValue.swift | 92 +++++++++++++++ Sources/Schema/Lexical.swift | 130 +++++++++++++++++++++ Sources/Schema/Primitive.swift | 106 +++++++++++++++++ Sources/Schema/SimpleType.swift | 166 ++++++++++++++++++++++++++ Tests/XSDDatatypeTests.swift | 124 ++++++++++++++++++++ 10 files changed, 995 insertions(+) create mode 100644 Sources/Schema/BuiltinType.swift create mode 100644 Sources/Schema/DateTimeParser.swift create mode 100644 Sources/Schema/DateTimeValue.swift create mode 100644 Sources/Schema/DecimalValue.swift create mode 100644 Sources/Schema/Lexical.swift create mode 100644 Sources/Schema/Primitive.swift create mode 100644 Sources/Schema/SimpleType.swift create mode 100644 Tests/XSDDatatypeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb1944..3f45c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSD simple-type datatype library (toward #2 schema validation). The new + `PureXML.Schema` namespace validates a lexical value against the XSD Part 2 + built-in datatypes (`string`, `boolean`, `decimal`, the bounded integer family, + `float`/`double`, `duration`, the eight date/time types, `hexBinary`, + `base64Binary`, `anyURI`, `QName`, and the `Name`/`NCName`/`NMTOKEN`/`language` + family) and the constraining facets (`length`/`minLength`/`maxLength`, + `pattern` via the regex engine, `enumeration`, `whiteSpace`, + `min`/`maxInclusive`/`Exclusive`, `totalDigits`, `fractionDigits`). Decimals are + compared exactly with no floating-point loss, date/time fields are + range-validated (leap years, `24:00:00`, timezone bounds), and the integer + types enforce their intrinsic bounds. - Regular-expression engine (#30, the XML Schema regex flavor, gating XSD). The new `PureXML.Regex` namespace compiles a pattern (literals, `.`, character classes with ranges and negation, the escapes `\d \D \w \W \s \S \i \I \c \C`, diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index 7783ff1..9a77205 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -42,4 +42,7 @@ public enum PureXML { /// Regular-expression types (the XML Schema regex flavor). public enum Regex {} + + /// XML Schema (XSD) and RELAX NG validation types. + public enum Schema {} } diff --git a/Sources/Schema/BuiltinType.swift b/Sources/Schema/BuiltinType.swift new file mode 100644 index 0000000..4b70c54 --- /dev/null +++ b/Sources/Schema/BuiltinType.swift @@ -0,0 +1,127 @@ +public extension PureXML.Schema { + /// Whitespace processing applied before a value is validated. + enum WhiteSpace: Sendable { + case preserve + case replace + case collapse + } + + /// A built-in XSD datatype. Each carries its primitive value space, its + /// intrinsic `whiteSpace`, and, for the bounded integer types, its inclusive + /// bounds, exactly as XSD Part 2 derives them. + enum BuiltinType: String, Sendable, CaseIterable { + case string + case normalizedString + case token + case language + case name = "Name" + case ncName = "NCName" + case nmtoken = "NMTOKEN" + case id = "ID" + case idref = "IDREF" + case entity = "ENTITY" + case boolean + case decimal + case integer + case long + case int + case short + case byte + case nonNegativeInteger + case positiveInteger + case nonPositiveInteger + case negativeInteger + case unsignedLong + case unsignedInt + case unsignedShort + case unsignedByte + case float + case double + case duration + case dateTime + case date + case time + case gYearMonth + case gYear + case gMonthDay + case gDay + case gMonth + case hexBinary + case base64Binary + case anyURI + case qName = "QName" + + var whiteSpace: WhiteSpace { + switch self { + case .string: .preserve + case .normalizedString: .replace + default: .collapse + } + } + + var primitive: Primitive { + if let primitive = Self.stringLike[self] { return primitive } + if Self.integerLike.contains(self) { return .integer } + return computedPrimitive + } + + private var computedPrimitive: Primitive { + switch self { + case .boolean: .boolean + case .decimal: .decimal + case .float: .float + case .double: .double + case .duration: .duration + case .dateTime: .dateKind(.dateTime) + case .date: .dateKind(.date) + case .time: .dateKind(.time) + case .gYearMonth: .dateKind(.gYearMonth) + case .gYear: .dateKind(.gYear) + case .gMonthDay: .dateKind(.gMonthDay) + case .gDay: .dateKind(.gDay) + case .gMonth: .dateKind(.gMonth) + case .hexBinary: .hexBinary + case .base64Binary: .base64Binary + case .anyURI: .anyURI + case .qName: .qName + default: .string + } + } + + private static let stringLike: [BuiltinType: Primitive] = [ + .string: .string, .normalizedString: .string, .token: .string, + .language: .language, .name: .name, + .ncName: .ncName, .id: .ncName, .idref: .ncName, .entity: .ncName, + .nmtoken: .nmtoken, + ] + + private static let integerLike: Set = [ + .integer, .long, .int, .short, .byte, + .nonNegativeInteger, .positiveInteger, .nonPositiveInteger, .negativeInteger, + .unsignedLong, .unsignedInt, .unsignedShort, .unsignedByte, + ] + + /// The intrinsic inclusive bounds of the bounded integer types. + var bounds: (lower: DecimalValue?, upper: DecimalValue?) { + switch self { + case .long: (Self.bound("-9223372036854775808"), Self.bound("9223372036854775807")) + case .int: (Self.bound("-2147483648"), Self.bound("2147483647")) + case .short: (Self.bound("-32768"), Self.bound("32767")) + case .byte: (Self.bound("-128"), Self.bound("127")) + case .unsignedLong: (Self.bound("0"), Self.bound("18446744073709551615")) + case .unsignedInt: (Self.bound("0"), Self.bound("4294967295")) + case .unsignedShort: (Self.bound("0"), Self.bound("65535")) + case .unsignedByte: (Self.bound("0"), Self.bound("255")) + case .nonNegativeInteger: (Self.bound("0"), nil) + case .positiveInteger: (Self.bound("1"), nil) + case .nonPositiveInteger: (nil, Self.bound("0")) + case .negativeInteger: (nil, Self.bound("-1")) + default: (nil, nil) + } + } + + private static func bound(_ lexical: String) -> DecimalValue? { + DecimalValue(lexical, allowFraction: false) + } + } +} diff --git a/Sources/Schema/DateTimeParser.swift b/Sources/Schema/DateTimeParser.swift new file mode 100644 index 0000000..d6f1da9 --- /dev/null +++ b/Sources/Schema/DateTimeParser.swift @@ -0,0 +1,174 @@ +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])) + } + } +} diff --git a/Sources/Schema/DateTimeValue.swift b/Sources/Schema/DateTimeValue.swift new file mode 100644 index 0000000..15b3881 --- /dev/null +++ b/Sources/Schema/DateTimeValue.swift @@ -0,0 +1,62 @@ +extension PureXML.Schema { + /// The eight XSD date/time datatypes, distinguished by which fields their + /// lexical form carries. + enum DateKind: Sendable { + case dateTime + case date + case time + case gYearMonth + case gYear + case gMonthDay + case gDay + case gMonth + } + + /// A parsed and range-validated XSD date/time value, with a UTC-normalized key + /// for ordering facet bounds. Values without a timezone are ordered as if in + /// UTC, which gives the total order facet checking needs. + struct DateTimeValue: Equatable, Comparable, Sendable { + var year = 1 + var month = 1 + var day = 1 + var hour = 0 + var minute = 0 + var second = 0 + var fraction = 0.0 + var offsetMinutes = 0 + + static func < (lhs: DateTimeValue, rhs: DateTimeValue) -> Bool { + lhs.key < rhs.key + } + + /// Seconds from a fixed epoch in UTC, the ordering key. + private var key: Double { + let days = Self.daysFromCivil(year: year, month: month, day: day) + let seconds = Double(days * 86400 + hour * 3600 + minute * 60 + second) + return seconds + fraction - Double(offsetMinutes * 60) + } + + /// The civil-date-to-day-count algorithm (proleptic Gregorian), valid for + /// any year. Day 0 is 1970-01-01. + static func daysFromCivil(year: Int, month: Int, day: Int) -> Int { + let shifted = month <= 2 ? year - 1 : year + let era = (shifted >= 0 ? shifted : shifted - 399) / 400 + let yearOfEra = shifted - era * 400 + let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1 + let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + return era * 146_097 + dayOfEra - 719_468 + } + + static func isLeapYear(_ year: Int) -> Bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + } + + static func daysInMonth(year: Int, month: Int) -> Int { + switch month { + case 1, 3, 5, 7, 8, 10, 12: 31 + case 4, 6, 9, 11: 30 + default: isLeapYear(year) ? 29 : 28 + } + } + } +} diff --git a/Sources/Schema/DecimalValue.swift b/Sources/Schema/DecimalValue.swift new file mode 100644 index 0000000..0299ef3 --- /dev/null +++ b/Sources/Schema/DecimalValue.swift @@ -0,0 +1,92 @@ +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[.. [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 + } + } +} diff --git a/Sources/Schema/Lexical.swift b/Sources/Schema/Lexical.swift new file mode 100644 index 0000000..8c1ce1f --- /dev/null +++ b/Sources/Schema/Lexical.swift @@ -0,0 +1,130 @@ +extension PureXML.Schema { + /// Lexical recognizers for the XSD primitive value spaces that are not date or + /// decimal. Each takes an already whiteSpace-processed string. + enum Lexical { + static func isBoolean(_ value: String) -> Bool { + value == "true" || value == "false" || value == "0" || value == "1" + } + + /// `float`/`double`: an optional-sign mantissa with an optional exponent, + /// or the special values `INF`, `-INF`, `NaN`. + static func isFloating(_ value: String) -> Bool { + if value == "INF" || value == "-INF" || value == "NaN" { return true } + var characters = Substring(value) + if characters.first == "+" || characters.first == "-" { characters = characters.dropFirst() } + let exponentSplit = characters.split(separator: "e", omittingEmptySubsequences: false) + let altSplit = characters.split(separator: "E", omittingEmptySubsequences: false) + let parts = exponentSplit.count == 2 ? exponentSplit : altSplit + guard parts.count <= 2 else { return false } + guard isDecimalMantissa(parts[0]) else { return false } + return parts.count == 1 || isSignedInteger(parts[1]) + } + + private static func isDecimalMantissa(_ value: Substring) -> Bool { + let segments = value.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count <= 2 else { return false } + let whole = segments[0] + let fraction = segments.count == 2 ? segments[1] : Substring("") + guard whole.allSatisfy(\.isNumber), fraction.allSatisfy(\.isNumber) else { return false } + return !whole.isEmpty || !fraction.isEmpty + } + + private static func isSignedInteger(_ value: Substring) -> Bool { + var digits = value + if digits.first == "+" || digits.first == "-" { digits = digits.dropFirst() } + return !digits.isEmpty && digits.allSatisfy(\.isNumber) + } + + /// `duration`: `-?P(nY)?(nM)?(nD)?(T(nH)?(nM)?(nS, with fraction)?)?` with at + /// least one component and no empty `T`. + static func isDuration(_ value: String) -> Bool { + var rest = Substring(value) + if rest.first == "-" { rest = rest.dropFirst() } + guard rest.first == "P" else { return false } + rest = rest.dropFirst() + let timeSplit = rest.split(separator: "T", omittingEmptySubsequences: false) + guard timeSplit.count <= 2 else { return false } + let datePart = timeSplit[0] + let timePart = timeSplit.count == 2 ? timeSplit[1] : nil + let dateOK = matchDuration(datePart, designators: "YMD", allowFraction: false) + guard let dateCount = dateOK else { return false } + guard let timePart else { return dateCount > 0 } + guard let timeCount = matchDuration(timePart, designators: "HMS", allowFraction: true), timeCount > 0 else { + return false + } + return true + } + + /// Matches a run of `` groups in the given designator + /// order, returning the group count or nil on a malformed run. + private static func matchDuration(_ value: Substring, designators: String, allowFraction: Bool) -> Int? { + var rest = value + var remaining = Array(designators) + var count = 0 + while !rest.isEmpty { + let digits = rest.prefix { $0.isNumber || (allowFraction && $0 == ".") } + guard !digits.isEmpty, isUnsignedNumber(digits, allowFraction: allowFraction) else { return nil } + rest = rest.dropFirst(digits.count) + guard let designator = rest.first, let position = remaining.firstIndex(of: designator) else { return nil } + remaining.removeFirst(position + 1) + rest = rest.dropFirst() + count += 1 + } + return count + } + + private static func isUnsignedNumber(_ value: Substring, allowFraction: Bool) -> Bool { + guard allowFraction else { return value.allSatisfy(\.isNumber) } + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count <= 2, parts[0].allSatisfy(\.isNumber) else { return false } + return parts.count == 1 || parts[1].allSatisfy(\.isNumber) + } + + static func isHexBinary(_ value: String) -> Bool { + value.count.isMultiple(of: 2) && value.allSatisfy(\.isHexDigit) + } + + static func isBase64Binary(_ value: String) -> Bool { + guard value.count.isMultiple(of: 4) else { return value.isEmpty } + let alphabet = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/") + let body = value.prefix { alphabet.contains($0) } + let padding = value.dropFirst(body.count) + return padding.allSatisfy { $0 == "=" } && padding.count <= 2 + } + + static func isLanguage(_ value: String) -> Bool { + let parts = value.split(separator: "-", omittingEmptySubsequences: false) + guard let first = parts.first, (1 ... 8).contains(first.count), first.allSatisfy(\.isLetter) else { + return false + } + return parts.dropFirst().allSatisfy { (1 ... 8).contains($0.count) && $0.allSatisfy(\.isLetterOrDigit) } + } + + static func isNCName(_ value: String) -> Bool { + !value.contains(":") && isName(value) + } + + static func isQName(_ value: String) -> Bool { + let parts = value.split(separator: ":", omittingEmptySubsequences: false) + switch parts.count { + case 1: return isName(value) + case 2: return isNCName(String(parts[0])) && isNCName(String(parts[1])) + default: return false + } + } + + static func isName(_ value: String) -> Bool { + PureXML.Parsing.XMLCharacter.isValidName(value) + } + + static func isNMToken(_ value: String) -> Bool { + !value.isEmpty && value.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) + } + } +} + +private extension Character { + var isLetterOrDigit: Bool { + isLetter || isNumber + } +} diff --git a/Sources/Schema/Primitive.swift b/Sources/Schema/Primitive.swift new file mode 100644 index 0000000..30fa09a --- /dev/null +++ b/Sources/Schema/Primitive.swift @@ -0,0 +1,106 @@ +extension PureXML.Schema { + /// A value compared for the ordering facets (`minInclusive` and friends). + enum OrderedValue: Equatable, Comparable { + case decimal(DecimalValue) + case double(Double) + case dateTime(DateTimeValue) + + static func < (lhs: OrderedValue, rhs: OrderedValue) -> Bool { + switch (lhs, rhs) { + case let (.decimal(left), .decimal(right)): left < right + case let (.double(left), .double(right)): left < right + case let (.dateTime(left), .dateTime(right)): left < right + default: false + } + } + } + + /// An XSD primitive value space: the lexical recognizer, and for the ordered + /// spaces a value usable by the ordering facets. + enum Primitive: Sendable { + case string + case boolean + case decimal + case integer + case double + case float + case duration + case dateKind(DateKind) + case hexBinary + case base64Binary + case anyURI + case qName + case name + case ncName + case nmtoken + case language + + func isValid(_ value: String) -> Bool { + stringValidity(value) ?? numericValidity(value) + } + + /// Validity for the string-like and boolean spaces; nil when this primitive + /// is handled by ``numericValidity(_:)``. + private func stringValidity(_ value: String) -> Bool? { + switch self { + case .string, .anyURI: true + case .boolean: Lexical.isBoolean(value) + case .qName: Lexical.isQName(value) + case .name: Lexical.isName(value) + case .ncName: Lexical.isNCName(value) + case .nmtoken: Lexical.isNMToken(value) + case .language: Lexical.isLanguage(value) + default: nil + } + } + + private func numericValidity(_ value: String) -> Bool { + switch self { + case .decimal: DecimalValue(value, allowFraction: true) != nil + case .integer: DecimalValue(value, allowFraction: false) != nil + case .double, .float: Lexical.isFloating(value) + case .duration: Lexical.isDuration(value) + case let .dateKind(kind): DateTimeParser.parse(value, kind: kind) != nil + case .hexBinary: Lexical.isHexBinary(value) + case .base64Binary: Lexical.isBase64Binary(value) + default: false + } + } + + /// The ordering value for a lexically valid string, or nil for the + /// unordered spaces. + func ordered(_ value: String) -> OrderedValue? { + switch self { + case .decimal: DecimalValue(value, allowFraction: true).map(OrderedValue.decimal) + case .integer: DecimalValue(value, allowFraction: false).map(OrderedValue.decimal) + case .double, .float: Self.floatingValue(value).map(OrderedValue.double) + case let .dateKind(kind): DateTimeParser.parse(value, kind: kind).map(OrderedValue.dateTime) + default: nil + } + } + + private static func floatingValue(_ value: String) -> Double? { + switch value { + case "INF": .infinity + case "-INF": -.infinity + case "NaN": .nan + default: Double(value) + } + } + + /// The unit `length`, `minLength`, and `maxLength` count: octets for binary + /// types, characters otherwise. + func measuredLength(_ value: String) -> Int { + switch self { + case .hexBinary: value.count / 2 + case .base64Binary: Self.base64Octets(value) + default: value.count + } + } + + private static func base64Octets(_ value: String) -> Int { + let padding = value.reversed().prefix { $0 == "=" }.count + return value.count / 4 * 3 - padding + } + } +} diff --git a/Sources/Schema/SimpleType.swift b/Sources/Schema/SimpleType.swift new file mode 100644 index 0000000..e499351 --- /dev/null +++ b/Sources/Schema/SimpleType.swift @@ -0,0 +1,166 @@ +public extension PureXML.Schema { + /// The constraining facets of a simple type. Every facet is optional; an unset + /// facet imposes no constraint. `patterns` are combined with AND. + struct Facets: Sendable { + public var length: Int? + public var minLength: Int? + public var maxLength: Int? + public var patterns: [String] + public var enumeration: [String]? + public var whiteSpace: WhiteSpace? + public var minInclusive: String? + public var maxInclusive: String? + public var minExclusive: String? + public var maxExclusive: String? + public var totalDigits: Int? + public var fractionDigits: Int? + + public init( + length: Int? = nil, + minLength: Int? = nil, + maxLength: Int? = nil, + patterns: [String] = [], + enumeration: [String]? = nil, + whiteSpace: WhiteSpace? = nil, + minInclusive: String? = nil, + maxInclusive: String? = nil, + minExclusive: String? = nil, + maxExclusive: String? = nil, + totalDigits: Int? = nil, + fractionDigits: Int? = nil, + ) { + self.length = length + self.minLength = minLength + self.maxLength = maxLength + self.patterns = patterns + self.enumeration = enumeration + self.whiteSpace = whiteSpace + self.minInclusive = minInclusive + self.maxInclusive = maxInclusive + self.minExclusive = minExclusive + self.maxExclusive = maxExclusive + self.totalDigits = totalDigits + self.fractionDigits = fractionDigits + } + } + + /// A simple type: a built-in base restricted by facets. Validates a lexical + /// value against the XSD Part 2 rules: whitespace processing, the base value + /// space, the base's intrinsic bounds, then the facets. + struct SimpleType: Sendable { + public var base: BuiltinType + public var facets: Facets + + public init(base: BuiltinType, facets: Facets = Facets()) { + self.base = base + self.facets = facets + } + + /// Whether `lexical` is a valid value of this type. + public func isValid(_ lexical: String) -> Bool { + validate(lexical) == nil + } + + /// A description of the first constraint `lexical` violates, or nil when it + /// is valid. + public func validate(_ lexical: String) -> String? { + let value = Self.process(lexical, whiteSpace: facets.whiteSpace ?? base.whiteSpace) + let primitive = base.primitive + guard primitive.isValid(value) else { + return "'\(lexical)' is not a valid \(base.rawValue)" + } + return boundsError(value, primitive) + ?? lengthError(value, primitive) + ?? digitsError(value) + ?? patternError(value) + ?? enumerationError(value, primitive) + ?? rangeError(value, primitive) + } + + // MARK: Whitespace + + static func process(_ value: String, whiteSpace: WhiteSpace) -> String { + switch whiteSpace { + case .preserve: + return value + case .replace: + return String(value.map { ($0 == "\t" || $0 == "\n" || $0 == "\r") ? " " : $0 }) + case .collapse: + let replaced = value.map { ($0 == "\t" || $0 == "\n" || $0 == "\r") ? " " : $0 } + return String(replaced).split(separator: " ").joined(separator: " ") + } + } + + // MARK: Facet checks + + private func boundsError(_ value: String, _: Primitive) -> String? { + let (lower, upper) = base.bounds + guard lower != nil || upper != nil, let decimal = DecimalValue(value, allowFraction: false) else { + return nil + } + if let lower, decimal < lower { return "\(value) is below the minimum for \(base.rawValue)" } + if let upper, decimal > upper { return "\(value) is above the maximum for \(base.rawValue)" } + return nil + } + + private func lengthError(_ value: String, _ primitive: Primitive) -> String? { + let length = primitive.measuredLength(value) + if let exact = facets.length, length != exact { return "length \(length) is not \(exact)" } + if let minimum = facets.minLength, length < minimum { return "length \(length) is below \(minimum)" } + if let maximum = facets.maxLength, length > maximum { return "length \(length) is above \(maximum)" } + return nil + } + + private func digitsError(_ value: String) -> String? { + guard facets.totalDigits != nil || facets.fractionDigits != nil, + let decimal = DecimalValue(value, allowFraction: true) else { return nil } + if let total = facets.totalDigits, decimal.totalDigits > total { return "more than \(total) total digits" } + if let fraction = facets.fractionDigits, decimal.fractionDigits.count > fraction { + return "more than \(fraction) fraction digits" + } + return nil + } + + private func patternError(_ value: String) -> String? { + for pattern in facets.patterns { + guard let regex = try? PureXML.Regex.Pattern(pattern), regex.matches(value) else { + return "'\(value)' does not match pattern '\(pattern)'" + } + } + return nil + } + + private func enumerationError(_ value: String, _ primitive: Primitive) -> String? { + guard let enumeration = facets.enumeration else { return nil } + let whiteSpace = facets.whiteSpace ?? base.whiteSpace + let matched = enumeration.contains { candidate in + let normalized = Self.process(candidate, whiteSpace: whiteSpace) + if let lhs = primitive.ordered(value), let rhs = primitive.ordered(normalized) { return lhs == rhs } + return normalized == value + } + return matched ? nil : "'\(value)' is not in the enumeration" + } + + private func rangeError(_ value: String, _ primitive: Primitive) -> String? { + guard let actual = primitive.ordered(value) else { return nil } + if let bound = facets.minInclusive, let limit = primitive.ordered(bound), actual < limit { + return "\(value) is below the inclusive minimum" + } + if let bound = facets.maxInclusive, let limit = primitive.ordered(bound), actual > limit { + return "\(value) is above the inclusive maximum" + } + if let bound = facets.minExclusive, let limit = primitive.ordered(bound), !(limit < actual) { + return "\(value) is not above the exclusive minimum" + } + if let bound = facets.maxExclusive, let limit = primitive.ordered(bound), !(actual < limit) { + return "\(value) is not below the exclusive maximum" + } + return nil + } + } + + /// Validates `value` against a built-in type with no extra facets. + static func isValid(_ value: String, type: BuiltinType) -> Bool { + SimpleType(base: type).isValid(value) + } +} diff --git a/Tests/XSDDatatypeTests.swift b/Tests/XSDDatatypeTests.swift new file mode 100644 index 0000000..1c638d9 --- /dev/null +++ b/Tests/XSDDatatypeTests.swift @@ -0,0 +1,124 @@ +@testable import PureXML +import Testing + +@Suite("XSD datatypes") +struct XSDDatatypeTests { + private typealias Schema = PureXML.Schema + + private func valid(_ value: String, _ type: Schema.BuiltinType) -> Bool { + Schema.isValid(value, type: type) + } + + // MARK: Primitives + + @Test("boolean accepts the four lexical forms") + func test_boolean() { + #expect(valid("true", .boolean) && valid("false", .boolean)) + #expect(valid("0", .boolean) && valid("1", .boolean)) + #expect(!valid("True", .boolean) && !valid("yes", .boolean)) + } + + @Test("decimal and integer lexical spaces") + func test_decimalInteger() { + #expect(valid("-12.345", .decimal) && valid("+.5", .decimal) && valid("100", .decimal)) + #expect(!valid("1.2.3", .decimal) && !valid("1e3", .decimal)) + #expect(valid("-42", .integer) && !valid("4.0", .integer)) + } + + @Test("double accepts exponents and the special values") + func test_double() { + #expect(valid("1.5e-9", .double) && valid("INF", .double) && valid("-INF", .double) && valid("NaN", .double)) + #expect(!valid("1.5e", .double) && !valid("inf", .double)) + } + + @Test("bounded integer types enforce their ranges") + func test_integerBounds() { + #expect(valid("127", .byte) && !valid("128", .byte)) + #expect(valid("-128", .byte) && !valid("-129", .byte)) + #expect(valid("255", .unsignedByte) && !valid("-1", .unsignedByte)) + #expect(valid("1", .positiveInteger) && !valid("0", .positiveInteger)) + #expect(valid("0", .nonNegativeInteger) && !valid("-1", .nonNegativeInteger)) + } + + // MARK: Dates + + @Test("date validates field ranges and leap years") + func test_date() { + #expect(valid("2024-02-29", .date) && !valid("2023-02-29", .date)) + #expect(!valid("2024-02-30", .date) && !valid("2024-13-01", .date)) + #expect(valid("-0044-03-15", .date)) + } + + @Test("dateTime accepts 24:00:00 but not 24:00:01, and timezones") + func test_dateTime() { + #expect(valid("2026-06-08T24:00:00", .dateTime)) + #expect(!valid("2026-06-08T24:00:01", .dateTime)) + #expect(valid("2026-06-08T12:30:00Z", .dateTime) && valid("2026-06-08T12:30:00+05:30", .dateTime)) + #expect(!valid("2026-06-08T12:30:00+15:00", .dateTime)) + } + + @Test("the g* date types parse their reduced forms") + func test_gTypes() { + #expect(valid("--02-29", .gMonthDay) && !valid("--02-30", .gMonthDay)) + #expect(valid("---31", .gDay) && valid("--12", .gMonth) && valid("2026", .gYear)) + } + + // MARK: Other primitives + + @Test("hexBinary, base64Binary, and names") + func test_binaryAndNames() { + #expect(valid("0fB7", .hexBinary) && !valid("0fB", .hexBinary)) + #expect(valid("YW55", .base64Binary) && valid("YW4=", .base64Binary)) + #expect(valid("ns:local", .qName) && !valid("ns:1bad", .qName)) + #expect(valid("Hello", .ncName) && !valid("a:b", .ncName)) + #expect(valid("en-US", .language) && !valid("english-", .language)) + #expect(valid("P1Y2M3DT4H", .duration) && !valid("P", .duration) && !valid("PT", .duration)) + } + + // MARK: Facets + + @Test("whiteSpace collapse is applied before validation") + func test_whiteSpace() { + #expect(Schema.SimpleType(base: .token).isValid(" a b ")) + #expect(Schema.SimpleType(base: .int).isValid(" 42 ")) + } + + @Test("length, minLength, and maxLength count characters") + func test_lengthFacets() { + let type = Schema.SimpleType(base: .string, facets: .init(minLength: 2, maxLength: 4)) + #expect(type.isValid("abc") && !type.isValid("a") && !type.isValid("abcde")) + } + + @Test("pattern facets use the regex engine") + func test_patternFacet() { + let type = Schema.SimpleType(base: .string, facets: .init(patterns: ["[A-Z]{3}-\\d{2}"])) + #expect(type.isValid("XYZ-42") && !type.isValid("xyz-42")) + } + + @Test("enumeration compares in value space") + func test_enumerationFacet() { + let type = Schema.SimpleType(base: .decimal, facets: .init(enumeration: ["1.0", "2.5"])) + #expect(type.isValid("1.00") && type.isValid("2.5") && !type.isValid("3")) + } + + @Test("inclusive and exclusive ranges") + func test_rangeFacets() { + let inclusive = Schema.SimpleType(base: .integer, facets: .init(minInclusive: "1", maxInclusive: "10")) + #expect(inclusive.isValid("1") && inclusive.isValid("10") && !inclusive.isValid("11")) + let exclusive = Schema.SimpleType(base: .integer, facets: .init(minExclusive: "1", maxExclusive: "10")) + #expect(!exclusive.isValid("1") && exclusive.isValid("2") && !exclusive.isValid("10")) + } + + @Test("totalDigits and fractionDigits") + func test_digitFacets() { + let type = Schema.SimpleType(base: .decimal, facets: .init(totalDigits: 4, fractionDigits: 2)) + #expect(type.isValid("12.34") && type.isValid("12") && !type.isValid("123.45") && !type.isValid("1.234")) + } + + @Test("exact decimal comparison avoids floating-point error") + func test_exactDecimal() { + // 0.1 + 0.2 != 0.3 in binary; exact comparison handles boundary values. + let type = Schema.SimpleType(base: .decimal, facets: .init(maxInclusive: "0.3")) + #expect(type.isValid("0.3") && type.isValid("0.30") && !type.isValid("0.30000000001")) + } +}