PureXML/Tests/EntitySplicingTests.swift
Mihaela Mihaljevic 497e2460a3 feat(parsing): splice entity replacement markup into content
A general entity whose replacement text contains markup (directly or
transitively) is reparsed as content at the reference (4.4.2 Included):
the replacement is WFC-validated, cycle-checked, and budgeted, then
pushed back into the reader stream, so its elements become elements in
the tree and the pull-event stream rather than escaped character data.
Character references in internal entity literals now expand at
declaration time for general entities too (4.4.5), making char-ref
markup splice and the Appendix D double escape render as a literal
ampersand; the reference-time WFC validates remaining references
instead of re-expanding. Text-only entities keep the coalescing
single-pass path. Sun valid is 28/28.

Closes #133
2026-06-10 06:55:40 +02:00

131 lines
4.4 KiB
Swift

@testable import PureXML
import Testing
/// Content splicing (4.4.2 Included, #133): a general entity whose replacement
/// text contains markup is reparsed as content, so elements inside an entity
/// become elements in the tree and in the pull-event stream, with character
/// references expanded at declaration time (4.4.5).
@Suite("Entity replacement splicing")
struct EntitySplicingTests {
private func limits() -> PureXML.Parsing.Limits {
PureXML.Parsing.Limits(allowDoctype: true)
}
private let document = """
<!DOCTYPE root [
<!ELEMENT root (p)>
<!ELEMENT p (#PCDATA)>
<!ENTITY example "<p>text</p>">
]>
<root>&example;</root>
"""
@Test("An entity's markup becomes elements in the tree")
func test_treeShape() throws {
let node = try PureXML.parse(document, limits: limits())
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
Issue.record("no root")
return
}
#expect(root.children.count == 1)
guard case let .element(paragraph)? = root.children.first else {
Issue.record("expected a p element, got \(String(describing: root.children.first))")
return
}
#expect(paragraph.name.localName == "p")
#expect(paragraph.children.count == 1)
if case let .text(value)? = paragraph.children.first {
#expect(value == "text")
} else {
Issue.record("expected text inside p")
}
}
@Test("The pull-event stream sees the spliced elements")
func test_pullEvents() throws {
var reader = PureXML.Parsing.EventReader(document, limits: limits())
var names: [String] = []
while let event = try reader.next() {
switch event {
case let .startElement(name, _): names.append("+\(name.localName)")
case let .endElement(name): names.append("-\(name.localName)")
case let .characters(text) where !text.isEmpty: names.append("t:\(text)")
default: break
}
}
#expect(names == ["+root", "+p", "t:text", "-p", "-root"])
}
@Test("The spliced document validates against its DTD content model")
func test_dtdValidates() throws {
let errors = try PureXML.validateAgainstInternalDTD(document, limits: limits())
#expect(errors.isEmpty, "\(errors)")
}
@Test("Character references expand at declaration, so markup from them splices")
func test_characterReferenceMarkup() throws {
// xmltest valid/sa/024 and 087 shapes.
let viaCharRef = """
<!DOCTYPE doc [
<!ELEMENT doc (foo)>
<!ELEMENT foo (#PCDATA)>
<!ENTITY e "&#60;foo></foo>">
]>
<doc>&e;</doc>
"""
let node = try PureXML.parse(viaCharRef, limits: limits())
guard case let .document(children) = node, let doc = children.compactMap(\.element).first else {
Issue.record("no root")
return
}
#expect(doc.children.compactMap(\.element).first?.name.localName == "foo")
}
@Test("The Appendix D double escape yields a literal ampersand in content")
func test_doubleEscape() throws {
let xml = """
<!DOCTYPE doc [
<!ELEMENT doc (#PCDATA)>
<!ENTITY e "a &#38;#38; b">
]>
<doc>&e;</doc>
"""
let node = try PureXML.parse(xml, limits: limits())
guard case let .document(children) = node, let doc = children.compactMap(\.element).first,
case let .text(value)? = doc.children.first
else {
Issue.record("no text")
return
}
#expect(value == "a & b")
}
@Test("A recursive markup entity is rejected, not looped")
func test_recursionRejected() {
let xml = """
<!DOCTYPE doc [
<!ELEMENT doc ANY>
<!ENTITY a "<p>&a;</p>">
]>
<doc>&a;</doc>
"""
#expect(throws: PureXML.Parsing.ParseError.self) {
_ = try PureXML.parse(xml, limits: limits())
}
}
@Test("Tags spanning the entity boundary stay rejected")
func test_boundarySpanRejected() {
let xml = """
<!DOCTYPE doc [
<!ELEMENT doc ANY>
<!ENTITY e "</doc><doc>">
]>
<doc>&e;</doc>
"""
#expect(throws: PureXML.Parsing.ParseError.self) {
_ = try PureXML.parse(xml, limits: limits())
}
}
}