refactor(validation): located structured validity findings
DocumentType.validityFindings carries ValidityFinding values (reason plus subject) instead of strings, DTDSchema.declarationErrors holds ready ValidationErrors, and every declaration-level finding renders at its declaration's coding path (x, r/@a) instead of at the document root, matching the XSD consistency-errors precedent. Texts unchanged.
This commit is contained in:
parent
5e7cc292f9
commit
d0175d5ca0
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Scanner-side validity findings are now located structured values (validation-framework audit follow-up): `DocumentType.validityFindings` carries `ValidityFinding` values (reason plus the declared name they are about) instead of strings, `DTDSchema.declarationErrors` holds ready `ValidationError`s, and every declaration-level finding renders at its declaration's coding path (`x`, `r/@a`) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- RELAX NG grammar scoping and include depth, third burn-down (#131): each `grammar` element opens its own define scope (names are scope-mangled in the flat table, so nested grammars do not collide) and `parentRef` reaches the enclosing scope; `include` is transitive (an included grammar's own includes merge first) and a define carried by the include element replaces the included grammar's same-name define (the 4.7 override); `div` wrappers flatten into their grammar while still contributing ns/datatypeLibrary inheritance (4.11); `value` text is kept untrimmed so whitespace-preserving datatypes compare exactly (6.2.9); and `dataExcept` recurses through choice patterns. The spec suite is now clean except one class: valid 286/289 and invalid 287/291, with all six remaining instance cases the QName datatype (its value space needs prefix resolution in both the schema and instance contexts), plus the asserted schema-correctness item.
|
||||
|
||||
@ -125,7 +125,7 @@ flowchart TB
|
||||
|
||||
PureXML is a working, dependency-free XML library today: parse, emit, validate,
|
||||
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
|
||||
currently runs **932 tests in 160 suites** (`swift test`).
|
||||
currently runs **933 tests in 160 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
@ -345,9 +345,10 @@ extension DTDScanner {
|
||||
guard !read.closed else { return read.text }
|
||||
let expanded = expandParameterReferences(read.text)
|
||||
guard let close = expanded.firstIndex(of: ">") else { return read.text }
|
||||
doctype.validityFindings.append(
|
||||
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
||||
"the \(kind) declaration for '\(name)' is completed inside a parameter-entity replacement (VC: Proper Declaration/PE Nesting)",
|
||||
)
|
||||
subject: name,
|
||||
))
|
||||
return String(expanded[..<close])
|
||||
}
|
||||
|
||||
|
||||
@ -73,9 +73,10 @@ extension DTDScanner {
|
||||
let opens = replacement.count(where: { $0 == "(" })
|
||||
let closes = replacement.count(where: { $0 == ")" })
|
||||
if opens != closes {
|
||||
doctype.validityFindings.append(
|
||||
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
||||
"the content model of '\(element)' uses parameter entity '%\(name);' with improper group/PE nesting",
|
||||
)
|
||||
subject: element,
|
||||
))
|
||||
}
|
||||
}
|
||||
index = rawModel.index(after: semicolon)
|
||||
|
||||
@ -47,7 +47,7 @@ extension DTDScanner {
|
||||
if entityDeclaredIsWellFormedness {
|
||||
throw ParseError.undefinedEntity(name: name, refMark)
|
||||
}
|
||||
doctype.validityFindings.append("parameter entity '%\(name);' is referenced but not declared")
|
||||
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("parameter entity '%\(name);' is referenced but not declared"))
|
||||
return
|
||||
}
|
||||
guard depth < maxDepth else {
|
||||
|
||||
@ -298,7 +298,7 @@ extension DTDScanner {
|
||||
if entityDeclaredIsWellFormedness {
|
||||
throw ParseError.undefinedEntity(name: name, mark)
|
||||
}
|
||||
doctype.validityFindings.append("entity '&\(name);' in an attribute default is referenced but not declared")
|
||||
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("entity '&\(name);' in an attribute default is referenced but not declared"))
|
||||
return
|
||||
}
|
||||
try walkDefaultReferences(replacement, visiting: visiting.union([name]), at: mark)
|
||||
|
||||
@ -42,8 +42,9 @@ public extension PureXML.Parsing {
|
||||
/// the DTD: an undeclared entity referenced where the external subset
|
||||
/// might have declared it (VC: Entity Declared) or a content-model
|
||||
/// group split across parameter entities (VC: Proper Group/PE
|
||||
/// Nesting). Reported by the validator at the document root.
|
||||
public var validityFindings: [String] = []
|
||||
/// Nesting). The validator turns each into a located error at the
|
||||
/// finding's subject.
|
||||
public var validityFindings: [ValidityFinding] = []
|
||||
|
||||
public init(
|
||||
entities: [String: String] = [:],
|
||||
|
||||
@ -36,7 +36,7 @@ extension PureXML.Parsing.EventReader {
|
||||
undeclared: &undeclared,
|
||||
)
|
||||
for name in undeclared {
|
||||
documentType.validityFindings.append("general entity '&\(name);' is referenced but not declared")
|
||||
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding("general entity '&\(name);' is referenced but not declared"))
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@ -79,7 +79,10 @@ extension PureXML.Parsing.EventReader {
|
||||
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description],
|
||||
model == "EMPTY"
|
||||
else { return }
|
||||
documentType.validityFindings.append("\(what) appears in the EMPTY element <\(current.description)>")
|
||||
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
||||
"\(what) appears in the EMPTY element <\(current.description)>",
|
||||
subject: current.description,
|
||||
))
|
||||
}
|
||||
|
||||
/// The errata E15 validity findings a text run can carry: any reference
|
||||
@ -91,7 +94,10 @@ extension PureXML.Parsing.EventReader {
|
||||
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description]
|
||||
else { return }
|
||||
if model == "EMPTY" {
|
||||
documentType.validityFindings.append("a reference appears in the EMPTY element <\(current.description)>")
|
||||
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
||||
"a reference appears in the EMPTY element <\(current.description)>",
|
||||
subject: current.description,
|
||||
))
|
||||
return
|
||||
}
|
||||
// Errata E15: a direct character reference is character data even
|
||||
@ -100,7 +106,10 @@ extension PureXML.Parsing.EventReader {
|
||||
let isElementContent = model.hasPrefix("(") && !model.hasPrefix("(#PCDATA")
|
||||
let isReferenceWhitespace = !decoded.isEmpty && decoded.allSatisfy(\.isWhitespace)
|
||||
if isElementContent, isReferenceWhitespace, referencesCharacter(raw) {
|
||||
documentType.validityFindings.append("character-reference whitespace in the element content of <\(current.description)>")
|
||||
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
||||
"character-reference whitespace in the element content of <\(current.description)>",
|
||||
subject: current.description,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
Sources/Parsing/ValidityFinding.swift
Normal file
16
Sources/Parsing/ValidityFinding.swift
Normal file
@ -0,0 +1,16 @@
|
||||
public extension PureXML.Parsing {
|
||||
/// One validity (not well-formedness) discovery made while parsing: a
|
||||
/// reason and, when known, the declared name it is about, which becomes
|
||||
/// the error's coding path when the validation framework reports it.
|
||||
struct ValidityFinding: Equatable, Sendable {
|
||||
public var reason: String
|
||||
/// The element or declaration name the finding addresses, or nil when
|
||||
/// it belongs to the document as a whole.
|
||||
public var subject: String?
|
||||
|
||||
public init(_ reason: String, subject: String? = nil) {
|
||||
self.reason = reason
|
||||
self.subject = subject
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,25 +2,31 @@ extension PureXML.Validation.DTDSchema {
|
||||
/// The declaration-level validity constraints: violations that exist in the
|
||||
/// DTD itself, independent of any instance content. Computed once when the
|
||||
/// schema is built and reported at the document root.
|
||||
typealias Finding = PureXML.Validation.ValidationError
|
||||
typealias Key = PureXML.Validation.PathKey
|
||||
|
||||
static func declarationFindings(
|
||||
_ documentType: PureXML.Parsing.DocumentType,
|
||||
attributes: [String: [PureXML.Validation.AttributeDeclaration]],
|
||||
notations: Set<String>,
|
||||
) -> [String] {
|
||||
var findings: [String] = []
|
||||
) -> [Finding] {
|
||||
var findings: [Finding] = []
|
||||
// VC: Unique Element Type Declaration.
|
||||
for name in documentType.duplicateElements.sorted() {
|
||||
findings.append("element type '\(name)' is declared more than once")
|
||||
findings.append(Finding(reason: "element type '\(name)' is declared more than once", at: [.element(name)]))
|
||||
}
|
||||
// VC: Notation Declared, an unparsed entity's notation must exist.
|
||||
for (entity, unparsed) in documentType.unparsedEntities.sorted(by: { $0.key < $1.key }) {
|
||||
guard !notations.contains(unparsed.notation) else { continue }
|
||||
findings.append("unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'")
|
||||
findings.append(Finding(
|
||||
reason: "unparsed entity '\(entity)' names undeclared notation '\(unparsed.notation)'",
|
||||
at: [.element(entity)],
|
||||
))
|
||||
}
|
||||
// VC: No Duplicate Types in mixed content.
|
||||
for (name, model) in documentType.elementModels.sorted(by: { $0.key < $1.key }) {
|
||||
if let duplicate = duplicateMixedName(model) {
|
||||
findings.append("mixed content of '\(name)' repeats '\(duplicate)'")
|
||||
findings.append(Finding(reason: "mixed content of '\(name)' repeats '\(duplicate)'", at: [.element(name)]))
|
||||
}
|
||||
}
|
||||
for (element, declarations) in attributes.sorted(by: { $0.key < $1.key }) {
|
||||
@ -35,24 +41,28 @@ extension PureXML.Validation.DTDSchema {
|
||||
element: String,
|
||||
declarations: [PureXML.Validation.AttributeDeclaration],
|
||||
notations: Set<String>,
|
||||
) -> [String] {
|
||||
var findings: [String] = []
|
||||
) -> [Finding] {
|
||||
var findings: [Finding] = []
|
||||
if declarations.count(where: { $0.type == .id }) > 1 {
|
||||
findings.append("element '\(element)' declares more than one ID attribute")
|
||||
findings.append(Finding(reason: "element '\(element)' declares more than one ID attribute", at: [.element(element)]))
|
||||
}
|
||||
for declaration in declarations {
|
||||
let path: [Key] = [.element(element), .attribute(declaration.name)]
|
||||
if case let .notation(allowed) = declaration.type {
|
||||
for name in allowed.sorted() where !notations.contains(name) {
|
||||
findings.append("NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'")
|
||||
findings.append(Finding(
|
||||
reason: "NOTATION attribute '\(declaration.name)' on '\(element)' lists undeclared notation '\(name)'",
|
||||
at: path,
|
||||
))
|
||||
}
|
||||
}
|
||||
// Errata E2: tokens in an enumerated or NOTATION list must be
|
||||
// distinct.
|
||||
if let duplicate = duplicateToken(declaration.type) {
|
||||
findings.append("attribute '\(declaration.name)' on '\(element)' repeats the token '\(duplicate)'")
|
||||
findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' repeats the token '\(duplicate)'", at: path))
|
||||
}
|
||||
if let problem = defaultProblem(declaration) {
|
||||
findings.append("attribute '\(declaration.name)' on '\(element)' \(problem)")
|
||||
findings.append(Finding(reason: "attribute '\(declaration.name)' on '\(element)' \(problem)", at: path))
|
||||
}
|
||||
}
|
||||
return findings
|
||||
|
||||
@ -17,8 +17,8 @@ public extension PureXML.Validation {
|
||||
/// Validity findings about the declarations themselves (duplicate
|
||||
/// element types, repeated mixed-content names, multiple ID attributes,
|
||||
/// undeclared notations in NOTATION lists, illegal attribute defaults),
|
||||
/// reported once per validation at the document root.
|
||||
let declarationErrors: [String]
|
||||
/// each located at the declaration it is about.
|
||||
let declarationErrors: [PureXML.Validation.ValidationError]
|
||||
/// Whether the document declared `standalone='yes'`, which forbids
|
||||
/// depending on external declarations (2.9).
|
||||
let standalone: Bool
|
||||
@ -43,7 +43,13 @@ public extension PureXML.Validation {
|
||||
parsedAttributes[name] = AttributeListParser.parse(body)
|
||||
}
|
||||
attributes = parsedAttributes
|
||||
declarationErrors = documentType.validityFindings + Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations)
|
||||
let scanned = documentType.validityFindings.map { finding in
|
||||
PureXML.Validation.ValidationError(
|
||||
reason: finding.reason,
|
||||
at: finding.subject.map { [PureXML.Validation.PathKey.element($0)] } ?? [],
|
||||
)
|
||||
}
|
||||
declarationErrors = scanned + Self.declarationFindings(documentType, attributes: parsedAttributes, notations: notations)
|
||||
self.standalone = standalone
|
||||
externalElementModels = Set(documentType.elementModels.keys).subtracting(documentType.internalElementModels)
|
||||
var external: [String: [AttributeDeclaration]] = [:]
|
||||
|
||||
@ -119,13 +119,12 @@ public extension PureXML.Validation {
|
||||
|
||||
/// The DTD's own declarations are valid (duplicate element types,
|
||||
/// repeated mixed names, multiple IDs, undeclared notations in lists,
|
||||
/// illegal defaults). Reported once, at the document root.
|
||||
/// illegal defaults). Emitted once per validation, each finding
|
||||
/// already located at the declaration it is about.
|
||||
static var declarationValidity: Validation<PureXML.Model.Node, DTDSchema> {
|
||||
.init(
|
||||
description: "The DTD declarations satisfy their validity constraints",
|
||||
check: { context in
|
||||
context.document.declarationErrors.map { DTDFailure(reason: $0, at: context.codingPath) }
|
||||
},
|
||||
check: { context in context.document.declarationErrors },
|
||||
when: { $0.codingPath.isEmpty },
|
||||
)
|
||||
}
|
||||
|
||||
@ -25,6 +25,15 @@ struct DTDValidityConstraintTests {
|
||||
#expect(try errors(xml).contains { $0.contains("declared more than once") })
|
||||
}
|
||||
|
||||
@Test("Declaration findings carry the declaration's coding path")
|
||||
func test_findingsAreLocated() throws {
|
||||
let xml = "<!DOCTYPE r [<!ELEMENT r EMPTY><!ELEMENT x (#PCDATA)><!ELEMENT x (#PCDATA)><!ATTLIST r a (one|one) #IMPLIED>]>\n<r/>"
|
||||
let rendered = try PureXML.validateAgainstInternalDTD(xml).map { String(describing: $0) }
|
||||
#expect(rendered.contains { $0.hasSuffix("at path: x") })
|
||||
#expect(rendered.contains { $0.hasSuffix("at path: r/@a") })
|
||||
#expect(!rendered.contains { $0.contains("at root of document") })
|
||||
}
|
||||
|
||||
@Test("Mixed content may not repeat a name")
|
||||
func test_mixedDuplicates() throws {
|
||||
let xml = "<!DOCTYPE r [<!ELEMENT r EMPTY><!ELEMENT y (#PCDATA|x|x)*>]>\n<r/>"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user