feat(xslt): key() over attribute nodes, key/id match patterns, doctyped sources

key() and document() read the string value of attribute and namespace
node arguments instead of dropping them, so the Muenchian grouping
idiom works; match patterns evaluate with the XSLT function table,
making key('k','v') and id('x') patterns match; and the XSLT entry
points parse sources, stylesheets, and document() loads with doctypes
allowed, as the conformance corpora require. Xalan baseline
292 -> 263 (idkey 39 -> 16).
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 13:29:27 +02:00
parent b41678064e
commit da8bb6b92a
7 changed files with 31 additions and 46 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Keys, ids, and doctyped sources, seventh xalan burn-down (#130): `key()` and `document()` read the string value of attribute and namespace node arguments instead of dropping them (the Muenchian grouping idiom works); match patterns evaluate with the XSLT function table, so `key('k', 'v')` and `id('x')` patterns match (a key/id pattern branch is also no longer prefixed with `//`); and the XSLT entry points parse sources, stylesheets, and `document()` loads with doctypes allowed, which the conformance corpora use throughout. Baseline 292 to 263 (idkey 39 to 16, the rest spread).
- Templates over attribute and namespace nodes, sixth xalan burn-down (#130): xsl:apply-templates and xsl:for-each now keep attribute and namespace nodes in their selections instead of dropping them at the tree-node conversion. The evaluation context carries the non-tree current node (its owner element backs the tree-only machinery), XPath evaluation and current() start from it, match patterns select attribute nodes (the match cache keeps attribute identities, owner plus name, alongside tree identities), the built-in rules copy an attribute's or namespace's value as text, xsl:copy copies the focused attribute or namespace declaration itself, and an attribute result arriving after element content is ignored (the XSLT recovery). Sorting and apply-imports work over the generalized node kind. Baseline 352 to 292 (axes 35 to 16, copy 29 to 16, attribset 18 to 13, the rest spread).
- Output methods in depth, fifth xalan burn-down (#130): `disable-output-escaping` on xsl:value-of and xsl:text (raw text travels bracketed by private-use sentinels and the serializer's escaping is undone after the fact; a string extracted from a result tree fragment loses the markers per 16.4, so the variable round-trip case re-escapes); the default output method is html when the first result element is named html with a null namespace (16.1); the xml method writes the XML declaration unless `omit-xml-declaration="yes"` (the spec default, with the caller's emit options as fallback only on explicit stylesheet silence); the html method injects `META http-equiv="Content-Type"` into head (16.2), keeps SCRIPT/STYLE raw and void elements end-tag-free case-insensitively, and escapes U+00A0-U+00FF to the HTML 4.01 Latin-1 entity names. Whitespace-only detection in stylesheet/source stripping now means XML whitespace only, so an NBSP-only text node survives. The conf comparison treats whitespace adjacent to tag boundaries as layout for non-canonical comparisons. Baseline 371 to 352 (output 49 to 30).
- Literal result element namespace copying, fourth xalan burn-down (#130): a literal result element now copies its in-scope namespace declarations to the result per 7.1.1, minus the XSLT namespace and the namespaces of the prefixes listed in `exclude-result-prefixes`/`extension-element-prefixes` in scope (`#default` included); an aliased stylesheet namespace declares its result prefix and URI instead. The namespace-fixup pass drops any declaration that repeats an inherited binding, so the copied nodes do not redeclare on every descendant, and created names now reuse the stylesheet's own prefixes (`anamespace:Attr1` rather than `ns0:Attr1`). `xsl:output indent="yes"` switches to the corpus golds' Xalan indentation form: children on their own lines with no leading spaces. Two unit expectations updated to the spec behavior (a literal element under stylesheet-level declarations carries them; collapsed alias+result declarations). The xalan baseline drops from 446 to 371 (namespace 89 to 42, output 50 to 49 and others spread across categories).

View File

@ -200,7 +200,7 @@ extension PureXML.XSLT {
// A node-set second argument unions the matches for each node's
// string value; any other value is used directly as a string.
let values: [String] = if let nodes = arguments[1].nodes {
nodes.compactMap(\.treeNode).map(\.stringValue)
nodes.map(\.stringValue)
} else {
[arguments[1].string]
}
@ -216,7 +216,7 @@ extension PureXML.XSLT {
// A string or a node-set of URI references, each optionally with
// a `#fragment` selecting a subset of the loaded document.
let references: [String] = if let nodes = arguments.first?.nodes {
nodes.compactMap(\.treeNode).map(\.stringValue)
nodes.map(\.stringValue)
} else {
[arguments.first?.string ?? ""]
}
@ -252,7 +252,7 @@ extension PureXML.XSLT {
fragment = nil
}
if cache.trees[path] == nil {
guard let text = loader(path), let parsed = try? PureXML.parse(text) else { return [] }
guard let text = loader(path), let parsed = try? PureXML.parse(text, limits: .init(allowDoctype: true)) else { return [] }
cache.sources[path] = parsed
cache.trees[path] = PureXML.Model.TreeNode(parsed)
}

View File

@ -19,25 +19,26 @@ extension PureXML.XSLT {
private var matched: [String: PatternMatches] = [:]
/// The identities of the tree nodes `pattern` selects over `root`.
func nodes(matching pattern: String, over root: PureXML.Model.TreeNode) -> Set<ObjectIdentifier> {
compute(pattern, root).trees
func nodes(matching pattern: String, over root: PureXML.Model.TreeNode, functions: PureXML.XPath.FunctionTable = .init()) -> Set<ObjectIdentifier> {
compute(pattern, root, functions).trees
}
/// The identities of the attribute nodes `pattern` selects over `root`.
func attributes(matching pattern: String, over root: PureXML.Model.TreeNode) -> Set<AttributeIdentity> {
compute(pattern, root).attributes
func attributes(matching pattern: String, over root: PureXML.Model.TreeNode, functions: PureXML.XPath.FunctionTable = .init()) -> Set<AttributeIdentity> {
compute(pattern, root, functions).attributes
}
/// Computed and cached on first use; a branch that fails to compile
/// selects nothing.
private func compute(_ pattern: String, _ root: PureXML.Model.TreeNode) -> PatternMatches {
/// selects nothing. The function table makes `key()` and `id()`
/// patterns work.
private func compute(_ pattern: String, _ root: PureXML.Model.TreeNode, _ functions: PureXML.XPath.FunctionTable) -> PatternMatches {
if let cached = matched[pattern] { return cached }
var result = PatternMatches()
for branch in pattern.split(separator: "|") {
let trimmed = branch.trimmingXMLWhitespace()
let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed
let path = trimmed.hasPrefix("/") || trimmed.hasPrefix("key(") || trimmed.hasPrefix("id(") ? trimmed : "//" + trimmed
guard let query = try? PureXML.XPath.Query(path),
let value = try? query.value(atNode: .tree(root), position: 1, size: 1, variables: [:], functions: PureXML.XPath.FunctionTable()),
let value = try? query.value(atNode: .tree(root), position: 1, size: 1, variables: [:], functions: functions),
let selected = value.nodes
else { continue }
for node in selected {

View File

@ -92,7 +92,7 @@ extension PureXML.XSLT {
/// are literal result elements.
enum XSLTParser {
static func parse(_ xsl: String, loader: (String) -> String? = { _ in nil }) throws -> Stylesheet {
let root = try PureXML.parseTree(xsl)
let root = try PureXML.parseTree(xsl, limits: .init(allowDoctype: true))
guard let top = stylesheetElement(root) else { throw XSLTError.notAStylesheet }
return compile(top, loader: loader, precedence: 0)
}
@ -150,7 +150,7 @@ extension PureXML.XSLT {
private static func load(_ node: XSLTTree, 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)
let root = try? PureXML.parseTree(text, limits: .init(allowDoctype: true)), let top = stylesheetElement(root)
else {
return nil
}

View File

@ -13,7 +13,7 @@ public extension PureXML.XSLT {
) throws -> String {
let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
let root = try PureXML.parseTree(source, limits: .init(allowDoctype: true))
Whitespace.strip(root, stylesheet: sheet)
let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader)
let result = transformer.run()
@ -137,7 +137,7 @@ public extension PureXML.XSLT {
) throws -> PureXML.Model.Node {
let documentLoader = resolvingLoader(documentLoader, baseURI: baseURI)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
let root = try PureXML.parseTree(source, limits: .init(allowDoctype: true))
Whitespace.strip(root, stylesheet: sheet)
let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader)
let result = transformer.run()

View File

@ -54,7 +54,7 @@ extension PureXML.XSLT {
}
func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool {
matchCache.nodes(matching: pattern, over: root).contains(ObjectIdentifier(node))
matchCache.nodes(matching: pattern, over: root, functions: patternFunctions()).contains(ObjectIdentifier(node))
}
/// Pattern membership for any XPath node kind.
@ -63,13 +63,25 @@ extension PureXML.XSLT {
case let .tree(tree):
matches(tree, pattern)
case let .attribute(owner, attribute):
matchCache.attributes(matching: pattern, over: root)
matchCache.attributes(matching: pattern, over: root, functions: patternFunctions())
.contains(PureXML.XSLT.AttributeIdentity(owner: ObjectIdentifier(owner), name: attribute.name.description))
case .namespace:
false
}
}
/// The function table match patterns evaluate with (`key()`/`id()`
/// patterns), rooted at the document.
private func patternFunctions() -> PureXML.XPath.FunctionTable {
PureXML.XSLT.Library.table(
current: .tree(root),
keys: keyIndex,
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
)
}
fileprivate func applyTemplates(
to nodes: [PureXML.XPath.Node],
mode: String?,

View File

@ -42,7 +42,6 @@ conflictres/conflictres30
conflictres/conflictres34
conflictres/conflictres37
copy/copy09
copy/copy16
copy/copy19
copy/copy20
copy/copy21
@ -62,49 +61,25 @@ expression/expression02
extend/extend01
extend/extend02
extend/extend04
idkey/idkey01
idkey/idkey03
idkey/idkey04
idkey/idkey09
idkey/idkey12
idkey/idkey18
idkey/idkey21
idkey/idkey22
idkey/idkey23
idkey/idkey24
idkey/idkey26
idkey/idkey30
idkey/idkey31
idkey/idkey32
idkey/idkey33
idkey/idkey35
idkey/idkey37
idkey/idkey38
idkey/idkey39
idkey/idkey40
idkey/idkey41
idkey/idkey42
idkey/idkey43
idkey/idkey44
idkey/idkey45
idkey/idkey46
idkey/idkey47
idkey/idkey48
idkey/idkey50
idkey/idkey53
idkey/idkey55
idkey/idkey56
idkey/idkey57
idkey/idkey58
idkey/idkey59
idkey/idkey60
idkey/idkey61
idkey/idkey62
idkey/idkey63
impincl/impincl07
impincl/impincl08
impincl/impincl11
impincl/impincl17
impincl/impincl19
impincl/impincl21
impincl/impincl23
@ -112,7 +87,6 @@ impincl/impincl25
impincl/impincl26
impincl/impincl27
lre/lre12
match/match11
match/match14
mdocs/mdocs01
mdocs/mdocs05
@ -188,8 +162,6 @@ node/node13
node/node15
numbering/numbering63
numbering/numbering87
numbering/numbering90
numbering/numbering91
numbering/numbering92
numbering/numbering93
output/output13
@ -249,7 +221,6 @@ select/select28
select/select59
select/select67
select/select68
select/select73
select/select75
sort/sort-alphabet-english
sort/sort-alphabet-polish