fix(xslt): gate raw-output markers, cache pattern table, add CI tests

Raw-output marker resolution and 16.4 stripping run only when the
stylesheet mentions disable-output-escaping, so source text containing
U+E000/U+E001 survives every other transform untouched, and the text
method removes markers without unescaping content that was never
escaped. The pattern-matching function table is built once per
transform instead of per (template, node) membership test, and a dead
variable left in the numbering engine is gone. Eight focused unit
tests cover d-o-e (with the fragment rule and marker safety), CDATA
terminator splitting, simplified syntax, the html default method with
META injection, per-document key(), and attribute-start axes, so the
burn-down behaviors are CI-visible without the opt-in corpus.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 15:01:07 +02:00
parent a86a57eec4
commit 2b5080e30e
8 changed files with 183 additions and 20 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Critic-loop hardening of the xalan burn-down (#130): raw-output marker handling is now gated on the stylesheet actually mentioning `disable-output-escaping`, so a source document that happens to contain the private-use marker characters U+E000/U+E001 passes through every transform untouched (previously they were stripped, and text between them unescaped, unconditionally), and the text output method only removes markers without "unescaping" content that was never escaped; the pattern-matching function table is built once per transform instead of once per (template, node) membership test; and the d-o-e, CDATA "]]>" splitting, simplified-syntax, html default-method/META, per-document key(), and attribute-axes behaviors gain focused unit tests that run in CI without the opt-in corpus (eight new tests).
- Following/preceding from attribute nodes, twelfth xalan burn-down (#130): the following and preceding axes now work from attribute and namespace starts, anchored at the owner element per the XPath 1.0 document order (an attribute follows its element and precedes its children, and has no descendants of its own). Baseline 182 to 174 (axes 16 to 8).
- Keys per document, in document order, eleventh xalan burn-down (#130): `key()` resolves its index against the current node's own document (built lazily per document root, so keys work inside document() trees), a node-set `use` expression indexes the matched node under every member's string value, and key() results return in document order regardless of the lookup value order. Baseline 187 to 182.
- Sort keys in context and cross-document template matching, tenth xalan burn-down (#130): xsl:sort keys are computed once per node with the node's own evaluation context (its position and size in the selection, the caller's variables, functions, and namespace bindings), so position()-based and variable-referencing keys work, and NaN keys order before every number under data-type="number" (the Xalan order); match patterns evaluate over the matched node's own document rather than the transform source, so templates match nodes loaded through document() (the match cache keys include the document identity). Baseline 204 to 187 (sort 18 to 11, mdocs 14 to 6).

View File

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

View File

@ -254,6 +254,11 @@ public extension PureXML.XSLT {
/// `xsl:decimal-format` declarations for `format-number`, keyed by name; the
/// empty key is the default (unnamed) format.
public var decimalFormats: [String: DecimalFormat]
/// Whether the stylesheet mentions `disable-output-escaping` anywhere,
/// so the serializer only post-processes raw-text markers when the
/// feature can actually be in play (a source document that happens to
/// contain the private-use marker characters is otherwise untouched).
public var usesRawText: Bool = false
/// `xsl:namespace-alias` rewrites, keyed by the stylesheet namespace URI
/// (the empty key is the no-namespace/default case).
public var namespaceAliases: [String: NamespaceAlias]

View File

@ -34,16 +34,11 @@ enum XSLTNumbering {
return counts.reversed()
case "any":
var total = 0
var stopped = false
walkPreceding(node) { candidate in
if matchesFrom(candidate) {
stopped = true
return false
}
if matchesFrom(candidate) { return false }
if matchesCount(candidate) { total += 1 }
return true
}
_ = stopped
return [total]
default: // single
var current: Tree? = node

View File

@ -43,8 +43,11 @@ struct Parts {
extension PureXML.XSLT.XSLTParser {
static func parse(_ xsl: String, loader: (String) -> String? = { _ in nil }) throws -> PureXML.XSLT.Stylesheet {
let root = try PureXML.parseTree(xsl, limits: .init(allowDoctype: true))
let usesRawText = containsSubstring(xsl, "disable-output-escaping")
if let top = stylesheetElement(root) {
return compile(top, loader: loader, precedence: 0)
var sheet = compile(top, loader: loader, precedence: 0)
sheet.usesRawText = usesRawText
return sheet
}
// Simplified syntax (2.3): a literal result element carrying
// xsl:version becomes the body of a match="/" template.
@ -61,11 +64,25 @@ extension PureXML.XSLT.XSLTParser {
parameters: [],
body: [body],
))
return parts.stylesheet
var sheet = parts.stylesheet
sheet.usesRawText = usesRawText
return sheet
}
throw PureXML.XSLT.XSLTError.notAStylesheet
}
/// Substring search without Foundation (the stdlib String-argument
/// `contains` needs a newer platform floor).
private static func containsSubstring(_ text: String, _ needle: String) -> Bool {
guard let head = needle.first else { return true }
var search = text[...]
while let start = search.firstIndex(of: head) {
if search[start...].hasPrefix(needle) { return true }
search = search[search.index(after: start)...]
}
return false
}
static func stylesheetElement(_ root: XSLTTree) -> XSLTTree? {
guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top),
XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform"

View File

@ -19,7 +19,11 @@ public extension PureXML.XSLT {
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
let method = sheet.output.method ?? defaultMethod(for: result)
if method == "text" { return RawText.resolve(textValue(of: result)) }
if method == "text" {
// Text output is never escaped, so raw markers are only removed,
// never "unescaped".
return sheet.usesRawText ? RawText.stripped(textValue(of: result)) : textValue(of: result)
}
let body: String
if method == "html" {
body = PureXML.HTML.serialize(withContentTypeMeta(result, encoding: sheet.output.encoding ?? "UTF-8"))
@ -31,7 +35,7 @@ public extension PureXML.XSLT {
body = PureXML.serialize(prepared, options: emitOptions)
}
// The XML declaration stays first; the doctype follows it.
let resolved = RawText.resolve(body)
let resolved = sheet.usesRawText ? RawText.resolve(body) : body
let documentType = doctype(for: result, sheet.output, method: method)
guard !documentType.isEmpty else { return resolved }
if resolved.hasPrefix("<?xml "), let end = rangeOf("?>", in: resolved[...]) {

View File

@ -28,6 +28,23 @@ extension PureXML.XSLT {
self.stylesheet = stylesheet
self.root = root
self.documentLoader = documentLoader
// 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.
let caches = keyIndexes
patternTable = PureXML.XSLT.Library.table(
current: .tree(root),
keys: { documentRoot in
let identity = ObjectIdentifier(documentRoot)
if let cached = caches.indexes[identity] { return cached }
let built = PureXML.XSLT.Library.buildKeyIndex(stylesheet: stylesheet, root: documentRoot)
caches.indexes[identity] = built
return built
},
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
)
}
/// The key index for `documentRoot`, built on first use (keys apply
@ -86,15 +103,11 @@ extension PureXML.XSLT {
}
/// The function table match patterns evaluate with (`key()`/`id()`
/// patterns), rooted at the document.
/// patterns), rooted at the document and built once in init.
private let patternTable: PureXML.XPath.FunctionTable
private func patternFunctions() -> PureXML.XPath.FunctionTable {
PureXML.XSLT.Library.table(
current: .tree(root),
keys: { keyIndex(for: $0) },
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
)
patternTable
}
func applyTemplates(
@ -209,7 +222,10 @@ extension PureXML.XSLT {
// Raw-output markers do not survive string extraction (16.4:
// escaping is disabled only for text written directly to the
// result tree), so a fragment round-trip re-enables escaping.
PureXML.XSLT.RawText.stripped(value(expression, context)?.string ?? "")
// Gated so source text in a stylesheet without
// disable-output-escaping passes through untouched.
let extracted = value(expression, context)?.string ?? ""
return stylesheet.usesRawText ? PureXML.XSLT.RawText.stripped(extracted) : extracted
}
func boolean(_ expression: String, _ context: XSLTContext) -> Bool {

View File

@ -0,0 +1,125 @@
@testable import PureXML
import Testing
/// The output-method behaviors of XSLT 1.0 section 16 added in the xalan
/// burn-down (#130): disable-output-escaping (including its 16.4 fragment
/// rule and the marker-safety gate), CDATA "]]>" splitting, the simplified
/// stylesheet syntax, the html default method with META injection, key()
/// inside document() trees, and the attribute-start following/preceding axes.
@Suite("XSLT output methods and runtime corners")
struct XSLTOutputMethodTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
@Test("disable-output-escaping writes text through unescaped")
func test_disableOutputEscaping() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:text disable-output-escaping="yes">&lt;P&gt;&amp;nbsp;&lt;/P&gt;</xsl:text></out></xsl:template>
</xsl:stylesheet>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out><P>&nbsp;</P></out>")
}
@Test("disable-output-escaping does not survive a fragment round-trip (16.4)")
func test_rawTextFragmentRoundTrip() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/">
<out>
<xsl:variable name="held"><xsl:text disable-output-escaping="yes">a &lt;b&gt; c</xsl:text></xsl:variable>
<xsl:value-of select="$held"/>
</out>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out>a &lt;b&gt; c</out>")
}
@Test("Source text holding the private-use marker characters is untouched")
func test_markerCharactersInSource() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:value-of select="d"/></out></xsl:template>
</xsl:stylesheet>
"""
let source = "<d>a\u{E000}&lt;kept&gt;\u{E001}b</d>"
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "<out>a\u{E000}&lt;kept&gt;\u{E001}b</out>")
}
@Test("cdata-section-elements split ]]> across section boundaries")
func test_cdataTerminatorSplitting() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes" cdata-section-elements="example"/>
<xsl:template match="/"><out><example><xsl:text>]]&gt;</xsl:text></example></out></xsl:template>
</xsl:stylesheet>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out><example><![CDATA[]]]]><![CDATA[>]]></example></out>")
}
@Test("A literal result element with xsl:version is a one-template stylesheet (2.3)")
func test_simplifiedSyntax() throws {
let style = """
<out xsl:version="1.0" \(xsl)>value: <xsl:value-of select="doc/v"/></out>
"""
let result = try PureXML.XSLT.transform(stylesheet: style, source: "<doc><v>7</v></doc>")
#expect(result.hasSuffix("<out>value: 7</out>"))
}
@Test("A null-namespace html root selects the html method and gains META (16.1/16.2)")
func test_htmlDefaultMethod() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:template match="/"><HTML><HEAD><TITLE>t</TITLE></HEAD><BODY><BR/></BODY></HTML></xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(stylesheet: style, source: "<x/>")
#expect(result == "<HTML><HEAD><META http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"><TITLE>t</TITLE></HEAD><BODY><BR></BODY></HTML>")
}
@Test("key() indexes the current node's own document, document() trees included")
func test_keyPerDocument() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:key name="k" match="item" use="@id"/>
<xsl:template match="/">
<out>
<xsl:for-each select="document('other.xml')">
<xsl:value-of select="key('k', 'b')"/>
</xsl:for-each>
</out>
</xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(
stylesheet: style,
source: "<doc><item id=\"b\">local</item></doc>",
documentLoader: { $0 == "other.xml" ? "<doc><item id=\"b\">external</item></doc>" : nil },
)
#expect(result == "<out>external</out>")
}
@Test("following and preceding work from attribute starts")
func test_axesFromAttributes() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/">
<out>
<f><xsl:for-each select="//b/@m/following::*"><xsl:value-of select="name()"/><xsl:text> </xsl:text></xsl:for-each></f>
<p><xsl:for-each select="//b/@m/preceding::*"><xsl:value-of select="name()"/><xsl:text> </xsl:text></xsl:for-each></p>
</out>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<r><a/><b m=\"1\"><inner/></b><c/></r>"
// Document order places the attribute after b and before b's children,
// so following includes inner and c; preceding holds a only (r is an
// ancestor).
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "<out><f>inner c </f><p>a </p></out>")
}
}