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).
This commit is contained in:
parent
cff26b255d
commit
ef4e7afa7a
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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] = [:]
|
||||
|
||||
@ -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)
|
||||
|
||||
11
Sources/XSLT/XSLTUnitCollector.swift
Normal file
11
Sources/XSLT/XSLTUnitCollector.swift
Normal file
@ -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] = []
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -172,6 +172,63 @@ struct XSLTOutputMethodTests {
|
||||
== "<Form><Input Type=\"checkbox\" CHECKED><Input Type=\"text\" Value=\"CHECKED\"></Form>")
|
||||
}
|
||||
|
||||
@Test("apply-imports searches only the current stylesheet's own imports (5.6)")
|
||||
func test_applyImportsScope() throws {
|
||||
let leaf = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:template match="item"><leaf/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let sibling = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:template match="item"><sibling><xsl:apply-imports/></sibling></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:import href="leaf.xsl"/>
|
||||
<xsl:import href="sibling.xsl"/>
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><out><xsl:apply-templates select="//item"/></out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let result = try PureXML.XSLT.transform(
|
||||
stylesheet: style,
|
||||
source: "<r><item>x</item></r>",
|
||||
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 == "<out><sibling>x</sibling></out>")
|
||||
}
|
||||
|
||||
@Test("Include and import hrefs resolve against the including stylesheet's URI")
|
||||
func test_relativeIncludeChain() throws {
|
||||
let inner = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:template match="item"><deep/></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let middle = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:include href="inner.xsl"/>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let style = """
|
||||
<xsl:stylesheet version="1.0" \(xsl)>
|
||||
<xsl:include href="sub/middle.xsl"/>
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
<xsl:template match="/"><out><xsl:apply-templates select="//item"/></out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let result = try PureXML.XSLT.transform(
|
||||
stylesheet: style,
|
||||
source: "<r><item/></r>",
|
||||
documentLoader: { ["sub/middle.xsl": middle, "sub/inner.xsl": inner][$0] },
|
||||
)
|
||||
#expect(result == "<out><deep/></out>")
|
||||
}
|
||||
|
||||
@Test("Stylesheet and source entities resolve through the document loader")
|
||||
func test_loaderEntityResolution() throws {
|
||||
let style = """
|
||||
|
||||
Loading…
Reference in New Issue
Block a user