Merge: RELAX NG attribute-error detail

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 01:11:54 +02:00
commit 2551fd16fd
3 changed files with 46 additions and 5 deletions

View File

@ -33,7 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unexpected elements, invalid or missing attributes, invalid text/datatype
content, and missing required content. A datatype mismatch quotes the offending
value and names the expected type (`'abc' is not a valid integer`), and a value
mismatch quotes both the actual text and the required literal. `RelaxNG.validation()` exposes the schema
mismatch quotes both the actual text and the required literal. An attribute
failure distinguishes an undeclared attribute from a declared one with a bad
value, quoting the value and naming its type. `RelaxNG.validation()` exposes the schema
as a `Validation<Node, Void>`, so RELAX NG composes with the same validation
framework as the XSD, DTD, and Schematron rules. The boolean engine remains the
authority on validity; the walk only places and explains the failures.

View File

@ -37,10 +37,7 @@ extension PureXML.Schema.RelaxNGEngine {
for attribute in element.attributes where !Self.isNamespaceDeclaration(attribute) {
let next = attributeDeriv(residual, attribute)
if isNotAllowed(next) {
errors.append(.init(
reason: "attribute @\(attribute.name.description) is not allowed on <\(name)> or has an invalid value",
at: path + [.attribute(attribute.name.description)],
))
errors.append(.init(reason: attributeDetail(residual, attribute, on: name), at: path + [.attribute(attribute.name.description)]))
} else {
residual = next
}
@ -115,6 +112,32 @@ extension PureXML.Schema.RelaxNGEngine {
return (forceClose(opened), false)
}
/// Explains an attribute failure: an undeclared attribute, or, when the name
/// is declared, why its value is invalid (reusing the datatype detail).
private func attributeDetail(_ residual: Pattern, _ attribute: PureXML.Model.Attribute, on name: String) -> String {
guard let content = attributeContent(residual, attribute.name, visiting: []) else {
return "attribute @\(attribute.name.description) is not allowed on <\(name)>"
}
return "attribute @\(attribute.name.description) on <\(name)>: \(textDetail(content, attribute.value))"
}
/// The value pattern of a declared attribute whose name matches, or nil when
/// no attribute of that name is permitted at this point.
private func attributeContent(_ pattern: Pattern, _ wanted: PureXML.Model.QualifiedName, visiting: Set<String>) -> Pattern? {
switch pattern {
case let .attribute(nameClass, content):
nameClass.contains(wanted) ? content : nil
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
attributeContent(lhs, wanted, visiting: visiting) ?? attributeContent(rhs, wanted, visiting: visiting)
case let .after(lhs, _), let .oneOrMore(lhs):
attributeContent(lhs, wanted, visiting: visiting)
case let .ref(reference):
visiting.contains(reference) ? nil : attributeContent(resolve(reference), wanted, visiting: visiting.union([reference]))
default:
nil
}
}
/// Explains why `value` does not satisfy the text/datatype `pattern`, quoting
/// the offending value and naming the datatype it failed (`'abc' is not a
/// valid integer`) or the literal it should have matched, falling back to a

View File

@ -78,9 +78,25 @@ struct RelaxNGLocatedErrorTests {
let found = try errors(rng, "<n count='abc'/>")
#expect(found.count == 1)
#expect(found.first?.reason.contains("@count") == true)
// The declared attribute's bad value is quoted and the type named.
#expect(found.first?.reason.contains("'abc'") == true)
#expect(found.first?.reason.contains("integer") == true)
#expect(found.first?.codingPath.map(\.stringValue) == ["n", "@count"])
}
@Test("An undeclared attribute is distinguished from a bad value")
func test_undeclaredAttribute() throws {
let rng = """
<element name="n" xmlns="http://relaxng.org/ns/structure/1.0">
<attribute name="a"><text/></attribute>
</element>
"""
let found = try errors(rng, "<n a='x' b='y'/>")
#expect(found.count == 1)
#expect(found.first?.reason.contains("@b") == true)
#expect(found.first?.reason.contains("is not allowed") == true)
}
@Test("Invalid datatype content is located on the element")
func test_dataValue() throws {
let rng = """