From ef4e7afa7a4805f3147efbc00d05c992ac93afdb Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 10 Jun 2026 16:27:06 +0200 Subject: [PATCH] feat(xslt): post-order import precedence, scoped apply-imports, relative hrefs Import precedences are assigned in post-order over the import tree, so sibling imports are distinct and every unit sits above its own imports; apply-imports searches only the current template's stylesheet's own import subtree per 5.6, reverting to the built-in rule when that unit imports nothing; an included stylesheet's declarations join the including unit at its precedence while its imports join the import list; and include/import hrefs resolve against the including stylesheet's URI. Included documents' roots stay retained until their declarations are parsed (tree parents are weak, so the in-scope xmlns chain could vanish mid-compile). Xalan baseline 159 -> 144 (reluri 11 -> 3, impincl 9 -> 2). --- CHANGELOG.md | 1 + README.md | 2 +- Sources/XSLT/XSLTModel.swift | 4 ++ Sources/XSLT/XSLTParser.swift | 72 ++++++++++++++++++++++------ Sources/XSLT/XSLTParserEntry.swift | 3 +- Sources/XSLT/XSLTRuntime.swift | 3 ++ Sources/XSLT/XSLTTransformer.swift | 6 ++- Sources/XSLT/XSLTUnitCollector.swift | 11 +++++ Tests/Fixtures/xalan-baseline.txt | 15 ------ Tests/XSLTOutputMethodTests.swift | 57 ++++++++++++++++++++++ 10 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 Sources/XSLT/XSLTUnitCollector.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b38e613..104d943 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 +- Import precedence and relative composition, fourteenth xalan burn-down (#130): import precedences are assigned in post-order over the import tree, so sibling imports are distinct (later higher) and every unit sits above its own imports; apply-imports searches only the current template's stylesheet's own import subtree per 5.6 (templates carry their unit's precedence range), falling back to the built-in rule when that unit imports nothing; an included stylesheet's declarations join the including unit at its precedence while its imports join the import list; and include/import hrefs resolve against the including stylesheet's own URI, so nested relative chains load correctly. Also fixed along the way: an included document's tree was only weakly referenced once flattened, so its ancestor chain (xmlns declarations, exclusions in scope) could disappear before its templates were parsed. Two unit tests pin apply-imports scoping and relative chains. Baseline 159 to 144 (reluri 11 to 3, impincl 9 to 2). - Attribute sets, copied namespaces, html booleans, loader entities, thirteenth xalan burn-down (#130): same-name `xsl:attribute-set` declarations merge as ordered definitions per 7.1.4 (each expands its used sets before its own attributes, a later definition's attributes winning); `xsl:copy` of an element carries the source element's in-scope namespace nodes per 7.5 (the fixup pass drops result-redundant ones); the namespace fixup lets a carried attribute prefix shadow an inherited binding locally (matching the source document's shape) unless the element's own name relies on it; the html method minimizes boolean attributes whose value repeats their name (CHECKED="CHECKED" serializes as CHECKED); and external DTDs and entities referenced by the stylesheet or source now resolve through the transform's document loader, the same channel as document(). Four unit tests pin the new behaviors. Baseline 174 to 159 (attribset 13 to 4, copy 12 to 7). ### Fixed diff --git a/README.md b/README.md index f5a0c01..defc187 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 **970 tests in 166 suites** (`swift test`). +currently runs **972 tests in 166 suites** (`swift test`). ### Shipped (libxml2-aligned surface) diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index b22265c..b839ec8 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -229,6 +229,10 @@ public extension PureXML.XSLT { public var mode: String? public var priority: Double public var importPrecedence: Int + /// The lowest precedence inside this template's stylesheet's own + /// import subtree: apply-imports considers only templates with + /// precedence in [importRangeLow, importPrecedence). + public var importRangeLow: Int = 0 public var parameters: [Binding] public var body: [Instruction] /// The stylesheet xmlns bindings in scope at the template, for diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 6523456..d2c3be4 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -52,28 +52,59 @@ extension PureXML.XSLT { /// are literal result elements. enum XSLTParser { /// Compiles a stylesheet element, folding in `xsl:include`/`xsl:import`. - static func compile(_ top: XSLTTree, loader: (String) -> String?, precedence: Int) -> Stylesheet { + /// Compiles one stylesheet unit. Import precedences are assigned in + /// post-order over the import tree (each import lower than its + /// importer, later sibling imports higher than earlier ones), and a + /// unit's templates carry the [low, precedence) range of its own + /// import subtree, the set apply-imports searches. An included + /// stylesheet's declarations join the including unit at its + /// precedence; its imports join the including unit's import list. + static func compile(_ top: XSLTTree, loader: (String) -> String?, counter: inout Int, base: String = "") -> Stylesheet { var parts = Parts() - for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) { - absorb(child, into: &parts, loader: loader, precedence: precedence) + let low = counter + var collector = XSLTUnitCollector(counter: counter) + collectUnit(top, loader: loader, base: base, into: &collector, imports: &parts) + counter = collector.counter + let precedence = counter + counter += 1 + for (child, _) in collector.declarations { + _ = absorbDeclaration(child, into: &parts, precedence: precedence, low: low) } return parts.stylesheet } - private static func absorb(_ child: XSLTTree, into parts: inout Parts, loader: (String) -> String?, precedence: Int) { - if absorbDeclaration(child, into: &parts, precedence: precedence) { return } - switch XSLTNode.localName(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 + // Walks a unit's xsl children: imports compile immediately (in + // document order, so later ones take higher precedence), includes + // flatten recursively, other declarations are queued for the unit's + // own precedence. + + private static func collectUnit( + _ top: XSLTTree, + loader: (String) -> String?, + base: String, + into collector: inout XSLTUnitCollector, + imports parts: inout Parts, + ) { + for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) { + switch XSLTNode.localName(child) { + case "import": + parts.fold(load(child, loader: loader, counter: &collector.counter, base: base), isImport: true) + case "include": + if let (tree, resolved) = loadTree(child, loader: loader, base: base) { + collector.retainedRoots.append(tree) + collectUnit(tree, loader: loader, base: resolved, into: &collector, imports: &parts) + } + default: + collector.declarations.append((child, base)) + } } } /// Absorbs a non-composition top-level declaration, returning whether it /// was one (so the caller can then try `include`/`import`). - private static func absorbDeclaration(_ child: XSLTTree, into parts: inout Parts, precedence: Int) -> Bool { + 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)) + case "template": parts.templates.append(template(child, precedence: precedence, low: low)) 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)) @@ -93,13 +124,23 @@ extension PureXML.XSLT { Set((XSLTNode.attribute(node, "elements") ?? "").split(whereSeparator: \.isWhitespace).map(String.init)) } - private static func load(_ node: XSLTTree, loader: (String) -> String?, precedence: Int) -> Stylesheet? { - guard let href = XSLTNode.attribute(node, "href"), let text = loader(href), + /// Loads an include/import target's stylesheet element: the href + /// resolves against the importing stylesheet's own URI, which becomes + /// the base for the loaded sheet's own includes and imports. + private static func loadTree(_ node: XSLTTree, loader: (String) -> String?, base: String) -> (XSLTTree, String)? { + guard let href = XSLTNode.attribute(node, "href") else { return nil } + let resolved = base.isEmpty ? href : PureXML.XInclude.URIReference.resolve(href, against: base) + guard let text = loader(resolved), let root = try? PureXML.parseTree(text, limits: .init(allowDoctype: true)), let top = stylesheetElement(root) else { return nil } - return compile(top, loader: loader, precedence: precedence) + return (top, resolved) + } + + private static func load(_ node: XSLTTree, loader: (String) -> String?, counter: inout Int, base: String) -> Stylesheet? { + guard let (top, resolved) = loadTree(node, loader: loader, base: base) else { return nil } + return compile(top, loader: loader, counter: &counter, base: resolved) } private static func parseOutput(_ node: XSLTTree) -> Output { @@ -126,7 +167,7 @@ extension PureXML.XSLT { // MARK: Templates - private static func template(_ node: XSLTTree, precedence: Int) -> Template { + private static func template(_ node: XSLTTree, precedence: Int, low: Int) -> Template { let match = XSLTNode.attribute(node, "match") let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init) ?? match.map(defaultPriority) ?? 0 @@ -140,6 +181,7 @@ extension PureXML.XSLT { mode: XSLTNode.attribute(node, "mode"), priority: priority, importPrecedence: precedence, + importRangeLow: low, parameters: parameters, body: body, namespaces: inScopeNamespaces(node).filter { !$0.key.isEmpty }, diff --git a/Sources/XSLT/XSLTParserEntry.swift b/Sources/XSLT/XSLTParserEntry.swift index f631d6c..fe3f704 100644 --- a/Sources/XSLT/XSLTParserEntry.swift +++ b/Sources/XSLT/XSLTParserEntry.swift @@ -45,7 +45,8 @@ extension PureXML.XSLT.XSLTParser { let root = try PureXML.parseTree(xsl, limits: .init(allowDoctype: true), resolver: PureXML.XSLT.loaderResolver(loader)) let usesRawText = containsSubstring(xsl, "disable-output-escaping") if let top = stylesheetElement(root) { - var sheet = compile(top, loader: loader, precedence: 0) + var counter = 0 + var sheet = compile(top, loader: loader, counter: &counter) sheet.usesRawText = usesRawText return sheet } diff --git a/Sources/XSLT/XSLTRuntime.swift b/Sources/XSLT/XSLTRuntime.swift index 8d92740..3301d6c 100644 --- a/Sources/XSLT/XSLTRuntime.swift +++ b/Sources/XSLT/XSLTRuntime.swift @@ -27,6 +27,9 @@ extension PureXML.XSLT { /// The import precedence of the template currently being instantiated, so /// `apply-imports` considers only templates below it. `.max` outside a template. var importPrecedence: Int + /// The floor of the current template's stylesheet's import subtree; + /// apply-imports searches [importRangeLow, importPrecedence). + var importRangeLow: Int = 0 /// The stylesheet xmlns bindings of the template being instantiated, /// so prefixed names in its expressions resolve by URI. var namespaces: [String: String] = [:] diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 2c6bbbd..636b2c9 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -69,10 +69,11 @@ extension PureXML.XSLT { return .document(applyTemplates(to: [.tree(root)], mode: nil, parameters: [], context).compactMap(Self.nodeOf)) } - fileprivate func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max) -> Template? { + fileprivate func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? { stylesheet.templates.enumerated() .filter { entry in entry.element.mode == mode && entry.element.importPrecedence < ceiling + && entry.element.importPrecedence >= floor && (entry.element.match.map { matches(node, $0, entry.element.namespaces) } ?? false) } .max { lhs, rhs in @@ -146,6 +147,7 @@ extension PureXML.XSLT { ) -> [ResultItem] { var context = context context.importPrecedence = template.importPrecedence + context.importRangeLow = template.importRangeLow context.namespaces = template.namespaces for parameter in template.parameters { if let passed = parameters.first(where: { $0.name == parameter.name }) { @@ -316,7 +318,7 @@ extension PureXML.XSLT.Transformer { /// only those below the current template's import precedence, falling back to /// the built-in rule when none match. private func applyImports(_ context: XSLTContext) -> [ResultItem] { - if let template = bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence) { + if let template = bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence, atLeast: context.importRangeLow) { return instantiateTemplate(template, context, passing: [], from: context) } return builtInRule(context.focus, mode: context.mode, context) diff --git a/Sources/XSLT/XSLTUnitCollector.swift b/Sources/XSLT/XSLTUnitCollector.swift new file mode 100644 index 0000000..96a3a0f --- /dev/null +++ b/Sources/XSLT/XSLTUnitCollector.swift @@ -0,0 +1,11 @@ +/// The state accumulated while flattening one unit: its queued +/// declarations, the import-precedence counter, and the included +/// documents' roots. Tree nodes hold their parents weakly, so the +/// roots must stay retained until the declarations are parsed or the +/// ancestor chain (xmlns declarations, exclusions, aliases in scope) +/// silently disappears. +struct XSLTUnitCollector { + var counter: Int + var declarations: [(XSLTTree, String)] = [] + var retainedRoots: [XSLTTree] = [] +} diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index e62981e..fb64673 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -46,14 +46,7 @@ idkey/idkey47 idkey/idkey48 idkey/idkey50 idkey/idkey53 -impincl/impincl07 impincl/impincl08 -impincl/impincl11 -impincl/impincl19 -impincl/impincl21 -impincl/impincl23 -impincl/impincl25 -impincl/impincl26 impincl/impincl27 match/match14 mdocs/mdocs07 @@ -116,14 +109,6 @@ position/position106 position/position70 position/position75 processorinfo/processorinfo03 -reluri/reluri01 -reluri/reluri02 -reluri/reluri03 -reluri/reluri04 -reluri/reluri05 -reluri/reluri06 -reluri/reluri07 -reluri/reluri08 reluri/reluri09 reluri/reluri10 reluri/reluri11 diff --git a/Tests/XSLTOutputMethodTests.swift b/Tests/XSLTOutputMethodTests.swift index 1b21ebc..b0ad5fc 100644 --- a/Tests/XSLTOutputMethodTests.swift +++ b/Tests/XSLTOutputMethodTests.swift @@ -172,6 +172,63 @@ struct XSLTOutputMethodTests { == "
") } + @Test("apply-imports searches only the current stylesheet's own imports (5.6)") + func test_applyImportsScope() throws { + let leaf = """ + + + + """ + let sibling = """ + + + + """ + let style = """ + + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: style, + source: "x", + documentLoader: { ["leaf.xsl": leaf, "sibling.xsl": sibling][$0] }, + ) + // sibling.xsl wins (later import); its apply-imports has no imports of + // its own, so the built-in rule runs, not leaf.xsl's template. + #expect(result == "x") + } + + @Test("Include and import hrefs resolve against the including stylesheet's URI") + func test_relativeIncludeChain() throws { + let inner = """ + + + + """ + let middle = """ + + + + """ + let style = """ + + + + + + """ + let result = try PureXML.XSLT.transform( + stylesheet: style, + source: "", + documentLoader: { ["sub/middle.xsl": middle, "sub/inner.xsl": inner][$0] }, + ) + #expect(result == "") + } + @Test("Stylesheet and source entities resolve through the document loader") func test_loaderEntityResolution() throws { let style = """