feat(xslt): DTD-typed id() and node-kind generate-id()

The XSLT layer's id() follows the XPath definition exactly: only
attributes the source's DTD declares as type ID identify elements
(internal or external subset, via the transform's resolver), and a
document without ID declarations yields the empty set. generate-id()
produces non-empty, distinct identifiers for attribute and namespace
nodes through their identity hash. Lookup implementations move to
XSLTLookups.swift for the file caps. Xalan baseline 131 -> 129.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 17:03:39 +02:00
parent 06a7a9fcbd
commit 62db18f5f6
10 changed files with 178 additions and 65 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- DTD-typed id() and node-kind generate-id(), seventeenth xalan burn-down (#130): the XSLT layer's id() now follows the XPath definition exactly, resolving only attributes the source document's DTD declares as type ID (internal or external subset, parsed through the transform's entity resolver) and yielding the empty set without a DTD, replacing the name-based heuristic for transforms; and generate-id() produces non-empty, distinct identifiers for attribute and namespace nodes through their identity hash instead of returning the empty string. Two unit tests pin both. Baseline 131 to 129.
- HTML 4 serialization corners, sixteenth xalan burn-down (#130): `basefont` and `isindex` join the void-element set (no end tags), and the html method leaves an ampersand unescaped when a brace follows, the historical `&{...}` attribute convention of 16.2. Baseline 134 to 131 (output 20 to 17).
- Globals, prefix wildcards, fifteenth xalan burn-down (#130): a top-level variable now evaluates with every previously evaluated global in scope, so a variable whose body references an earlier top-level param (directly or via call-template) resolves; same-name globals resolve by import precedence (folds happen in precedence order and the last same-name entry wins, so a later sibling import beats an earlier one and the importing sheet beats both); the stylesheet fold uniformly favors later contributions for attribute-set definitions, decimal formats, namespace aliases, and output settings, matching the post-order precedence model; and the XPath compiler accepts the `NCName:*` name-test form, matching any name in the prefix's namespace (bound by URI, falling back to the in-document prefix string) at its -0.25 default priority, so `@ped:*` outranks `@*`. Three unit tests pin the behaviors. Baseline 144 to 134 (variable 10 to 5, conflictres 8 to 5).
- 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).

View File

@ -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 **975 tests in 167 suites** (`swift test`).
currently runs **977 tests in 167 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -29,5 +29,22 @@ extension PureXML.XSLT {
final class DocumentCache {
var trees: [String: PureXML.Model.TreeNode] = [:]
var sources: [String: PureXML.Model.Node] = [:]
/// DTD-declared ID attributes per document root identity: element
/// name to the names of its ID-typed attributes. A document with no
/// entry has no IDs (the XPath id() definition needs the DTD).
var idAttributes: [ObjectIdentifier: [String: Set<String>]] = [:]
}
/// The ID-typed attributes a document type declares, element name to
/// attribute names (XPath id() resolves through these).
static func declaredIDAttributes(_ documentType: PureXML.Parsing.DocumentType) -> [String: Set<String>] {
var declared: [String: Set<String>] = [:]
for (element, body) in documentType.attributeLists {
let names = PureXML.Validation.AttributeListParser.parse(body)
.filter { $0.type == .id }
.map(\.name)
if !names.isEmpty { declared[element, default: []].formUnion(names) }
}
return declared
}
}

View File

@ -218,12 +218,17 @@ extension PureXML.XSLT {
}
return .nodeSet(references.flatMap { documentReference($0, loader, documents) })
}
.adding("id") { arguments, context in
idLookup(arguments, context, documents)
}
.adding("generate-id") { arguments, context in
// No argument uses the context node; an explicit empty node-set
// is the empty string, per the XSLT definition.
let node = arguments.isEmpty ? context.node.treeNode : arguments.first?.nodes?.first?.treeNode
// is the empty string, per the XSLT definition. Attribute and
// namespace nodes get IDs through their identity hash (owner
// plus name), distinct from every tree node's.
let node: PureXML.XPath.Node? = arguments.isEmpty ? context.node : arguments.first?.nodes?.first
guard let node else { return .string("") }
return .string("N\(UInt(bitPattern: ObjectIdentifier(node).hashValue))")
return .string("N\(UInt(bitPattern: node.hashValue))")
}
.adding("system-property") { arguments, _ in systemProperty(arguments.first?.string ?? "") }
.adding("element-available") { arguments, _ in .boolean(instructionNames.contains(localPart(arguments.first?.string ?? ""))) }
@ -231,39 +236,6 @@ extension PureXML.XSLT {
.adding("unparsed-entity-uri") { _, _ in .string("") }
}
/// The `key()` implementation: the index belongs to the current
/// node's document; a node-set second argument unions the matches for
/// each member's string value; results come back in document order.
private static func keyLookup(
_ arguments: [PureXML.XPath.Value],
_ context: PureXML.XPath.EvaluationContext,
_ keys: (PureXML.Model.TreeNode) -> KeyIndex,
) -> PureXML.XPath.Value {
let name = arguments.first?.string ?? ""
guard arguments.count > 1 else { return .nodeSet([]) }
let values: [String] = if let nodes = arguments[1].nodes {
nodes.map(\.stringValue)
} else {
[arguments[1].string]
}
let owner: PureXML.Model.TreeNode? = switch context.node {
case let .tree(tree): tree
case let .attribute(treeOwner, _), let .namespace(treeOwner, _, _): treeOwner
}
guard var documentRoot = owner else { return .nodeSet([]) }
while let parent = documentRoot.parent {
documentRoot = parent
}
let index = keys(documentRoot)
var matched: [PureXML.Model.TreeNode] = []
for value in values {
for node in index[name]?[value] ?? [] where !matched.contains(where: { $0 === node }) {
matched.append(node)
}
}
return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sorted(by: PureXML.XPath.Node.precedes))
}
/// Loads one `document()` reference: the whole document, or, when the
/// reference has a `#fragment`, the nodes its XPointer selects.
private static func documentReference(

View File

@ -0,0 +1,81 @@
extension PureXML.XSLT.Library {
/// The XPath `id()` definition exactly: only attributes the DTD
/// declares as type ID identify elements; a document without ID
/// declarations yields the empty set.
static func idLookup(
_ arguments: [PureXML.XPath.Value],
_ context: PureXML.XPath.EvaluationContext,
_ documents: PureXML.XSLT.DocumentCache,
) -> PureXML.XPath.Value {
let strings: [String] = if let nodes = arguments.first?.nodes {
nodes.map(\.stringValue)
} else {
[arguments.first?.string ?? ""]
}
let tokens = Set(strings.flatMap { $0.split(whereSeparator: \.isWhitespace).map(String.init) })
let owner: PureXML.Model.TreeNode? = switch context.node {
case let .tree(tree): tree
case let .attribute(treeOwner, _), let .namespace(treeOwner, _, _): treeOwner
}
guard !tokens.isEmpty, var documentRoot = owner else { return .nodeSet([]) }
while let parent = documentRoot.parent {
documentRoot = parent
}
guard let declared = documents.idAttributes[ObjectIdentifier(documentRoot)], !declared.isEmpty else {
return .nodeSet([])
}
var matched: [PureXML.XPath.Node] = []
collectIdentified(documentRoot, declared: declared, tokens: tokens, into: &matched)
return .nodeSet(matched)
}
private static func collectIdentified(
_ node: PureXML.Model.TreeNode,
declared: [String: Set<String>],
tokens: Set<String>,
into matched: inout [PureXML.XPath.Node],
) {
let idNames = node.name.flatMap { declared[$0.description] } ?? []
let identified = node.kind == .element && !idNames.isEmpty
&& node.attributes.contains { idNames.contains($0.name.description) && tokens.contains($0.value) }
if identified {
matched.append(.tree(node))
}
for child in node.children {
collectIdentified(child, declared: declared, tokens: tokens, into: &matched)
}
}
/// The `key()` implementation: the index belongs to the current
/// node's document; a node-set second argument unions the matches for
/// each member's string value; results come back in document order.
static func keyLookup(
_ arguments: [PureXML.XPath.Value],
_ context: PureXML.XPath.EvaluationContext,
_ keys: (PureXML.Model.TreeNode) -> PureXML.XSLT.KeyIndex,
) -> PureXML.XPath.Value {
let name = arguments.first?.string ?? ""
guard arguments.count > 1 else { return .nodeSet([]) }
let values: [String] = if let nodes = arguments[1].nodes {
nodes.map(\.stringValue)
} else {
[arguments[1].string]
}
let owner: PureXML.Model.TreeNode? = switch context.node {
case let .tree(tree): tree
case let .attribute(treeOwner, _), let .namespace(treeOwner, _, _): treeOwner
}
guard var documentRoot = owner else { return .nodeSet([]) }
while let parent = documentRoot.parent {
documentRoot = parent
}
let index = keys(documentRoot)
var matched: [PureXML.Model.TreeNode] = []
for value in values {
for node in index[name]?[value] ?? [] where !matched.contains(where: { $0 === node }) {
matched.append(node)
}
}
return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sorted(by: PureXML.XPath.Node.precedes))
}
}

View File

@ -109,4 +109,26 @@ extension PureXML.XSLT.Transformer {
}
return []
}
/// Binds the template's parameters (a passed `with-param` wins over the
/// declared default) and instantiates its body.
func instantiateTemplate(
_ template: PureXML.XSLT.Template,
_ context: XSLTContext,
passing parameters: [PureXML.XSLT.Binding],
from caller: XSLTContext,
) -> [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 }) {
context.variables[parameter.name] = variableValue(passed.select, passed.body, caller)
} else {
context.variables[parameter.name] = variableValue(parameter.select, parameter.body, context)
}
}
return instantiate(template.body, context)
}
}

View File

@ -13,9 +13,15 @@ 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, limits: .init(allowDoctype: true), resolver: PureXML.XSLT.loaderResolver(documentLoader))
let parsed = try PureXML.Parsing.Parser().parseWithDocumentType(
source,
limits: .init(allowDoctype: true),
resolver: PureXML.XSLT.loaderResolver(documentLoader),
)
let root = PureXML.Model.TreeNode(parsed.node)
let idAttributes = PureXML.XSLT.declaredIDAttributes(parsed.documentType)
Whitespace.strip(root, stylesheet: sheet)
let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader)
let transformer = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader, idAttributes: idAttributes)
let result = transformer.run()
if let message = transformer.terminationMessage { throw XSLTError.terminated(message) }
let method = sheet.output.method ?? defaultMethod(for: result)

View File

@ -24,10 +24,18 @@ extension PureXML.XSLT {
termination.message
}
init(stylesheet: Stylesheet, root: PureXML.Model.TreeNode, documentLoader: @escaping (String) -> String? = { _ in nil }) {
init(
stylesheet: Stylesheet,
root: PureXML.Model.TreeNode,
documentLoader: @escaping (String) -> String? = { _ in nil },
idAttributes: [String: Set<String>] = [:],
) {
self.stylesheet = stylesheet
self.root = root
self.documentLoader = documentLoader
if !idAttributes.isEmpty {
documentCache.idAttributes[ObjectIdentifier(root)] = idAttributes
}
// One table for all pattern matching: matches() runs per
// (template, node) during template selection, so a fresh table
// per call would be allocated millions of times on large inputs.
@ -140,28 +148,6 @@ extension PureXML.XSLT {
return items
}
/// Binds the template's parameters (a passed `with-param` wins over the
/// declared default) and instantiates its body.
fileprivate func instantiateTemplate(
_ template: Template,
_ context: XSLTContext,
passing parameters: [Binding],
from caller: XSLTContext,
) -> [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 }) {
context.variables[parameter.name] = variableValue(passed.select, passed.body, caller)
} else {
context.variables[parameter.name] = variableValue(parameter.select, parameter.body, context)
}
}
return instantiate(template.body, context)
}
func instantiate(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] {
var items: [ResultItem] = []
var context = context
@ -179,7 +165,7 @@ extension PureXML.XSLT {
return items
}
private func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value {
func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value {
if let select { return value(select, context) ?? .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)

View File

@ -31,8 +31,6 @@ expression/expression02
extend/extend01
extend/extend02
extend/extend04
idkey/idkey09
idkey/idkey30
idkey/idkey31
idkey/idkey43
idkey/idkey45

View File

@ -154,4 +154,34 @@ struct XSLTCompositionTests {
#expect(try PureXML.XSLT.transform(stylesheet: style, source: source)
== "<out><inner xmlns:a=\"urn:a\" xmlns:b=\"urn:b\"/></out>")
}
@Test("id() resolves only DTD-declared ID attributes; no DTD means no IDs")
func test_idRequiresDTD() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:value-of select="id('c')"/></out></xsl:template>
</xsl:stylesheet>
"""
let withDTD = """
<!DOCTYPE doc [<!ELEMENT doc (e*)> <!ELEMENT e (#PCDATA)> <!ATTLIST e id ID #IMPLIED>]>
<doc><e id="b">no</e><e id="c">yes</e></doc>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: withDTD) == "<out>yes</out>")
let withoutDTD = "<doc><e id=\"c\">yes</e></doc>"
#expect(try PureXML.XSLT.transform(stylesheet: style, source: withoutDTD) == "<out></out>")
}
@Test("generate-id() is non-empty and distinct for attribute and namespace nodes")
func test_generateIDNodeKinds() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/">
<xsl:if test="generate-id(//e) != '' and generate-id(//e/@a) != '' and generate-id(//e/@a) != generate-id(//e)">distinct</xsl:if>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try PureXML.XSLT.transform(stylesheet: style, source: "<r><e a=\"1\"/></r>") == "distinct")
}
}