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:
parent
a86a57eec4
commit
2b5080e30e
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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[...]) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
125
Tests/XSLTOutputMethodTests.swift
Normal file
125
Tests/XSLTOutputMethodTests.swift
Normal 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"><P>&nbsp;</P></xsl:text></out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out><P> </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 <b> 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 <b> 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}<kept>\u{E001}b</d>"
|
||||
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "<out>a\u{E000}<kept>\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>]]></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>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user