From 3f652a1b23286e9beb43a623ba91423e1b89fc13 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 13 Jun 2026 20:07:20 +0200 Subject: [PATCH] fix(schema): preserve significant whitespace in simple-content values textContent trimmed leading/trailing whitespace unconditionally before the type's whiteSpace facet ran, so an xs:string (whiteSpace="preserve") value that was whitespace-only or had significant edges was emptied or shortened: a tab value did not match a \t pattern, a length facet undercounted, and so on. Simple-content validation now reads the raw character content (rawTextContent) and lets the facet normalize it (collapse types still trim/collapse through process). The element-only and empty "no significant text" check keeps using the trimmed form, so indentation whitespace between child elements is still ignored. XSTS: valid instances rejected 288 to 248. Baseline updated; regression tests cover a preserved tab matching \t, a length facet counting whitespace, and element-only content still ignoring indentation. Refs #147, #129. --- CHANGELOG.md | 4 ++ Sources/Schema/ComplexValidator.swift | 4 +- Sources/Schema/ComplexValidatorHelpers.swift | 14 +++++- Tests/SchemaStringWhitespaceTests.swift | 49 ++++++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- 5 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 Tests/SchemaStringWhitespaceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e82692d..1ca8d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Simple-content values preserve significant whitespace per the type's `whiteSpace` facet (#147). `textContent` trimmed leading/trailing whitespace unconditionally before the facet ran, so an `xs:string` (`whiteSpace="preserve"`) value that was whitespace-only or had significant edges (e.g. matching a `\t` pattern, or counted by a `length` facet) was wrongly emptied or shortened. Simple-content validation now reads the raw character content and lets the facet normalize it; the element-only/empty "no significant text" check still ignores indentation whitespace. XSTS: valid instances rejected 288 to 248. + ### Added - `Schema.Document.validate(_:schemaLoader:)` honors an instance's `xsi:schemaLocation` and `xsi:noNamespaceSchemaLocation` (#147). Each referenced schema document is loaded through the supplied loader, compiled, and its global declarations merged in (the primary schema's own declarations win on conflict), so a strict or lax wildcard can resolve an element declared in another document. The plain `validate(_:)` is unchanged. XSTS: valid instances rejected 308 to 288 (the particle clusters whose wildcard-matched elements live in a separate, instance-referenced schema). diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index ad0f3a7..16a42f2 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -154,7 +154,7 @@ public extension PureXML.Schema { rejectText(element, at: path, into: &errors) case let .simpleContent(type): if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) } - if let error = type.validate(Self.textContent(element)) { + if let error = type.validate(Self.rawTextContent(element)) { errors.append(XSDFailure(reason: "content: \(error)", at: path)) } case let .elementOnly(particle): @@ -271,7 +271,7 @@ public extension PureXML.Schema { // An empty element takes its `default`/`fixed` value; that value // (not the empty string) is what must be valid against the type, // including any xsi:type override already resolved into `simple`. - let text = Self.textContent(child) + let text = Self.rawTextContent(child) let effective = text.isEmpty ? (elementConstraints[child.name.localName]?.value ?? text) : text if let error = simple.validate(effective) { errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path)) diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift index 1ec1f3a..71e534d 100644 --- a/Sources/Schema/ComplexValidatorHelpers.swift +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -86,14 +86,24 @@ extension PureXML.Schema.ComplexValidator { lhs.namespaceURI == rhs.namespaceURI && lhs.localName == rhs.localName } + /// The element's character content, trimmed: used to decide whether an + /// element-only or empty type wrongly carries significant text (indentation + /// whitespace between child elements is not significant). static func textContent(_ element: PureXML.Model.Element) -> String { - let text = element.children.reduce(into: "") { result, child in + rawTextContent(element).trimmingXMLWhitespace() + } + + /// The element's character content verbatim. Simple-content validation uses + /// this so the type's own `whiteSpace` facet decides normalization: a + /// `preserve` type (xs:string) keeps leading/trailing whitespace, while a + /// `collapse` type still trims and collapses through `process`. + static func rawTextContent(_ element: PureXML.Model.Element) -> String { + element.children.reduce(into: "") { result, child in switch child { case let .text(value), let .cdata(value): result += value default: break } } - return text.trimmingXMLWhitespace() } static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { diff --git a/Tests/SchemaStringWhitespaceTests.swift b/Tests/SchemaStringWhitespaceTests.swift new file mode 100644 index 0000000..709c344 --- /dev/null +++ b/Tests/SchemaStringWhitespaceTests.swift @@ -0,0 +1,49 @@ +@testable import PureXML +import Testing + +/// xs:string has whiteSpace="preserve", so a simple-content value keeps its +/// leading/trailing and whitespace-only content; validation no longer trims it +/// before the facet runs (#147, XSTS reI set). Element-only content still +/// ignores indentation whitespace. +@Suite("String content whitespace is preserved") +struct SchemaStringWhitespaceTests { + private func patternSchema(_ pattern: String) throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(""" + + + + + + """) + } + + @Test("A tab-only value matches the \\t pattern (whitespace preserved)") + func test_tabPreserved() throws { + #expect(try patternSchema(#"\t"#).validate(" ").isEmpty) + } + + @Test("A length facet on string counts preserved whitespace") + func test_lengthCountsWhitespace() throws { + let doc = try PureXML.Schema.Document(""" + + + + + + """) + #expect(try doc.validate(" a ").isEmpty) // space,a,space = 3 chars + #expect(try !doc.validate("a").isEmpty) // 1 char + } + + @Test("Element-only content still ignores indentation whitespace") + func test_elementOnlyIgnoresWhitespace() throws { + let doc = try PureXML.Schema.Document(""" + + + + + + """) + #expect(try doc.validate("\n x\n").isEmpty) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 457addc..2bb5351 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -30,7 +30,7 @@ struct XSTSSuiteTests { /// are written to /tmp/xsts-failures.txt for the burn-down. private let knownSchemaValidRejected = 72 private let knownSchemaInvalidAccepted = 2461 - private let knownInstanceValidRejected = 288 + private let knownInstanceValidRejected = 248 private let knownInstanceInvalidAccepted = 165 @Test("Every XSTS case behaves: compile, reject, validate, invalidate")