feat(xslt): top-level stylesheet parameters

PureXML.XSLT.transform accepts parameters overriding xsl:param globals
by name; xsl:variable globals are never overridable, with parameter
names recorded across imports and includes. The conformance runner
honors the xalan harness contract, reading <case>.param files (CRLF
is one Character in Swift, so the runner splits on newline graphemes,
which the param files exposed). Closes #141. Xalan baseline
128 -> 127.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 17:35:11 +02:00
parent c96933df89
commit 3ea4e6f37d
10 changed files with 75 additions and 6 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Top-level stylesheet parameters (#141), nineteenth xalan burn-down (#130): `PureXML.XSLT.transform` accepts `parameters: [String: String]`, overriding `xsl:param` globals by name (xsl:variable globals are never overridable; the stylesheet records which names are parameters across imports and includes). The conformance runner honors the xalan harness contract, reading `<case>.param` files; en alphabet sorting passes, and the CRLF param files exposed a Swift grapheme gotcha (CRLF is one Character, so splitting on "\n" never splits; the runner splits on newline graphemes). Baseline 128 to 127; Polish/Russian alphabet tailoring stays baselined as the locale class.
- Space-ignorable sort collation, eighteenth xalan burn-down (#130): xsl:sort's default text order now treats spaces as ignorable at the primary level, breaking ties by later-first-space (the Java Collator semantics the conformance golds encode), derived from the gold orderings rather than delegated to a locale library. Baseline 129 to 128.
- DTD-typed id() and node-kind generate-id(), seventeenth xalan burn-down (#130): the XSLT layer's id() now follows the XPath definition exactly, resolving only attributes the source document's DTD declares as type ID (internal or external subset, parsed through the transform's entity resolver) and yielding the empty set without a DTD, replacing the name-based heuristic for transforms; and generate-id() produces non-empty, distinct identifiers for attribute and namespace nodes through their identity hash instead of returning the empty string. Two unit tests pin both. Baseline 131 to 129.
- HTML 4 serialization corners, sixteenth xalan burn-down (#130): `basefont` and `isindex` join the void-element set (no end tags), and the html method leaves an ampersand unescaped when a brace follows, the historical `&{...}` attribute convention of 16.2. Baseline 134 to 131 (output 20 to 17).

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 **977 tests in 167 suites** (`swift test`).
currently runs **978 tests in 167 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -258,6 +258,9 @@ 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]
/// The names of top-level xsl:param globals (overridable by
/// caller-supplied parameter values; xsl:variable globals are not).
public var parameterNames: Set<String> = []
/// 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

View File

@ -105,7 +105,7 @@ extension PureXML.XSLT {
private static func absorbDeclaration(_ child: XSLTTree, into parts: inout Parts, precedence: Int, low: Int) -> Bool {
switch XSLTNode.localName(child) {
case "template": parts.templates.append(template(child, precedence: precedence, low: low))
case "variable", "param": parts.globals.append(variable(child))
case "variable", "param": addGlobal(child, into: &parts)
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))
@ -118,6 +118,15 @@ extension PureXML.XSLT {
return true
}
/// A top-level xsl:variable or xsl:param; param names are recorded
/// so caller-supplied values can override their defaults.
private static func addGlobal(_ child: XSLTTree, into parts: inout Parts) {
parts.globals.append(variable(child))
if XSLTNode.localName(child) == "param", let name = XSLTNode.attribute(child, "name") {
parts.parameterNames.insert(name)
}
}
/// The whitespace-separated element name tests of an `xsl:strip-space` or
/// `xsl:preserve-space` element's `elements` attribute.
private static func elementNames(_ node: XSLTTree) -> Set<String> {

View File

@ -5,6 +5,7 @@ struct Parts {
var keys: [PureXML.XSLT.Key] = []
var output = PureXML.XSLT.Output()
var stripSpace: Set<String> = []
var parameterNames: Set<String> = []
var preserveSpace: Set<String> = []
var attributeSets: [String: [PureXML.XSLT.AttributeSet]] = [:]
var decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:]
@ -19,7 +20,7 @@ struct Parts {
guard let name = Self.globalName(instruction) else { return true }
return seen.insert(name).inserted
}.reversed()
return PureXML.XSLT.Stylesheet(
var sheet = PureXML.XSLT.Stylesheet(
templates: templates,
globals: resolvedGlobals,
keys: keys,
@ -30,6 +31,8 @@ struct Parts {
decimalFormats: decimalFormats,
namespaceAliases: namespaceAliases,
)
sheet.parameterNames = parameterNames
return sheet
}
private static func globalName(_ instruction: PureXML.XSLT.Instruction) -> String? {
@ -48,6 +51,7 @@ struct Parts {
templates += sub.templates
keys += sub.keys
stripSpace.formUnion(sub.stripSpace)
parameterNames.formUnion(sub.parameterNames)
preserveSpace.formUnion(sub.preserveSpace)
attributeSets.merge(sub.attributeSets) { mine, theirs in mine + theirs }
decimalFormats.merge(sub.decimalFormats) { _, theirs in theirs }

View File

@ -10,6 +10,7 @@ public extension PureXML.XSLT {
options: PureXML.Emitting.Options = .compact,
documentLoader: @escaping (String) -> String? = { _ in nil },
baseURI: String = "",
parameters: [String: String] = [:],
) throws -> String {
let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
@ -21,7 +22,7 @@ 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)
let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader, idAttributes: idAttributes, parameters: parameters)
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
let method = sheet.output.method ?? defaultMethod(for: result)

View File

@ -24,12 +24,18 @@ extension PureXML.XSLT {
termination.message
}
/// Caller-supplied top-level parameter values, overriding xsl:param
/// defaults by name.
let parameters: [String: String]
init(
stylesheet: Stylesheet,
root: PureXML.Model.TreeNode,
documentLoader: @escaping (String) -> String? = { _ in nil },
idAttributes: [String: Set<String>] = [:],
parameters: [String: String] = [:],
) {
self.parameters = parameters
self.stylesheet = stylesheet
self.root = root
self.documentLoader = documentLoader
@ -69,6 +75,11 @@ extension PureXML.XSLT {
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).

View File

@ -109,7 +109,6 @@ select/select28
select/select67
select/select68
select/select75
sort/sort-alphabet-english
sort/sort-alphabet-polish
sort/sort-alphabet-russian
sort/sort02

View File

@ -184,4 +184,23 @@ struct XSLTCompositionTests {
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<r><e a=\"1\"/></r>") == "distinct")
}
@Test("Caller-supplied parameters override xsl:param defaults, not xsl:variable")
func test_topLevelParameters() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:param name="p" select="'default'"/>
<xsl:variable name="v" select="'fixed'"/>
<xsl:template match="/"><out><xsl:value-of select="$p"/>-<xsl:value-of select="$v"/></out></xsl:template>
</xsl:stylesheet>
"""
let overridden = try PureXML.XSLT.transform(
stylesheet: style,
source: "<x/>",
parameters: ["p": "supplied", "v": "ignored"],
)
#expect(overridden == "<out>supplied-fixed</out>")
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<x/>") == "<out>default-fixed</out>")
}
}

View File

@ -91,13 +91,35 @@ struct XalanConfSuiteTests {
}
let actual: String
do {
actual = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source, documentLoader: loader)
actual = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: source,
documentLoader: loader,
parameters: harnessParameters(directory + "/" + name + ".param"),
)
} catch {
return "transform threw: \(error)"
}
return Self.equivalent(actual: actual, gold: gold) ? nil : "output differs from gold"
}
/// The xalan harness contract: a `<case>.param` file supplies top-level
/// parameters as `name=value` lines (comments and blanks ignored, values
/// cut at whitespace).
private func harnessParameters(_ path: String) -> [String: String] {
guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] }
var parameters: [String: String] = [:]
// CRLF is one Character in Swift: split on any newline grapheme.
for line in text.split(whereSeparator: \.isNewline) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), let equals = trimmed.firstIndex(of: "=") else { continue }
let name = String(trimmed[..<equals])
let value = trimmed[trimmed.index(after: equals)...].split(whereSeparator: \.isWhitespace).first.map(String.init) ?? ""
parameters[name] = value
}
return parameters
}
/// Gold comparison: canonical XML when both sides parse (indentation is
/// not normative), otherwise collapsed whitespace with whitespace
/// adjacent to tag boundaries dropped (the html method's indentation is