From 23db1289edd473b56e84d66240df43c1ddad72db Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 08:08:38 +0200 Subject: [PATCH] feat(xslt): add xsl:include/import, xsl:output, comment, and processing-instruction Round out the XSLT 1.0 stylesheet and output controls: - xsl:include folds another stylesheet's templates, keys, globals, and output at the same import precedence; xsl:import folds them one precedence lower, so the importing stylesheet's rules win conflicts. Both resolve their href through the injected document loader, never the filesystem. - xsl:output drives serialization: method (xml or text), indent, omit-xml-declaration, encoding, version, and standalone, layered over the caller's emitter options. The text method emits character data only. - xsl:comment and xsl:processing-instruction produce comment and PI nodes from their instantiated bodies. Templates now carry an import precedence that the matcher ranks ahead of priority and document order. Closes #64 --- Sources/XSLT/XSLTFunctions.swift | 15 +++ Sources/XSLT/XSLTModel.swift | 53 ++++++++++- Sources/XSLT/XSLTParser.swift | 61 +++++++++++- Sources/XSLT/XSLTTransformer.swift | 31 ++++-- Tests/XSLTParityTests.swift | 145 +++++++++++++++++++++++++++++ Tests/XSLTTests.swift | 57 ------------ 6 files changed, 292 insertions(+), 70 deletions(-) create mode 100644 Tests/XSLTParityTests.swift diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 0a22e16..deadceb 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -183,3 +183,18 @@ extension PureXML.XSLT { } } } + +extension PureXML.Emitting.Options { + /// These options with an `xsl:output`'s settings layered over them. Only an + /// explicit setting overrides; unspecified `xsl:output` attributes leave the + /// caller's corresponding option untouched. + func applying(_ output: PureXML.XSLT.Output) -> Self { + var copy = self + if let indent = output.indent { copy.prettyPrint = indent } + if let omit = output.omitXMLDeclaration { copy.includeXMLDeclaration = !omit } + if let encoding = output.encoding { copy.encodingName = encoding } + if let version = output.version { copy.xmlVersion = version } + if let standalone = output.standalone { copy.standalone = standalone } + return copy + } +} diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index c1ce2b7..7ab3600 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -54,6 +54,51 @@ public extension PureXML.XSLT { case variable(name: String, select: String?, body: [Instruction]) /// `xsl:number`: a generated sequence number for the context node. case number(count: String?, from: String?, format: String) + /// `xsl:comment`: a comment node whose text is the instantiated body. + case comment(body: [Instruction]) + /// `xsl:processing-instruction` whose target is `name` and whose data is + /// the instantiated body. + case processingInstruction(name: ValueTemplate, body: [Instruction]) + } + + /// The `xsl:output` controls that shape serialization: the output method, and + /// the declaration and indentation settings. Each is nil when unspecified, so + /// the serializer falls back to the caller's options. + struct Output: Sendable { + public var method: String? + public var indent: Bool? + public var omitXMLDeclaration: Bool? + public var encoding: String? + public var version: String? + public var standalone: Bool? + + public init( + method: String? = nil, + indent: Bool? = nil, + omitXMLDeclaration: Bool? = nil, + encoding: String? = nil, + version: String? = nil, + standalone: Bool? = nil, + ) { + self.method = method + self.indent = indent + self.omitXMLDeclaration = omitXMLDeclaration + self.encoding = encoding + self.version = version + self.standalone = standalone + } + + /// This output's settings with `other`'s non-nil settings layered over them. + func merged(with other: Output) -> Output { + Output( + method: other.method ?? method, + indent: other.indent ?? indent, + omitXMLDeclaration: other.omitXMLDeclaration ?? omitXMLDeclaration, + encoding: other.encoding ?? encoding, + version: other.version ?? version, + standalone: other.standalone ?? standalone, + ) + } } /// An `xsl:key` declaration: a name, the nodes it indexes (a match pattern), @@ -79,20 +124,24 @@ public extension PureXML.XSLT { } /// A template rule: a match pattern and/or a name, a mode, a priority, its - /// declared parameters, and a body. + /// import precedence (higher wins, used to resolve `xsl:import`), its declared + /// parameters, and a body. struct Template: Sendable { public var match: String? public var name: String? public var mode: String? public var priority: Double + public var importPrecedence: Int public var parameters: [Binding] public var body: [Instruction] } - /// A compiled stylesheet: its template rules, global variables, and keys. + /// A compiled stylesheet: its template rules, global variables, keys, and + /// output controls (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 } } diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index b086bf5..9b37f9b 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -44,25 +44,71 @@ extension PureXML.XSLT { /// vocabulary is recognized by namespace or the `xsl` prefix; other elements /// are literal result elements. enum XSLTParser { - static func parse(_ xsl: String) throws -> Stylesheet { + static func parse(_ xsl: String, loader: (String) -> String? = { _ in nil }) throws -> Stylesheet { let root = try PureXML.parseTree(xsl) + guard let top = stylesheetElement(root) else { throw XSLTError.notAStylesheet } + return compile(top, loader: loader, precedence: 0) + } + + private static func stylesheetElement(_ root: Tree) -> Tree? { guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top), XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform" else { - throw XSLTError.notAStylesheet + return nil } + return top + } + + /// Compiles a stylesheet element, recursively folding in `xsl:include` + /// (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() for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) { switch XSLTNode.localName(child) { - case "template": templates.append(template(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 } } - return Stylesheet(templates: templates, globals: globals, keys: keys) + return Stylesheet(templates: templates, globals: globals, keys: keys, output: output) + } + + private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? { + guard let href = XSLTNode.attribute(node, "href"), let text = loader(href), + let root = try? PureXML.parseTree(text), let top = stylesheetElement(root) + else { + return nil + } + return compile(top, loader: loader, precedence: precedence) + } + + private static func parseOutput(_ node: Tree) -> Output { + Output( + method: XSLTNode.attribute(node, "method"), + indent: XSLTNode.attribute(node, "indent").map { $0 == "yes" }, + omitXMLDeclaration: XSLTNode.attribute(node, "omit-xml-declaration").map { $0 == "yes" }, + encoding: XSLTNode.attribute(node, "encoding"), + version: XSLTNode.attribute(node, "version"), + standalone: XSLTNode.attribute(node, "standalone").map { $0 == "yes" }, + ) } private static func key(_ node: Tree) -> Key { @@ -75,7 +121,7 @@ extension PureXML.XSLT { // MARK: Templates - private static func template(_ node: Tree) -> Template { + private static func template(_ node: Tree, precedence: Int) -> Template { let match = XSLTNode.attribute(node, "match") let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init) ?? match.map(defaultPriority) ?? 0 @@ -88,6 +134,7 @@ extension PureXML.XSLT { name: XSLTNode.attribute(node, "name"), mode: XSLTNode.attribute(node, "mode"), priority: priority, + importPrecedence: precedence, parameters: parameters, body: body, ) @@ -174,6 +221,10 @@ extension PureXML.XSLT { from: XSLTNode.attribute(node, "from"), format: XSLTNode.attribute(node, "format") ?? "1", ) + case "comment": + .comment(body: body(node)) + case "processing-instruction": + .processingInstruction(name: valueTemplate(XSLTNode.attribute(node, "name") ?? ""), body: body(node)) default: nil } diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index ba46f89..d8d31a0 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -48,7 +48,10 @@ extension PureXML.XSLT { fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?) -> Template? { stylesheet.templates.enumerated() .filter { $0.element.mode == mode && ($0.element.match.map { matches(node, $0) } ?? false) } - .max { lhs, rhs in (lhs.element.priority, lhs.offset) < (rhs.element.priority, rhs.offset) }? + .max { lhs, rhs in + (lhs.element.importPrecedence, lhs.element.priority, lhs.offset) + < (rhs.element.importPrecedence, rhs.element.priority, rhs.offset) + }? .element } @@ -222,6 +225,10 @@ extension PureXML.XSLT.Transformer { copyInstruction(body, context) case let .number(count, _, format): [.node(.text(PureXML.XSLT.Numbering.value(of: context.node, count: count, format: format)))] + case let .comment(body): + [.node(.comment(Self.text(of: instantiate(body, context))))] + case let .processingInstruction(name, body): + [.node(.processingInstruction(target: avt(name, context), data: Self.text(of: instantiate(body, context))))] default: [] } @@ -356,10 +363,11 @@ public extension PureXML.XSLT { options: PureXML.Emitting.Options = .compact, documentLoader: @escaping (String) -> String? = { _ in nil }, ) throws -> String { - try PureXML.serialize( - transformToNode(stylesheet: stylesheet, source: source, documentLoader: documentLoader), - options: options, - ) + let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) + let root = try PureXML.parseTree(source) + 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)) } /// Transforms `source` with `stylesheet`, returning the result tree. @@ -368,8 +376,19 @@ public extension PureXML.XSLT { source: String, documentLoader: @escaping (String) -> String? = { _ in nil }, ) throws -> PureXML.Model.Node { - let sheet = try XSLTParser.parse(stylesheet) + let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader) let root = try PureXML.parseTree(source) return Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run() } + + /// The concatenated text content of a result tree, for the `text` output + /// method: character data only, with markup, comments, and PIs dropped. + private static func textValue(of node: PureXML.Model.Node) -> String { + switch node { + case let .text(value), let .cdata(value): value + case let .document(children): children.map(textValue).joined() + case let .element(element): element.children.map(textValue).joined() + case .comment, .processingInstruction: "" + } + } } diff --git a/Tests/XSLTParityTests.swift b/Tests/XSLTParityTests.swift new file mode 100644 index 0000000..770474c --- /dev/null +++ b/Tests/XSLTParityTests.swift @@ -0,0 +1,145 @@ +@testable import PureXML +import Testing + +@Suite("XSLT keys, numbering, output") +struct XSLTParityTests { + 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("xsl:key indexes nodes and key() retrieves them") + func test_key() throws { + let stylesheet = """ + + + + + + + """ + let source = "applepeapear" + #expect(try transform(stylesheet, source) == "applepear") + } + + @Test("format-number renders per the picture string") + func test_formatNumber() throws { + let stylesheet = """ + + + + + + + + + """ + #expect(try transform(stylesheet, "") == "1,234.5025%") + } + + @Test("xsl:number generates the position of the context node") + func test_number() throws { + let stylesheet = """ + + + + + """ + #expect(try transform(stylesheet, "") == "iiiiii") + } + + @Test("document() loads external source through the injected loader") + func test_document() throws { + let stylesheet = """ + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: stylesheet, + source: "", + documentLoader: { uri in uri == "ext.xml" ? "loaded" : nil }, + ) + #expect(result == "loaded") + } + + @Test("xsl:comment and xsl:processing-instruction emit their nodes") + func test_commentAndPI() throws { + let stylesheet = """ + + + noterun + + + """ + #expect(try transform(stylesheet, "") == "") + } + + @Test("xsl:output text method drops markup and yields character data") + func test_outputText() throws { + let stylesheet = """ + + + xy + + """ + #expect(try transform(stylesheet, "") == "xy") + } + + @Test("xsl:output controls the XML declaration and indentation") + func test_outputDeclaration() throws { + let stylesheet = """ + + + z + + """ + #expect(try transform(stylesheet, "") == "\nz") + } + + @Test("xsl:include folds in another stylesheet's templates") + func test_include() throws { + let included = """ + + + + """ + let stylesheet = """ + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: stylesheet, + source: "a", + documentLoader: { $0 == "lib.xsl" ? included : nil }, + ) + #expect(result == "a") + } + + @Test("xsl:import is overridden by the importing stylesheet") + func test_importPrecedence() throws { + let base = """ + + + + """ + let stylesheet = """ + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: stylesheet, + source: "a", + documentLoader: { $0 == "base.xsl" ? base : nil }, + ) + #expect(result == "a") + } +} diff --git a/Tests/XSLTTests.swift b/Tests/XSLTTests.swift index 9d29021..87078c8 100644 --- a/Tests/XSLTTests.swift +++ b/Tests/XSLTTests.swift @@ -60,63 +60,6 @@ struct XSLTTests { #expect(try transform(stylesheet, source) == "
  • A
  • B
  • A

    B

    ") } - @Test("xsl:key indexes nodes and key() retrieves them") - func test_key() throws { - let stylesheet = """ - - - - - - - """ - let source = "applepeapear" - #expect(try transform(stylesheet, source) == "applepear") - } - - @Test("format-number renders per the picture string") - func test_formatNumber() throws { - let stylesheet = """ - - - - - - - - - """ - #expect(try transform(stylesheet, "") == "1,234.5025%") - } - - @Test("xsl:number generates the position of the context node") - func test_number() throws { - let stylesheet = """ - - - - - """ - #expect(try transform(stylesheet, "") == "iiiiii") - } - - @Test("document() loads external source through the injected loader") - func test_document() throws { - let stylesheet = """ - - - - - - """ - let result = try PureXML.XSLT.transform( - stylesheet: stylesheet, - source: "", - documentLoader: { uri in uri == "ext.xml" ? "loaded" : nil }, - ) - #expect(result == "loaded") - } - @Test("The identity transform reproduces the input") func test_identity() throws { let stylesheet = """