From 771fa4b4573cc6f1307ab01d016bf664c5fee7f9 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 00:13:14 +0200 Subject: [PATCH] XSLT/XPath conformance: close 68 Apache Xalan cases (#254) * fix(xslt): keep namespace nodes on an included literal result element An xsl:include'd stylesheet's declarations are absorbed after collectUnit returns, and each literal result element resolves its 7.1.1 namespace nodes by walking the weak parent chain back to its own included stylesheet element. Those elements lived only in the write-only retainedRoots array, so an optimized build released them before the deferred resolution ran and the in-scope xmlns declarations vanished, dropping the namespace nodes the included element should carry. The output was correct in debug and wrong in release. Pin the retained roots across the resolution with withExtendedLifetime. Imports were unaffected because they compile eagerly while their tree is still a live local. Catches Apache Xalan conformance case lre/lre05, surfaced by running the apache/xalan-test corpus. * fix(xslt): select a called template by import precedence, not document order xsl:call-template looked up the target with the first match by position in the flattened template list. Imports fold in before the including unit's own and included templates, so when an import and an include both define a template with the same name, the imported one was wrongly chosen. An included template carries the including unit's higher import precedence and must win (XSLT 1.0 section 6). The lookup now picks the highest-precedence definition among the same-name templates. Closes Apache Xalan conformance cases namedtemplate17, namedtemplate18, namedtemplate19, and namespace13 (removed from the xalan-test baseline). Full suite 1608 tests green in debug and release; the Xalan suite passes. * fix(xpath): string-value of a comment or PI node is its own data The XPath string-value of a comment node is its character data and of a processing-instruction node is its data (XPath 1.0 5.6, 5.7), but TreeNode returned the empty string for both, so value-of select="comment()", and any matching, sorting, or comparison over comment/PI nodes, produced nothing. stringValue was conflated with the text-only concatenation that forms an element's or root's string-value, where comment and PI descendants correctly contribute nothing (5.1, 5.5). That concatenation now uses a separate textContent, so an element's string-value still excludes comment and PI text while a comment or PI node reports its own data. Closes 24 Apache Xalan conformance cases across node, axes, position, select, modes, conflictres, and variable (removed from the baseline). Full suite 1609 tests green in debug and release; the Xalan suite passes. * fix(xslt): resolve top-level variables in any order, not document order Top-level variables and parameters may reference one another regardless of declaration order (XSLT 1.0 section 11.4), but they were evaluated once top-to-bottom, so a forward reference (a global declared before the one it uses) saw an undefined value and resolved to empty. Globals now evaluate to a fixpoint: each pass re-evaluates the unsettled ones with the latest bindings, so a dependency chain in any order settles in at most N passes, a pass that changes nothing stops early, and a circular reference stops at the cap. Extracted the evaluation into Transformer.evaluatedGlobals (XSLTGlobals.swift). Closes Apache Xalan conformance cases variable33, variable34, variable35. Full suite 1611 tests green in debug and release; the Xalan suite passes. * fix(xslt): an empty-content variable with no select is the empty string XSLT 1.0 section 11.2 makes (empty content, no select) equivalent to select="", so boolean() of it is false. It was always built as a result-tree-fragment document node, which boolean() reports as true. A variable with no select and empty content now yields the empty string; only non-empty content remains a result tree fragment. Closes Apache Xalan conformance cases boolean43 and boolean87. Full suite 1612 tests green in debug and release; the Xalan suite passes. * fix(xpath): allow whitespace around the :: axis separator XPath 1.0 allows whitespace between tokens, so `child :: sub` is the child axis with node test `sub`. The parser only matched `::` immediately after the axis name, so a spaced separator fell back to the child axis with the axis name read as an element test, selecting nothing. parseAxis now skips whitespace after the axis name and after `::`; on no match the reset to before the name is unchanged, so a bare name step is unaffected. Closes Apache Xalan conformance cases select16, select27, select28. Full suite 1613 tests green in debug and release; the Xalan suite passes. * feat(xslt): document('') returns the stylesheet's own document XSLT 1.0 section 12.1 makes an empty URI reference the document containing the expression, so a stylesheet can read its own xsl:* nodes (embedded lookup tables, self-inspection). The reference went straight to the document loader, which has no entry for it, so document('') returned an empty node-set. The transformer now keeps its parsed stylesheet tree and the document function returns it for an empty reference, cached like any loaded document; a #fragment still applies. Closes Apache Xalan conformance cases select67, select68, idkey50, mdocs17, namespace20. Full suite 1614 tests green in debug and release; Xalan passes. * fix(xslt): implement unparsed-entity-uri from the source DTD unparsed-entity-uri was a stub returning the empty string. It now reads the source document type's unparsed (NDATA) entities, threaded to the function table alongside the document loader, and returns the named entity's system URI; an unknown name still returns empty. Closes Apache Xalan conformance case expression02. Full suite 1615 tests green in debug and release; the Xalan suite passes. * fix(xpath): substring-after(s, '') returns s Per the XPath 1.0 errata, an empty second argument matches at the start of the string, so substring-after returns the whole string. The function bailed to the empty string on an empty separator. substring-before(s, '') stays empty, as the spec requires. Closes Apache Xalan conformance case string143. Full suite 1615 tests green in debug and release; the Xalan suite passes. * fix(xslt): match a template mode by expanded name, not its prefix XSLT 1.0 section 5.7 compares a mode by expanded name, so two prefixes bound to the same namespace name the same mode. The prefixed QName string was stored and compared verbatim, so apply-templates mode="foo:m" missed a template with mode="moo:m" even when foo and moo share a namespace. Mode names on xsl:template and xsl:apply-templates now resolve to {uri}local at parse time. Moved the parser's declaration helpers to XSLTParserDeclarations.swift to keep the file within the length budget. Closes Apache Xalan conformance case modes16. Full suite 1616 tests green in debug and release; the Xalan suite passes. * fix(xslt): name a variable by expanded QName, not its prefix XSLT 1.0 section 11.1 names a variable by expanded name, so two prefixes bound to the same namespace name the same variable. The declared name and the $prefix:local reference were compared as raw strings, so $new:x did not see a variable declared txt:x even when new and txt share a namespace. A prefixed declared name now resolves to {uri}local at parse time, and the XPath evaluator falls back to the namespace-resolved name when the raw key is unbound, so a caller-supplied raw binding still works and unprefixed names are unchanged. Closes Apache Xalan conformance case variable55. Full suite 1617 tests green in debug and release; the Xalan suite passes. * fix(xslt): a match pattern predicate sees the global variables XSLT 1.0 section 5.2 evaluates a pattern predicate with the top-level (global) variables in scope, but pattern matching ran with no variables bound, so a template like match="foo[. > $screen]" never matched ($screen was undefined). The transformer supplies the global bindings to the match cache; they are constant per transform, so they do not enter the cache key. Closes Apache Xalan conformance cases match14 and idkey43. Full suite 1618 tests green in debug and release; the Xalan suite passes. * fix(emit): make a comment ending in a hyphen or containing -- well-formed XML forbids `--` inside a comment and a `-` immediately before `-->`, so a comment whose text ends with `-` was emitted as (malformed). The serializer now inserts a space after each offending hyphen (the XSLT 1.0 16 recovery), so `text-` becomes `text- `. Applied in both the Serializer and the streaming Writer via a shared Escaping.comment helper. Closes Apache Xalan conformance cases output89 and output90. Full suite 1619 tests green in debug and release; the Xalan suite passes. * fix(xslt): preserve whitespace adjacent to a CDATA section in a template Text and CDATA are one text node in the data model, so is the node " test ", whose surrounding spaces must survive whitespace stripping (XSLT 1.0 3.4). The template body dropped each whitespace-only text node independently, so the spaces around the CDATA vanished. Adjacent text and CDATA children now coalesce into one run, dropped only when the whole run is whitespace; a purely whitespace run between elements is still removed. Closes Apache Xalan conformance case whitespace13. Full suite 1620 tests green in debug and release; the Xalan suite passes. * fix(xslt): xsl:processing-instruction/comment keep only text-node content XSLT 1.0 sections 7.4 and 7.6 make it an error for xsl:comment or xsl:processing-instruction content to create a non-text node; the recovery is to ignore the offending node together with its content. PureXML took the string-value of all instantiated content, so a copied element leaked its text into the PI or comment. The content now contributes only its text and CDATA node items. Closes Apache Xalan conformance case copy60. Full suite 1621 tests green in debug and release; the Xalan suite passes. * fix(xslt): xsl:attribute keeps only text-node content (errata E27) XSLT 1.0 errata E27 makes it an error for xsl:attribute content to create a non-text node, recovering by ignoring the offending node together with its content (the same rule as xsl:comment/processing-instruction). PureXML took the string-value of all instantiated content, so a copied element leaked its text into the attribute value. The content now contributes only its text and CDATA node items, reusing textNodesOnly. Closes Apache Xalan conformance cases copy56 and copy58. Full suite 1622 tests green in debug and release; the Xalan suite passes. * fix(xslt): xsl:copy-of carries an element's inherited namespace nodes XSLT 1.0 11.3 copies a node together with its namespace nodes, so copying an element must reproduce its full in-scope set, including a declaration inherited from an ancestor. copy-of reproduced only the element's own xmlns attributes, dropping inherited ones. The inherited declarations are now added to the copied element and the serializer's namespace fixup drops any already in the result scope. Closes Apache Xalan conformance cases copy09, copy27, copy48, mdocs09. Full suite 1623 tests green in debug and release; the Xalan suite passes. * fix(xslt): xsl:element with an unusable name passes its content through XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (the pinned element-name-not-QName "pass-through") emits the element's content without the wrapper. The check rejected only an empty or multi-colon name, so an undeclared prefix (none:foo) or a name that is not an NCName (this is a string) still produced a malformed element. The name is now also rejected when a part is not an NCName or its prefix is unbound. Closes Apache Xalan conformance cases namespace40, namespace42, namespace43, namespace60, namespace73, namespace79. Full suite 1624 tests green in debug and release; the Xalan suite passes. * fix(xslt): xsl:copy of the root node honors use-attribute-sets Copying the root node produces no element of its own, so the use-attribute-sets attributes have nowhere to attach on the copy and instead join the enclosing result element (XSLT 1.0 7.5). copyInstruction dropped them for the root case; they are now emitted ahead of the copied content so the enclosing element picks them up. Closes Apache Xalan conformance cases attribset27 and attribset29. Full suite 1625 tests green in debug and release; the Xalan suite passes. * refactor(xslt): address review (canonical name/whitespace checks, html comment safety) Review follow-ups on the conformance series: - xsl:element name validation now uses the canonical Parsing.XMLCharacter .isValidName instead of a hand-rolled grapheme check, which had narrowed valid XML names (it dropped a name using a non-ASCII NameChar such as U+00B7). - The template-body whitespace check reuses Parsing.XMLCharacter.isWhitespace instead of a third local copy of the same predicate. - The HTML serializer applies the section-16 comment recovery too, so a method="html" comment ending in a hyphen (or containing two) is well-formed; the C14N canonicalizer keeps comments verbatim as its contract requires. Adds regression tests for a non-ASCII NameChar element name and for html comment hyphen-safety. Full suite 1626 tests green in debug and release; the Xalan suite passes. --- CHANGELOG.md | 36 ++++++ Sources/Emitting/Escaping.swift | 15 +++ Sources/Emitting/Serializer.swift | 2 +- Sources/Emitting/Writer.swift | 2 +- Sources/HTML/HTMLSerializer.swift | 2 +- Sources/Model/TreeNode.swift | 2 +- Sources/Model/TreeNodeNavigation.swift | 28 ++++- Sources/XPath/CompilerPaths.swift | 6 + Sources/XPath/Evaluator.swift | 20 +++- Sources/XPath/StringFunctions.swift | 5 +- Sources/XSLT/XSLTElements.swift | 54 ++++++++- Sources/XSLT/XSLTFunctions.swift | 23 +++- Sources/XSLT/XSLTGlobals.swift | 41 +++++++ Sources/XSLT/XSLTMatchCache.swift | 7 +- Sources/XSLT/XSLTParser.swift | 111 ++++++------------ Sources/XSLT/XSLTParserDeclarations.swift | 85 ++++++++++++++ Sources/XSLT/XSLTTransform.swift | 22 +++- Sources/XSLT/XSLTTransformer.swift | 59 +++++----- Tests/ConformanceEmittingTests.swift | 10 ++ Tests/Fixtures/xalan-baseline.txt | 65 ---------- Tests/TreeNodeTests.swift | 17 +++ Tests/XPathAxisTests.swift | 10 ++ Tests/XPathFunctionTests.swift | 6 + Tests/XSLTCallTemplatePrecedenceTests.swift | 42 +++++++ Tests/XSLTCompositionTests.swift | 31 +++++ Tests/XSLTDocumentFunctionTests.swift | 38 ++++++ Tests/XSLTGlobalVariableTests.swift | 56 +++++++++ Tests/XSLTOutputMethodTests.swift | 13 ++ Tests/XSLTResultFragmentTests.swift | 17 +++ Tests/XSLTTests.swift | 124 ++++++++++++++++++++ 30 files changed, 757 insertions(+), 192 deletions(-) create mode 100644 Sources/XSLT/XSLTGlobals.swift create mode 100644 Sources/XSLT/XSLTParserDeclarations.swift create mode 100644 Tests/XSLTCallTemplatePrecedenceTests.swift create mode 100644 Tests/XSLTGlobalVariableTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 08aa3c0..1748d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `xsl:copy` of the root node now honors `use-attribute-sets`. Copying the root produces no element of its own, so the attribute-set attributes have nowhere to attach on the copy and instead join the enclosing result element (XSLT 1.0 7.5); they were silently dropped. Closes Apache Xalan conformance cases `attribset27` and `attribset29`. + +- `xsl:element` with an unusable name now passes its content through instead of creating a bad element. XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (pinned `element-name-not-QName` "pass-through") emits the element's content without the wrapper. The check rejected only an empty or multi-colon name, so an undeclared prefix (`none:foo`) or a name that is not an NCName (`this is a string`) still produced a malformed element. Closes Apache Xalan conformance cases `namespace40`, `namespace42`, `namespace43`, `namespace60`, `namespace73`, and `namespace79`. + +- `xsl:copy-of` now carries an element's inherited namespace nodes, not only the ones it declares itself. XSLT 1.0 11.3 copies a node together with its namespace nodes, so copying an element must reproduce its full in-scope set, including a declaration inherited from an ancestor; `copy-of` reproduced only the element's own `xmlns` attributes, dropping inherited ones. The inherited declarations are now added to the copied element and the serializer's namespace fixup drops any already in the result scope. Closes Apache Xalan conformance cases `copy09`, `copy27`, `copy48`, and `mdocs09`. + +- `xsl:processing-instruction`, `xsl:comment`, and `xsl:attribute` now use only the text-node content they instantiate, ignoring any element (or other) node together with its content. XSLT 1.0 sections 7.4 and 7.6 (and errata E27 for `xsl:attribute`) make it an error for the content to create a non-text node, with the recovery being to ignore the offending node and its content; PureXML instead took the string-value of all content, so a copied element leaked its text into the result. Closes Apache Xalan conformance cases `copy60`, `copy56`, and `copy58`. + +- Whitespace adjacent to a CDATA section in a template is no longer stripped. Text and CDATA are one text node in the data model, so ` ` is the node `" test "`, whose surrounding spaces must survive whitespace stripping (XSLT 1.0 3.4). The template body dropped each whitespace-only text node independently, so the spaces around the CDATA vanished. Adjacent text and CDATA children now coalesce into one run, dropped only when the whole run is whitespace; a purely whitespace run between elements is still removed. Closes Apache Xalan conformance case `whitespace13`. + +- A serialized comment whose text ends with `-` or contains `--` is now made well-formed by inserting a space after the offending hyphen. XML forbids `--` inside a comment and a `-` immediately before `-->`, so a comment ending in `-` was emitted as `` (a malformed comment); the serializer now applies the XSLT 1.0 section 16 recovery, so `text-` becomes `text- `. Applied in the XML serializer, the streaming writer, and the HTML serializer (so `method="html"` output is covered too); the C14N canonicalizer keeps comments verbatim as its contract requires. Closes Apache Xalan conformance cases `output89` and `output90`. + +- A match pattern's predicate now sees the top-level (global) variables. XSLT 1.0 section 5.2 evaluates a pattern predicate with the global variables in scope, but pattern matching ran with no variables bound, so a template like `match="foo[. > $screen]"` never matched (the predicate's `$screen` was undefined). The transformer now supplies the global bindings to the match cache, which are constant per transform and so do not affect caching. Closes Apache Xalan conformance cases `match14` and `idkey43`. + +- A variable or parameter is now named by expanded QName, not by its literal prefix. XSLT 1.0 section 11.1 names a variable by expanded name, so two prefixes bound to the same namespace name the same variable, but the declared name and the `$prefix:local` reference were compared as raw strings, so `$new:x` did not see a variable declared `txt:x` even when `new` and `txt` share a namespace. A prefixed declared name now resolves to `{uri}local` at parse time, and a variable reference falls back to the namespace-resolved name when the raw key is unbound (so a caller-supplied raw binding still works and unprefixed names are unchanged). Closes Apache Xalan conformance case `variable55`. + +- A template `mode` is now matched by expanded name, not by its literal prefix. XSLT 1.0 section 5.7 compares a mode by expanded name, so two prefixes bound to the same namespace name the same mode, but the prefixed QName string was stored and compared verbatim, so `apply-templates mode="foo:m"` missed a template with `mode="moo:m"` even when `foo` and `moo` share a namespace. Mode names on `xsl:template` and `xsl:apply-templates` now resolve to `{uri}local` at parse time. Closes Apache Xalan conformance case `modes16`. + +- `substring-after(s, '')` now returns `s` instead of the empty string. Per the XPath 1.0 errata, an empty second argument matches at the start of the string, so everything after it is the whole string; the function bailed to empty on an empty separator. `substring-before(s, '')` stays empty, as the spec requires. Closes Apache Xalan conformance case `string143`. + +- `unparsed-entity-uri` now returns the URI of a DTD-declared unparsed (`NDATA`) entity instead of always the empty string. The function was a stub; it now reads the source document type's unparsed entities, threaded to the function table alongside the document loader. An unknown name still returns empty. Closes Apache Xalan conformance case `expression02`. + +- `document('')` now returns the stylesheet's own document. XSLT 1.0 section 12.1 makes an empty URI reference the document that contains the expression, so a stylesheet can read its own `xsl:*` nodes (a common idiom for embedded lookup tables and self-inspection), but the reference was passed straight to the document loader, which has no entry for it, so the call returned an empty node-set. The transformer now keeps its parsed stylesheet tree and the `document` function returns it for an empty reference (caching it like any loaded document; a `#fragment` still applies). Closes Apache Xalan conformance cases `select67`, `select68`, `idkey50`, `mdocs17`, and `namespace20`. + +- An XPath location step now accepts whitespace around the `::` axis separator. XPath 1.0 allows whitespace between tokens, so `child :: sub` is the child axis with node test `sub`, but the parser only matched `::` immediately after the axis name, so a spaced separator silently fell back to the child axis with the axis name read as an element test and selected nothing. Closes Apache Xalan conformance cases `select16`, `select27`, and `select28`. + +- A variable or parameter with empty content and no `select` is now the empty string, not a result tree fragment. XSLT 1.0 section 11.2 makes `` equivalent to `select=""`, so `boolean()` of it is false, but it was always built as a (non-empty) result-tree-fragment document node, which `boolean()` reports as true. Only non-empty content is still an RTF. Closes Apache Xalan conformance cases `boolean43` and `boolean87`. + +- Top-level (global) variables and parameters now resolve references to one another regardless of declaration order. XSLT 1.0 section 11.4 lets a global reference any other global, but they were evaluated once top-to-bottom, so a forward reference (a variable declared before the one it uses) saw an undefined value and resolved to empty. Globals now evaluate to a fixpoint: each pass re-evaluates the unsettled ones with the latest bindings, so a dependency chain in any document order settles, while a genuine circular reference stops at the pass cap. Closes Apache Xalan conformance cases `variable33`, `variable34`, and `variable35`. + +- The XPath string-value of a comment or processing-instruction node is now that node's own character data, instead of the empty string. XPath 1.0 sections 5.6 and 5.7 define a comment's string-value as its text and a PI's as its data, so `value-of select="comment()"` (and matching, sorting, or comparing on such nodes) returned nothing. The node's `stringValue` was conflated with the text-only concatenation used for an element's string-value, where comments and PIs correctly contribute nothing; that concatenation now uses a separate `textContent`, so an element's string-value still excludes comment and PI text while a comment or PI reports its own. Closes 24 Apache Xalan conformance cases across the `node`, `axes`, `position`, `select`, `modes`, `conflictres`, and `variable` categories. + +- A named template (`xsl:call-template`) is now selected by import precedence, not document order. When an import and an include both define a template with the same name, the included one carries the including unit's (higher) precedence and must win (XSLT 1.0 section 6), but the lookup took the first match by position, which is the imported template (imports fold in first). The call now picks the highest-precedence definition. Closes Apache Xalan conformance cases `namedtemplate17`, `namedtemplate18`, `namedtemplate19`, and `namespace13`. + +- A literal result element brought in through `xsl:include` now keeps the namespace nodes it copies under XSLT 1.0 §7.1.1, instead of losing them in an optimized build. An included stylesheet's declarations are absorbed after `collectUnit` returns, and each literal result element resolves its 7.1.1 namespace nodes by walking the weak parent chain back to its own (included) stylesheet element. Those elements were retained only in a write-only `retainedRoots` array, so under optimization ARC released them before the deferred resolution ran and the ancestor chain (the `xmlns` declarations in scope) vanished, dropping every namespace node the included element should have carried. The result was correct in a debug build and wrong in release. The compose step now pins the retained roots across the resolution with `withExtendedLifetime`. Imports were unaffected because they compile eagerly while their tree is still a live local. Catches Apache Xalan conformance case `lre/lre05` (`exclude-result-prefixes` scoping across an include), found by running the Apache xalan-test corpus. + - A `redefine` whose group self-reference duplicates a particle across the redefine chain is now rejected as a Unique Particle Attribution (cos-nonambig) violation, instead of being silently accepted. Two gaps combined to miss it. First, the compositional determinism engine treated a `` to the name being redefined (a src-redefine self-reference) as contributing an empty decision set, where instance validation expands it to the ORIGINAL group; it now expands one level to the redefine base exactly as the instance content-model path does, so the original's particles are visible to the overlap test. The expansion only surfaces genuinely reachable items, so the computed decision sets stay a subset of the true model and the change can catch a real ambiguity but never invent one. Second, the UPA check walked only the root document, so an ambiguity created by a root `redefine` in a complex type that lives in an INCLUDED document was never evaluated; it now runs over every schema root in the composition under the shared (redefine-effective) context, mirroring the existing cross-document top-level-declaration check. Closes XSTS `schN10` (a three-level redefine chain). XSTS invalid-schemas-accepted 11 -> 10, valid-schemas-rejected held at 0. - A `complexContent` restriction whose base complex type has SIMPLE content is now rejected (cos-ct-restricts 5.2). A `complexContent` restriction's {content type} is element-only, mixed, or empty (derived from its model group), which can never validly restrict a simple content type, so restricting a simple-content base this way is invalid. Only a `.simple` base was flagged before; a complex type with `simpleContent` slipped through. The check is scoped to the restriction direction: the extension direction has a valid attribute-only idiom and stays handled by the existing simpleContent-extension rule. Catches particlesZ039. XSTS invalid-schemas-accepted 16 → 15, valid-schemas-rejected held at 0. diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift index c989949..60d0b9f 100644 --- a/Sources/Emitting/Escaping.swift +++ b/Sources/Emitting/Escaping.swift @@ -49,5 +49,20 @@ extension PureXML.Emitting { } return result } + + /// Makes `value` safe inside a comment: a comment may not contain `--` + /// or end with `-`, so a space is inserted after each offending hyphen + /// (the XSLT 1.0 16 recovery, so `after-` becomes `after- `). + static func comment(_ value: String) -> String { + var result = "" + var previousHyphen = false + for character in value { + if character == "-", previousHyphen { result.append(" ") } + result.append(character) + previousHyphen = character == "-" + } + if previousHyphen { result.append(" ") } + return result + } } } diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift index a6a6ec1..0ca841a 100644 --- a/Sources/Emitting/Serializer.swift +++ b/Sources/Emitting/Serializer.swift @@ -85,7 +85,7 @@ public extension PureXML.Emitting { ? Escaping.text(value, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn) : "" case let .comment(value): - output += "" + output += "" case let .processingInstruction(target, data): output += data.isEmpty ? "" : "" } diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift index b6dda82..534e1b5 100644 --- a/Sources/Emitting/Writer.swift +++ b/Sources/Emitting/Writer.swift @@ -99,7 +99,7 @@ public extension PureXML.Emitting { if options.prettyPrint, !open.isEmpty { output += options.lineEnding + pad(open.count) } - output += "" + output += "" markParentHasElements() } diff --git a/Sources/HTML/HTMLSerializer.swift b/Sources/HTML/HTMLSerializer.swift index 061dd67..ef372a8 100644 --- a/Sources/HTML/HTMLSerializer.swift +++ b/Sources/HTML/HTMLSerializer.swift @@ -13,7 +13,7 @@ extension PureXML.HTML { case let .text(value), let .cdata(value): escapeText(value) case let .comment(value): - "" + "" case let .processingInstruction(target, data): data.isEmpty ? "" : "" } diff --git a/Sources/Model/TreeNode.swift b/Sources/Model/TreeNode.swift index b6d89be..9f5c32f 100644 --- a/Sources/Model/TreeNode.swift +++ b/Sources/Model/TreeNode.swift @@ -149,7 +149,7 @@ public extension PureXML.Model { /// replacement content. `value` mirrors the children's text for quick use. public static func entityReference(_ name: String, children: [TreeNode] = []) -> TreeNode { let node = TreeNode(kind: .entityReference, name: PureXML.Model.QualifiedName(name), children: children) - node.value = children.reduce(into: "") { $0 += $1.stringValue } + node.value = children.reduce(into: "") { $0 += $1.textContent } return node } diff --git a/Sources/Model/TreeNodeNavigation.swift b/Sources/Model/TreeNodeNavigation.swift index cef2899..3373ca2 100644 --- a/Sources/Model/TreeNodeNavigation.swift +++ b/Sources/Model/TreeNodeNavigation.swift @@ -49,17 +49,33 @@ public extension PureXML.Model.TreeNode { return current } - /// The concatenated text of every text and CDATA node in this subtree, in - /// document order (the libxml2 `xmlNodeGetContent` behavior). + /// The XPath string-value of this node. For an element, document, or root it + /// is the concatenated text of the subtree (`textContent`); for a comment or + /// processing instruction it is that node's own character data (XPath 1.0 + /// sections 5.6 and 5.7), which `value-of select="comment()"` returns. var stringValue: String { + switch kind { + case .text, .cdata, .namespace, .comment, .processingInstruction: + value + case .element, .document, .entityReference: + textContent + case .doctype: + "" + } + } + + /// The concatenated text of every text and CDATA node in this subtree, in + /// document order (the libxml2 `xmlNodeGetContent` behavior): an element's or + /// root's string-value, where comment and processing-instruction descendants + /// contribute nothing (XPath 1.0 sections 5.1 and 5.5). Distinct from + /// `stringValue`, which on a comment or PI node is that node's own data. + var textContent: String { switch kind { case .text, .cdata: value case .element, .document, .entityReference: - children.reduce(into: "") { $0 += $1.stringValue } - case .namespace: - value - case .comment, .processingInstruction, .doctype: + children.reduce(into: "") { $0 += $1.textContent } + case .namespace, .comment, .processingInstruction, .doctype: "" } } diff --git a/Sources/XPath/CompilerPaths.swift b/Sources/XPath/CompilerPaths.swift index c018548..c4f3655 100644 --- a/Sources/XPath/CompilerPaths.swift +++ b/Sources/XPath/CompilerPaths.swift @@ -156,8 +156,14 @@ extension PureXML.XPath.Compiler { } let save = index let word = parseName() + // XPath 1.0 allows whitespace around the `::` axis separator (a child + // `::` sub is the child axis). Skip it before the test; on no match the + // reset to `save` (before the name) undoes the skip, so a bare name step + // is unaffected. + skipSpace() if !word.isEmpty, matches("::") { advance(by: 2) + skipSpace() return try Self.namedAxis(word) } index = save diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index d846119..056a8f5 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -129,8 +129,7 @@ extension PureXML.XPath { case let .string(value): return .string(value) case let .variable(name): - guard let value = context.variables[name] else { throw QueryError.undefinedVariable(name) } - return value + return try resolveVariable(name, context) case let .negate(inner): return try .number(-eval(inner, context).number) case let .function(name, arguments): @@ -147,6 +146,23 @@ extension PureXML.XPath { } } + /// The value bound to `$name`. A prefixed reference may be bound under its + /// expanded name `{uri}local` (an XSLT variable is named by expanded + /// QName): the raw key is tried first, so a caller's raw binding is + /// unaffected, then the namespace-resolved name. + private static func resolveVariable(_ name: String, _ context: EvaluationContext) throws -> Value { + if let value = context.variables[name] { return value } + if let expanded = expandedKey(name, context.namespaces), let value = context.variables[expanded] { return value } + throw QueryError.undefinedVariable(name) + } + + /// A prefixed name as its expanded key `{uri}local`, or nil when the name + /// has no prefix or the prefix is unbound in `namespaces`. + private static func expandedKey(_ name: String, _ namespaces: [String: String]) -> String? { + guard let colon = name.firstIndex(of: ":"), let uri = namespaces[String(name[.. Value { let start: Node = absolute ? root(of: context.node) : context.node return try .nodeSet(orderUnique(evaluateSteps(steps, from: [start], context))) diff --git a/Sources/XPath/StringFunctions.swift b/Sources/XPath/StringFunctions.swift index 98fa31e..36ee7c1 100644 --- a/Sources/XPath/StringFunctions.swift +++ b/Sources/XPath/StringFunctions.swift @@ -41,7 +41,10 @@ extension PureXML.XPath { private static func substringAfter(_ arguments: [Value]) -> String { let haystack = Array(argument(arguments, 0)) let needle = Array(argument(arguments, 1)) - guard !needle.isEmpty, let start = search(needle, in: haystack) else { return "" } + // XPath errata E22: substring-after(s, "") is s. The empty string + // matches at the start (search returns 0), so everything after it is + // the whole string; a non-empty needle that is absent still yields "". + guard let start = search(needle, in: haystack) else { return "" } return String(haystack[(start + needle.count)...]) } diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index 117b31e..f2d6e98 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -73,8 +73,15 @@ extension PureXML.XSLT.Transformer { } let raw = avt(nameTemplate, context) let parts = raw.split(separator: ":", omittingEmptySubsequences: false) - if raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2 { - // An unusable element name: the recovery is to emit the content + let hasExplicitNamespace = (namespaceTemplate.map { !avt($0, context).isEmpty }) ?? false + let prefix = parts.count == 2 ? String(parts[0]) : nil + // A prefix not bound to a namespace (and no explicit namespace attribute + // supplies one) makes the QName unusable, as does a non-NCName part. + let undeclaredPrefix = !hasExplicitNamespace && (prefix.map { $0 != "xml" && namespaces[$0] == nil } ?? false) + let unusableName = raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2 + || !parts.allSatisfy { PureXML.Parsing.XMLCharacter.isValidName(String($0)) } || undeclaredPrefix + if unusableName { + // The recovery (7.1.2, element-name-not-QName) emits the content // without the wrapper element. return instantiate(body, context).filter { if case .attribute = $0 { false } else { true } } } @@ -90,7 +97,9 @@ extension PureXML.XSLT.Transformer { _ context: XSLTContext, ) -> [PureXML.XSLT.ResultItem] { let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true) - return [.attribute(.init(name: name, value: Self.text(of: instantiate(body, context))))] + // xsl:attribute content that creates a non-text node ignores it with its + // content (XSLT 1.0 errata E27), like xsl:comment/processing-instruction. + return [.attribute(.init(name: name, value: Self.textNodesOnly(of: instantiate(body, context))))] } func copyInstruction(_ useAttributeSets: [String], _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] { @@ -122,6 +131,13 @@ extension PureXML.XSLT.Transformer { return [.node(.comment(context.node.value))] case .processingInstruction: return [.node(.processingInstruction(target: context.node.name?.description ?? "", data: context.node.value))] + case .document: + // Copying the root node produces no element of its own, but xsl:copy + // may carry use-attribute-sets (7.5); those attributes have no copied + // element to attach to, so they join the enclosing result element, + // ahead of the copied content. + let setAttributes = attributeSetAttributes(useAttributeSets, context, visiting: []).map(PureXML.XSLT.ResultItem.attribute) + return setAttributes + instantiate(body, context) default: return instantiate(body, context) } @@ -129,6 +145,21 @@ extension PureXML.XSLT.Transformer { } extension PureXML.XSLT.Transformer { + /// The concatenated value of only the text and CDATA node items, ignoring an + /// element (or other) node together with its content. xsl:comment and + /// xsl:processing-instruction may contain only text (XSLT 1.0 7.4, 7.6); the + /// recovery for a created non-text node is to ignore it and its content, + /// which is not the same as taking the string-value of everything. + static func textNodesOnly(of items: [PureXML.XSLT.ResultItem]) -> String { + items.reduce(into: "") { result, item in + guard case let .node(node) = item else { return } + switch node { + case let .text(value), let .cdata(value): result += value + default: break + } + } + } + /// The source element's in-scope namespace declarations as literal /// xmlns attributes (xsl:copy copies namespace nodes, 7.5); the fixup /// pass drops the ones already in scope in the result. @@ -152,6 +183,23 @@ extension PureXML.XSLT.Transformer { } } + /// A copied tree node for `xsl:copy-of`. For an element it carries the + /// element's full set of in-scope namespace nodes (XSLT 1.0 11.3: copying a + /// node copies its namespace nodes), so a declaration inherited from an + /// ancestor of the copied element is added alongside the element's own; the + /// serializer's namespace fixup later drops any already in the result scope. + static func withInScopeNamespaces(_ tree: PureXML.Model.TreeNode) -> PureXML.Model.Node { + guard case let .element(element) = tree.node else { return tree.node } + let declared = Set(element.attributes + .filter { $0.name.prefix == "xmlns" || ($0.name.prefix == nil && $0.name.localName == "xmlns") } + .map(\.name.description)) + let inherited = namespaceDeclarations(inScopeAt: tree).compactMap { decl -> PureXML.Model.Attribute? in + guard !declared.contains(decl.name.description), case let .literal(uri) = decl.value.first else { return nil } + return PureXML.Model.Attribute(name: decl.name, value: uri) + } + return .element(.init(name: element.name, attributes: element.attributes + inherited, children: element.children)) + } + /// The topmost ancestor of `node` (its document node). static func documentRoot(of node: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode { var current = node diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index f63e0fd..80fbf8a 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -196,6 +196,8 @@ extension PureXML.XSLT { loader: @escaping (String) -> String?, decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:], documents: PureXML.XSLT.DocumentCache, + selfDocument: PureXML.Model.Node? = nil, + unparsedEntities: [String: String] = [:], ) -> PureXML.XPath.FunctionTable { PureXML.XPath.FunctionTable() .adding("current") { _, _ in .nodeSet([current]) } @@ -218,7 +220,7 @@ extension PureXML.XSLT { } else { [arguments.first?.string ?? ""] } - return .nodeSet(references.flatMap { documentReference($0, loader, documents) }) + return .nodeSet(references.flatMap { documentReference($0, loader, documents, selfDocument) }) } .adding("id") { arguments, context in idLookup(arguments, context, documents) @@ -235,7 +237,9 @@ extension PureXML.XSLT { .adding("system-property") { arguments, _ in systemProperty(arguments.first?.string ?? "") } .adding("element-available") { arguments, _ in .boolean(instructionNames.contains(localPart(arguments.first?.string ?? ""))) } .adding("function-available") { arguments, _ in .boolean(functionNames.contains(localPart(arguments.first?.string ?? ""))) } - .adding("unparsed-entity-uri") { _, _ in .string("") } + .adding("unparsed-entity-uri") { arguments, _ in + .string(unparsedEntities[arguments.first?.string ?? ""] ?? "") + } } /// Loads one `document()` reference: the whole document, or, when the @@ -244,6 +248,7 @@ extension PureXML.XSLT { _ reference: String, _ loader: (String) -> String?, _ cache: PureXML.XSLT.DocumentCache, + _ selfDocument: PureXML.Model.Node?, ) -> [PureXML.XPath.Node] { let path: String let fragment: String? @@ -255,9 +260,17 @@ extension PureXML.XSLT { fragment = nil } if cache.trees[path] == nil { - guard let text = loader(path), let parsed = try? PureXML.parse(text, limits: .init(allowDoctype: true)) else { return [] } - cache.sources[path] = parsed - cache.trees[path] = PureXML.Model.TreeNode(parsed) + // An empty URI reference is the stylesheet's own document + // (XSLT 1.0 12.1: `document('')`); any other path loads. + if path.isEmpty { + guard let selfDocument else { return [] } + cache.sources[path] = selfDocument + cache.trees[path] = PureXML.Model.TreeNode(selfDocument) + } else { + guard let text = loader(path), let parsed = try? PureXML.parse(text, limits: .init(allowDoctype: true)) 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)] } diff --git a/Sources/XSLT/XSLTGlobals.swift b/Sources/XSLT/XSLTGlobals.swift new file mode 100644 index 0000000..28a3b2f --- /dev/null +++ b/Sources/XSLT/XSLTGlobals.swift @@ -0,0 +1,41 @@ +extension PureXML.XSLT.Transformer { + /// A top-level variable or parameter declaration, extracted from the + /// stylesheet's globals for fixpoint evaluation. + private struct Global { + let name: String + let select: String? + let body: [PureXML.XSLT.Instruction] + } + + /// The global variable bindings, resolved to a fixpoint so references + /// between top-level variables and parameters hold in any document order + /// (XSLT 1.0 section 11.4). Evaluating once top-to-bottom would leave a + /// forward reference (a global declared before the one it uses) unresolved. + /// A DAG of N globals settles in at most N passes; a pass that changes + /// nothing stops early; a genuine circular reference stops at the cap with + /// the offending variables left at their last value. + func evaluatedGlobals() -> [String: PureXML.XPath.Value] { + var variables: [String: PureXML.XPath.Value] = [:] + let globals: [Global] = stylesheet.globals.compactMap { + guard case let .variable(name, select, body) = $0 else { return nil } + return Global(name: name, select: select, body: body) + } + // A caller-supplied value overrides an xsl:param default and is fixed. + for global in globals where stylesheet.parameterNames.contains(global.name) { + if let supplied = parameters[global.name] { variables[global.name] = .string(supplied) } + } + for _ in 0 ..< max(1, globals.count) { + var changed = false + for global in globals where !(stylesheet.parameterNames.contains(global.name) && parameters[global.name] != nil) { + let context = XSLTContext(node: root, position: 1, size: 1, variables: variables) + let value = variableValue(global.select, global.body, context) + if variables[global.name] != value { + variables[global.name] = value + changed = true + } + } + if !changed { break } + } + return variables + } +} diff --git a/Sources/XSLT/XSLTMatchCache.swift b/Sources/XSLT/XSLTMatchCache.swift index f886db3..b223cfa 100644 --- a/Sources/XSLT/XSLTMatchCache.swift +++ b/Sources/XSLT/XSLTMatchCache.swift @@ -18,6 +18,11 @@ extension PureXML.XSLT { final class MatchCache { private var matched: [String: PatternMatches] = [:] + /// The top-level (global) variable bindings, in scope when a pattern + /// predicate is evaluated (XSLT 1.0 5.2). Constant per transform, so it + /// does not enter the cache key; the transformer sets it before matching. + var globalVariables: [String: PureXML.XPath.Value] = [:] + /// The identities of the tree nodes `pattern` selects over `root`. func nodes( matching pattern: String, @@ -51,7 +56,7 @@ extension PureXML.XSLT { let trimmed = branch.trimmingXMLWhitespace() let path = trimmed.hasPrefix("/") || trimmed.hasPrefix("key(") || trimmed.hasPrefix("id(") ? trimmed : "//" + trimmed guard let query = try? PureXML.XPath.Query(path), - let value = try? query.value(atNode: .tree(root), position: 1, size: 1, variables: [:], functions: functions, namespaces: namespaces), + let value = try? query.value(atNode: .tree(root), position: 1, size: 1, variables: globalVariables, functions: functions, namespaces: namespaces), let selected = value.nodes else { continue } for node in selected { diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index a9c723a..df78610 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -51,14 +51,11 @@ extension PureXML.XSLT { /// vocabulary is recognized by namespace or the `xsl` prefix; other elements /// are literal result elements. enum XSLTParser { - /// Compiles a stylesheet element, folding in `xsl:include`/`xsl:import`. - /// Compiles one stylesheet unit. Import precedences are assigned in - /// post-order over the import tree (each import lower than its - /// importer, later sibling imports higher than earlier ones), and a - /// unit's templates carry the [low, precedence) range of its own - /// import subtree, the set apply-imports searches. An included - /// stylesheet's declarations join the including unit at its - /// precedence; its imports join the including unit's import list. + /// Compiles one stylesheet unit, folding in `xsl:include`/`xsl:import`. + /// Import precedences are assigned post-order over the import tree (each + /// import lower than its importer, later siblings higher); a unit's + /// templates carry the [low, precedence) range apply-imports searches. An + /// included stylesheet joins the including unit at its precedence. static func compile(_ top: XSLTTree, loader: (String) -> String?, counter: inout Int, base: String = "", chain: Set = []) -> Stylesheet { var parts = Parts() let low = counter @@ -67,8 +64,13 @@ extension PureXML.XSLT { counter = collector.counter let precedence = counter counter += 1 - for (child, _) in collector.declarations { - _ = absorbDeclaration(child, into: &parts, precedence: precedence, low: low) + // Included declarations resolve their 7.1.1 namespace nodes via the + // weak parent chain to their own stylesheet element, retained only in + // the otherwise-unread retainedRoots; pin it so ARC keeps it here. + withExtendedLifetime(collector.retainedRoots) { + for (child, _) in collector.declarations { + _ = absorbDeclaration(child, into: &parts, precedence: precedence, low: low) + } } return parts.stylesheet } @@ -191,7 +193,7 @@ extension PureXML.XSLT { return Template( match: match, name: XSLTNode.attribute(node, "name"), - mode: XSLTNode.attribute(node, "mode"), + mode: expandedMode(node), priority: priority, importPrecedence: precedence, importRangeLow: low, @@ -203,7 +205,7 @@ extension PureXML.XSLT { private static func binding(_ node: XSLTTree) -> Binding { Binding( - name: XSLTNode.attribute(node, "name") ?? "", + name: expandedDeclaredName(node), select: XSLTNode.attribute(node, "select"), body: body(node), ) @@ -224,7 +226,26 @@ extension PureXML.XSLT { // MARK: Bodies and instructions static func body(_ node: XSLTTree) -> [Instruction] { - node.children.compactMap(instruction) + // Adjacent text and CDATA children are one text node in the data + // model, so a whitespace-only run is dropped (XSLT 1.0 3.4) only when + // the WHOLE coalesced run is whitespace: " " keeps its + // spaces because the run "x" surrounds them with non-whitespace. + var result: [Instruction] = [] + var run = "" + func flush() { + if !run.isEmpty, !run.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isWhitespace) { result.append(.literalText(run)) } + run = "" + } + for child in node.children { + if child.kind == .text || child.kind == .cdata { + run += child.value + } else { + flush() + if let instruction = instruction(child) { result.append(instruction) } + } + } + flush() + return result } static func instruction(_ node: XSLTTree) -> Instruction? { @@ -255,7 +276,7 @@ extension PureXML.XSLT { ) case "apply-templates": .applyTemplates( select: XSLTNode.attribute(node, "select"), - mode: XSLTNode.attribute(node, "mode"), + mode: expandedMode(node), sorts: sorts(node), parameters: withParameters(node), ) @@ -278,7 +299,7 @@ extension PureXML.XSLT { } private static func variable(_ node: XSLTTree) -> Instruction { - .variable(name: XSLTNode.attribute(node, "name") ?? "", select: XSLTNode.attribute(node, "select"), body: body(node)) + .variable(name: expandedDeclaredName(node), select: XSLTNode.attribute(node, "select"), body: body(node)) } static func choose(_ node: XSLTTree) -> Instruction { @@ -331,63 +352,3 @@ extension PureXML.XSLT { } } } - -/// Top-level declaration helpers for ``XSLTParser``, kept in an extension so the -/// parser enum stays within the type-body length budget. -private extension PureXML.XSLT.XSLTParser { - /// Records an `xsl:namespace-alias`: the namespace bound to `stylesheet-prefix` - /// is rewritten to the one bound to `result-prefix` (with that prefix) on output. - static func addNamespaceAlias(_ child: XSLTTree, into parts: inout Parts) { - let stylePrefix = XSLTNode.attribute(child, "stylesheet-prefix") ?? "#default" - let resultPrefix = XSLTNode.attribute(child, "result-prefix") ?? "#default" - let key = resolvePrefix(stylePrefix, at: child) ?? "" - parts.namespaceAliases[key] = PureXML.XSLT.NamespaceAlias( - uri: resolvePrefix(resultPrefix, at: child), - prefix: resultPrefix == "#default" ? nil : resultPrefix, - ) - } - - /// Resolves a namespace prefix (or `#default`) to its URI from the `xmlns` - /// declarations in scope at `node`, walking up to the stylesheet element. - static func resolvePrefix(_ prefix: String, at node: XSLTTree) -> String? { - var current: XSLTTree? = node - while let element = current { - for attribute in element.attributes { - let isDefault = prefix == "#default" && attribute.name.prefix == nil && attribute.name.localName == "xmlns" - let isPrefixed = attribute.name.prefix == "xmlns" && attribute.name.localName == prefix - if isDefault || isPrefixed { return attribute.value } - } - current = element.parent - } - return nil - } - - static func addAttributeSet(_ child: XSLTTree, into parts: inout Parts) { - guard let name = XSLTNode.attribute(child, "name") else { return } - // Same-name attribute sets merge (7.1.4) as ordered definitions: - // each expands its used sets before its own attributes, and a later - // definition's attributes override earlier same-named ones. - let addition = PureXML.XSLT.AttributeSet(attributes: body(child), use: useAttributeSets(child)) - parts.attributeSets[name, default: []].append(addition) - } - - /// Reads an `xsl:decimal-format`'s symbol overrides; each unset attribute - /// keeps the XSLT standard default. - static func decimalFormat(_ node: XSLTTree) -> PureXML.XSLT.DecimalFormat { - var format = PureXML.XSLT.DecimalFormat() - func char(_ name: String, _ keyPath: WritableKeyPath) { - if let value = XSLTNode.attribute(node, name)?.first { format[keyPath: keyPath] = value } - } - char("decimal-separator", \.decimalSeparator) - char("grouping-separator", \.groupingSeparator) - char("percent", \.percent) - char("per-mille", \.perMille) - char("zero-digit", \.zeroDigit) - char("digit", \.digit) - char("pattern-separator", \.patternSeparator) - char("minus-sign", \.minusSign) - if let infinity = XSLTNode.attribute(node, "infinity") { format.infinity = infinity } - if let notANumber = XSLTNode.attribute(node, "NaN") { format.notANumber = notANumber } - return format - } -} diff --git a/Sources/XSLT/XSLTParserDeclarations.swift b/Sources/XSLT/XSLTParserDeclarations.swift new file mode 100644 index 0000000..527db64 --- /dev/null +++ b/Sources/XSLT/XSLTParserDeclarations.swift @@ -0,0 +1,85 @@ +/// Top-level declaration helpers for ``XSLTParser``, kept in a separate file so +/// the parser enum stays within the type-body and file length budgets. +extension PureXML.XSLT.XSLTParser { + /// Records an `xsl:namespace-alias`: the namespace bound to `stylesheet-prefix` + /// is rewritten to the one bound to `result-prefix` (with that prefix) on output. + static func addNamespaceAlias(_ child: XSLTTree, into parts: inout Parts) { + let stylePrefix = XSLTNode.attribute(child, "stylesheet-prefix") ?? "#default" + let resultPrefix = XSLTNode.attribute(child, "result-prefix") ?? "#default" + let key = resolvePrefix(stylePrefix, at: child) ?? "" + parts.namespaceAliases[key] = PureXML.XSLT.NamespaceAlias( + uri: resolvePrefix(resultPrefix, at: child), + prefix: resultPrefix == "#default" ? nil : resultPrefix, + ) + } + + /// Resolves a namespace prefix (or `#default`) to its URI from the `xmlns` + /// declarations in scope at `node`, walking up to the stylesheet element. + static func resolvePrefix(_ prefix: String, at node: XSLTTree) -> String? { + var current: XSLTTree? = node + while let element = current { + for attribute in element.attributes { + let isDefault = prefix == "#default" && attribute.name.prefix == nil && attribute.name.localName == "xmlns" + let isPrefixed = attribute.name.prefix == "xmlns" && attribute.name.localName == prefix + if isDefault || isPrefixed { return attribute.value } + } + current = element.parent + } + return nil + } + + /// A `mode` attribute as expanded name `{uri}local` (bare local when + /// unprefixed): a mode is compared by expanded name (XSLT 1.0 5.7), so two + /// prefixes bound to one namespace are the same mode. nil when absent. + static func expandedMode(_ node: XSLTTree) -> String? { + guard let mode = XSLTNode.attribute(node, "mode") else { return nil } + guard let colon = mode.firstIndex(of: ":") else { return mode } + let prefix = String(mode[.. String { + let raw = XSLTNode.attribute(node, "name") ?? "" + guard let colon = raw.firstIndex(of: ":") else { return raw } + let prefix = String(raw[.. PureXML.XSLT.DecimalFormat { + var format = PureXML.XSLT.DecimalFormat() + func char(_ name: String, _ keyPath: WritableKeyPath) { + if let value = XSLTNode.attribute(node, name)?.first { format[keyPath: keyPath] = value } + } + char("decimal-separator", \.decimalSeparator) + char("grouping-separator", \.groupingSeparator) + char("percent", \.percent) + char("per-mille", \.perMille) + char("zero-digit", \.zeroDigit) + char("digit", \.digit) + char("pattern-separator", \.patternSeparator) + char("minus-sign", \.minusSign) + if let infinity = XSLTNode.attribute(node, "infinity") { format.infinity = infinity } + if let notANumber = XSLTNode.attribute(node, "NaN") { format.notANumber = notANumber } + return format + } +} diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 89404d1..c410990 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -22,7 +22,19 @@ public extension PureXML.XSLT { let root = PureXML.Model.TreeNode(parsed.node) let idAttributes = PureXML.XSLT.declaredIDAttributes(parsed.documentType) Whitespace.strip(root, stylesheet: sheet) - let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader, idAttributes: idAttributes, parameters: parameters) + // The stylesheet's own document, queried by `document('')` (XSLT 1.0 12.1). + let stylesheetDocument = try? PureXML.parse(stylesheet, limits: .init(allowDoctype: true)) + // The source DTD's unparsed entities, by name to system URI (12.4). + let unparsedEntityURIs = parsed.documentType.unparsedEntities.mapValues(\.id.resolvedSystemID) + let transformer = Transformer( + stylesheet: sheet, + root: root, + documentLoader: documentLoader, + idAttributes: idAttributes, + parameters: parameters, + stylesheetDocument: stylesheetDocument, + unparsedEntityURIs: unparsedEntityURIs, + ) let result = transformer.run() if let message = transformer.terminationMessage { throw XSLTError.terminated(message) } let method = sheet.output.method ?? defaultMethod(for: result) @@ -207,7 +219,13 @@ public extension PureXML.XSLT { let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) let root = try PureXML.parseTree(source, limits: .init(allowDoctype: true)) Whitespace.strip(root, stylesheet: sheet) - let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader) + let stylesheetDocument = try? PureXML.parse(stylesheet, limits: .init(allowDoctype: true)) + let transformer = Transformer( + stylesheet: sheet, + root: root, + documentLoader: documentLoader, + stylesheetDocument: stylesheetDocument, + ) let result = transformer.run() if let message = transformer.terminationMessage { throw XSLTError.terminated(message) } return result diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 93d716f..002ef4b 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -24,9 +24,12 @@ extension PureXML.XSLT { termination.message } - /// Caller-supplied top-level parameter values, overriding xsl:param - /// defaults by name. + /// Caller-supplied top-level parameter values, overriding xsl:param defaults. let parameters: [String: String] + /// The stylesheet's own parsed document, returned by `document('')` (12.1). + let stylesheetDocument: PureXML.Model.Node? + /// The source DTD's unparsed entities by name to system URI, for `unparsed-entity-uri` (12.4). + let unparsedEntityURIs: [String: String] init( stylesheet: Stylesheet, @@ -34,17 +37,20 @@ extension PureXML.XSLT { documentLoader: @escaping (String) -> String? = { _ in nil }, idAttributes: [String: Set] = [:], parameters: [String: String] = [:], + stylesheetDocument: PureXML.Model.Node? = nil, + unparsedEntityURIs: [String: String] = [:], ) { self.parameters = parameters self.stylesheet = stylesheet self.root = root self.documentLoader = documentLoader + self.stylesheetDocument = stylesheetDocument + self.unparsedEntityURIs = unparsedEntityURIs if !idAttributes.isEmpty { documentCache.idAttributes[ObjectIdentifier(root)] = idAttributes } - // One table for all pattern matching: matches() runs per - // (template, node) during template selection, so a fresh table - // per call would be allocated millions of times on large inputs. + // One table for all pattern matching: matches() runs per (template, + // node), so a fresh table per call would allocate millions of times. let caches = keyIndexes patternTable = PureXML.XSLT.Library.table( current: .tree(root), @@ -58,6 +64,8 @@ extension PureXML.XSLT { loader: documentLoader, decimalFormats: stylesheet.decimalFormats, documents: documentCache, + selfDocument: stylesheetDocument, + unparsedEntities: unparsedEntityURIs, ) } @@ -72,22 +80,9 @@ extension PureXML.XSLT { } func run() -> PureXML.Model.Node { - var variables: [String: PureXML.XPath.Value] = [:] - for global in stylesheet.globals { - if case let .variable(name, select, body) = global { - // A caller-supplied value overrides an xsl:param default. - if stylesheet.parameterNames.contains(name), let supplied = parameters[name] { - variables[name] = .string(supplied) - continue - } - // Each global evaluates with every previously evaluated - // global in scope (a top-level variable may reference an - // earlier top-level param, directly or via call-template). - let globalContext = XSLTContext(node: root, position: 1, size: 1, variables: variables) - variables[name] = variableValue(select, body, globalContext) - } - } - let context = XSLTContext(node: root, position: 1, size: 1, variables: variables) + let globals = evaluatedGlobals() + matchCache.globalVariables = globals // pattern predicates see globals (5.2) + let context = XSLTContext(node: root, position: 1, size: 1, variables: globals) return .document(applyTemplates(to: [.tree(root)], mode: nil, parameters: [], context).compactMap(Self.nodeOf)) } @@ -106,8 +101,7 @@ extension PureXML.XSLT { } func matches(_ node: PureXML.Model.TreeNode, _ pattern: String, _ namespaces: [String: String] = [:]) -> Bool { - // Patterns evaluate over the node's own document, so templates - // match nodes loaded through document() too. + // Patterns evaluate over the node's own document (so document()-loaded nodes match too). let documentRoot = Self.documentRoot(of: node) return matchCache.nodes(matching: pattern, over: documentRoot, functions: patternFunctions(), namespaces: namespaces).contains(ObjectIdentifier(node)) } @@ -178,6 +172,10 @@ extension PureXML.XSLT { func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value { if let select { return value(select, context) ?? .string("") } + // XSLT 1.0 11.2: a variable with no select and EMPTY content is the + // empty string (equivalent to select=""), not a result tree fragment, + // so boolean() of it is false. Only non-empty content is an RTF. + if body.isEmpty { return .string("") } // A body variable is a result-tree fragment: a queryable document node. let children = instantiate(body, context).compactMap(Self.nodeOf).map(PureXML.Model.TreeNode.init) return .nodeSet([.tree(PureXML.Model.TreeNode.document(children: children))]) @@ -196,6 +194,8 @@ extension PureXML.XSLT { loader: documentLoader, decimalFormats: stylesheet.decimalFormats, documents: documentCache, + selfDocument: stylesheetDocument, + unparsedEntities: unparsedEntityURIs, ), namespaces: context.namespaces, ) @@ -302,9 +302,9 @@ extension PureXML.XSLT.Transformer { case .number: [.node(.text(numberInstruction(instruction, context)))] case let .comment(body): - [.node(.comment(Self.text(of: instantiate(body, context))))] + [.node(.comment(Self.textNodesOnly(of: instantiate(body, context))))] case let .processingInstruction(name, body): - [.node(.processingInstruction(target: avt(name, context), data: Self.text(of: instantiate(body, context))))] + [.node(.processingInstruction(target: avt(name, context), data: Self.textNodesOnly(of: instantiate(body, context))))] case let .message(terminate, body): message(terminate, body, context) case .applyImports: @@ -325,7 +325,10 @@ extension PureXML.XSLT.Transformer { } private func callTemplate(_ name: String, _ parameters: [PureXML.XSLT.Binding], _ context: XSLTContext) -> [ResultItem] { - guard let template = stylesheet.templates.first(where: { $0.name == name }) else { return [] } + // XSLT 1.0 section 6: pick the highest import-precedence definition, not the + // first by position, so an included template outranks a same-name import. + guard let template = stylesheet.templates.filter({ $0.name == name }) + .max(by: { $0.importPrecedence < $1.importPrecedence }) else { return [] } return instantiateTemplate(template, context, passing: parameters, from: context) } @@ -336,7 +339,7 @@ extension PureXML.XSLT.Transformer { } return nodes.map { xnode in switch xnode { - case let .tree(tree): .node(tree.node) + case let .tree(tree): .node(Self.withInScopeNamespaces(tree)) case let .attribute(_, attribute): .attribute(attribute) case let .namespace(_, prefix, uri): .attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)", uri)) @@ -373,7 +376,7 @@ extension PureXML.XSLT.Transformer { /// The attributes contributed by `names` and the attribute sets they include, /// lower precedence first, with a `visiting` guard against recursive includes. - private func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set) -> [PureXML.Model.Attribute] { + func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set) -> [PureXML.Model.Attribute] { var result: [PureXML.Model.Attribute] = [] for name in names where !visiting.contains(name) { for definition in stylesheet.attributeSets[name] ?? [] { diff --git a/Tests/ConformanceEmittingTests.swift b/Tests/ConformanceEmittingTests.swift index c7f7fc0..6a2cbdc 100644 --- a/Tests/ConformanceEmittingTests.swift +++ b/Tests/ConformanceEmittingTests.swift @@ -53,4 +53,14 @@ struct ConformanceEmittingTests { let failures = try PureXML.Validation.Conformance.failures(in: corpus()) #expect(failures.isEmpty, "\(failures.map(\.reason))") } + + @Test("A comment ending in a hyphen or containing -- is made safe") + func test_commentHyphenSafety() { + // A comment may not end with `-` or contain `--`; a space is inserted + // after each offending hyphen (XSLT 1.0 16, Apache Xalan output89/90). + let trailing = PureXML.serialize(.element(.init("r", children: [.comment("ends-")])), options: compact) + #expect(trailing == "") + let doubled = PureXML.serialize(.element(.init("r", children: [.comment("a--b")])), options: compact) + #expect(doubled == "") + } } diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index 83a1267..6b7ce99 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -1,81 +1,35 @@ # The xalan-test conf cases this implementation does not yet pass, # one category/name per line, exact in both directions: a fixed case # must leave this list and a regression shows up as a new failure. -attribset/attribset27 -attribset/attribset29 attribset/attribset44 attribset/attribset48 attribvaltemplate/attribvaltemplate08 -axes/axes104 -axes/axes106 -axes/axes108 -axes/axes110 axes/axes116 -axes/axes124 -axes/axes126 -axes/axes128 -boolean/boolean43 -boolean/boolean87 -conflictres/conflictres22 -conflictres/conflictres23 conflictres/conflictres29 conflictres/conflictres30 -conflictres/conflictres34 -copy/copy09 -copy/copy27 -copy/copy48 -copy/copy56 -copy/copy58 -copy/copy60 -expression/expression02 extend/extend01 extend/extend02 extend/extend04 idkey/idkey31 -idkey/idkey43 idkey/idkey45 idkey/idkey46 idkey/idkey47 idkey/idkey48 -idkey/idkey50 idkey/idkey53 impincl/impincl08 impincl/impincl27 -match/match14 mdocs/mdocs07 -mdocs/mdocs09 mdocs/mdocs12 mdocs/mdocs13 mdocs/mdocs15 -mdocs/mdocs17 mdocs/mdocs19 -modes/modes11 -modes/modes12 -modes/modes16 namedtemplate/namedtemplate12 namedtemplate/namedtemplate16 -namedtemplate/namedtemplate17 -namedtemplate/namedtemplate18 -namedtemplate/namedtemplate19 namespace/namespace113 -namespace/namespace13 namespace/namespace19 -namespace/namespace20 namespace/namespace23 namespace/namespace24 namespace/namespace35 -namespace/namespace40 -namespace/namespace42 -namespace/namespace43 -namespace/namespace60 -namespace/namespace73 -namespace/namespace79 -node/node09 -node/node10 -node/node11 -node/node12 -node/node13 -node/node15 numbering/numbering63 output/output22 output/output26 @@ -92,23 +46,11 @@ output/output74 output/output75 output/output80 output/output86 -output/output89 -output/output90 -position/position101 position/position106 -position/position70 -position/position75 processorinfo/processorinfo03 reluri/reluri09 reluri/reluri10 reluri/reluri11 -select/select13 -select/select16 -select/select27 -select/select28 -select/select67 -select/select68 -select/select75 sort/sort02 sort/sort10 sort/sort14 @@ -117,13 +59,6 @@ sort/sort32 sort/sort33 sort/sort34 string/string132 -string/string143 -variable/variable33 -variable/variable34 -variable/variable35 -variable/variable41 -variable/variable55 whitespace/whitespace06 whitespace/whitespace07 -whitespace/whitespace13 whitespace/whitespace20 diff --git a/Tests/TreeNodeTests.swift b/Tests/TreeNodeTests.swift index 35734a6..e284c48 100644 --- a/Tests/TreeNodeTests.swift +++ b/Tests/TreeNodeTests.swift @@ -35,6 +35,23 @@ struct TreeNodeTests { #expect(tree.firstChild?.stringValue == "abc") } + @Test("A comment or PI node's stringValue is its own data, but it adds nothing to an ancestor's") + func test_stringValueOfCommentAndPI() throws { + // XPath 1.0 5.6/5.7: the string-value of a comment or processing- + // instruction node is its own character data (so value-of select= + // "comment()" returns it). But such a node contributes nothing to the + // string-value of an enclosing element (5.1), which is text+CDATA only. + let tree = try PureXML.parseTree("ab") + let root = try #require(tree.firstChild) + let comment = try #require(root.children.first { $0.kind == .comment }) + let instruction = try #require(root.children.first { $0.kind == .processingInstruction }) + #expect(comment.stringValue == " hi ") + #expect(instruction.stringValue == "go") + // The element excludes both: only the "a" and "b" text remain. + #expect(root.stringValue == "ab") + #expect(root.textContent == "ab") + } + @Test("elementChildren skips non-element nodes") func test_elementChildren() throws { let tree = try PureXML.parseTree("text") diff --git a/Tests/XPathAxisTests.swift b/Tests/XPathAxisTests.swift index 2ab0e00..1a7664f 100644 --- a/Tests/XPathAxisTests.swift +++ b/Tests/XPathAxisTests.swift @@ -99,4 +99,14 @@ struct XPathAxisTests { // ancestor axis is reverse, but the returned node-set is document-ordered. #expect(try names("//c/ancestor-or-self::node()") == ["root", "a", "b", "c"]) } + + @Test("Whitespace is allowed around the :: axis separator") + func test_whitespaceAroundAxisSeparator() throws { + // XPath 1.0 allows whitespace between tokens, so `child :: a` is the + // child axis with node test `a`, the same as `child::a` (Apache Xalan + // select16, select27, select28). + #expect(try names("/root/child::a") == ["a", "a"]) + #expect(try names("/root/child :: a") == ["a", "a"]) + #expect(try names("/root/descendant :: c") == ["c"]) + } } diff --git a/Tests/XPathFunctionTests.swift b/Tests/XPathFunctionTests.swift index 41c827a..c0c8925 100644 --- a/Tests/XPathFunctionTests.swift +++ b/Tests/XPathFunctionTests.swift @@ -40,6 +40,12 @@ struct XPathFunctionTests { func test_substringAround() throws { #expect(try string("substring-before('1999/04', '/')") == "1999") #expect(try string("substring-after('1999/04', '/')") == "04") + // XPath errata: an empty second argument matches at the start, so + // substring-after returns the whole string and substring-before is empty. + #expect(try string("substring-after('ABCDE', '')") == "ABCDE") + #expect(try string("substring-before('ABCDE', '')") == "") + // An absent non-empty separator yields the empty string for both. + #expect(try string("substring-after('ABCDE', 'x')") == "") } @Test("substring honors one-based positions and length") diff --git a/Tests/XSLTCallTemplatePrecedenceTests.swift b/Tests/XSLTCallTemplatePrecedenceTests.swift new file mode 100644 index 0000000..449cf46 --- /dev/null +++ b/Tests/XSLTCallTemplatePrecedenceTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import PureXML + +/// `xsl:call-template` selects its target by import precedence (XSLT 1.0 +/// section 6), not by the position of the definition in the flattened +/// stylesheet. An included template carries the including unit's precedence, +/// so it outranks a same-name template from an import (Apache Xalan +/// namedtemplate17-19, namespace13). +@Suite("XSLT call-template precedence") +struct XSLTCallTemplatePrecedenceTests { + private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" + + @Test("An include outranks a same-name import") + func test_includeOutranksImport() throws { + let imported = """ + + IMPORT + + """ + let included = """ + + INCLUDE + + """ + let style = """ + + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: style, + source: "", + documentLoader: { ["imp.xsl": imported, "inc.xsl": included][$0] }, + ) + // The included template (the including unit's precedence) wins over the + // imported one, which folds in first by position. + #expect(result == "INCLUDE") + } +} diff --git a/Tests/XSLTCompositionTests.swift b/Tests/XSLTCompositionTests.swift index ac3d0b7..a770c52 100644 --- a/Tests/XSLTCompositionTests.swift +++ b/Tests/XSLTCompositionTests.swift @@ -252,4 +252,35 @@ struct XSLTCompositionTests { // imported copy of shared.xsl. #expect(result == "RS") } + + @Test("A literal result element from an xsl:include keeps its 7.1.1 namespace nodes") + func test_includedLiteralKeepsNamespaceNode() throws { + // The included sheet declares ped on its own stylesheet element, so + // its literal result element copies that namespace node (7.1.1). The + // including sheet excludes ped from its own wrapper, so the node is + // not inherited and must be re-declared on the included element. The + // included declarations are absorbed after composition walks the weak + // parent chain back to the included stylesheet element; that element + // must stay alive for the chain to resolve (Xalan conf lre05). + let included = """ + + + + """ + let style = """ + + + + +
+
+
+ """ + let result = try PureXML.XSLT.transform( + stylesheet: style, + source: "", + documentLoader: { ["inc.xsl": included][$0] }, + ) + #expect(result == "
") + } } diff --git a/Tests/XSLTDocumentFunctionTests.swift b/Tests/XSLTDocumentFunctionTests.swift index c221ccf..5191542 100644 --- a/Tests/XSLTDocumentFunctionTests.swift +++ b/Tests/XSLTDocumentFunctionTests.swift @@ -70,4 +70,42 @@ struct XSLTDocumentFunctionTests { } #expect(out == "3") } + + @Test("unparsed-entity-uri returns the system URI of a DTD unparsed entity") + func test_unparsedEntityURI() throws { + // XSLT 1.0 12.4: unparsed-entity-uri returns the URI of the named NDATA + // entity declared in the source DTD, or empty for an unknown name + // (Apache Xalan expression02). + let style = """ + + + \ + , + + """ + let source = """ + + + + ]> + """ + #expect(try transform(style, source, loader: { _ in nil }) == "images/hatch.gif,") + } + + @Test("document('') is the stylesheet's own document") + func test_emptyReferenceIsStylesheet() throws { + // XSLT 1.0 12.1: an empty URI reference is the document containing the + // expression, so a stylesheet can read its own xsl:* nodes (Apache Xalan + // select67, select68, idkey50, mdocs17, namespace20). + let style = """ + + + + + FOUND + + """ + #expect(try transform(style, "", loader: { _ in nil }) == "FOUND") + } } diff --git a/Tests/XSLTGlobalVariableTests.swift b/Tests/XSLTGlobalVariableTests.swift new file mode 100644 index 0000000..223bbb5 --- /dev/null +++ b/Tests/XSLTGlobalVariableTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import PureXML + +/// Top-level variables and parameters may reference one another in any document +/// order (XSLT 1.0 section 11.4), so the engine resolves them to a fixpoint +/// rather than once top-to-bottom (Apache Xalan variable33-35). +@Suite("XSLT global variable evaluation order") +struct XSLTGlobalVariableTests { + private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" + + @Test("A global variable resolves a forward reference to a later global") + func test_forwardReference() throws { + // `b` is declared before `a` but references it; both must resolve. + let style = """ + + + + + + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "GotIt") == "GotIt") + } + + @Test("A cascaded chain of globals declared in reverse order all resolve") + func test_cascadedReverseOrder() throws { + // b<-a, e<-d, a<-source, d<-c, c<-b: a deep forward-reference chain + // that only settles when evaluated to a fixpoint, not in document order. + let style = """ + + + + + + + + + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "GotIt") == "GotIt") + } + + @Test("A variable is named by expanded QName: prefixes sharing a namespace name the same variable") + func test_variableMatchedByExpandedName() throws { + // txt and new are both bound to urn:v, so $new:x references the variable + // declared as txt:x (XSLT 1.0 11.1, Apache Xalan variable55). + let style = """ + + + + + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "Wizard") + } +} diff --git a/Tests/XSLTOutputMethodTests.swift b/Tests/XSLTOutputMethodTests.swift index 580d15a..31b19c5 100644 --- a/Tests/XSLTOutputMethodTests.swift +++ b/Tests/XSLTOutputMethodTests.swift @@ -152,4 +152,17 @@ struct XSLTOutputMethodTests { ) #expect(result == "a\u{E6}b") } + + @Test("A comment ending in a hyphen is well-formed in html output too") + func test_htmlCommentHyphenSafety() throws { + // The html output method must also apply the section-16 comment recovery, + // so a trailing hyphen does not produce a malformed ``. + let style = """ + + + x- + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "") + } } diff --git a/Tests/XSLTResultFragmentTests.swift b/Tests/XSLTResultFragmentTests.swift index dc6622e..49e707c 100644 --- a/Tests/XSLTResultFragmentTests.swift +++ b/Tests/XSLTResultFragmentTests.swift @@ -54,4 +54,21 @@ struct XSLTResultFragmentTests { """ #expect(try transform(style, "") == "1,3,") } + + @Test("A variable with empty content and no select is the empty string, not an RTF") + func test_emptyContentVariableIsEmptyString() throws { + // XSLT 1.0 11.2: with empty content and no + // select is equivalent to select="", so its boolean() is false (an RTF, + // by contrast, is always true). A variable with non-empty content stays + // an RTF and is true (Apache Xalan boolean43, boolean87). + let style = """ + + + + + , + + """ + #expect(try transform(style, "") == "false,true") + } } diff --git a/Tests/XSLTTests.swift b/Tests/XSLTTests.swift index db4de93..7490c5a 100644 --- a/Tests/XSLTTests.swift +++ b/Tests/XSLTTests.swift @@ -67,6 +67,130 @@ struct XSLTTests { #expect(try transform(stylesheet, source) == "
  • A
  • B
  • A

    B

    ") } + @Test("A mode is matched by expanded name: prefixes sharing a namespace name the same mode") + func test_modeMatchedByExpandedName() throws { + // foo and moo are both bound to urn:m, so apply-templates mode="foo:m" + // selects the template with mode="moo:m" (XSLT 1.0 5.7, Xalan modes16). + let stylesheet = """ + + + matched + unmatched + + """ + #expect(try transform(stylesheet, "
    ") == "matched") + } + + @Test("xsl:copy of the root with use-attribute-sets adds the attributes to the enclosing element") + func test_copyRootWithAttributeSets() throws { + // Copying the root makes no element of its own, so the attribute-set + // attributes join the enclosing (XSLT 1.0 7.5, Xalan attribset29). + let stylesheet = """ + + + 1 + + + """ + #expect(try transform(stylesheet, "") == "") + } + + @Test("xsl:element with an unusable name passes its content through") + func test_elementInvalidNamePassThrough() throws { + func out(_ name: String) throws -> String { + try transform(""" + + + + + """, "") + } + // An undeclared prefix or non-QName name is unusable; the recovery emits + // the content without the wrapper element (XSLT 1.0 7.1.2, Xalan namespace40/43). + #expect(try out("none:foo") == "") // undeclared prefix + #expect(try out("this is bad") == "") // not an NCName + #expect(try out("good") == "") // a valid name still wraps + // A name using a non-ASCII NameChar (U+00B7 middle dot) is a valid XML + // name and must still wrap, not be dropped as unusable. + #expect(try out("a\u{B7}b") == "") + } + + @Test("xsl:copy-of carries an element's inherited namespace nodes") + func test_copyOfInheritedNamespaces() throws { + // The copied inherits xmlns:ped from the root, with no ped-prefixed + // name of its own, so copy-of must still carry the ped namespace node + // (XSLT 1.0 11.3, Apache Xalan copy09). + let stylesheet = """ + + + + + """ + let source = "" + #expect(try transform(stylesheet, source) == "") + } + + @Test("xsl:processing-instruction and xsl:comment keep only text-node content") + func test_piCommentIgnoreNonTextContent() throws { + // Content that creates a non-text node (here a copied element) is ignored + // with its content, not flattened to its string value (XSLT 1.0 7.4/7.6, + // Apache Xalan copy60), so only the leading text node survives. + let stylesheet = """ + + + + + """ + // doc = text "foo" then an element; the element node is dropped. + #expect(try transform(stylesheet, "foobar") == "") + } + + @Test("xsl:attribute keeps only text-node content (errata E27)") + func test_attributeIgnoresNonTextContent() throws { + // a = "T1" + BBBB + "T2"; the element node is ignored with its + // content, so the attribute is "T1T2", not "T1BBBBT2" (Xalan copy58). + let stylesheet = """ + + + + + """ + #expect(try transform(stylesheet, "T1BBBBT2") == "") + } + + @Test("Whitespace around a CDATA section in a template is preserved") + func test_literalWhitespaceAroundCData() throws { + // Text and CDATA coalesce into one node, so " " is the + // non-whitespace node " test " and its spaces survive stripping, while a + // purely whitespace run between elements is still dropped (Xalan + // whitespace13). + let stylesheet = """ + + + + + """ + #expect(try transform(stylesheet, "") == " test ") + } + + @Test("A match pattern predicate sees top-level variables") + func test_patternReferencesGlobalVariable() throws { + // foo[. > $screen] matches only foo elements over the global threshold + // (XSLT 1.0 5.2: a pattern predicate sees the global variables; Xalan + // match14). Without them the predicate fails and nothing matches. + let stylesheet = """ + + + + + :over, + :under, + + """ + let source = "859" + #expect(try transform(stylesheet, source) == "a:over,b:under,c:over,") + } + @Test("The identity transform reproduces the input") func test_identity() throws { let stylesheet = """