Merge: XSLT strip-space/preserve-space (#81)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 01:23:00 +02:00
commit 83fdeb9bfa
6 changed files with 198 additions and 26 deletions

View File

@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- XSLT `xsl:strip-space` / `xsl:preserve-space` (#81). Whitespace-only text nodes
are removed from the source tree for elements named by `strip-space` before
transformation, with `preserve-space` and a more specific name test winning over
`*`, and `xml:space="preserve"` on a source element or ancestor overriding the
stripping. Folded across `xsl:include`/`xsl:import`.
- DTD located content-model errors. An element whose children break its content
model now names each stray child individually (`element <x> is not allowed in
<r>`), and reports a pure order/count violation once with the allowed elements

View File

@ -136,12 +136,34 @@ public extension PureXML.XSLT {
public var body: [Instruction]
}
/// A compiled stylesheet: its template rules, global variables, keys, and
/// output controls (with `xsl:include`/`xsl:import` already folded in).
/// A compiled stylesheet: its template rules, global variables, keys, output
/// controls, and the `xsl:strip-space`/`xsl:preserve-space` name tests (with
/// `xsl:include`/`xsl:import` already folded in).
struct Stylesheet: Sendable {
public var templates: [Template]
public var globals: [Instruction]
public var keys: [Key]
public var output: Output
/// Element name tests whose whitespace-only text children are stripped from
/// the source (an NCName, a qualified name, or `*`).
public var stripSpace: Set<String>
/// Element name tests that keep their whitespace, overriding `stripSpace`.
public var preserveSpace: Set<String>
public init(
templates: [Template],
globals: [Instruction],
keys: [Key],
output: Output,
stripSpace: Set<String> = [],
preserveSpace: Set<String> = [],
) {
self.templates = templates
self.globals = globals
self.keys = keys
self.output = output
self.stripSpace = stripSpace
self.preserveSpace = preserveSpace
}
}
}

View File

@ -1,5 +1,33 @@
private typealias Tree = PureXML.Model.TreeNode
/// The accumulating declarations of a stylesheet as its top-level elements are
/// compiled, plus the folding of an included or imported sub-stylesheet. File
/// scope and private.
private struct Parts {
var templates: [PureXML.XSLT.Template] = []
var globals: [PureXML.XSLT.Instruction] = []
var keys: [PureXML.XSLT.Key] = []
var output = PureXML.XSLT.Output()
var stripSpace: Set<String> = []
var preserveSpace: Set<String> = []
var stylesheet: PureXML.XSLT.Stylesheet {
PureXML.XSLT.Stylesheet(templates: templates, globals: globals, keys: keys, output: output, stripSpace: stripSpace, preserveSpace: preserveSpace)
}
/// Folds a sub-stylesheet in: an import has lower precedence, so its globals
/// come before and its output is overridden by this stylesheet's.
mutating func fold(_ sub: PureXML.XSLT.Stylesheet?, isImport: Bool) {
guard let sub else { return }
templates += sub.templates
keys += sub.keys
stripSpace.formUnion(sub.stripSpace)
preserveSpace.formUnion(sub.preserveSpace)
globals = isImport ? sub.globals + globals : globals + sub.globals
output = isImport ? sub.output.merged(with: output) : output.merged(with: sub.output)
}
}
/// Tree helpers for the XSLT parser. File-scope and private.
private enum XSLTNode {
static let namespace = "http://www.w3.org/1999/XSL/Transform"
@ -63,32 +91,31 @@ extension PureXML.XSLT {
/// (same import precedence) and `xsl:import` (one lower) resolved through
/// `loader`.
private static func compile(_ top: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet {
var templates: [Template] = []
var globals: [Instruction] = []
var keys: [Key] = []
var output = Output()
var parts = Parts()
for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) {
switch XSLTNode.localName(child) {
case "template": templates.append(template(child, precedence: precedence))
case "variable", "param": globals.append(variable(child))
case "key": keys.append(key(child))
case "output": output = output.merged(with: parseOutput(child))
case "include":
guard let sub = load(child, loader: loader, precedence: precedence) else { break }
templates += sub.templates
globals += sub.globals
keys += sub.keys
output = output.merged(with: sub.output)
case "import":
guard let sub = load(child, loader: loader, precedence: precedence - 1) else { break }
templates += sub.templates
globals = sub.globals + globals
keys += sub.keys
output = sub.output.merged(with: output)
default: break
}
absorb(child, into: &parts, loader: loader, precedence: precedence)
}
return Stylesheet(templates: templates, globals: globals, keys: keys, output: output)
return parts.stylesheet
}
private static func absorb(_ child: Tree, into parts: inout Parts, loader: (String) -> String?, precedence: Int) {
switch XSLTNode.localName(child) {
case "template": parts.templates.append(template(child, precedence: precedence))
case "variable", "param": parts.globals.append(variable(child))
case "key": parts.keys.append(key(child))
case "output": parts.output = parts.output.merged(with: parseOutput(child))
case "strip-space": parts.stripSpace.formUnion(elementNames(child))
case "preserve-space": parts.preserveSpace.formUnion(elementNames(child))
case "include": parts.fold(load(child, loader: loader, precedence: precedence), isImport: false)
case "import": parts.fold(load(child, loader: loader, precedence: precedence - 1), isImport: true)
default: break
}
}
/// The whitespace-separated element name tests of an `xsl:strip-space` or
/// `xsl:preserve-space` element's `elements` attribute.
private static func elementNames(_ node: Tree) -> Set<String> {
Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init))
}
private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? {

View File

@ -365,6 +365,7 @@ public extension PureXML.XSLT {
) throws -> String {
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
Whitespace.strip(root, stylesheet: sheet)
let result = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
if sheet.output.method == "text" { return textValue(of: result) }
return PureXML.serialize(result, options: options.applying(sheet.output))
@ -378,6 +379,7 @@ public extension PureXML.XSLT {
) throws -> PureXML.Model.Node {
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
Whitespace.strip(root, stylesheet: sheet)
return Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
}

View File

@ -0,0 +1,49 @@
extension PureXML.XSLT {
/// Applies a stylesheet's `xsl:strip-space` / `xsl:preserve-space` to the
/// source tree, removing whitespace-only text nodes from stripped elements
/// before transformation. `xml:space="preserve"` on an element or an ancestor
/// (until an `xml:space="default"` resets it) forces preservation regardless
/// of `strip-space`, per the XSLT 1.0 whitespace-stripping rules. CDATA is
/// significant and never stripped.
enum Whitespace {
static func strip(_ root: PureXML.Model.TreeNode, stylesheet: Stylesheet) {
guard !stylesheet.stripSpace.isEmpty else { return }
strip(root, stylesheet.stripSpace, stylesheet.preserveSpace, inScopePreserve: false)
}
private static func strip(
_ node: PureXML.Model.TreeNode,
_ stripSet: Set<String>,
_ preserveSet: Set<String>,
inScopePreserve: Bool,
) {
let preserveHere = xmlSpace(node).map { $0 == "preserve" } ?? inScopePreserve
if node.kind == .element, !preserveHere, shouldStrip(node, stripSet, preserveSet) {
for child in node.children where child.kind == .text && isWhitespace(child.value) {
node.removeChild(child)
}
}
for child in node.children {
strip(child, stripSet, preserveSet, inScopePreserve: preserveHere)
}
}
/// Whether an element with this name has its whitespace stripped: a name
/// test wins over `*`, and `preserve-space` beats `strip-space` on a tie.
private static func shouldStrip(_ node: PureXML.Model.TreeNode, _ stripSet: Set<String>, _ preserveSet: Set<String>) -> Bool {
let names = [node.name?.localName, node.name?.description].compactMap(\.self)
if names.contains(where: preserveSet.contains) { return false }
if names.contains(where: stripSet.contains) { return true }
if preserveSet.contains("*") { return false }
return stripSet.contains("*")
}
private static func xmlSpace(_ node: PureXML.Model.TreeNode) -> String? {
node.attributes.first { $0.name.localName == "space" && $0.name.prefix == "xml" }?.value
}
private static func isWhitespace(_ value: String) -> Bool {
value.allSatisfy(\.isWhitespace)
}
}
}

View File

@ -0,0 +1,67 @@
@testable import PureXML
import Testing
@Suite("XSLT strip-space / preserve-space")
struct XSLTWhitespaceTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ stylesheet: String, _ source: String) throws -> String {
try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
}
@Test("strip-space removes whitespace-only text nodes; a copy reflects it")
func test_stripSpace() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:strip-space elements="a"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
</xsl:stylesheet>
"""
let source = "<a>\n <b/>\n <b/>\n</a>"
// The indentation whitespace between <b/> elements is stripped.
#expect(try transform(style, source) == "<a><b/><b/></a>")
}
@Test("Without strip-space the whitespace is preserved")
func test_noStrip() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
</xsl:stylesheet>
"""
let source = "<a>\n <b/>\n</a>"
#expect(try transform(style, source).contains("\n "))
}
@Test("preserve-space overrides strip-space for a named element")
func test_preserveOverrides() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:strip-space elements="*"/>
<xsl:preserve-space elements="keep"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><xsl:copy-of select="*"/></xsl:template>
</xsl:stylesheet>
"""
// strip-space="*" strips <drop>'s whitespace, but <keep> preserves its own.
let stripped = try transform(style, "<drop>\n <x/>\n</drop>")
#expect(stripped == "<drop><x/></drop>")
let kept = try transform(style, "<keep>\n <x/>\n</keep>")
#expect(kept.contains("\n "))
}
@Test("xml:space=preserve on a source element keeps its whitespace despite strip-space")
func test_xmlSpacePreserve() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:strip-space elements="a"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><xsl:copy-of select="a"/></xsl:template>
</xsl:stylesheet>
"""
let source = "<a xml:space=\"preserve\">\n <b/>\n</a>"
#expect(try transform(style, source).contains("\n "))
}
}