From c96933df898bb3ca56ec09b6703ab882c42c7473 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 17:23:09 +0200 Subject: [PATCH] feat(xslt): space-ignorable sort collation xsl:sort's default text order treats spaces as ignorable at the primary level with a later-first-space tie-break, the Java Collator semantics the conformance golds encode, derived from the gold orderings. Xalan baseline 129 -> 128. --- CHANGELOG.md | 1 + Sources/XSLT/XSLTSorting.swift | 25 ++++++++++++++++++++++++- Tests/Fixtures/xalan-baseline.txt | 1 - 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd82d0..9e25294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Space-ignorable sort collation, eighteenth xalan burn-down (#130): xsl:sort's default text order now treats spaces as ignorable at the primary level, breaking ties by later-first-space (the Java Collator semantics the conformance golds encode), derived from the gold orderings rather than delegated to a locale library. Baseline 129 to 128. - DTD-typed id() and node-kind generate-id(), seventeenth xalan burn-down (#130): the XSLT layer's id() now follows the XPath definition exactly, resolving only attributes the source document's DTD declares as type ID (internal or external subset, parsed through the transform's entity resolver) and yielding the empty set without a DTD, replacing the name-based heuristic for transforms; and generate-id() produces non-empty, distinct identifiers for attribute and namespace nodes through their identity hash instead of returning the empty string. Two unit tests pin both. Baseline 131 to 129. - HTML 4 serialization corners, sixteenth xalan burn-down (#130): `basefont` and `isindex` join the void-element set (no end tags), and the html method leaves an ampersand unescaped when a brace follows, the historical `&{...}` attribute convention of 16.2. Baseline 134 to 131 (output 20 to 17). - Globals, prefix wildcards, fifteenth xalan burn-down (#130): a top-level variable now evaluates with every previously evaluated global in scope, so a variable whose body references an earlier top-level param (directly or via call-template) resolves; same-name globals resolve by import precedence (folds happen in precedence order and the last same-name entry wins, so a later sibling import beats an earlier one and the importing sheet beats both); the stylesheet fold uniformly favors later contributions for attribute-set definitions, decimal formats, namespace aliases, and output settings, matching the post-order precedence model; and the XPath compiler accepts the `NCName:*` name-test form, matching any name in the prefix's namespace (bound by URI, falling back to the in-document prefix string) at its -0.25 default priority, so `@ped:*` outranks `@*`. Three unit tests pin the behaviors. Baseline 144 to 134 (variable 10 to 5, conflictres 8 to 5). diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index 0b03172..be8a73f 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -47,11 +47,34 @@ extension PureXML.XSLT.Transformer { } else if let caseOrder = sort.caseOrder { order = caseInsensitiveCompare(left, right, caseOrder) } else { - order = left == right ? 0 : (left < right ? -1 : 1) + order = collate(left, right) } return sort.descending ? -order : order } + /// Text sort order: spaces are ignorable at the primary level (Java + /// Collator semantics, which the conformance golds encode); when the + /// space-stripped strings tie, the string whose first space comes later + /// (or that has none) sorts first. + static func collate(_ left: String, _ right: String) -> Int { + let strippedLeft = left.filter { $0 != " " } + let strippedRight = right.filter { $0 != " " } + if strippedLeft != strippedRight { + return strippedLeft < strippedRight ? -1 : 1 + } + let leftSpaces = spacePositions(left) + let rightSpaces = spacePositions(right) + if leftSpaces == rightSpaces { return 0 } + for (leftIndex, rightIndex) in zip(leftSpaces, rightSpaces) where leftIndex != rightIndex { + return leftIndex > rightIndex ? -1 : 1 + } + return leftSpaces.count < rightSpaces.count ? -1 : 1 + } + + private static func spacePositions(_ text: String) -> [Int] { + text.enumerated().compactMap { $1 == " " ? $0 : nil } + } + func forEach( _ select: String, _ sorts: [PureXML.XSLT.Sort], diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index 01be679..db7b713 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -113,7 +113,6 @@ sort/sort-alphabet-english sort/sort-alphabet-polish sort/sort-alphabet-russian sort/sort02 -sort/sort08 sort/sort10 sort/sort14 sort/sort27