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
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:29:37 +02:00
parent 2bc7dd2410
commit e862f364aa
10 changed files with 995 additions and 0 deletions

View File

@ -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`,

View File

@ -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 {}
}

View File

@ -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<BuiltinType> = [
.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)
}
}
}

View File

@ -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]))
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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[..<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
}
}
}

View File

@ -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 `<number><designator>` 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
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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"))
}
}