diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88f5a..1bf43e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, 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. diff --git a/Sources/Schema/RelaxNGDiagnostics.swift b/Sources/Schema/RelaxNGDiagnostics.swift index c93ed63..fbabd61 100644 --- a/Sources/Schema/RelaxNGDiagnostics.swift +++ b/Sources/Schema/RelaxNGDiagnostics.swift @@ -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) -> 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 diff --git a/Tests/RelaxNGLocatedErrorTests.swift b/Tests/RelaxNGLocatedErrorTests.swift index 4c652e8..a758b33 100644 --- a/Tests/RelaxNGLocatedErrorTests.swift +++ b/Tests/RelaxNGLocatedErrorTests.swift @@ -78,9 +78,25 @@ struct RelaxNGLocatedErrorTests { let found = try errors(rng, "") #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 = """ + + + + """ + let found = try errors(rng, "") + #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 = """