diff --git a/CHANGELOG.md b/CHANGELOG.md index 8473cf1..adfb210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index f6c169a..2958fb7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/XSLT/XSLTDocumentCache.swift b/Sources/XSLT/XSLTDocumentCache.swift new file mode 100644 index 0000000..02a442a --- /dev/null +++ b/Sources/XSLT/XSLTDocumentCache.swift @@ -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] = [:] + } +} diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index 596e37f..eab3e0e 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -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) + } +} diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 4aeb767..35a7362 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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 { diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 3198577..aaa2b97 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -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. diff --git a/Sources/XSLT/XSLTNumbering.swift b/Sources/XSLT/XSLTNumbering.swift new file mode 100644 index 0000000..e45995c --- /dev/null +++ b/Sources/XSLT/XSLTNumbering.swift @@ -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 + } +} diff --git a/Sources/XSLT/XSLTParserStructural.swift b/Sources/XSLT/XSLTParserStructural.swift index ae68e5c..7b89097 100644 --- a/Sources/XSLT/XSLTParserStructural.swift +++ b/Sources/XSLT/XSLTParserStructural.swift @@ -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": diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 1a5b5fd..d2aee88 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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): diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index a0d4f10..4453485 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -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 diff --git a/Tests/XSLTNumberingTests.swift b/Tests/XSLTNumberingTests.swift new file mode 100644 index 0000000..df5c01c --- /dev/null +++ b/Tests/XSLTNumberingTests.swift @@ -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 = """ + + + + \(number) + + """ + 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 = """ + + """ + let result = try transform( + "", + source: source, + ) + #expect(result == "1.1.1. 1.2.1. 1.2.2. 2.1.1. ") + } + + @Test("level=any counts matching nodes before this one, bounded by from") + func test_levelAny() throws { + let source = "" + let across = try transform("", source: source) + #expect(across == "1 2 3 ") + let bounded = try transform("", source: source) + #expect(bounded == "1 2 1 ") + } + + @Test("single/multiple with no matching node renders as the empty string") + func test_emptyMatchIsEmptyOutput() throws { + let result = try transform( + "", + source: "", + ) + #expect(result == " ") + } + + @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("", source: "") + #expect(formatted == "(VII) ") + let zero = try transform("", source: "") + #expect(zero == "0 ") + } +}