PureXML/Sources/Schema/Lexical.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

131 lines
6.2 KiB
Swift

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