fix(xslt): resolve string-form document() against the stylesheet element's base URI (closes mdocs12/13) (#257)

* fix(xslt): resolve string-form document() against the stylesheet element's base URI

A string-form document() reference resolves against the base URI of the
stylesheet element holding it (XSLT 1.0 12.1). For a declaration in an
included or imported file that is the included file's URI, not the top
stylesheet's, so document('data.xml') (or document('')) in an included file
must resolve against the included file.

The parser already tracked each top-level declaration's source-file base but
discarded it after collection. That base is now carried onto the compiled
templates and global variables, into the runtime context, and through to the
function table, so the document() string form resolves against it. Templates
and globals both carry it, so the rule holds wherever the call appears.

Closes Apache Xalan conformance cases mdocs12 and mdocs13.

* fix(xslt): carry base URI into for-each and sort contexts; keep document('') self-referential

Follow-up to the string-form document() base URI work. Three coverage gaps:

- xsl:for-each and xsl:sort evaluate their body and key expressions in a
  context derived from, not equal to, the enclosing template's, so a
  document() call there resolved against the empty base when the template came
  from an included file. Both now inherit the enclosing context's base URI.

- A non-empty base URI made document('') in the top stylesheet resolve to that
  base and route through the loader, rather than returning the stylesheet's own
  parsed document as before. The parsed stylesheet is now registered under its
  base URI, so document('') reaches it without the loader; document('') in an
  included file (a different base) still loads that file.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 02:03:32 +02:00 committed by GitHub
parent b6af499558
commit 37ea8fcbfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 149 additions and 31 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A string-form `document()` reference now resolves against the base URI of the stylesheet element holding it (XSLT 1.0 12.1), which for a declaration in an `xsl:include`d or `xsl:import`ed file is that file's URI rather than the top stylesheet's. The parser already tracked each top-level declaration's source-file base; that base is now carried onto the compiled templates and global variables and threaded into expression evaluation, so a relative `document('data.xml')` (or `document('')`, the self-reference) in an included file resolves against the included file, not the importing one. Closes Apache Xalan conformance cases `mdocs12` and `mdocs13`.
- The `document()` function now resolves a relative URI reference against the correct base URI (XSLT 1.0 12.1). The two-argument form resolves the first argument against the base URI of the second argument's first node, and the node-set form resolves each reference against the base URI of the node that supplied it; previously every reference resolved against the single global base, so a reference relative to a document that `document()` itself had loaded from a subdirectory could not be found. Each loaded document now records the URI it came from as its base URI, and a relative reference is resolved against that base before the loader applies its own source-document base. Closes Apache Xalan conformance cases `reluri09`, `reluri10`, and `reluri11`.
- Named templates, `xsl:key` names, `xsl:attribute-set` names, and `xsl:decimal-format` names are now matched by expanded QName, like mode and variable names already were. XSLT 1.0 compares all of these by expanded name, so two prefixes bound to the same namespace name the same thing, but they were keyed and compared as raw prefix strings. A declaration name now resolves to `{uri}local` at parse time, and the runtime references (`call-template`/`use-attribute-sets` at parse, `key()` and `format-number`'s format name at evaluation) resolve their prefix against the in-scope namespaces the same way. Closes Apache Xalan conformance cases `namedtemplate16`, `attribset48`, and `idkey53`.

View File

@ -42,6 +42,16 @@ extension PureXML.XSLT {
/// 12.1); a root absent here is the source document, whose base the loader
/// already applies, so it resolves against the empty string.
var baseURIs: [ObjectIdentifier: String] = [:]
/// Registers the top stylesheet's parsed document under its base URI, so a
/// `document('')` from the top stylesheet (whose empty reference resolves
/// to that base) reaches it without the loader; `document('')` in an
/// included file, a different base, still loads that file (XSLT 1.0 12.1).
func registerSelfDocument(_ document: PureXML.Model.Node?, at baseURI: String) {
guard !baseURI.isEmpty, let document else { return }
trees[baseURI] = PureXML.Model.TreeNode(document)
sources[baseURI] = document
}
}
/// The ID-typed attributes a document type declares, element name to

View File

@ -198,6 +198,7 @@ extension PureXML.XSLT {
documents: PureXML.XSLT.DocumentCache,
selfDocument: PureXML.Model.Node? = nil,
unparsedEntities: [String: String] = [:],
baseURI stylesheetBase: String = "",
) -> PureXML.XPath.FunctionTable {
PureXML.XPath.FunctionTable()
.adding("current") { _, _ in .nodeSet([current]) }
@ -228,7 +229,9 @@ extension PureXML.XSLT {
if let nodes = arguments.first?.nodes {
return .nodeSet(nodes.flatMap { load($0.stringValue, secondBase ?? baseURI(of: $0, documents)) })
}
return .nodeSet(load(arguments.first?.string ?? "", secondBase ?? ""))
// A string-form reference resolves against the base URI of the
// stylesheet element holding the expression (XSLT 1.0 12.1).
return .nodeSet(load(arguments.first?.string ?? "", secondBase ?? stylesheetBase))
}
.adding("id") { arguments, context in
idLookup(arguments, context, documents)

View File

@ -5,6 +5,7 @@ extension PureXML.XSLT.Transformer {
let name: String
let select: String?
let body: [PureXML.XSLT.Instruction]
let base: String
}
/// The global variable bindings, resolved to a fixpoint so references
@ -17,8 +18,8 @@ extension PureXML.XSLT.Transformer {
func evaluatedGlobals() -> [String: PureXML.XPath.Value] {
var variables: [String: PureXML.XPath.Value] = [:]
let globals: [Global] = stylesheet.globals.compactMap {
guard case let .variable(name, select, body) = $0 else { return nil }
return Global(name: name, select: select, body: body)
guard case let .variable(name, select, body) = $0.instruction else { return nil }
return Global(name: name, select: select, body: body, base: $0.baseURI)
}
// A caller-supplied value overrides an xsl:param default and is fixed.
for global in globals where stylesheet.parameterNames.contains(global.name) {
@ -27,7 +28,7 @@ extension PureXML.XSLT.Transformer {
for _ in 0 ..< max(1, globals.count) {
var changed = false
for global in globals where !(stylesheet.parameterNames.contains(global.name) && parameters[global.name] != nil) {
let context = XSLTContext(node: root, position: 1, size: 1, variables: variables)
let context = XSLTContext(node: root, position: 1, size: 1, variables: variables, baseURI: global.base)
let value = variableValue(global.select, global.body, context)
if variables[global.name] != value {
variables[global.name] = value

View File

@ -242,6 +242,19 @@ public extension PureXML.XSLT {
/// The stylesheet xmlns bindings in scope at the template, for
/// resolving prefixes in its match pattern and expressions.
public var namespaces: [String: String] = [:]
/// The base URI of the stylesheet element this template was parsed from
/// (the including/imported file's URI), against which a relative
/// `document()` reference in its body resolves (XSLT 1.0 12.1).
public var baseURI: String = ""
}
/// A top-level variable or parameter paired with the base URI of the
/// stylesheet element it was parsed from, so a relative `document()`
/// reference in its select expression resolves against the right file
/// (XSLT 1.0 12.1) even after an include or import is folded in.
struct GlobalDeclaration: Sendable {
public var instruction: Instruction
public var baseURI: String
}
/// A compiled stylesheet: its template rules, global variables, keys, output
@ -249,7 +262,7 @@ public extension PureXML.XSLT {
/// `xsl:include`/`xsl:import` already folded in).
struct Stylesheet: Sendable {
public var templates: [Template]
public var globals: [Instruction]
public var globals: [GlobalDeclaration]
public var keys: [Key]
public var output: Output
/// Element name tests whose whitespace-only text children are stripped from
@ -276,7 +289,7 @@ public extension PureXML.XSLT {
public init(
templates: [Template],
globals: [Instruction],
globals: [GlobalDeclaration],
keys: [Key],
output: Output,
stripSpace: Set<String> = [],

View File

@ -68,8 +68,8 @@ extension PureXML.XSLT {
// weak parent chain to their own stylesheet element, retained only in
// the otherwise-unread retainedRoots; pin it so ARC keeps it here.
withExtendedLifetime(collector.retainedRoots) {
for (child, _) in collector.declarations {
_ = absorbDeclaration(child, into: &parts, precedence: precedence, low: low)
for (child, base) in collector.declarations {
_ = absorbDeclaration(child, into: &parts, precedence: precedence, low: low, base: base)
}
}
return parts.stylesheet
@ -113,10 +113,10 @@ extension PureXML.XSLT {
/// 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, low: Int) -> Bool {
private static func absorbDeclaration(_ child: XSLTTree, into parts: inout Parts, precedence: Int, low: Int, base: String) -> Bool {
switch XSLTNode.localName(child) {
case "template": parts.templates.append(template(child, precedence: precedence, low: low))
case "variable", "param": addGlobal(child, into: &parts)
case "template": parts.templates.append(template(child, precedence: precedence, low: low, base: base))
case "variable", "param": addGlobal(child, base, 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))
@ -131,8 +131,8 @@ extension PureXML.XSLT {
/// 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))
private static func addGlobal(_ child: XSLTTree, _ base: String, into parts: inout Parts) {
parts.globals.append(PureXML.XSLT.GlobalDeclaration(instruction: variable(child), baseURI: base))
if XSLTNode.localName(child) == "param", let name = XSLTNode.attribute(child, "name") {
parts.parameterNames.insert(name)
}
@ -184,7 +184,7 @@ extension PureXML.XSLT {
// MARK: Templates
private static func template(_ node: XSLTTree, precedence: Int, low: Int) -> Template {
private static func template(_ node: XSLTTree, precedence: Int, low: Int, base: String) -> Template {
let match = XSLTNode.attribute(node, "match")
let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init)
?? match.map(defaultPriority) ?? 0
@ -202,6 +202,7 @@ extension PureXML.XSLT {
parameters: parameters,
body: body,
namespaces: inScopeNamespaces(node).filter { !$0.key.isEmpty },
baseURI: base,
)
}

View File

@ -1,7 +1,7 @@
/// The accumulating halves of a stylesheet under compilation.
struct Parts {
var templates: [PureXML.XSLT.Template] = []
var globals: [PureXML.XSLT.Instruction] = []
var globals: [PureXML.XSLT.GlobalDeclaration] = []
var keys: [PureXML.XSLT.Key] = []
var output = PureXML.XSLT.Output()
var stripSpace: Set<String> = []
@ -16,8 +16,8 @@ struct Parts {
// precedence order (earlier imports lower, the unit's own
// declarations appended last), so the last same-name entry wins.
var seen = Set<String>()
let resolvedGlobals: [PureXML.XSLT.Instruction] = globals.reversed().filter { instruction in
guard let name = Self.globalName(instruction) else { return true }
let resolvedGlobals: [PureXML.XSLT.GlobalDeclaration] = globals.reversed().filter { declaration in
guard let name = Self.globalName(declaration.instruction) else { return true }
return seen.insert(name).inserted
}.reversed()
var sheet = PureXML.XSLT.Stylesheet(

View File

@ -33,6 +33,10 @@ extension PureXML.XSLT {
/// The stylesheet xmlns bindings of the template being instantiated,
/// so prefixed names in its expressions resolve by URI.
var namespaces: [String: String] = [:]
/// The base URI of the stylesheet element currently being instantiated
/// (its template's or global variable's source file), against which a
/// relative string-form `document()` reference resolves (XSLT 1.0 12.1).
var baseURI: String = ""
init(
node: PureXML.Model.TreeNode,
@ -42,6 +46,7 @@ extension PureXML.XSLT {
variables: [String: PureXML.XPath.Value],
mode: String? = nil,
importPrecedence: Int = .max,
baseURI: String = "",
) {
self.node = node
self.current = current
@ -50,6 +55,7 @@ extension PureXML.XSLT {
self.variables = variables
self.mode = mode
self.importPrecedence = importPrecedence
self.baseURI = baseURI
}
}

View File

@ -21,6 +21,7 @@ extension PureXML.XSLT.Transformer {
variables: context.variables,
)
keyContext.namespaces = context.namespaces
keyContext.baseURI = context.baseURI
return SortEntry(offset: offset, node: xnode, keys: sorts.map { string($0.select, keyContext) })
}
// A lang attribute value template evaluates once per sort, in the
@ -96,13 +97,14 @@ extension PureXML.XSLT.Transformer {
var items: [ResultItem] = []
for (offset, xnode) in nodes.enumerated() {
guard let owner = Self.ownerNode(xnode) else { continue }
let itemContext = XSLTContext(
var itemContext = XSLTContext(
node: owner,
current: xnode.treeNode == nil ? xnode : nil,
position: offset + 1,
size: nodes.count,
variables: context.variables,
)
itemContext.baseURI = context.baseURI
items += instantiate(body, itemContext)
}
return items
@ -156,6 +158,7 @@ extension PureXML.XSLT.Transformer {
context.importPrecedence = template.importPrecedence
context.importRangeLow = template.importRangeLow
context.namespaces = template.namespaces
context.baseURI = template.baseURI
for parameter in template.parameters {
if let passed = parameters.first(where: { $0.name == parameter.name }) {
context.variables[parameter.name] = variableValue(passed.select, passed.body, caller)

View File

@ -24,8 +24,6 @@ public extension PureXML.XSLT {
Whitespace.strip(root, stylesheet: sheet)
// The stylesheet's own document, queried by `document('')` (XSLT 1.0 12.1).
let stylesheetDocument = try? PureXML.parse(stylesheet, limits: .init(allowDoctype: true))
// The source DTD's unparsed entities, by name to system URI (12.4).
let unparsedEntityURIs = parsed.documentType.unparsedEntities.mapValues(\.id.resolvedSystemID)
let transformer = Transformer(
stylesheet: sheet,
root: root,
@ -33,7 +31,9 @@ public extension PureXML.XSLT {
idAttributes: idAttributes,
parameters: parameters,
stylesheetDocument: stylesheetDocument,
unparsedEntityURIs: unparsedEntityURIs,
// The source DTD's unparsed entities, by name to system URI (12.4).
unparsedEntityURIs: parsed.documentType.unparsedEntities.mapValues(\.id.resolvedSystemID),
baseURI: baseURI,
)
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
@ -225,6 +225,7 @@ public extension PureXML.XSLT {
root: root,
documentLoader: documentLoader,
stylesheetDocument: stylesheetDocument,
baseURI: baseURI,
)
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }

View File

@ -39,6 +39,7 @@ extension PureXML.XSLT {
parameters: [String: String] = [:],
stylesheetDocument: PureXML.Model.Node? = nil,
unparsedEntityURIs: [String: String] = [:],
baseURI: String = "",
) {
self.parameters = parameters
self.stylesheet = stylesheet
@ -49,6 +50,7 @@ extension PureXML.XSLT {
if !idAttributes.isEmpty {
documentCache.idAttributes[ObjectIdentifier(root)] = idAttributes
}
documentCache.registerSelfDocument(stylesheetDocument, at: baseURI)
// One table for all pattern matching: matches() runs per (template,
// node), so a fresh table per call would allocate millions of times.
let caches = keyIndexes
@ -172,9 +174,8 @@ extension PureXML.XSLT {
func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value {
if let select { return value(select, context) ?? .string("") }
// XSLT 1.0 11.2: a variable with no select and EMPTY content is the
// empty string (equivalent to select=""), not a result tree fragment,
// so boolean() of it is false. Only non-empty content is an RTF.
// XSLT 1.0 11.2: a variable with no select and empty content is the
// empty string, not an RTF (so boolean() is false); only content is.
if body.isEmpty { return .string("") }
// A body variable is a result-tree fragment: a queryable document node.
let children = instantiate(body, context).compactMap(Self.nodeOf).map(PureXML.Model.TreeNode.init)
@ -196,6 +197,7 @@ extension PureXML.XSLT {
documents: documentCache,
selfDocument: stylesheetDocument,
unparsedEntities: unparsedEntityURIs,
baseURI: context.baseURI,
),
namespaces: context.namespaces,
)
@ -221,11 +223,9 @@ extension PureXML.XSLT {
}
func string(_ expression: String, _ context: XSLTContext) -> String {
// Raw-output markers do not survive string extraction (16.4:
// escaping is disabled only for text written directly to the
// result tree), so a fragment round-trip re-enables escaping.
// Gated so source text in a stylesheet without
// disable-output-escaping passes through untouched.
// Raw-output markers do not survive string extraction (16.4: escaping is disabled only for text written
// directly to the result tree), so a fragment round-trip re-enables escaping. Gated so source text in a
// stylesheet without disable-output-escaping passes through untouched.
let extracted = value(expression, context)?.string ?? ""
return stylesheet.usesRawText ? PureXML.XSLT.RawText.stripped(extracted) : extracted
}

View File

@ -17,8 +17,6 @@ idkey/idkey48
impincl/impincl08
impincl/impincl27
mdocs/mdocs07
mdocs/mdocs12
mdocs/mdocs13
mdocs/mdocs15
mdocs/mdocs19
namedtemplate/namedtemplate12

View File

@ -52,4 +52,84 @@ struct XSLTDocumentBaseURITests {
let out = try run("document('target.xml', document('dir/sub/inner.xml#xpointer(/inner)'))/hit")
#expect(out == "<out>TWO_ARG</out>")
}
/// A string-form `document()` reference resolves against the base URI of the
/// stylesheet element holding it, which for a declaration in an included or
/// imported file is that file's URI, not the top stylesheet's (XSLT 1.0 12.1;
/// the semantics behind Apache Xalan impincl08).
@Test("a global document() in an included file resolves against the included file's base")
func test_includedGlobalBase() throws {
let files = [
"lib/inc.xsl": """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:variable name="d" select="document('data.xml')"/>
<xsl:template name="emit"><xsl:value-of select="$d/d/v"/></xsl:template>
</xsl:stylesheet>
""",
"lib/data.xml": "<d><v>INCLUDED</v></d>",
]
let out = try PureXML.XSLT.transform(
stylesheet: """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:include href="lib/inc.xsl"/>
<xsl:template match="/"><out><xsl:call-template name="emit"/></out></xsl:template>
</xsl:stylesheet>
""",
source: "<doc/>",
documentLoader: { files[$0] },
)
// `data.xml` resolves against lib/inc.xsl, reaching lib/data.xml; without
// base provenance it would resolve against the top stylesheet and miss.
#expect(out == "<out>INCLUDED</out>")
}
/// The base URI must reach a `document()` call inside an `xsl:for-each` body,
/// which runs in a context derived from (not equal to) the enclosing
/// template's, when that template comes from an included file.
@Test("document() inside a for-each body in an included file keeps the file's base")
func test_forEachBodyBase() throws {
let files = [
"lib/inc.xsl": """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:template name="emit">
<xsl:for-each select="/doc/item"><xsl:value-of select="document('data.xml')/d/v"/></xsl:for-each>
</xsl:template>
</xsl:stylesheet>
""",
"lib/data.xml": "<d><v>INCLUDED</v></d>",
]
let out = try PureXML.XSLT.transform(
stylesheet: """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:include href="lib/inc.xsl"/>
<xsl:template match="/"><out><xsl:call-template name="emit"/></out></xsl:template>
</xsl:stylesheet>
""",
source: "<doc><item/></doc>",
documentLoader: { files[$0] },
)
#expect(out == "<out>INCLUDED</out>")
}
/// `document('')` in the top stylesheet is the stylesheet's own document and
/// must not start depending on the loader to serve the stylesheet's URL once
/// a non-empty base URI is supplied (a regression the base threading could
/// introduce, since the empty reference resolves to that base).
@Test("document('') uses the self document even with a non-empty base URI")
func test_emptyReferenceWithBaseURI() throws {
let out = try PureXML.XSLT.transform(
stylesheet: """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:value-of select="count(document('')/xsl:stylesheet)"/></out></xsl:template>
</xsl:stylesheet>
""",
source: "<doc/>",
documentLoader: { _ in nil },
baseURI: "http://example.org/style.xsl",
)
#expect(out == "<out>1</out>")
}
}