feat(parsing): strict <!ELEMENT> content-model grammar

Third W3C-suite strictness pass: DTDContentModelGrammar validates each
element declaration's content specification against the XML 1.0 grammar
(productions 46-51) at DTD scan time, EMPTY/ANY, Mixed (with #PCDATA first,
names only, the trailing * required with alternatives, no nesting), and
children (one connector per group, quantifiers bound directly to their
particle, balanced parentheses, no empty groups, no SGML leftovers like
'- -', 'O O', '&' connectors, or inclusion/exclusion suffixes). The element
declaration also requires whitespace after <!ELEMENT and after the name. The
lenient ContentModelParser still drives validation behavior; this is a
well-formedness gate. not-wf/sa: 129/186 rejected (from 109); valid/sa
unchanged; zero false positives across all 846 suite tests; baseline rebased
to the exact 57 remaining (declaration-grammar details and the entity
replacement-text class).

Part of #120, #105
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 05:20:33 +02:00
parent 848c7002c7
commit 805993e656
6 changed files with 177 additions and 13 deletions

View File

@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Parser strictness, first W3C-suite pass (#105 Tier 2, #120): the strict parser now rejects `--` inside comments (including the `--->` ending), a raw `<` in attribute values, the literal `]]>` sequence in character data, attributes not separated by whitespace, and characters outside the XML `Char` production in content, five new located `ParseError` cases. Driven by a new opt-in runner for the W3C XML Conformance Test Suite's xmltest section (`XMLTS_ROOT`-gated; the suite is never vendored because its license permits redistribution only as the unmodified archive): valid/sa 118/119, not-wf/sa rejection improved from 80 to 109 of 186 across the first two strictness passes (the second adds character-reference validation, lowercase-x-only hex references and the referenced code point must be a valid XML Char; the reserved 'xml' PI target, legal only as the declaration at the document's very first bytes; and XML Char validation in comments and PI data), with the exact remaining baseline carried in the test and the two remaining leniency classes (internal-subset DTD grammar, entity replacement-text well-formedness) tracked as #120.
- Parser strictness, first W3C-suite pass (#105 Tier 2, #120): the strict parser now rejects `--` inside comments (including the `--->` ending), a raw `<` in attribute values, the literal `]]>` sequence in character data, attributes not separated by whitespace, and characters outside the XML `Char` production in content, five new located `ParseError` cases. Driven by a new opt-in runner for the W3C XML Conformance Test Suite's xmltest section (`XMLTS_ROOT`-gated; the suite is never vendored because its license permits redistribution only as the unmodified archive): valid/sa 118/119, not-wf/sa rejection improved from 80 to 129 of 186 across the first three strictness passes (the second adds character-reference validation, lowercase-x-only hex references and the referenced code point must be a valid XML Char; the reserved 'xml' PI target, legal only as the declaration at the document's very first bytes; and XML Char validation in comments and PI data; the third validates <!ELEMENT> content models against the XML 1.0 grammar at DTD scan time, rejecting unbalanced parentheses, #PCDATA misplacement and nesting, mixed connectors, whitespace-detached or doubled quantifiers, empty groups, and SGML leftovers), with the exact remaining baseline carried in the test and the two remaining leniency classes (internal-subset DTD grammar, entity replacement-text well-formedness) tracked as #120.
- XSD Particle Valid (Restriction) (#117): a complex type derived by restriction is now checked at schema compile to accept a structural subset of its base, covering element name matching, element-vs-wildcard admission, wildcard narrowing, occurrence-range subsumption, and the group recursions (sequence/all order-preserving with emptiable skips, choice mapping, sequence-into-choice, sequence-into-all, RecurseAsIfGroup). A structurally unfaithful restriction (widened occurrence, new element name, reordered sequence, content added to an EMPTY or required-into-empty base, an element outside a base wildcard's namespace) throws `SchemaError.invalidRestriction` with the type, base, and reason. The spec's effective-total-range arithmetic for MapAndSum is approximated by per-particle checks, a documented simplification.
- Conformance corpus harness (#105, Tier 2): the start of conformance-suite checking, expressed through the validation framework (the OpenAPIKit idiom). A `PureXML.Validation.ConformanceCase` is a `Validatable` named expectation (actual vs spec-authoritative expected output), and `Conformance.matchesExpected` is a `Validation<ConformanceCase, Void>` rule that emits one located `ValidationError` per diverging case, so a whole suite reports every failure at once with the case name as its coding path. Seeded with a Canonical XML 1.0 corpus (attribute ordering, empty-element expansion, comment removal, CDATA-to-escaped-text, processing instructions, namespace rendering) an XPath 1.0 core-function corpus, and an XSD datatype/facet corpus (length, range, pattern, enumeration, lexical space), a RELAX NG pattern corpus (text, optional, repetition, choice, ordered group, attribute, interleave, empty), an XSLT 1.0 transformation corpus (value-of, for-each, if, choose, sort, variable, format-number, apply-templates, call-template), an HTML5 tree-construction corpus (void elements, implied tag closing, case normalization, the adoption agency, entities), a Schematron corpus (assert, report, rule context), a C14N exclusive/2.0 corpus (unused-namespace dropping, render-at-use, whitespace trimming), an XPath axes/predicates corpus (descendant, ancestor, sibling axes, positional and attribute predicates, union), a DTD content-model corpus (sequence, choice, occurrence indicators, mixed, EMPTY, ANY), an XInclude/XPointer corpus (substitution, text inclusion with escaping, fallback, relative href resolution; shorthand ids, element() navigation, xpointer() including the per-step predicate semantic), an XSD regex corpus (implicit whole-string anchoring, the character-class escapes, Unicode categories, quantifier ranges, alternation, class negation and subtraction), and an XML Catalog corpus (system/public/uri entries, rewrite prefixes, unmatched identifiers). The HTML corpus surfaced its first divergence (the fragment parser does not reconstruct active formatting elements like the document parser, tracked as #109). Growing the corpora against the official W3C/OASIS suites is the ongoing Tier-2 work.
- EXSLT extension functions (#105, Tier 3): the `common`, `math`, and `sets` EXSLT modules, dispatched by resolving a function's prefix to its namespace (so any prefix bound to an EXSLT namespace works). `math:min`, `math:max`, `math:highest`, `math:lowest`, `math:abs`, `math:sqrt` (with the EXSLT NaN rule for non-numeric nodes); `set:distinct`, `set:difference`, `set:intersection`, `set:has-same-node`, `set:leading`, `set:trailing` (by node identity and document order); `exsl:object-type`. `math:power` and the other transcendentals are omitted because they need a C math library, which the pure-Swift, Foundation-free target excludes; `exsl:node-set` awaits result-tree-fragment support.

View File

@ -0,0 +1,152 @@
extension PureXML.Parsing {
/// Validates an `<!ELEMENT>` content specification against the XML 1.0
/// grammar (productions 46-51): `EMPTY`, `ANY`, `Mixed`, or `children`.
/// The lenient `ContentModelParser` still drives validation behavior; this
/// strict check runs at DTD scan time so a malformed model (unbalanced
/// parentheses, `#PCDATA` misplacement, mixed connectors, a quantifier
/// detached by whitespace, SGML leftovers) is rejected as not well-formed.
enum DTDContentModelGrammar {
static func isValid(_ model: String) -> Bool {
let text = model.trimmingXMLWhitespace()
if text == "EMPTY" || text == "ANY" { return true }
var scanner = ModelScanner(text)
guard scanner.peek() == "(" else { return false }
// Mixed and children both start with '('; Mixed is identified by
// '#PCDATA' as the first token inside.
let isMixed = scanner.looksMixed()
let parsed = isMixed ? scanner.mixed() : scanner.children()
return parsed && scanner.isAtEnd
}
}
}
/// A tiny recursive-descent scanner over one content-model expression.
private struct ModelScanner {
private let characters: [Character]
private var index = 0
init(_ text: String) {
characters = Array(text)
}
var isAtEnd: Bool {
index >= characters.count
}
func peek(_ ahead: Int = 0) -> Character? {
index + ahead < characters.count ? characters[index + ahead] : nil
}
private mutating func advance() -> Character? {
guard index < characters.count else { return nil }
defer { index += 1 }
return characters[index]
}
private mutating func skipSpace() {
while let character = peek(), character.isWhitespace {
index += 1
}
}
private mutating func consume(_ character: Character) -> Bool {
if peek() == character {
index += 1
return true
}
return false
}
/// Whether the expression opens as `( S? #PCDATA`.
func looksMixed() -> Bool {
var probe = self
guard probe.consume("(") else { return false }
probe.skipSpace()
return probe.peek() == "#"
}
/// `Mixed ::= '(' S? '#PCDATA' (S? '|' S? Name)* S? ')*' | '(' S? '#PCDATA' S? ')'`
mutating func mixed() -> Bool {
guard consume("(") else { return false }
skipSpace()
guard consumeLiteral("#PCDATA") else { return false }
var sawAlternative = false
while true {
skipSpace()
if consume(")") { break }
guard consume("|") else { return false }
sawAlternative = true
skipSpace()
guard name() else { return false }
}
// With alternatives the trailing '*' is required; without, optional none.
if sawAlternative {
return consume("*")
}
return peek() != "*" || consume("*")
}
/// `children ::= (choice | seq) ('?' | '*' | '+')?` with
/// `cp ::= (Name | choice | seq) ('?' | '*' | '+')?`. A group commits to one
/// connector (`|` or `,`); a quantifier must follow its particle directly.
mutating func children() -> Bool {
guard group() else { return false }
_ = quantifier()
return true
}
private mutating func group() -> Bool {
guard consume("(") else { return false }
skipSpace()
guard particle() else { return false }
skipSpace()
var connector: Character?
while !consume(")") {
guard let next = advance(), next == "|" || next == "," else { return false }
if let connector, connector != next { return false }
connector = next
skipSpace()
guard particle() else { return false }
skipSpace()
}
return true
}
private mutating func particle() -> Bool {
if peek() == "(" {
guard group() else { return false }
} else {
guard name() else { return false }
}
_ = quantifier()
return true
}
/// A quantifier binds directly to its particle: no whitespace before it,
/// and at most one.
private mutating func quantifier() -> Bool {
guard let next = peek(), next == "?" || next == "*" || next == "+" else { return false }
index += 1
return true
}
private mutating func name() -> Bool {
guard let first = peek(), first.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameStart) else {
return false
}
index += 1
while let next = peek(), next.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) {
index += 1
}
return true
}
private mutating func consumeLiteral(_ literal: String) -> Bool {
let count = literal.count
guard index + count <= characters.count,
String(characters[index ..< index + count]) == literal
else { return false }
index += count
return true
}
}

View File

@ -46,7 +46,7 @@ public extension PureXML.Parsing.ParseError {
let .duplicateAttribute(_, mark), let .invalidReference(_, mark),
let .undefinedEntity(_, mark), let .recursiveEntity(_, mark),
let .unexpectedEndTag(_, mark), let .undefinedNamespacePrefix(_, mark),
let .nestingTooDeep(_, mark), let .nameTooLong(_, mark), let .contentTooLong(_, mark):
let .nestingTooDeep(_, mark), let .nameTooLong(_, mark), let .contentTooLong(_, mark), let .invalidContentModel(_, mark):
mark
}
}

View File

@ -112,7 +112,7 @@ private struct DTDScanner {
} else if reader.matches("<![") {
try scanConditionalSection(&reader, depth: depth, at: mark)
} else {
scanMarkupDeclaration(&reader)
try scanMarkupDeclaration(&reader)
}
}
}
@ -159,11 +159,11 @@ private struct DTDScanner {
return body
}
private mutating func scanMarkupDeclaration(_ reader: inout Reader) {
private mutating func scanMarkupDeclaration(_ reader: inout Reader) throws {
if reader.matches("<!ENTITY") {
scanEntityDeclaration(&reader)
} else if reader.matches("<!ELEMENT") {
scanElementDeclaration(&reader)
try scanElementDeclaration(&reader)
} else if reader.matches("<!ATTLIST") {
scanAttributeListDeclaration(&reader)
} else if reader.matches("<!NOTATION") {
@ -266,14 +266,24 @@ private struct DTDScanner {
}
}
private mutating func scanElementDeclaration(_ reader: inout Reader) {
private mutating func scanElementDeclaration(_ reader: inout Reader) throws {
let mark = reader.mark
reader.consume("<!ELEMENT")
guard reader.peek()?.isWhitespace == true else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: "", mark)
}
reader.skipSpace()
let name = scanName(&reader)
guard reader.peek()?.isWhitespace == true else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: name, mark)
}
reader.skipSpace()
let model = expandParameterReferences(readUntilClose(&reader))
let model = expandParameterReferences(readUntilClose(&reader)).trimmingXMLWhitespace()
guard PureXML.Parsing.DTDContentModelGrammar.isValid(model) else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: name, mark)
}
guard !name.isEmpty, doctype.elementModels[name] == nil else { return }
doctype.elementModels[name] = model.trimmingXMLWhitespace()
doctype.elementModels[name] = model
}
private mutating func scanAttributeListDeclaration(_ reader: inout Reader) {

View File

@ -48,6 +48,8 @@ public extension PureXML.Parsing {
/// A processing instruction uses the reserved target `xml` (any case),
/// which is only legal as the document's XML declaration.
case reservedProcessingInstructionTarget(Mark)
/// An `<!ELEMENT>` content model does not match the XML 1.0 grammar.
case invalidContentModel(element: String, Mark)
public var description: String {
switch self {
@ -111,6 +113,8 @@ public extension PureXML.Parsing {
"character is not allowed in XML content at \(mark)"
case let .reservedProcessingInstructionTarget(mark):
"the processing-instruction target 'xml' is reserved at \(mark)"
case let .invalidContentModel(element, mark):
"the content model of <!ELEMENT \(element)> is malformed at \(mark)"
case let .malformedDeclaration(mark):
"malformed XML declaration at \(mark)"
}

View File

@ -29,11 +29,9 @@ struct W3CXMLSuiteTests {
"079.xml", "080.xml", "082.xml", "084.xml", "085.xml", "086.xml", "087.xml", "089.xml",
"090.xml", "091.xml", "092.xml", "096.xml", "101.xml", "102.xml", "103.xml", "107.xml",
"113.xml", "114.xml", "115.xml", "116.xml", "117.xml", "119.xml", "120.xml", "121.xml",
"122.xml", "123.xml", "124.xml", "125.xml", "126.xml", "127.xml", "128.xml", "129.xml",
"130.xml", "131.xml", "132.xml", "133.xml", "134.xml", "135.xml", "136.xml", "137.xml",
"138.xml", "139.xml", "140.xml", "141.xml", "149.xml", "153.xml", "158.xml", "160.xml",
"161.xml", "162.xml", "165.xml", "168.xml", "169.xml", "170.xml", "173.xml", "174.xml",
"175.xml", "180.xml", "182.xml", "183.xml", "184.xml",
"140.xml", "141.xml", "149.xml", "153.xml", "158.xml", "160.xml", "161.xml", "162.xml",
"165.xml", "168.xml", "169.xml", "170.xml", "173.xml", "174.xml", "175.xml", "180.xml",
"182.xml",
]
/// valid/sa/114.xml: a CDATA section inside an entity value must protect