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:
parent
3e3ca0f971
commit
fb31033ce8
@ -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;
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
38
Tests/DTDLocatedContentTests.swift
Normal file
38
Tests/DTDLocatedContentTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user