diff --git a/CHANGELOG.md b/CHANGELOG.md index f2948a0..e606343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/Sources/XSLT/XSLTDocumentCache.swift b/Sources/XSLT/XSLTDocumentCache.swift index 3977730..eb19b94 100644 --- a/Sources/XSLT/XSLTDocumentCache.swift +++ b/Sources/XSLT/XSLTDocumentCache.swift @@ -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 diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 90a3c4c..db852b0 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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) diff --git a/Sources/XSLT/XSLTGlobals.swift b/Sources/XSLT/XSLTGlobals.swift index 28a3b2f..a4e2638 100644 --- a/Sources/XSLT/XSLTGlobals.swift +++ b/Sources/XSLT/XSLTGlobals.swift @@ -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 diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index b7c2b9d..dfd5250 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -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 = [], diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 8c2cee3..805c26d 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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, ) } diff --git a/Sources/XSLT/XSLTParserEntry.swift b/Sources/XSLT/XSLTParserEntry.swift index 24c73bd..1e45911 100644 --- a/Sources/XSLT/XSLTParserEntry.swift +++ b/Sources/XSLT/XSLTParserEntry.swift @@ -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 = [] @@ -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() - 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( diff --git a/Sources/XSLT/XSLTRuntime.swift b/Sources/XSLT/XSLTRuntime.swift index 3301d6c..1674e96 100644 --- a/Sources/XSLT/XSLTRuntime.swift +++ b/Sources/XSLT/XSLTRuntime.swift @@ -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 } } diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index b1f2709..4f31fd4 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -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) diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index c410990..4add3ca 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -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) } diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 002ef4b..d45b7b3 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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 } diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index bbc43be..b236689 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -17,8 +17,6 @@ idkey/idkey48 impincl/impincl08 impincl/impincl27 mdocs/mdocs07 -mdocs/mdocs12 -mdocs/mdocs13 mdocs/mdocs15 mdocs/mdocs19 namedtemplate/namedtemplate12 diff --git a/Tests/XSLTDocumentBaseURITests.swift b/Tests/XSLTDocumentBaseURITests.swift index f1ca7aa..14d2448 100644 --- a/Tests/XSLTDocumentBaseURITests.swift +++ b/Tests/XSLTDocumentBaseURITests.swift @@ -52,4 +52,84 @@ struct XSLTDocumentBaseURITests { let out = try run("document('target.xml', document('dir/sub/inner.xml#xpointer(/inner)'))/hit") #expect(out == "TWO_ARG") } + + /// 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": """ + + + + + """, + "lib/data.xml": "INCLUDED", + ] + let out = try PureXML.XSLT.transform( + stylesheet: """ + + + + + + """, + source: "", + 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 == "INCLUDED") + } + + /// 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": """ + + + + + + """, + "lib/data.xml": "INCLUDED", + ] + let out = try PureXML.XSLT.transform( + stylesheet: """ + + + + + + """, + source: "", + documentLoader: { files[$0] }, + ) + #expect(out == "INCLUDED") + } + + /// `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: """ + + + + + """, + source: "", + documentLoader: { _ in nil }, + baseURI: "http://example.org/style.xsl", + ) + #expect(out == "1") + } }