feat(xslt): full xsl:number engine and stable document() identity

xsl:number implements all of XSLT 1.0 section 7.7: level
single/multiple/any with count and from matched as patterns through the
match cache, the value expression with the below-one bypass, and the
format-token engine (punctuation, separator and token reuse, zero
padding, alphabetic/roman/Greek sequences, digit grouping). An
unmatched single/multiple count renders empty; level=any always yields
a number. document() now caches per transform so repeated loads of the
same URI return the identical document and generate-id() stays unique
against live nodes (the idkey49 flake). Xalan baseline 557 -> 475
(numbering 84 -> 7).
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 12:37:03 +02:00
parent e5402f8bf5
commit 2134a23c41
11 changed files with 383 additions and 151 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Full xsl:number per XSLT 1.0 section 7.7, second xalan burn-down (#130): `level` single/multiple/any with `count` and `from` as real patterns matched through the transform's match cache (default count: same node type and expanded name; single finds the nearest matching ancestor-or-self and numbers it among matching preceding siblings, multiple numbers every matching ancestor-or-self outermost first, any counts matching nodes at or before this one in document order with `from` as the boundary, always yielding a number so an unmatched count renders 0); the `value` expression evaluates and rounds, bypassing formatting below one; and the full format-token engine (leading/trailing punctuation, alternating alphanumeric tokens and separators with last-token and last-separator reuse, zero-padded decimal widths, `a`/`A`/`i`/`I` and the Greek alphabetic sequence, `grouping-separator`/`grouping-size` digit grouping). A single/multiple level whose count matches nothing renders as the empty string, punctuation included. The xalan baseline drops from 557 to 475 (numbering 84 to 7, the rest pattern-engine prefixed/key/id cases).
- `document()` is stable per transform (#130): the same URI reference now returns the identical document object across calls within one transform, per the XSLT `document()` definition, via a per-transformer document cache; previously each call re-parsed, so node identity, and with it `generate-id()` uniqueness against live nodes, could be violated when an address was reused (the idkey49 flake).
- XSLT namespaced name creation and result fixup, first xalan burn-down (#130): xsl:attribute and xsl:element honor the `namespace` attribute and resolve a prefixed `name` against the instruction's own in-scope stylesheet declarations (7.1.2/7.1.3, captured at parse time so AVT names resolve correctly); xsl:copy applies `use-attribute-sets`; and a namespace-fixup pass runs over the result tree before serialization, declaring every namespace a created element or attribute name carries, reusing an existing binding for the same URI or generating `ns0`, `ns1`, ... prefixes when the carried prefix is absent or taken, and undeclaring an inherited default namespace that would capture an unqualified name. The xalan baseline drops from 604 to 557 (attribset 52 to 20, namespace 123 to 92, axes 68 to 35); one namespace-alias unit test updated to expect the now-declared result namespace.
- XSLT 1.0 conformance runner over the Apache xalan-test corpus (#122, #130): the de facto OASIS-era suite (1690 gold-bearing cases across 36 categories) runs opt-in via XALAN_TS_ROOT, never vendored. Comparison is deliberately normalized: outputs that parse as XML compare by canonical form (Xalan's indentation is not normative), others with whitespace runs collapsed. First measurement: 1086 pass; the 604 known failures are carried exactly in Tests/Fixtures/xalan-baseline.txt, asserted in both directions (a fixed case must leave the file, a regression shows as a new failure). The top burn-down classes by count: namespace handling (123, led by namespaced xsl:attribute creation), xsl:number depth (85), axes corners (68), output/serialization options (55), attribute sets (52), id()/key() (41).
- RELAX NG schema-correctness validation (#131): a schema document is now validated before any pattern interpretation, in three layers. The grammar check (section 3) rejects unknown or misplaced elements, illegal unqualified and RELAX NG-namespace attributes, wrong child arity, stray text, malformed or undeclared-prefix QNames, non-URI datatypeLibrary values (absolute, fragment-free, valid percent escapes), fragment-carrying hrefs, the 4.16 except restrictions (anyName/nsName inside excepts, propagated through choice), and the xmlns name prohibitions including the ns-attribute and except-mention forms. The compiler checks reject combine conflicts and double combine-less defines (4.17), unresolved refs and parentRef without a parent, startless grammars, include/externalRef self-reference, include overrides with nothing to override, non-grammar include targets (loaded documents are grammar-checked too), unknown datatypes, and params against the built-in library. The restrictions checker (4.19, 7.1-7.4) runs over the compiled algebra after 4.20/4.21 normalization (notAllowed/empty operands simplify away first; recursion detection deliberately precedes it): prohibited paths under attribute, oneOrMore-group, list, and in start; computable content types; duplicate attributes by representative-name overlap; infinite-name-class attributes outside oneOrMore; and the interleave text/element constraints. The spec suite's incorrect class drops from 213 compiling to 7, all pre-Fifth-Edition name-class cases (the xmltest-141 class, count asserted), with zero correct-schema or instance regressions. Two leniency tests updated to the spec behavior (double combine-less defines and unknown datatypes are schema errors).

View File

@ -125,7 +125,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **944 tests in 164 suites** (`swift test`).
currently runs **950 tests in 165 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -0,0 +1,10 @@
extension PureXML.XSLT {
/// The documents a transform has loaded, keyed by URI: per the XSLT
/// `document()` definition the same reference returns the identical
/// document, so node identity (and `generate-id()`) is stable across
/// calls within one transform.
final class DocumentCache {
var trees: [String: PureXML.Model.TreeNode] = [:]
var sources: [String: PureXML.Model.Node] = [:]
}
}

View File

@ -97,3 +97,26 @@ extension PureXML.XSLT.Transformer {
}
}
}
extension PureXML.XSLT.Transformer {
/// `xsl:number`: an explicit `value` expression rounds per the XSLT 1.0
/// rules; otherwise the level/count/from machinery numbers the context
/// node, with patterns matched through the transform's match cache.
func numberInstruction(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> String {
guard case let .number(spec) = instruction else { return "" }
let grouping = spec.groupingSeparator.flatMap { separator in
spec.groupingSize.map { (separator: separator, size: $0) }
}
if let valueExpression = spec.value {
let number = value(valueExpression, context)?.number ?? .nan
guard number.isFinite, number.rounded() >= 1 else {
return PureXML.XPath.Value.format(number)
}
return XSLTNumbering.format([Int(number.rounded())], spec.format, grouping)
}
let numbers = XSLTNumbering.numbers(of: context.node, level: spec.level, count: spec.count, from: spec.from) { node, pattern in
matches(node, pattern)
}
return XSLTNumbering.format(numbers, spec.format, grouping)
}
}

View File

@ -101,6 +101,7 @@ extension PureXML.XSLT {
keys: KeyIndex,
loader: @escaping (String) -> String?,
decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:],
documents: PureXML.XSLT.DocumentCache,
) -> PureXML.XPath.FunctionTable {
PureXML.XPath.FunctionTable()
.adding("current") { _, _ in .nodeSet([.tree(current)]) }
@ -138,7 +139,7 @@ extension PureXML.XSLT {
} else {
[arguments.first?.string ?? ""]
}
return .nodeSet(references.flatMap { documentReference($0, loader) })
return .nodeSet(references.flatMap { documentReference($0, loader, documents) })
}
.adding("generate-id") { arguments, context in
// No argument uses the context node; an explicit empty node-set
@ -155,7 +156,11 @@ extension PureXML.XSLT {
/// Loads one `document()` reference: the whole document, or, when the
/// reference has a `#fragment`, the nodes its XPointer selects.
private static func documentReference(_ reference: String, _ loader: (String) -> String?) -> [PureXML.XPath.Node] {
private static func documentReference(
_ reference: String,
_ loader: (String) -> String?,
_ cache: PureXML.XSLT.DocumentCache,
) -> [PureXML.XPath.Node] {
let path: String
let fragment: String?
if let hash = reference.firstIndex(of: "#") {
@ -165,8 +170,13 @@ extension PureXML.XSLT {
path = reference
fragment = nil
}
guard let text = loader(path), let parsed = try? PureXML.parse(text) else { return [] }
guard let fragment, !fragment.isEmpty else { return [.tree(PureXML.Model.TreeNode(parsed))] }
if cache.trees[path] == nil {
guard let text = loader(path), let parsed = try? PureXML.parse(text) else { return [] }
cache.sources[path] = parsed
cache.trees[path] = PureXML.Model.TreeNode(parsed)
}
guard let tree = cache.trees[path], let parsed = cache.sources[path] else { return [] }
guard let fragment, !fragment.isEmpty else { return [.tree(tree)] }
let selections = (try? PureXML.XPointer.evaluate(fragment, over: parsed)) ?? []
return selections.compactMap { selection in
guard case let .node(node) = selection else { return nil }
@ -223,63 +233,6 @@ extension PureXML.XSLT {
return index
}
}
/// The `xsl:number` numbering machinery: a node's position rendered per a
/// format token (`1` arabic, `A`/`a` alphabetic, `I`/`i` roman).
enum Numbering {
/// The number for `node`: its 1-based position among preceding siblings of
/// the same name (or the `count` name), rendered per `format`.
static func value(of node: PureXML.Model.TreeNode, count: String?, format: String) -> String {
let target = count ?? node.name?.localName
var position = 1
if let parent = node.parent {
for sibling in parent.children {
if sibling === node { break }
if sibling.kind == .element, sibling.name?.localName == target { position += 1 }
}
}
return render(position, format)
}
private static func render(_ number: Int, _ format: String) -> String {
switch format.first {
case "A": alphabetic(number, base: 65)
case "a": alphabetic(number, base: 97)
case "I": roman(number).uppercased()
case "i": roman(number)
default: String(number)
}
}
private static func alphabetic(_ number: Int, base: UInt32) -> String {
var value = number
var result = ""
while value > 0 {
let remainder = UInt32((value - 1) % 26)
if let scalar = Unicode.Scalar(base + remainder) {
result = String(Character(scalar)) + result
}
value = (value - 1) / 26
}
return result
}
private static func roman(_ number: Int) -> String {
let table: [(Int, String)] = [
(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"),
(50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"),
]
var value = number
var result = ""
for (amount, numeral) in table {
while value >= amount {
result += numeral
value -= amount
}
}
return result
}
}
}
extension PureXML.Emitting.Options {

View File

@ -71,7 +71,7 @@ public extension PureXML.XSLT {
/// `xsl:variable` bound for the rest of the sequence constructor.
case variable(name: String, select: String?, body: [Instruction])
/// `xsl:number`: a generated sequence number for the context node.
case number(count: String?, from: String?, format: String)
case number(NumberSpec)
/// `xsl:comment`: a comment node whose text is the instantiated body.
case comment(body: [Instruction])
/// `xsl:processing-instruction` whose target is `name` and whose data is
@ -155,6 +155,19 @@ public extension PureXML.XSLT {
}
}
/// The attribute bundle of an `xsl:number` instruction: the numbering
/// level, the count/from patterns, an explicit value expression, the
/// format string, and the digit grouping settings.
struct NumberSpec: Sendable {
var level: String
var count: String?
var from: String?
var value: String?
var format: String
var groupingSeparator: String?
var groupingSize: Int?
}
/// The symbols an `xsl:decimal-format` sets for `format-number`: those used in
/// the picture (digit places, separators, percent) and in the output. The
/// defaults are the XSLT standard format.

View File

@ -0,0 +1,226 @@
/// The full `xsl:number` of XSLT 1.0 section 7.7: `level` single/multiple/any
/// with `count` and `from` patterns, an explicit `value` expression, and the
/// format-token engine (alternating separators and `1`/`01`/`a`/`A`/`i`/`I`
/// tokens, the last token reused, `.` as the default separator).
enum XSLTNumbering {
typealias Tree = PureXML.Model.TreeNode
/// The numbers `xsl:number` renders for `node`, computed per level with
/// `matches` deciding pattern membership (the transformer's match cache).
static func numbers(
of node: Tree,
level: String,
count: String?,
from: String?,
matches: (Tree, String) -> Bool,
) -> [Int] {
func matchesCount(_ candidate: Tree) -> Bool {
if let count { return matches(candidate, count) }
return candidate.kind == node.kind && candidate.name?.description == node.name?.description
}
func matchesFrom(_ candidate: Tree) -> Bool {
from.map { matches(candidate, $0) } ?? false
}
switch level {
case "multiple":
var counts: [Int] = []
var current: Tree? = node
while let candidate = current, !matchesFrom(candidate) {
if matchesCount(candidate) {
counts.append(siblingPosition(of: candidate, matching: matchesCount))
}
current = candidate.parent
}
return counts.reversed()
case "any":
var total = 0
var stopped = false
walkPreceding(node) { candidate in
if matchesFrom(candidate) {
stopped = true
return false
}
if matchesCount(candidate) { total += 1 }
return true
}
_ = stopped
return [total]
default: // single
var current: Tree? = node
while let candidate = current, !matchesFrom(candidate) {
if matchesCount(candidate) {
return [siblingPosition(of: candidate, matching: matchesCount)]
}
current = candidate.parent
}
return []
}
}
/// 1 + the count of preceding siblings the count pattern matches.
private static func siblingPosition(of node: Tree, matching matchesCount: (Tree) -> Bool) -> Int {
var position = 1
guard let parent = node.parent else { return position }
for sibling in parent.children {
if sibling === node { break }
if matchesCount(sibling) { position += 1 }
}
return position
}
/// Visits `node` and everything before it in document order (ancestors
/// included), nearest first; the visitor returns false to stop.
private static func walkPreceding(_ node: Tree, _ visit: (Tree) -> Bool) {
var current: Tree? = node
while let candidate = current {
if !visit(candidate) { return }
var sibling = precedingSibling(of: candidate)
while let visited = sibling {
if !visitSubtreeReversed(visited, visit) { return }
sibling = precedingSibling(of: visited)
}
current = candidate.parent
}
}
private static func visitSubtreeReversed(_ node: Tree, _ visit: (Tree) -> Bool) -> Bool {
for child in node.children.reversed() where !visitSubtreeReversed(child, visit) {
return false
}
return visit(node)
}
private static func precedingSibling(of node: Tree) -> Tree? {
guard let parent = node.parent else { return nil }
var previous: Tree?
for sibling in parent.children {
if sibling === node { return previous }
previous = sibling
}
return nil
}
// MARK: Format tokens
/// Renders `numbers` with the 7.7.1 format engine. An empty list (a
/// single/multiple level whose count pattern matched nothing) renders as
/// the empty string, punctuation included.
static func format(_ numbers: [Int], _ format: String, _ grouping: (separator: String, size: Int)? = nil) -> String {
guard !numbers.isEmpty else { return "" }
let pieces = tokenize(format.isEmpty ? "1" : format)
var result = pieces.prefix
var lastToken = "1"
var lastSeparator = "."
for (offset, number) in numbers.enumerated() {
if offset > 0 {
result += offset - 1 < pieces.separators.count ? pieces.separators[offset - 1] : lastSeparator
}
let token = offset < pieces.tokens.count ? pieces.tokens[offset] : lastToken
result += render(number, token, grouping)
if offset < pieces.tokens.count { lastToken = pieces.tokens[offset] }
if offset > 0, offset - 1 < pieces.separators.count { lastSeparator = pieces.separators[offset - 1] }
}
return result + pieces.suffix
}
private struct FormatPieces {
var prefix = ""
var tokens: [String] = []
var separators: [String] = []
var suffix = ""
}
/// Splits a format string into leading punctuation, alternating format
/// tokens and separators, and trailing punctuation.
private static func tokenize(_ format: String) -> FormatPieces {
var pieces = FormatPieces()
var currentSeparator = ""
var sawToken = false
var index = format.startIndex
while index < format.endIndex {
if format[index].isLetter || format[index].isNumber {
var token = ""
while index < format.endIndex, format[index].isLetter || format[index].isNumber {
token.append(format[index])
index = format.index(after: index)
}
if sawToken {
pieces.separators.append(currentSeparator)
} else {
pieces.prefix = currentSeparator
}
currentSeparator = ""
pieces.tokens.append(token)
sawToken = true
} else {
currentSeparator.append(format[index])
index = format.index(after: index)
}
}
pieces.suffix = currentSeparator
if !sawToken {
pieces.prefix = ""
pieces.suffix = currentSeparator
}
return pieces
}
/// One number in one token's style.
static func render(_ number: Int, _ token: String, _ grouping: (separator: String, size: Int)? = nil) -> String {
switch token.first {
case "A": return alphabetic(number, base: 65, letters: 26)
case "a": return alphabetic(number, base: 97, letters: 26)
case "I": return roman(number).uppercased()
case "i": return roman(number)
case "\u{3B1}": return alphabetic(number, base: 0x3B1, letters: 25)
default:
var digits = String(number)
let width = token.count
if digits.count < width { digits = String(repeating: "0", count: width - digits.count) + digits }
return grouped(digits, grouping)
}
}
/// Digit grouping per `grouping-separator`/`grouping-size`, applied from
/// the right.
private static func grouped(_ digits: String, _ grouping: (separator: String, size: Int)?) -> String {
guard let grouping, grouping.size > 0, digits.count > grouping.size else { return digits }
var groups: [String] = []
var rest = digits[...]
while rest.count > grouping.size {
groups.append(String(rest.suffix(grouping.size)))
rest = rest.dropLast(grouping.size)
}
groups.append(String(rest))
return groups.reversed().joined(separator: grouping.separator)
}
private static func alphabetic(_ number: Int, base: UInt32, letters: Int) -> String {
var value = number
var result = ""
while value > 0 {
let remainder = UInt32((value - 1) % letters)
if let scalar = Unicode.Scalar(base + remainder) {
result = String(Character(scalar)) + result
}
value = (value - 1) / letters
}
return result
}
private static func roman(_ number: Int) -> String {
let table: [(Int, String)] = [
(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"),
(50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"),
]
var value = number
var result = ""
for (amount, numeral) in table {
while value >= amount {
result += numeral
value -= amount
}
}
return result
}
}

View File

@ -23,11 +23,15 @@ extension PureXML.XSLT.XSLTParser {
body: body(node),
)
case "number":
.number(
.number(PureXML.XSLT.NumberSpec(
level: XSLTNode.attribute(node, "level") ?? "single",
count: XSLTNode.attribute(node, "count"),
from: XSLTNode.attribute(node, "from"),
value: XSLTNode.attribute(node, "value"),
format: XSLTNode.attribute(node, "format") ?? "1",
)
groupingSeparator: XSLTNode.attribute(node, "grouping-separator"),
groupingSize: XSLTNode.attribute(node, "grouping-size").flatMap { Int($0) },
))
case "comment":
.comment(body: body(node))
case "processing-instruction":

View File

@ -17,6 +17,7 @@ extension PureXML.XSLT {
private let keyIndex: PureXML.XSLT.KeyIndex
private let termination = Termination()
private let matchCache = MatchCache()
private let documentCache = PureXML.XSLT.DocumentCache()
/// The `xsl:message terminate="yes"` text, if one fired during `run()`.
var terminationMessage: String? {
@ -52,7 +53,7 @@ extension PureXML.XSLT {
.element
}
private func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool {
func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool {
matchCache.nodes(matching: pattern, over: root).contains(ObjectIdentifier(node))
}
@ -129,14 +130,20 @@ extension PureXML.XSLT {
return .nodeSet([.tree(PureXML.Model.TreeNode.document(children: children))])
}
fileprivate func value(_ expression: String, _ context: XSLTContext) -> PureXML.XPath.Value? {
func value(_ expression: String, _ context: XSLTContext) -> PureXML.XPath.Value? {
guard let query = try? PureXML.XPath.Query(expression) else { return nil }
return try? query.value(
at: context.node,
position: context.position,
size: context.size,
variables: context.variables,
functions: PureXML.XSLT.Library.table(current: context.node, keys: keyIndex, loader: documentLoader, decimalFormats: stylesheet.decimalFormats),
functions: PureXML.XSLT.Library.table(
current: context.node,
keys: keyIndex,
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
),
)
}
@ -232,8 +239,8 @@ extension PureXML.XSLT.Transformer {
attributeInstruction(nameTemplate, namespaceTemplate, namespaces, body, context)
case let .copy(useAttributeSets, body):
copyInstruction(useAttributeSets, body, context)
case let .number(count, _, format):
[.node(.text(PureXML.XSLT.Numbering.value(of: context.node, count: count, format: format)))]
case .number:
[.node(.text(numberInstruction(instruction, context)))]
case let .comment(body):
[.node(.comment(Self.text(of: instantiate(body, context))))]
case let .processingInstruction(name, body):

View File

@ -140,7 +140,6 @@ idkey/idkey46
idkey/idkey47
idkey/idkey48
idkey/idkey50
idkey/idkey51
idkey/idkey53
idkey/idkey55
idkey/idkey56
@ -262,9 +261,6 @@ namespace/namespace39
namespace/namespace40
namespace/namespace42
namespace/namespace43
namespace/namespace44
namespace/namespace45
namespace/namespace46
namespace/namespace48
namespace/namespace49
namespace/namespace50
@ -339,90 +335,13 @@ numberformat/numberformat36
numberformat/numberformat41
numberformat/numberformat43
numberformat/numberformat44
numbering/numbering03
numbering/numbering04
numbering/numbering05
numbering/numbering06
numbering/numbering07
numbering/numbering08
numbering/numbering09
numbering/numbering10
numbering/numbering11
numbering/numbering12
numbering/numbering13
numbering/numbering14
numbering/numbering17
numbering/numbering18
numbering/numbering19
numbering/numbering20
numbering/numbering21
numbering/numbering22
numbering/numbering23
numbering/numbering24
numbering/numbering25
numbering/numbering26
numbering/numbering27
numbering/numbering28
numbering/numbering29
numbering/numbering30
numbering/numbering31
numbering/numbering32
numbering/numbering33
numbering/numbering34
numbering/numbering35
numbering/numbering36
numbering/numbering37
numbering/numbering38
numbering/numbering39
numbering/numbering40
numbering/numbering41
numbering/numbering42
numbering/numbering43
numbering/numbering44
numbering/numbering45
numbering/numbering46
numbering/numbering47
numbering/numbering48
numbering/numbering49
numbering/numbering50
numbering/numbering51
numbering/numbering52
numbering/numbering56
numbering/numbering57
numbering/numbering58
numbering/numbering59
numbering/numbering60
numbering/numbering61
numbering/numbering62
numbering/numbering63
numbering/numbering65
numbering/numbering66
numbering/numbering67
numbering/numbering68
numbering/numbering69
numbering/numbering70
numbering/numbering71
numbering/numbering72
numbering/numbering73
numbering/numbering74
numbering/numbering77
numbering/numbering78
numbering/numbering79
numbering/numbering80
numbering/numbering81
numbering/numbering82
numbering/numbering83
numbering/numbering84
numbering/numbering85
numbering/numbering86
numbering/numbering87
numbering/numbering89
numbering/numbering90
numbering/numbering91
numbering/numbering92
numbering/numbering93
numbering/numbering94
numbering/numbering95
output/output01
output/output02
output/output03

View File

@ -0,0 +1,75 @@
@testable import PureXML
import Testing
/// The full `xsl:number` of XSLT 1.0 section 7.7 (#130): level
/// single/multiple/any with count and from patterns, the format-token engine
/// with punctuation and separators, digit grouping, and the value expression.
@Suite("XSLT numbering")
struct XSLTNumberingTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ number: String, source: String) throws -> String {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:apply-templates select="//mark"/></out></xsl:template>
<xsl:template match="mark">\(number)<xsl:text> </xsl:text></xsl:template>
</xsl:stylesheet>
"""
return try PureXML.XSLT.transform(stylesheet: style, source: source)
}
@Test("level=multiple numbers every matching ancestor-or-self, outermost first")
func test_levelMultiple() throws {
let source = """
<doc><c><s><mark/></s><s><mark/><mark/></s></c><c><s><mark/></s></c></doc>
"""
let result = try transform(
"<xsl:number level=\"multiple\" count=\"c|s|mark\" format=\"1.1.\"/>",
source: source,
)
#expect(result == "<out>1.1.1. 1.2.1. 1.2.2. 2.1.1. </out>")
}
@Test("level=any counts matching nodes before this one, bounded by from")
func test_levelAny() throws {
let source = "<doc><c><mark/><mark/></c><c><mark/></c></doc>"
let across = try transform("<xsl:number level=\"any\" count=\"mark\"/>", source: source)
#expect(across == "<out>1 2 3 </out>")
let bounded = try transform("<xsl:number level=\"any\" count=\"mark\" from=\"c\"/>", source: source)
#expect(bounded == "<out>1 2 1 </out>")
}
@Test("single/multiple with no matching node renders as the empty string")
func test_emptyMatchIsEmptyOutput() throws {
let result = try transform(
"<xsl:number level=\"multiple\" count=\"missing\" format=\"1.1. \"/>",
source: "<doc><mark/></doc>",
)
#expect(result == "<out> </out>")
}
@Test("format tokens: padding, alphabetic, roman, punctuation, last-token reuse")
func test_formatTokens() {
#expect(XSLTNumbering.format([3], "(001) ") == "(003) ")
#expect(XSLTNumbering.format([2, 4], "A.i. ") == "B.iv. ")
#expect(XSLTNumbering.format([1, 2, 3], "1-1. ") == "1-2-3. ")
#expect(XSLTNumbering.format([1, 2, 3], "1") == "1.2.3")
#expect(XSLTNumbering.format([], "(1) ").isEmpty)
}
@Test("digit grouping applies the separator from the right")
func test_grouping() {
#expect(XSLTNumbering.format([1234], "1", (separator: ",", size: 3)) == "1,234")
#expect(XSLTNumbering.format([123], "1", (separator: ":", size: 2)) == "1:23")
#expect(XSLTNumbering.format([12], "1", (separator: ",", size: 3)) == "12")
}
@Test("value= evaluates the expression; below one it bypasses formatting")
func test_valueExpression() throws {
let formatted = try transform("<xsl:number value=\"7\" format=\"(I)\"/>", source: "<doc><mark/></doc>")
#expect(formatted == "<out>(VII) </out>")
let zero = try transform("<xsl:number value=\"0\" format=\"(I) \"/>", source: "<doc><mark/></doc>")
#expect(zero == "<out>0 </out>")
}
}