From 3ea4e6f37da9b9f005d22cccd802388b283f3256 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 17:35:11 +0200 Subject: [PATCH] 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 .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. --- CHANGELOG.md | 1 + README.md | 2 +- Sources/XSLT/XSLTModel.swift | 3 +++ Sources/XSLT/XSLTParser.swift | 11 ++++++++++- Sources/XSLT/XSLTParserEntry.swift | 6 +++++- Sources/XSLT/XSLTTransform.swift | 3 ++- Sources/XSLT/XSLTTransformer.swift | 11 +++++++++++ Tests/Fixtures/xalan-baseline.txt | 1 - Tests/XSLTCompositionTests.swift | 19 +++++++++++++++++++ Tests/XalanConfSuiteTests.swift | 24 +++++++++++++++++++++++- 10 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e25294..74ddbdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.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). diff --git a/README.md b/README.md index 032a3e4..742862b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index b839ec8..30b930f 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -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 = [] /// 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 diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index d2c3be4..7ca6baa 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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 { diff --git a/Sources/XSLT/XSLTParserEntry.swift b/Sources/XSLT/XSLTParserEntry.swift index b157c58..24c73bd 100644 --- a/Sources/XSLT/XSLTParserEntry.swift +++ b/Sources/XSLT/XSLTParserEntry.swift @@ -5,6 +5,7 @@ struct Parts { var keys: [PureXML.XSLT.Key] = [] var output = PureXML.XSLT.Output() var stripSpace: Set = [] + var parameterNames: Set = [] var preserveSpace: Set = [] 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 } diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 71d43dd..89404d1 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -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) diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 053c9ba..93d716f 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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] = [:], + 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). diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index db7b713..535f507 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -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 diff --git a/Tests/XSLTCompositionTests.swift b/Tests/XSLTCompositionTests.swift index c58cc75..06e7d15 100644 --- a/Tests/XSLTCompositionTests.swift +++ b/Tests/XSLTCompositionTests.swift @@ -184,4 +184,23 @@ struct XSLTCompositionTests { """ #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "distinct") } + + @Test("Caller-supplied parameters override xsl:param defaults, not xsl:variable") + func test_topLevelParameters() throws { + let style = """ + + + + + - + + """ + let overridden = try PureXML.XSLT.transform( + stylesheet: style, + source: "", + parameters: ["p": "supplied", "v": "ignored"], + ) + #expect(overridden == "supplied-fixed") + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "default-fixed") + } } diff --git a/Tests/XalanConfSuiteTests.swift b/Tests/XalanConfSuiteTests.swift index dd6d94c..d1b8322 100644 --- a/Tests/XalanConfSuiteTests.swift +++ b/Tests/XalanConfSuiteTests.swift @@ -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 `.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[..