diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3a9b1..dfd9564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 is not allowed in + `), and reports a pure order/count violation once with the allowed elements + as a hint (`… do not match its content model; allowed: , `), 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 `, `content is incomplete; diff --git a/Sources/Validation/ContentModelMatcher.swift b/Sources/Validation/ContentModelMatcher.swift index 62906bd..3c74d7e 100644 --- a/Sources/Validation/ContentModelMatcher.swift +++ b/Sources/Validation/ContentModelMatcher.swift @@ -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 { + switch particle { + case let .name(name, _): + [name] + case let .sequence(items, _), let .choice(items, _): + items.reduce(into: Set()) { $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) -> Set { diff --git a/Sources/Validation/DTDValidationHelpers.swift b/Sources/Validation/DTDValidationHelpers.swift index 3bbd403..b58214a 100644 --- a/Sources/Validation/DTDValidationHelpers.swift +++ b/Sources/Validation/DTDValidationHelpers.swift @@ -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 = [] + return names.filter { seen.insert($0).inserted } + } + private static func childContent(of element: DTDElement) -> (names: [String], hasText: Bool) { var names: [String] = [] var hasText = false diff --git a/Tests/DTDLocatedContentTests.swift b/Tests/DTDLocatedContentTests.swift new file mode 100644 index 0000000..74ec92c --- /dev/null +++ b/Tests/DTDLocatedContentTests.swift @@ -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 = "]>" + let found = try reasons("\(dtd)") + #expect(found.contains { $0.contains("") && $0.contains("not allowed in ") }) + } + + @Test("Two distinct stray children are each reported (recovery)") + func test_multipleStray() throws { + let dtd = "]>" + let found = try reasons("\(dtd)") + #expect(found.contains { $0.contains("") }) + #expect(found.contains { $0.contains("") }) + } + + @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 = "]>" + let found = try reasons("\(dtd)") + #expect(found.contains { $0.contains("do not match its content model") && $0.contains("") && $0.contains("") }) + } + + @Test("A conforming document has no content errors") + func test_valid() throws { + let dtd = "]>" + #expect(try reasons("\(dtd)").isEmpty) + } +}