diff --git a/CHANGELOG.md b/CHANGELOG.md
index 751ac3f..49c3fed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-- An XSLT transform with unbounded template recursion no longer overflows the stack; it fails gracefully with `XSLTError.recursionLimitExceeded` instead (#356). A recursive named template whose depth is driven by source data (for example ``) could recurse without bound and crash the process (SIGBUS) on an attacker-controllable input, the way every XSLT processor guards against (libxslt's `xsltMaxDepth`). Template-instantiation nesting is now counted and capped at `maxTemplateDepth` (default 300, exposed as `PureXML.XSLT.defaultMaxTemplateDepth`), a new optional parameter on `PureXML.XSLT.transform`. The default clears the parser's maximum source nesting depth (256) so an identity transform of the deepest permitted source still runs, while staying within an 8 MB main-thread stack with margin; a stylesheet with deeper legitimate recursion can raise the limit (on a correspondingly larger stack). Measurement note: source *nesting* depth was never the exposure here, since the engine only ever sees a source parsed at the default `maxDepth` and an identity transform of a 254-deep source completes normally; the reachable crash was data-driven template recursion. The full XSLT/XPath conformance suite is unchanged (no corpus transform recurses past the default). Making the engine fully iterative (so deep recursion succeeds rather than failing gracefully, and is safe on a small Swift-concurrency Task stack) remains tracked in #356.
+- The XSLT evaluator is now iterative, so template recursion and result-tree nesting no longer consume the native stack (#356). A recursive named template whose depth is driven by source data (for example ``) could previously recurse without bound and crash the process (SIGBUS) on attacker-controllable input; and even an identity transform of a deeply-nested source overflowed the stack on a small (Swift-concurrency Task) stack. The mutually-recursive `instantiate`/`evaluate`/`applyTemplates` engine is replaced by an explicit work-stack driver (`XSLTDriver`): each instruction appends its produced items to a sink or pushes sub-work, with the rest of the body pushed first so document order is preserved; template application, the built-in rule's source descent, and result-element nesting all run on the heap. Only stylesheet-bounded helper evaluation (a variable's or parameter's result-tree fragment, an attribute set, an `xsl:message` body) re-enters through a nested driver call, whose native depth is bounded by the stylesheet's own nesting. The result-tree serialization walks the engine now exposes at depth (the namespace fixup, the `text` and string-value extractions, and the content-type-meta and CDATA-section rewrites) were made iterative as well. Deep recursion now *succeeds* on any stack (a 5000-level recursive template builds and serializes on the test Task stack); `maxTemplateDepth` is retained as runaway protection (an unbounded result tree would otherwise exhaust memory), now defaulting to 40000 and tripping `XSLTError.recursionLimitExceeded` only past that. Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance corpus and the in-repo XSLT/XPath suites pass identically (one previously-failing Xalan case, `mdocs07`, now passes and was de-baselined). `maxTemplateDepth` and `PureXML.XSLT.defaultMaxTemplateDepth` (added for the earlier stack-based guard) keep their public shape.
- Serializing a tree as HTML (`PureXML.HTML.serialize`, the html output method's serializer) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested tree (as shallow as a couple of thousand deep on a Swift Task stack). The serializer now drives a deferred-close work stack (the same shape as the XML serializer and `Canonicalizer`): an element pushes a close step then its children reversed, and a raw-text element (`script`, `style`) flags its direct text children to emit verbatim. The output is byte-identical: the void-element, raw-text, comment, and escaping semantics are unchanged, with the HTML conformance corpus passing. A 50000-deep tree now serializes without crashing. (#350)
diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift
index b79abbf..1cf229d 100644
--- a/Sources/XSLT/XSLTElements.swift
+++ b/Sources/XSLT/XSLTElements.swift
@@ -70,82 +70,6 @@ extension PureXML.XSLT.Transformer {
}
return name
}
-
- func elementInstruction(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] {
- guard case let .element(nameTemplate, namespaceTemplate, namespaces, useAttributeSets, body) = instruction else {
- return []
- }
- let raw = avt(nameTemplate, context)
- let parts = raw.split(separator: ":", omittingEmptySubsequences: false)
- let hasExplicitNamespace = (namespaceTemplate.map { !avt($0, context).isEmpty }) ?? false
- let prefix = parts.count == 2 ? String(parts[0]) : nil
- // A prefix not bound to a namespace (and no explicit namespace attribute
- // supplies one) makes the QName unusable, as does a non-NCName part.
- let undeclaredPrefix = !hasExplicitNamespace && (prefix.map { $0 != "xml" && namespaces[$0] == nil } ?? false)
- let unusableName = raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2
- || !parts.allSatisfy { PureXML.Parsing.XMLCharacter.isValidName(String($0)) } || undeclaredPrefix
- if unusableName {
- // The recovery (7.1.2, element-name-not-QName) emits the content
- // without the wrapper element.
- return instantiate(body, context).filter { if case .attribute = $0 { false } else { true } }
- }
- let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: false)
- return [buildElement(name: name, literalAttributes: [], useAttributeSets: useAttributeSets, body: body, context)]
- }
-
- func attributeInstruction(
- _ nameTemplate: PureXML.XSLT.ValueTemplate,
- _ namespaceTemplate: PureXML.XSLT.ValueTemplate?,
- _ namespaces: [String: String],
- _ body: [PureXML.XSLT.Instruction],
- _ context: XSLTContext,
- ) -> [PureXML.XSLT.ResultItem] {
- let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true)
- // xsl:attribute content that creates a non-text node ignores it with its
- // content (XSLT 1.0 errata E27), like xsl:comment/processing-instruction.
- return [.attribute(.init(name: name, value: escapedTextValue(of: instantiate(body, context))))]
- }
-
- func copyInstruction(_ useAttributeSets: [String], _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] {
- // A non-tree current node copies itself: an attribute node yields an
- // attribute result, a namespace node its declaration.
- if let current = context.current {
- switch current {
- case let .attribute(_, attribute):
- return [.attribute(attribute)]
- case let .namespace(_, prefix, uri):
- return [.attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:" + prefix, uri))]
- case .tree:
- break
- }
- }
- switch context.node.kind {
- case .element:
- let copied = buildElement(
- name: context.node.name ?? .init(""),
- literalAttributes: Self.namespaceDeclarations(inScopeAt: context.node),
- useAttributeSets: useAttributeSets,
- body: body,
- context,
- )
- return [copied]
- case .text, .cdata:
- return [.node(.text(context.node.value))]
- case .comment:
- return [.node(.comment(context.node.value))]
- case .processingInstruction:
- return [.node(.processingInstruction(target: context.node.name?.description ?? "", data: context.node.value))]
- case .document:
- // Copying the root node produces no element of its own, but xsl:copy
- // may carry use-attribute-sets (7.5); those attributes have no copied
- // element to attach to, so they join the enclosing result element,
- // ahead of the copied content.
- let setAttributes = attributeSetAttributes(useAttributeSets, context, visiting: []).map(PureXML.XSLT.ResultItem.attribute)
- return setAttributes + instantiate(body, context)
- default:
- return instantiate(body, context)
- }
- }
}
extension PureXML.XSLT.Transformer {
@@ -256,30 +180,3 @@ extension PureXML.XSLT.Transformer {
return XSLTNumbering.format(numbers, format, grouping)
}
}
-
-extension PureXML.XSLT.Transformer {
- /// A literal result element: aliased name and attributes, the copied
- /// 7.1.1 namespace declarations, then the shared element builder.
- func literalResult(_ instruction: PureXML.XSLT.Instruction, _ context: PureXML.XSLT.XSLTContext) -> PureXML.XSLT.ResultItem {
- guard case let .literalElement(name, attributes, namespaces, useAttributeSets, body) = instruction else {
- return .node(.text(""))
- }
- let aliasedAttributes = attributes.map { PureXML.XSLT.LiteralAttribute(name: aliased($0.name), value: $0.value) }
- // The copied namespace nodes (7.1.1) travel as xmlns attributes; the
- // fixup pass reuses them and drops the ones already in scope. An
- // aliased stylesheet namespace declares its result namespace instead.
- var declarations: [PureXML.XSLT.LiteralAttribute] = []
- for (prefix, uri) in namespaces.sorted(by: { $0.key < $1.key }) {
- let alias = stylesheet.namespaceAliases[uri]
- // Keep the literal prefix; an alias only remaps its namespace URI.
- let resolvedPrefix = prefix.isEmpty ? nil : prefix
- let resolvedURI = alias?.uri ?? uri
- let attributeName = resolvedPrefix.map { "xmlns:" + $0 } ?? "xmlns"
- declarations.append(PureXML.XSLT.LiteralAttribute(
- name: PureXML.Model.QualifiedName(attributeName),
- value: [.literal(resolvedURI)],
- ))
- }
- return buildElement(name: aliased(name), literalAttributes: declarations + aliasedAttributes, useAttributeSets: useAttributeSets, body: body, context)
- }
-}
diff --git a/Sources/XSLT/XSLTInstantiation.swift b/Sources/XSLT/XSLTInstantiation.swift
new file mode 100644
index 0000000..25af4ee
--- /dev/null
+++ b/Sources/XSLT/XSLTInstantiation.swift
@@ -0,0 +1,130 @@
+// The XSLT sequence-constructor evaluator, driven by an explicit work stack so
+// template recursion and result-tree nesting do not consume the native stack.
+// A recursive named template, an identity transform of a deeply-nested source,
+// or any deep result tree is evaluated iteratively here; only stylesheet-bounded
+// helper evaluation (a variable's or parameter's result-tree fragment, an
+// attribute set, an xsl:message body) re-enters through a nested driver call,
+// whose depth is bounded by the stylesheet's own nesting.
+//
+// A work item's `depth` is the logical template-application nesting (one per
+// apply-templates / call-template / built-in-rule descent), not a native frame
+// count. `maxTemplateDepth` caps it as runaway protection (an unbounded result
+// tree would otherwise exhaust memory), failing gracefully via `recursionGuard`.
+
+extension PureXML.XSLT.Transformer {
+ /// Instantiates a sequence constructor, iteratively.
+ func instantiate(_ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] {
+ XSLTDriver(self).run(body, context)
+ }
+
+ /// Applies templates to `nodes` (already selected and sorted), iteratively.
+ func applyTemplates(
+ to nodes: [PureXML.XPath.Node],
+ mode: String?,
+ parameters: [PureXML.XSLT.Binding],
+ _ context: XSLTContext,
+ ) -> [ResultItem] {
+ XSLTDriver(self).applyTemplates(nodes, mode: mode, parameters: parameters, context)
+ }
+}
+
+/// The iterative evaluator. One per top-level `instantiate`/`applyTemplates`
+/// call; nested helper evaluation makes its own. Holds the work stack so the step
+/// methods need not thread it as a parameter.
+final class XSLTDriver {
+ typealias Host = PureXML.XSLT.Transformer
+ typealias Instruction = PureXML.XSLT.Instruction
+ typealias Binding = PureXML.XSLT.Binding
+
+ /// A mutable, ordered bag of produced items a branch's work appends into; a
+ /// deferred `finish` step assembles the parent node from it.
+ final class Accumulator {
+ var items: [ResultItem] = []
+ }
+
+ /// Where produced items go and at what logical recursion depth.
+ struct Sink {
+ let depth: Int
+ let into: Accumulator
+ }
+
+ /// The shared context of one template application across its node-set: the
+ /// mode, the parameters passed, the caller's context, and the sink.
+ struct Application {
+ let mode: String?
+ let parameters: [Binding]
+ let caller: XSLTContext
+ let sink: Sink
+ }
+
+ /// How a completed child accumulator is turned into a node appended to a parent.
+ enum Finish {
+ /// Partition the child items into attributes (before the first node) and
+ /// children, then wrap them in an element with the precomputed name and
+ /// base attributes (attribute sets plus literal attributes).
+ case element(PureXML.Model.QualifiedName, [PureXML.Model.Attribute])
+ /// The `xsl:element` recovery for an unusable name: emit the content with
+ /// no wrapper, dropping attribute items (XSLT 1.0 7.1.2).
+ case filteredElement
+ case comment
+ case processingInstruction(String)
+ case attribute(PureXML.Model.QualifiedName)
+ }
+
+ /// One unit of evaluation work.
+ enum Work {
+ /// Run `body` from `cursor` in `context`, appending to the sink.
+ case run([Instruction], Int, XSLTContext, Sink)
+ /// Apply templates to already-selected, already-sorted `nodes`.
+ case applyTemplates([PureXML.XPath.Node], Application)
+ /// Apply templates to one node (its position and size fixed). Each node is
+ /// its own deferred work item so a leaf node's text and a nested node's
+ /// template output land in the sink in document order.
+ case applyOne(PureXML.XPath.Node, Int, Int, Application)
+ /// Assemble a node from a finished child accumulator into a parent.
+ case finish(Finish, Accumulator, Accumulator)
+ }
+
+ let transformer: Host
+ var stack: [Work] = []
+
+ init(_ transformer: Host) {
+ self.transformer = transformer
+ }
+
+ func run(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] {
+ let root = Accumulator()
+ stack = [.run(body, 0, context, Sink(depth: 0, into: root))]
+ drive()
+ return root.items
+ }
+
+ func applyTemplates(
+ _ nodes: [PureXML.XPath.Node],
+ mode: String?,
+ parameters: [Binding],
+ _ context: XSLTContext,
+ ) -> [ResultItem] {
+ let root = Accumulator()
+ let application = Application(mode: mode, parameters: parameters, caller: context, sink: Sink(depth: 0, into: root))
+ stack = [.applyTemplates(nodes, application)]
+ drive()
+ return root.items
+ }
+
+ private func drive() {
+ while let work = stack.popLast() {
+ if transformer.termination.message != nil || transformer.recursionGuard.exceeded { continue }
+ switch work {
+ case let .run(body, cursor, context, sink):
+ stepRun(body, cursor, context, sink)
+ case let .applyTemplates(nodes, application):
+ stepApplyTemplates(nodes, application)
+ case let .applyOne(xnode, position, size, application):
+ stepApplyOne(xnode, position, size, application)
+ case let .finish(kind, child, into):
+ stepFinish(kind, child, into)
+ }
+ }
+ }
+}
diff --git a/Sources/XSLT/XSLTInstantiationElements.swift b/Sources/XSLT/XSLTInstantiationElements.swift
new file mode 100644
index 0000000..082d801
--- /dev/null
+++ b/Sources/XSLT/XSLTInstantiationElements.swift
@@ -0,0 +1,221 @@
+// Element construction, template application, and node assembly for the iterative
+// XSLT evaluator (see XSLTInstantiation.swift).
+
+extension XSLTDriver {
+ // MARK: Element construction
+
+ /// The attributes an element carries before its body content: attribute sets
+ /// (lowest precedence) then literal attributes.
+ private func elementBaseAttributes(
+ _ literalAttributes: [PureXML.XSLT.LiteralAttribute],
+ _ useAttributeSets: [String],
+ _ context: XSLTContext,
+ ) -> [PureXML.Model.Attribute] {
+ var attributes = transformer.attributeSetAttributes(useAttributeSets, context, visiting: [])
+ attributes += literalAttributes.map { PureXML.Model.Attribute(name: $0.name, value: transformer.avt($0.value, context)) }
+ return attributes
+ }
+
+ /// Pushes a finished element and its body, so the body fills a child
+ /// accumulator that the finish step then wraps.
+ private func pushElementBody(_ finish: Finish, _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) {
+ let child = Accumulator()
+ stack.append(.finish(finish, child, sink.into))
+ stack.append(.run(body, 0, context, Sink(depth: sink.depth, into: child)))
+ }
+
+ func pushLiteralElement(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) {
+ guard case let .literalElement(name, attributes, namespaces, useAttributeSets, body) = instruction else { return }
+ let aliasedAttributes = attributes.map { PureXML.XSLT.LiteralAttribute(name: transformer.aliased($0.name), value: $0.value) }
+ // The copied namespace nodes (7.1.1) travel as xmlns attributes; an
+ // aliased stylesheet namespace declares its result namespace instead.
+ var declarations: [PureXML.XSLT.LiteralAttribute] = []
+ for (prefix, uri) in namespaces.sorted(by: { $0.key < $1.key }) {
+ let alias = transformer.stylesheet.namespaceAliases[uri]
+ let resolvedPrefix = prefix.isEmpty ? nil : prefix
+ let resolvedURI = alias?.uri ?? uri
+ let attributeName = resolvedPrefix.map { "xmlns:" + $0 } ?? "xmlns"
+ declarations.append(PureXML.XSLT.LiteralAttribute(name: PureXML.Model.QualifiedName(attributeName), value: [.literal(resolvedURI)]))
+ }
+ let base = elementBaseAttributes(declarations + aliasedAttributes, useAttributeSets, context)
+ pushElementBody(.element(transformer.aliased(name), base), body, context, sink)
+ }
+
+ func pushElement(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) {
+ guard case let .element(nameTemplate, namespaceTemplate, namespaces, useAttributeSets, body) = instruction else { return }
+ let raw = transformer.avt(nameTemplate, context)
+ let parts = raw.split(separator: ":", omittingEmptySubsequences: false)
+ let hasExplicitNamespace = (namespaceTemplate.map { !transformer.avt($0, context).isEmpty }) ?? false
+ let prefix = parts.count == 2 ? String(parts[0]) : nil
+ let undeclaredPrefix = !hasExplicitNamespace && (prefix.map { $0 != "xml" && namespaces[$0] == nil } ?? false)
+ let unusableName = raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2
+ || !parts.allSatisfy { PureXML.Parsing.XMLCharacter.isValidName(String($0)) } || undeclaredPrefix
+ if unusableName {
+ // The recovery emits the content without the wrapper element.
+ pushElementBody(.filteredElement, body, context, sink)
+ return
+ }
+ let name = transformer.createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: false)
+ let base = elementBaseAttributes([], useAttributeSets, context)
+ pushElementBody(.element(name, base), body, context, sink)
+ }
+
+ func pushCopy(_ useAttributeSets: [String], _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) {
+ // A non-tree current node copies itself.
+ if let current = context.current {
+ switch current {
+ case let .attribute(_, attribute):
+ sink.into.items.append(.attribute(attribute))
+ return
+ case let .namespace(_, prefix, uri):
+ sink.into.items.append(.attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:" + prefix, uri)))
+ return
+ case .tree:
+ break
+ }
+ }
+ switch context.node.kind {
+ case .element:
+ let base = elementBaseAttributes(Host.namespaceDeclarations(inScopeAt: context.node), useAttributeSets, context)
+ pushElementBody(.element(context.node.name ?? .init(""), base), body, context, sink)
+ case .text, .cdata:
+ sink.into.items.append(.node(.text(context.node.value)))
+ case .comment:
+ sink.into.items.append(.node(.comment(context.node.value)))
+ case .processingInstruction:
+ sink.into.items.append(.node(.processingInstruction(target: context.node.name?.description ?? "", data: context.node.value)))
+ case .document:
+ // The root copies to no element; use-attribute-sets join the enclosing
+ // result element ahead of the copied content (7.5).
+ let setAttributes = transformer.attributeSetAttributes(useAttributeSets, context, visiting: []).map(ResultItem.attribute)
+ sink.into.items.append(contentsOf: setAttributes)
+ stack.append(.run(body, 0, context, sink))
+ default:
+ stack.append(.run(body, 0, context, sink))
+ }
+ }
+
+ // MARK: Template application
+
+ func stepApplyTemplates(_ nodes: [PureXML.XPath.Node], _ application: Application) {
+ // Reversed so the per-node work pops in document order; each node's own
+ // sub-work then completes before the next node's pops.
+ let size = nodes.count
+ for (offset, xnode) in nodes.enumerated().reversed() {
+ stack.append(.applyOne(xnode, offset + 1, size, application))
+ }
+ }
+
+ func stepApplyOne(_ xnode: PureXML.XPath.Node, _ position: Int, _ size: Int, _ application: Application) {
+ guard let owner = Host.ownerNode(xnode) else { return }
+ let nodeContext = XSLTContext(
+ node: owner,
+ current: xnode.treeNode == nil ? xnode : nil,
+ position: position,
+ size: size,
+ variables: application.caller.variables,
+ mode: application.mode,
+ )
+ if let template = transformer.bestTemplate(for: xnode, mode: application.mode) {
+ pushTemplateBody(template, nodeContext, application.parameters, application.caller, application.sink)
+ } else {
+ pushBuiltInRule(xnode, application.mode, nodeContext, application.sink)
+ }
+ }
+
+ /// Binds a template's parameters and schedules its body (one deeper).
+ func pushTemplateBody(
+ _ template: PureXML.XSLT.Template,
+ _ context: XSLTContext,
+ _ parameters: [Binding],
+ _ caller: XSLTContext,
+ _ sink: Sink,
+ ) {
+ let depth = sink.depth + 1
+ guard depth <= transformer.maxTemplateDepth else {
+ transformer.recursionGuard.exceeded = true
+ return
+ }
+ var context = context
+ 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] = transformer.variableValue(passed.select, passed.body, caller)
+ } else {
+ context.variables[parameter.name] = transformer.variableValue(parameter.select, parameter.body, context)
+ }
+ }
+ stack.append(.run(template.body, 0, context, Sink(depth: depth, into: sink.into)))
+ }
+
+ func pushBuiltInRule(_ xnode: PureXML.XPath.Node, _ mode: String?, _ context: XSLTContext, _ sink: Sink) {
+ let depth = sink.depth + 1
+ guard depth <= transformer.maxTemplateDepth else {
+ transformer.recursionGuard.exceeded = true
+ return
+ }
+ switch xnode {
+ case let .tree(node):
+ switch node.kind {
+ case .element, .document:
+ let application = Application(mode: mode, parameters: [], caller: context, sink: Sink(depth: depth, into: sink.into))
+ stack.append(.applyTemplates(node.children.map { .tree($0) }, application))
+ case .text, .cdata:
+ sink.into.items.append(.node(.text(node.value)))
+ default:
+ break
+ }
+ case let .attribute(_, attribute):
+ sink.into.items.append(.node(.text(attribute.value)))
+ case let .namespace(_, _, uri):
+ sink.into.items.append(.node(.text(uri)))
+ }
+ }
+
+ func pushCallTemplate(_ name: String, _ parameters: [Binding], _ context: XSLTContext, _ sink: Sink) {
+ // XSLT 1.0 section 6: the highest import-precedence definition wins.
+ guard let template = transformer.stylesheet.templates.filter({ $0.name == name })
+ .max(by: { $0.importPrecedence < $1.importPrecedence }) else { return }
+ pushTemplateBody(template, context, parameters, context, sink)
+ }
+
+ func pushApplyImports(_ context: XSLTContext, _ sink: Sink) {
+ if let template = transformer.bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence, atLeast: context.importRangeLow) {
+ pushTemplateBody(template, context, [], context, sink)
+ } else {
+ pushBuiltInRule(context.focus, context.mode, context, sink)
+ }
+ }
+
+ // MARK: Finishing a node
+
+ func stepFinish(_ kind: Finish, _ child: Accumulator, _ into: Accumulator) {
+ switch kind {
+ case let .element(name, baseAttributes):
+ var attributes = baseAttributes
+ var children: [PureXML.Model.Node] = []
+ for item in child.items {
+ switch item {
+ case let .attribute(attribute):
+ // Ignored once content has been added (the recovery for an
+ // attribute created after children).
+ if children.isEmpty { attributes.append(attribute) }
+ case let .node(node):
+ children.append(node)
+ }
+ }
+ into.items.append(.node(.element(.init(name: name, attributes: Host.deduplicated(attributes), children: children))))
+ case .filteredElement:
+ into.items.append(contentsOf: child.items.filter { if case .attribute = $0 { false } else { true } })
+ case .comment:
+ into.items.append(.node(.comment(transformer.escapedTextValue(of: child.items))))
+ case let .processingInstruction(target):
+ into.items.append(.node(.processingInstruction(target: target, data: transformer.escapedTextValue(of: child.items))))
+ case let .attribute(name):
+ into.items.append(.attribute(.init(name: name, value: transformer.escapedTextValue(of: child.items))))
+ }
+ }
+}
diff --git a/Sources/XSLT/XSLTInstantiationSteps.swift b/Sources/XSLT/XSLTInstantiationSteps.swift
new file mode 100644
index 0000000..daa60ba
--- /dev/null
+++ b/Sources/XSLT/XSLTInstantiationSteps.swift
@@ -0,0 +1,131 @@
+// The step methods of the iterative XSLT evaluator (see XSLTInstantiation.swift).
+// Each instruction either appends its produced items to the sink synchronously or
+// pushes sub-work; the rest of the body is always pushed first so it pops last,
+// keeping result-tree document order.
+
+extension XSLTDriver {
+ func stepRun(_ body: [Instruction], _ cursor: Int, _ context: XSLTContext, _ sink: Sink) {
+ guard cursor < body.count else { return }
+ let instruction = body[cursor]
+ switch instruction {
+ case let .variable(name, select, variableBody):
+ // A variable binds for the rest of the body; thread the updated context.
+ var context = context
+ context.variables[name] = transformer.variableValue(select, variableBody, context)
+ stack.append(.run(body, cursor + 1, context, sink))
+ return
+ case let .fallback(fallbackBody):
+ stack.append(.run(body, cursor + 1, context, sink))
+ stack.append(.run(fallbackBody, 0, context, sink))
+ return
+ default:
+ break
+ }
+ // The remainder of the body runs after this instruction's items.
+ stack.append(.run(body, cursor + 1, context, sink))
+ if runSimple(instruction, context, sink) { return }
+ if runControl(instruction, context, sink) { return }
+ if runTemplate(instruction, context, sink) { return }
+ runConstruct(instruction, context, sink)
+ }
+
+ private func runSimple(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool {
+ switch instruction {
+ case let .literalText(text):
+ sink.into.items.append(.node(.text(text)))
+ case let .valueOf(select, raw):
+ let value = transformer.string(select, context)
+ sink.into.items.append(.node(.text(raw ? PureXML.XSLT.RawText.marked(value) : value)))
+ case .number:
+ sink.into.items.append(.node(.text(transformer.numberInstruction(instruction, context))))
+ case let .copyOf(select):
+ sink.into.items.append(contentsOf: transformer.copyOf(select, context))
+ case let .message(terminate, messageBody):
+ if terminate, transformer.termination.message == nil {
+ transformer.termination.message = Host.text(of: transformer.instantiate(messageBody, context))
+ }
+ default:
+ return false
+ }
+ return true
+ }
+
+ private func runControl(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool {
+ switch instruction {
+ case let .ifInstruction(test, thenBody):
+ if transformer.boolean(test, context) { stack.append(.run(thenBody, 0, context, sink)) }
+ case let .choose(whens, otherwise):
+ let branch = whens.first { transformer.boolean($0.test, context) }?.body ?? otherwise
+ stack.append(.run(branch, 0, context, sink))
+ case let .forEach(select, sorts, eachBody):
+ pushForEach(select, sorts, eachBody, context, sink)
+ default:
+ return false
+ }
+ return true
+ }
+
+ private func runTemplate(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool {
+ switch instruction {
+ case let .applyTemplates(select, mode, sorts, parameters):
+ let nodes = transformer.sorted(transformer.selectXPathNodes(select ?? "node()", context), sorts, context)
+ stack.append(.applyTemplates(nodes, Application(mode: mode, parameters: parameters, caller: context, sink: sink)))
+ case let .callTemplate(name, parameters):
+ pushCallTemplate(name, parameters, context, sink)
+ case .applyImports:
+ pushApplyImports(context, sink)
+ default:
+ return false
+ }
+ return true
+ }
+
+ private func runConstruct(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) {
+ switch instruction {
+ case .literalElement:
+ pushLiteralElement(instruction, context, sink)
+ case .element:
+ pushElement(instruction, context, sink)
+ case let .attribute(nameTemplate, namespaceTemplate, namespaces, attributeBody):
+ let name = transformer.createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true)
+ pushTextNode(.attribute(name), attributeBody, context, sink)
+ case let .copy(useAttributeSets, copyBody):
+ pushCopy(useAttributeSets, copyBody, context, sink)
+ case let .comment(commentBody):
+ pushTextNode(.comment, commentBody, context, sink)
+ case let .processingInstruction(nameTemplate, piBody):
+ pushTextNode(.processingInstruction(transformer.avt(nameTemplate, context)), piBody, context, sink)
+ default:
+ break
+ }
+ }
+
+ private func pushForEach(
+ _ select: String,
+ _ sorts: [PureXML.XSLT.Sort],
+ _ body: [Instruction],
+ _ context: XSLTContext,
+ _ sink: Sink,
+ ) {
+ let nodes = transformer.sorted(transformer.selectXPathNodes(select, context), sorts, context)
+ // Reversed so the nodes' bodies pop in document order into the sink.
+ for (offset, xnode) in nodes.enumerated().reversed() {
+ guard let owner = Host.ownerNode(xnode) else { continue }
+ var itemContext = XSLTContext(
+ node: owner,
+ current: xnode.treeNode == nil ? xnode : nil,
+ position: offset + 1,
+ size: nodes.count,
+ variables: context.variables,
+ )
+ itemContext.baseURI = context.baseURI
+ stack.append(.run(body, 0, itemContext, sink))
+ }
+ }
+
+ private func pushTextNode(_ kind: Finish, _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) {
+ let child = Accumulator()
+ stack.append(.finish(kind, child, sink.into))
+ stack.append(.run(body, 0, context, Sink(depth: sink.depth, into: child)))
+ }
+}
diff --git a/Sources/XSLT/XSLTNamespaceFixup.swift b/Sources/XSLT/XSLTNamespaceFixup.swift
index 7b2d343..d4816bc 100644
--- a/Sources/XSLT/XSLTNamespaceFixup.swift
+++ b/Sources/XSLT/XSLTNamespaceFixup.swift
@@ -1,11 +1,61 @@
+/// A mutable bag of fixed-up nodes a branch's children gather into, so the
+/// deferred build step assembles the parent once they are done.
+private final class FixupAccumulator {
+ var nodes: [PureXML.Model.Node] = []
+}
+
+/// One unit of namespace-fixup work: visit a source node (with the namespace
+/// bindings in scope, appending its fixed form to `into`), or, after a branch's
+/// children are gathered, build the element or document.
+private enum FixupWork {
+ case visit(PureXML.Model.Node, inScope: [String: String], into: FixupAccumulator)
+ case buildElement(PureXML.Model.QualifiedName, [PureXML.Model.Attribute], FixupAccumulator, FixupAccumulator)
+ case buildDocument(FixupAccumulator, FixupAccumulator)
+}
+
/// Namespace fixup over an XSLT result tree (the serialization step of
/// XSLT 1.0 section 7.1): every element and attribute name that carries a
/// namespace URI gets an in-scope declaration, with `ns0`, `ns1`, ... prefixes
/// generated when the carried prefix is absent or bound to a different URI.
enum XSLTNamespaceFixup {
+ private static let xmlNamespace = "http://www.w3.org/XML/1998/namespace"
+
+ /// Rebuilds the tree with namespace declarations fixed, without recursing on
+ /// its depth: an explicit work stack drives a pre-order visit (so the `ns0`,
+ /// `ns1`, ... counter advances in document order, exactly as the recursive form
+ /// did) that gathers each branch's fixed children into an accumulator a
+ /// deferred build step assembles.
static func apply(_ node: PureXML.Model.Node) -> PureXML.Model.Node {
var counter = 0
- return fix(node, inScope: ["xml": "http://www.w3.org/XML/1998/namespace"], counter: &counter)
+ let root = FixupAccumulator()
+ var stack: [FixupWork] = [.visit(node, inScope: ["xml": xmlNamespace], into: root)]
+ while let work = stack.popLast() {
+ switch work {
+ case let .visit(node, inScope, into):
+ switch node {
+ case let .document(children):
+ let gathered = FixupAccumulator()
+ stack.append(.buildDocument(gathered, into))
+ for child in children.reversed() {
+ stack.append(.visit(child, inScope: inScope, into: gathered))
+ }
+ case let .element(element):
+ let (attributes, childScope) = fixElement(element, inScope: inScope, counter: &counter)
+ let gathered = FixupAccumulator()
+ stack.append(.buildElement(element.name, attributes, gathered, into))
+ for child in element.children.reversed() {
+ stack.append(.visit(child, inScope: childScope, into: gathered))
+ }
+ default:
+ into.nodes.append(node)
+ }
+ case let .buildElement(name, attributes, gathered, into):
+ into.nodes.append(.element(.init(name: name, attributes: attributes, children: gathered.nodes)))
+ case let .buildDocument(gathered, into):
+ into.nodes.append(.document(gathered.nodes))
+ }
+ }
+ return root.nodes.first ?? node
}
/// Declarations the element already carries enter scope first.
@@ -34,17 +84,6 @@ enum XSLTNamespaceFixup {
}
}
- private static func fix(_ node: PureXML.Model.Node, inScope: [String: String], counter: inout Int) -> PureXML.Model.Node {
- switch node {
- case let .document(children):
- .document(children.map { fix($0, inScope: inScope, counter: &counter) })
- case let .element(element):
- .element(fix(element, inScope: inScope, counter: &counter))
- default:
- node
- }
- }
-
/// Drops declarations that repeat an inherited binding: copied 7.1.1
/// namespace nodes travel on every literal element and would otherwise
/// redeclare on each descendant.
@@ -60,7 +99,14 @@ enum XSLTNamespaceFixup {
}
}
- private static func fix(_ element: PureXML.Model.Element, inScope: [String: String], counter: inout Int) -> PureXML.Model.Element {
+ /// Fixes an element's own attributes and computes the namespace scope its
+ /// children inherit (the per-element work of the fixup, without recursing into
+ /// the children). The `ns0`, `ns1`, ... counter advances here, in visit order.
+ private static func fixElement(
+ _ element: PureXML.Model.Element,
+ inScope: [String: String],
+ counter: inout Int,
+ ) -> (attributes: [PureXML.Model.Attribute], scope: [String: String]) {
var scope = inScope
var attributes = withoutRedundantDeclarations(element.attributes, inScope: inScope)
enterDeclarations(attributes, into: &scope)
@@ -79,9 +125,8 @@ enum XSLTNamespaceFixup {
continue // Already bound correctly.
}
if let candidate = prefix, !localPrefixes.contains(candidate), element.name.prefix != candidate {
- // Redeclare the carried prefix locally, shadowing any
- // inherited binding (the source document's own shape). The
- // element's own prefix is never shadowed.
+ // Redeclare the carried prefix locally, shadowing any inherited
+ // binding. The element's own prefix is never shadowed.
localPrefixes.insert(candidate)
} else if prefix == nil || scope[prefix ?? ""] != nil {
// Generate a fresh prefix when absent or taken locally.
@@ -105,7 +150,6 @@ enum XSLTNamespaceFixup {
value: attributes[index].value,
)
}
- let children = element.children.map { fix($0, inScope: scope, counter: &counter) }
- return .init(name: element.name, attributes: attributes, children: children)
+ return (attributes, scope)
}
}
diff --git a/Sources/XSLT/XSLTRuntime.swift b/Sources/XSLT/XSLTRuntime.swift
index 1dfbff4..de0cc0a 100644
--- a/Sources/XSLT/XSLTRuntime.swift
+++ b/Sources/XSLT/XSLTRuntime.swift
@@ -1,11 +1,11 @@
extension PureXML.XSLT {
- /// The default ceiling on template-instantiation nesting (see
- /// ``RecursionGuard``). Chosen to clear the parser's max source depth (256),
- /// so an identity transform of the deepest permitted source still runs, while
- /// staying within an 8 MB stack with margin; unbounded template recursion
- /// (which can overflow the stack) trips it well before that. Configurable per
- /// transform for stylesheets with deeper legitimate recursion.
- public static let defaultMaxTemplateDepth = 300
+ /// The default ceiling on logical template-application nesting (see
+ /// ``RecursionGuard``). The evaluator is iterative, so this is no longer a
+ /// stack limit but a runaway guard: legitimate deep recursion (well into the
+ /// thousands) succeeds, while an infinite or unbounded recursion, whose result
+ /// tree would grow without bound, fails gracefully once it passes this depth.
+ /// Configurable per transform.
+ public static let defaultMaxTemplateDepth = 40000
/// One produced item: a node for the result tree, or an attribute to attach to
/// the enclosing element.
@@ -73,14 +73,15 @@ extension PureXML.XSLT {
var message: String?
}
- /// Shared, mutable guard against unbounded template-instantiation recursion
- /// (a recursive template whose depth is driven by source data would otherwise
- /// overflow the native stack). `depth` tracks the current nesting of template
- /// applications; `exceeded` latches once it passes the configured limit, so the
- /// instantiation loops unwind and the transform fails gracefully. A reference
- /// type so the value-semantics transformer can update it.
+ /// Shared, mutable latch for runaway template recursion. The evaluator is
+ /// iterative, so deep recursion no longer overflows the stack; this instead
+ /// caps the *logical* template-application depth (carried on each work item)
+ /// so an unbounded or infinite recursion, whose result tree would grow without
+ /// bound, fails gracefully rather than exhausting memory. `exceeded` latches
+ /// once the depth passes `maxTemplateDepth`, so the work stack drains and the
+ /// transform throws. A reference type so the value-semantics transformer can
+ /// set it.
final class RecursionGuard {
- var depth = 0
var exceeded = false
}
}
diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift
index 40e3110..2a15f93 100644
--- a/Sources/XSLT/XSLTSorting.swift
+++ b/Sources/XSLT/XSLTSorting.swift
@@ -87,64 +87,6 @@ extension PureXML.XSLT.Transformer {
text.enumerated().compactMap { $1 == " " ? $0 : nil }
}
- func forEach(
- _ select: String,
- _ sorts: [PureXML.XSLT.Sort],
- _ body: [PureXML.XSLT.Instruction],
- _ context: XSLTContext,
- ) -> [ResultItem] {
- let nodes = sorted(selectXPathNodes(select, context), sorts, context)
- var items: [ResultItem] = []
- for (offset, xnode) in nodes.enumerated() {
- guard let owner = Self.ownerNode(xnode) else { continue }
- 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
- }
-
- func chooseInstruction(
- _ whens: [PureXML.XSLT.Branch],
- _ otherwise: [PureXML.XSLT.Instruction],
- _ context: XSLTContext,
- ) -> [ResultItem] {
- for branch in whens where boolean(branch.test, context) {
- return instantiate(branch.body, context)
- }
- return instantiate(otherwise, context)
- }
-
- func builtInRule(_ xnode: PureXML.XPath.Node, mode: String?, _ context: XSLTContext) -> [ResultItem] {
- recursionGuard.depth += 1
- defer { recursionGuard.depth -= 1 }
- guard recursionGuard.depth <= maxTemplateDepth else {
- recursionGuard.exceeded = true
- return []
- }
- return switch xnode {
- case let .tree(node):
- switch node.kind {
- case .element, .document:
- applyTemplates(to: node.children.map { .tree($0) }, mode: mode, parameters: [], context)
- case .text, .cdata:
- [.node(.text(node.value))]
- default:
- []
- }
- case let .attribute(_, attribute):
- [.node(.text(attribute.value))]
- case let .namespace(_, _, uri):
- [.node(.text(uri))]
- }
- }
-
/// Copies the nodes selected by `select` into the result tree (`xsl:copy-of`);
/// a non-node-set result copies as its string value. Whole subtrees are taken
/// as-is, so this does not recurse on their depth.
@@ -162,40 +104,4 @@ extension PureXML.XSLT.Transformer {
}
}
}
-
- func message(_ terminate: Bool, _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] {
- if terminate, termination.message == nil {
- termination.message = Self.text(of: instantiate(body, context))
- }
- 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] {
- recursionGuard.depth += 1
- defer { recursionGuard.depth -= 1 }
- guard recursionGuard.depth <= maxTemplateDepth else {
- recursionGuard.exceeded = true
- return []
- }
- var context = context
- 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)
- } 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 2c6955c..73633b8 100644
--- a/Sources/XSLT/XSLTTransform.swift
+++ b/Sources/XSLT/XSLTTransform.swift
@@ -86,27 +86,31 @@ public extension PureXML.XSLT {
/// prepended to the children of the first `head` element (16.2: the html
/// output method should add a meta element giving the encoding).
private static func withContentTypeMeta(_ node: PureXML.Model.Node, encoding: String) -> PureXML.Model.Node {
- switch node {
- case let .document(children):
- return .document(children.map { withContentTypeMeta($0, encoding: encoding) })
- case let .element(element):
- var children = element.children.map { withContentTypeMeta($0, encoding: encoding) }
- let needsMeta = element.name.localName.lowercased() == "head"
- && !children.contains(where: { isContentTypeMeta($0) })
- if needsMeta {
- let meta = PureXML.Model.Element(
- name: .init("META"),
- attributes: [
- .init("http-equiv", "Content-Type"),
- .init("content", "text/html; charset=\(encoding)"),
- ],
- children: [],
- )
- children.insert(.element(meta), at: 0)
+ // Rebuilt bottom-up through the shared iterative spine so a deeply-nested
+ // result does not overflow the stack.
+ node.rebuildingBottomUp { node, rebuiltChildren in
+ switch node {
+ case .document:
+ return .document(rebuiltChildren)
+ case let .element(element):
+ var children = rebuiltChildren
+ let needsMeta = element.name.localName.lowercased() == "head"
+ && !children.contains(where: { isContentTypeMeta($0) })
+ if needsMeta {
+ let meta = PureXML.Model.Element(
+ name: .init("META"),
+ attributes: [
+ .init("http-equiv", "Content-Type"),
+ .init("content", "text/html; charset=\(encoding)"),
+ ],
+ children: [],
+ )
+ children.insert(.element(meta), at: 0)
+ }
+ return .element(.init(name: element.name, attributes: element.attributes, children: children))
+ default:
+ return node
}
- return .element(.init(name: element.name, attributes: element.attributes, children: children))
- default:
- return node
}
}
@@ -119,18 +123,24 @@ public extension PureXML.XSLT {
/// replaced by CDATA nodes, so the serializer emits them as CDATA sections.
private static func withCDATASections(_ node: PureXML.Model.Node, _ names: Set) -> PureXML.Model.Node {
guard !names.isEmpty else { return node }
- switch node {
- case let .document(children):
- return .document(children.map { withCDATASections($0, names) })
- case let .element(element):
- let wrap = names.contains(element.name.localName) || names.contains(element.name.description)
- let children = element.children.flatMap { child -> [PureXML.Model.Node] in
- if wrap, case let .text(value) = child { return cdataNodes(value) }
- return [withCDATASections(child, names)]
+ // Rebuilt bottom-up through the shared iterative spine; an element named in
+ // `names` has its own text children re-wrapped as CDATA.
+ return node.rebuildingBottomUp { node, rebuiltChildren in
+ switch node {
+ case .document:
+ return .document(rebuiltChildren)
+ case let .element(element):
+ let wrap = names.contains(element.name.localName) || names.contains(element.name.description)
+ let children = wrap
+ ? rebuiltChildren.flatMap { child -> [PureXML.Model.Node] in
+ if case let .text(value) = child { return cdataNodes(value) }
+ return [child]
+ }
+ : rebuiltChildren
+ return .element(.init(name: element.name, attributes: element.attributes, children: children))
+ default:
+ return node
}
- return .element(.init(name: element.name, attributes: element.attributes, children: children))
- default:
- return node
}
}
@@ -244,11 +254,23 @@ public extension PureXML.XSLT {
/// The concatenated text content of a result tree, for the `text` output
/// method: character data only, with markup, comments, and PIs dropped.
private static func textValue(of node: PureXML.Model.Node) -> String {
- switch node {
- case let .text(value), let .cdata(value): value
- case let .document(children): children.map(textValue).joined()
- case let .element(element): element.children.map(textValue).joined()
- case .comment, .processingInstruction: ""
+ // Iterative pre-order walk so a deeply-nested result tree does not overflow
+ // the stack; children push reversed to keep document order. Only character
+ // data contributes; markup, comments, and PIs drop out.
+ var result = ""
+ var stack: [PureXML.Model.Node] = [node]
+ while let current = stack.popLast() {
+ switch current {
+ case let .text(value), let .cdata(value):
+ result += value
+ case let .document(children):
+ stack.append(contentsOf: children.reversed())
+ case let .element(element):
+ stack.append(contentsOf: element.children.reversed())
+ case .comment, .processingInstruction:
+ break
+ }
}
+ return result
}
}
diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift
index fec9e95..99c92d0 100644
--- a/Sources/XSLT/XSLTTransformer.swift
+++ b/Sources/XSLT/XSLTTransformer.swift
@@ -104,7 +104,7 @@ extension PureXML.XSLT {
return .document(applyTemplates(to: [.tree(root)], mode: nil, parameters: [], context).compactMap(Self.nodeOf))
}
- fileprivate func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? {
+ func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? {
stylesheet.templates.enumerated()
.filter { entry in
entry.element.mode == mode && entry.element.importPrecedence < ceiling
@@ -144,50 +144,6 @@ extension PureXML.XSLT {
patternTable
}
- func applyTemplates(
- to nodes: [PureXML.XPath.Node],
- mode: String?,
- parameters: [Binding],
- _ context: XSLTContext,
- ) -> [ResultItem] {
- var items: [ResultItem] = []
- for (offset, xnode) in nodes.enumerated() {
- if termination.message != nil || recursionGuard.exceeded { break }
- guard let owner = Self.ownerNode(xnode) else { continue }
- let nodeContext = XSLTContext(
- node: owner,
- current: xnode.treeNode == nil ? xnode : nil,
- position: offset + 1,
- size: nodes.count,
- variables: context.variables,
- mode: mode,
- )
- if let template = bestTemplate(for: xnode, mode: mode) {
- items += instantiateTemplate(template, nodeContext, passing: parameters, from: context)
- } else {
- items += builtInRule(xnode, mode: mode, nodeContext)
- }
- }
- return items
- }
-
- func instantiate(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] {
- var items: [ResultItem] = []
- var context = context
- for instruction in body {
- if termination.message != nil || recursionGuard.exceeded { break }
- switch instruction {
- case let .variable(name, select, varBody):
- context.variables[name] = variableValue(select, varBody, context)
- case let .fallback(fallbackBody):
- items += instantiate(fallbackBody, context)
- default:
- items += evaluate(instruction, context)
- }
- }
- return items
- }
-
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
@@ -258,119 +214,28 @@ extension PureXML.XSLT {
}
private static func stringValue(_ node: PureXML.Model.Node) -> String {
- switch node {
- case let .text(value), let .cdata(value): value
- case let .element(element): element.children.reduce(into: "") { $0 += stringValue($1) }
- case let .document(children): children.reduce(into: "") { $0 += stringValue($1) }
- default: ""
+ // Iterative pre-order walk so a deeply-nested result tree does not
+ // overflow the stack; only text and CDATA contribute.
+ var result = ""
+ var stack: [PureXML.Model.Node] = [node]
+ while let current = stack.popLast() {
+ switch current {
+ case let .text(value), let .cdata(value):
+ result += value
+ case let .element(element):
+ stack.append(contentsOf: element.children.reversed())
+ case let .document(children):
+ stack.append(contentsOf: children.reversed())
+ default:
+ break
+ }
}
+ return result
}
}
}
extension PureXML.XSLT.Transformer {
- fileprivate func evaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] {
- simpleEvaluate(instruction, context) ?? structuralEvaluate(instruction, context)
- }
-
- private func simpleEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem]? {
- switch instruction {
- case let .literalText(text): [.node(.text(text))]
- case let .valueOf(select, raw):
- [.node(.text(raw ? PureXML.XSLT.RawText.marked(string(select, context)) : string(select, context)))]
- case let .applyTemplates(select, mode, sorts, parameters):
- applyTemplates(
- to: sorted(selectXPathNodes(select ?? "node()", context), sorts, context),
- mode: mode,
- parameters: parameters,
- context,
- )
- case let .forEach(select, sorts, body): forEach(select, sorts, body, context)
- case let .ifInstruction(test, body): boolean(test, context) ? instantiate(body, context) : []
- case let .choose(whens, otherwise): chooseInstruction(whens, otherwise, context)
- case let .copyOf(select): copyOf(select, context)
- case let .callTemplate(name, parameters): callTemplate(name, parameters, context)
- case .variable: []
- default: nil
- }
- }
-
- // Instantiates an `xsl:message` body as its text; `terminate` records the
- // signal so the transform aborts with it. Produces no result-tree output.
-
- // Builds a literal result element, rewriting its name and attribute names
- // through any `xsl:namespace-alias` in effect.
-
- private func structuralEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] {
- switch instruction {
- case .literalElement:
- [literalResult(instruction, context)]
- case .element:
- elementInstruction(instruction, context)
- case let .attribute(nameTemplate, namespaceTemplate, namespaces, body):
- attributeInstruction(nameTemplate, namespaceTemplate, namespaces, body, context)
- case let .copy(useAttributeSets, body):
- copyInstruction(useAttributeSets, body, context)
- case .number:
- [.node(.text(numberInstruction(instruction, context)))]
- case let .comment(body):
- [.node(.comment(escapedTextValue(of: instantiate(body, context))))]
- case let .processingInstruction(name, body):
- [.node(.processingInstruction(target: avt(name, context), data: escapedTextValue(of: instantiate(body, context))))]
- case let .message(terminate, body):
- message(terminate, body, context)
- case .applyImports:
- applyImports(context)
- default:
- []
- }
- }
-
- /// Re-applies templates to the current node in the current mode, considering
- /// only those below the current template's import precedence, falling back to
- /// the built-in rule when none match.
- private func applyImports(_ context: XSLTContext) -> [ResultItem] {
- if let template = bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence, atLeast: context.importRangeLow) {
- return instantiateTemplate(template, context, passing: [], from: context)
- }
- return builtInRule(context.focus, mode: context.mode, context)
- }
-
- private func callTemplate(_ name: String, _ parameters: [PureXML.XSLT.Binding], _ context: XSLTContext) -> [ResultItem] {
- // XSLT 1.0 section 6: pick the highest import-precedence definition, not the
- // first by position, so an included template outranks a same-name import.
- guard let template = stylesheet.templates.filter({ $0.name == name })
- .max(by: { $0.importPrecedence < $1.importPrecedence }) else { return [] }
- return instantiateTemplate(template, context, passing: parameters, from: context)
- }
-
- // MARK: Building elements
-
- func buildElement(
- name: PureXML.Model.QualifiedName,
- literalAttributes: [PureXML.XSLT.LiteralAttribute],
- useAttributeSets: [String],
- body: [PureXML.XSLT.Instruction],
- _ context: XSLTContext,
- ) -> ResultItem {
- // Attribute sets are lowest precedence, then the element's literal
- // attributes, then its xsl:attribute body; a later same-named attribute
- // replaces an earlier one.
- var attributes = attributeSetAttributes(useAttributeSets, context, visiting: [])
- attributes += literalAttributes.map { PureXML.Model.Attribute(name: $0.name, value: avt($0.value, context)) }
- var children: [PureXML.Model.Node] = []
- for item in instantiate(body, context) {
- switch item {
- case let .attribute(attribute):
- // Ignored once content has been added (the XSLT recovery for
- // an attribute created after children).
- if children.isEmpty { attributes.append(attribute) }
- case let .node(node): children.append(node)
- }
- }
- return .node(.element(.init(name: name, attributes: Self.deduplicated(attributes), children: children)))
- }
-
/// The attributes contributed by `names` and the attribute sets they include,
/// lower precedence first, with a `visiting` guard against recursive includes.
func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set) -> [PureXML.Model.Attribute] {
diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt
index b0d3398..184ac1d 100644
--- a/Tests/Fixtures/xalan-baseline.txt
+++ b/Tests/Fixtures/xalan-baseline.txt
@@ -9,7 +9,6 @@ extend/extend04
idkey/idkey31
impincl/impincl08
impincl/impincl27
-mdocs/mdocs07
mdocs/mdocs19
output/output22
output/output26
diff --git a/Tests/XSLTRecursionLimitTests.swift b/Tests/XSLTRecursionLimitTests.swift
index d94feb7..5beec08 100644
--- a/Tests/XSLTRecursionLimitTests.swift
+++ b/Tests/XSLTRecursionLimitTests.swift
@@ -1,96 +1,59 @@
-// The recursion guard must be exercised on a large stack (the limit is above
-// what a small stack holds), which needs Foundation's Thread. WASI is
-// single-threaded and has no Thread/Dispatch, so these tests are excluded
-// there; the guard itself is platform-independent pure Swift, covered by the
-// macOS and Linux runs.
-#if !os(WASI)
- import Foundation
- import Testing
- @testable import PureXML
+import Testing
+@testable import PureXML
- /// XSLT template recursion is bounded so an unbounded recursive template (whose
- /// depth can be driven by source data) fails gracefully instead of overflowing
- /// the stack (#356). Source *nesting* depth is already bounded by the parser's
- /// `maxDepth`, so it is not the exposure; a recursive named template is.
- ///
- /// These run on a dedicated large-stack thread: the recursion limit (300) is
- /// above what swift-testing's small Task stack holds, so the guard must be given
- /// a production-sized stack to fire on rather than the test harness overflowing
- /// first. That is exactly the gap the limit closes on a normal 8 MB main thread.
- @Suite("XSLT template recursion limit")
- struct XSLTRecursionLimitTests {
- private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
+/// The XSLT evaluator is iterative, so template recursion is bounded by heap, not
+/// the native stack (#356). Deep recursion that would once have overflowed the
+/// stack now succeeds, even here where swift-testing runs on a small Task stack;
+/// only a runaway/infinite recursion (whose result tree would grow without bound)
+/// fails gracefully at the configurable `maxTemplateDepth`.
+@Suite("XSLT template recursion")
+struct XSLTRecursionTests {
+ private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
- /// Runs `body` on a thread with a large stack and returns its result, so the
- /// recursion guard is exercised on a production-sized stack.
- private func onLargeStack(_ body: @escaping @Sendable () -> T) -> T {
- let box = Box()
- let done = DispatchSemaphore(value: 0)
- let thread = Thread {
- box.value = body()
- done.signal()
- }
- thread.stackSize = 64 * 1024 * 1024
- thread.start()
- done.wait()
- guard let value = box.value else { preconditionFailure("the worker thread produced no result") }
- return value
- }
+ /// A stylesheet whose named template recurses `count` times, driving the
+ /// recursion from the passed parameter (a stand-in for `count(//x)`).
+ private func recursiveStylesheet(_ count: Int) -> String {
+ ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ + ""
+ }
- private final class Box: @unchecked Sendable { var value: T? }
+ @Test("deep recursion succeeds on a small stack instead of overflowing")
+ func test_deepRecursionSucceeds() throws {
+ // 5000 levels far exceeds what the recursive engine survived on a Task
+ // stack (a few hundred); the iterative engine builds it on the heap.
+ let out = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "")
+ #expect(out.components(separatedBy: "").count - 1 == 4999)
+ }
- /// A stylesheet whose named template recurses `count` times, driving the
- /// recursion from the passed parameter (a stand-in for `count(//x)`).
- private func recursiveStylesheet(_ count: Int) -> String {
- ""
- + ""
- + ""
- + ""
- + ""
- + ""
- + ""
- }
-
- @Test("recursion past the default limit throws rather than crashing")
- func test_deepRecursionFailsGracefully() {
- let error: (any Error)? = onLargeStack {
- do {
- _ = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "")
- return nil
- } catch {
- return error
- }
- }
- #expect(error as? PureXML.XSLT.XSLTError == .recursionLimitExceeded(PureXML.XSLT.defaultMaxTemplateDepth))
- }
-
- @Test("recursion within the limit transforms normally")
- func test_shallowRecursionSucceeds() {
- let out = onLargeStack { (try? PureXML.XSLT.transform(stylesheet: recursiveStylesheet(100), source: "")) ?? "" }
- #expect(out.contains(""))
- }
-
- @Test("a raised maxTemplateDepth allows deeper legitimate recursion")
- func test_configurableLimit() {
- let out = onLargeStack {
- (try? PureXML.XSLT.transform(stylesheet: recursiveStylesheet(1000), source: "", maxTemplateDepth: 4000)) ?? ""
- }
- // Recursion went far past the default limit of 300 (it would otherwise have
- // thrown), producing ~1000 nested elements.
- #expect(out.components(separatedBy: "").count - 1 > 900)
- }
-
- @Test("an identity transform of the deepest permitted source still runs")
- func test_identityOfMaxDepthSource() {
- let depth = 254 // the parser permits up to maxDepth (256) nesting.
- let src = String(repeating: "", count: depth) + "leaf" + String(repeating: "", count: depth)
- let sheet = ""
- + ""
- + ""
- let out = onLargeStack { (try? PureXML.XSLT.transform(stylesheet: sheet, source: src)) ?? "" }
- #expect(out.contains("leaf"))
- #expect(out.contains(String(repeating: "", count: depth)))
+ @Test("a runaway recursion fails gracefully at the configured limit")
+ func test_runawayFailsGracefully() {
+ // A low limit stands in for an unbounded recursion: it trips the guard
+ // cheaply and throws rather than building without bound.
+ #expect(throws: PureXML.XSLT.XSLTError.recursionLimitExceeded(100)) {
+ _ = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "", maxTemplateDepth: 100)
}
}
-#endif
+ @Test("recursion within the limit transforms normally")
+ func test_withinLimitSucceeds() throws {
+ let out = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(100), source: "")
+ #expect(out.components(separatedBy: "").count - 1 == 99)
+ }
+
+ @Test("an identity transform of the deepest permitted source runs on a small stack")
+ func test_identityOfMaxDepthSource() throws {
+ let depth = 254 // the parser permits up to maxDepth (256) nesting.
+ let src = String(repeating: "", count: depth) + "leaf" + String(repeating: "", count: depth)
+ let sheet = ""
+ + ""
+ + ""
+ let out = try PureXML.XSLT.transform(stylesheet: sheet, source: src)
+ #expect(out.contains("leaf"))
+ #expect(out.contains(String(repeating: "", count: depth)))
+ }
+}