feat(dtd): located content-model errors with recovery hints

An element whose children break its content model now names each stray
child outside the model's alphabet individually, and reports a pure
order/count violation once with the allowed elements as a hint, instead
of a single opaque "do not match its content model". Adds an alphabet
extractor to the content-model matcher. This brings DTD to the same
located standard as the XSD, RELAX NG, and Schematron validators, so all
four schema languages give editor-grade diagnostics.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 00:59:25 +02:00
parent 3e3ca0f971
commit fb31033ce8
4 changed files with 74 additions and 2 deletions

View File

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- DTD located content-model errors. An element whose children break its content
model now names each stray child individually (`element <x> is not allowed in
<r>`), and reports a pure order/count violation once with the allowed elements
as a hint (`… do not match its content model; allowed: <a>, <b>`), instead of a
single opaque message, matching the editor-grade diagnostics of the other
validators.
- XSD located content-model errors. A content-model violation is now placed at
the offending child with a recovery hint naming what was expected there
(`element 'x' is not allowed here; expected <b>`, `content is incomplete;

View File

@ -8,6 +8,17 @@ extension PureXML.Validation {
advance(particle, names, from: [0]).contains(names.count)
}
/// Every element name the content model can contain (its alphabet), used to
/// tell a stray child from one that is merely out of order or count.
static func allowedNames(_ particle: Particle) -> Set<String> {
switch particle {
case let .name(name, _):
[name]
case let .sequence(items, _), let .choice(items, _):
items.reduce(into: Set<String>()) { $0.formUnion(allowedNames($1)) }
}
}
/// End positions reachable by matching `particle` (with its occurrence)
/// once from each given start position.
private static func advance(_ particle: Particle, _ names: [String], from starts: Set<Int>) -> Set<Int> {

View File

@ -34,12 +34,29 @@ extension PureXML.Validation.DTD {
if content.hasText {
result.append(DTDFailure(reason: "element <\(name)> has element content but contains character data", at: path))
}
if !PureXML.Validation.ContentModelMatcher.matchesChildren(particle, content.names) {
result.append(DTDFailure(reason: "the children of <\(name)> do not match its content model", at: path))
guard !PureXML.Validation.ContentModelMatcher.matchesChildren(particle, content.names) else { return result }
// Name each child outside the model's alphabet; if every child is in the
// alphabet the fault is order or count, so report it once with the allowed
// elements as a recovery hint.
let allowed = PureXML.Validation.ContentModelMatcher.allowedNames(particle)
let stray = content.names.filter { !allowed.contains($0) }
if stray.isEmpty {
let hint = allowed.sorted().map { "<\($0)>" }.joined(separator: ", ")
result.append(DTDFailure(reason: "the children of <\(name)> do not match its content model; allowed: \(hint)", at: path))
} else {
for child in orderedUnique(stray) {
result.append(DTDFailure(reason: "element <\(child)> is not allowed in <\(name)>", at: path))
}
}
return result
}
/// The distinct values of `names`, in first-seen order.
private static func orderedUnique(_ names: [String]) -> [String] {
var seen: Set<String> = []
return names.filter { seen.insert($0).inserted }
}
private static func childContent(of element: DTDElement) -> (names: [String], hasText: Bool) {
var names: [String] = []
var hasText = false

View File

@ -0,0 +1,38 @@
@testable import PureXML
import Testing
@Suite("DTD located content-model errors")
struct DTDLocatedContentTests {
private func reasons(_ xml: String) throws -> [String] {
try PureXML.validateAgainstInternalDTD(xml).map(\.reason)
}
@Test("A stray child outside the model is named individually")
func test_strayChild() throws {
let dtd = "<!DOCTYPE r [<!ELEMENT r (a, b)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT x EMPTY>]>"
let found = try reasons("\(dtd)<r><a/><x/></r>")
#expect(found.contains { $0.contains("<x>") && $0.contains("not allowed in <r>") })
}
@Test("Two distinct stray children are each reported (recovery)")
func test_multipleStray() throws {
let dtd = "<!DOCTYPE r [<!ELEMENT r (a)><!ELEMENT a EMPTY><!ELEMENT x EMPTY><!ELEMENT y EMPTY>]>"
let found = try reasons("\(dtd)<r><x/><y/></r>")
#expect(found.contains { $0.contains("<x>") })
#expect(found.contains { $0.contains("<y>") })
}
@Test("An ordering-only violation lists the allowed elements as a hint")
func test_orderingHint() throws {
// Both children are in the alphabet, but reversed against (a, b).
let dtd = "<!DOCTYPE r [<!ELEMENT r (a, b)><!ELEMENT a EMPTY><!ELEMENT b EMPTY>]>"
let found = try reasons("\(dtd)<r><b/><a/></r>")
#expect(found.contains { $0.contains("do not match its content model") && $0.contains("<a>") && $0.contains("<b>") })
}
@Test("A conforming document has no content errors")
func test_valid() throws {
let dtd = "<!DOCTYPE r [<!ELEMENT r (a, b)><!ELEMENT a EMPTY><!ELEMENT b EMPTY>]>"
#expect(try reasons("\(dtd)<r><a/><b/></r>").isEmpty)
}
}