PureXML/Sources/Schema/BuiltinType.swift
Mihaela Mihaljevic 4bd23d8286 feat(schema): built-in xsi:type resolution gated by substitution validity
Resolve a built-in datatype named by xsi:type (xsd:int, xsd:decimal,
xsd:string), which previously resolved only against schema-declared types and
so reported "unknown" and wrongly rejected the element. The built-in is now
resolved when it validly substitutes for the declared type per Schema-Validity
Assessment: the ur-type (anyType/anySimpleType) admits any type, and a bare
built-in declared type admits any built-in derived from it through the XSD
Part 2 datatype lattice (new BuiltinType.derives(from:)). An invalid built-in
substitution is a located error, not a silent acceptance.

Resolving the override correctly exposed two pre-existing gaps, both closed so
the change carries no invalid-instances-accepted regression:

- A simple-typed element rejects non-schema-instance attributes. A simple type
  declares no attribute uses, so an element of simple type (including via an
  xsi:type override, and including an xsi:nil element) may carry only xsi:* and
  namespace attributes. Checked before the nil short-circuit.
- Identity-constraint fields compare in their value space when the selected
  node carries an xsi:type. unique/key/keyref tuples compared lexically, so two
  xsi:type="xsd:decimal" fields 3.0 and 3 were treated as distinct and a unique
  violation was missed. Each field value now carries the type read from the
  node's xsi:type and compares via SimpleType.valueMatches when both share a
  built-in base, otherwise lexically.

XSTS: valid instances rejected 248 to 233; invalid instances accepted held at
165. Identity tests corrected to spec-valid schemas (an element carrying an
attribute must be a complex type, not type="xs:string").
2026-06-13 21:21:35 +02:00

166 lines
5.9 KiB
Swift

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
}
}
/// The built-in this type is directly derived from in the XSD Part 2
/// datatype lattice, or nil for the primitives whose base is the implicit
/// root `anySimpleType`.
var derivationBase: BuiltinType? {
switch self {
case .normalizedString: .string
case .token: .normalizedString
case .language, .name, .nmtoken: .token
case .ncName: .name
case .id, .idref, .entity: .ncName
case .integer: .decimal
case .long: .integer
case .int: .long
case .short: .int
case .byte: .short
case .nonNegativeInteger, .nonPositiveInteger: .integer
case .positiveInteger: .nonNegativeInteger
case .negativeInteger: .nonPositiveInteger
case .unsignedLong: .nonNegativeInteger
case .unsignedInt: .unsignedLong
case .unsignedShort: .unsignedInt
case .unsignedByte: .unsignedShort
default: nil
}
}
/// Whether this type is `other` or is derived from it through the built-in
/// lattice. Used to check an `xsi:type` built-in validly substitutes for a
/// built-in declared type: a derived type may always stand in for its base.
func derives(from other: BuiltinType) -> Bool {
var current: BuiltinType? = self
while let type = current {
if type == other { return true }
current = type.derivationBase
}
return false
}
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)
}
}
}