diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb1bd7..3bd82d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index bd7bff1..032a3e4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/XSLT/XSLTDocumentCache.swift b/Sources/XSLT/XSLTDocumentCache.swift index e4d8cf1..7e0516f 100644 --- a/Sources/XSLT/XSLTDocumentCache.swift +++ b/Sources/XSLT/XSLTDocumentCache.swift @@ -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]] = [:] + } + + /// 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] { + var declared: [String: Set] = [:] + 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 } } diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 0f6842e..97c62b6 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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( diff --git a/Sources/XSLT/XSLTLookups.swift b/Sources/XSLT/XSLTLookups.swift new file mode 100644 index 0000000..744ca55 --- /dev/null +++ b/Sources/XSLT/XSLTLookups.swift @@ -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], + tokens: Set, + 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)) + } +} diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index f07a165..0b03172 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -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) + } } diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 3e34224..71d43dd 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -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) diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index c32b45f..053c9ba 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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] = [:], + ) { 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) diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index d250b1c..01be679 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -31,8 +31,6 @@ expression/expression02 extend/extend01 extend/extend02 extend/extend04 -idkey/idkey09 -idkey/idkey30 idkey/idkey31 idkey/idkey43 idkey/idkey45 diff --git a/Tests/XSLTCompositionTests.swift b/Tests/XSLTCompositionTests.swift index 8667b0a..c58cc75 100644 --- a/Tests/XSLTCompositionTests.swift +++ b/Tests/XSLTCompositionTests.swift @@ -154,4 +154,34 @@ struct XSLTCompositionTests { #expect(try PureXML.XSLT.transform(stylesheet: style, source: source) == "") } + + @Test("id() resolves only DTD-declared ID attributes; no DTD means no IDs") + func test_idRequiresDTD() throws { + let style = """ + + + + + """ + let withDTD = """ + ]> + noyes + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: withDTD) == "yes") + let withoutDTD = "yes" + #expect(try PureXML.XSLT.transform(stylesheet: style, source: withoutDTD) == "") + } + + @Test("generate-id() is non-empty and distinct for attribute and namespace nodes") + func test_generateIDNodeKinds() throws { + let style = """ + + + + distinct + + + """ + #expect(try PureXML.XSLT.transform(stylesheet: style, source: "") == "distinct") + } }