feat(relaxng): quote the offending value and name the datatype in text errors

A RELAX NG text/datatype mismatch now reports the rich datatype reason
(quoting the value and naming the type, e.g. 'abc' is not a valid
integer) and a value mismatch quotes both the actual text and the
required literal, instead of a generic "text content is not valid". The
detail is peeled from the content pattern at the failure point through
choice, group, list, and ref.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 01:09:29 +02:00
parent 3021615b66
commit 3be6832c23
3 changed files with 42 additions and 2 deletions

View File

@ -31,7 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
once rather than only the first. Errors carry recovery hints (`expected <a>,
<b>`) naming the element types accepted at the failure point, and distinguish
unexpected elements, invalid or missing attributes, invalid text/datatype
content, and missing required content. `RelaxNG.validation()` exposes the schema
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
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

@ -73,7 +73,7 @@ extension PureXML.Schema.RelaxNGEngine {
case let .text(value), let .cdata(value):
let next = textDeriv(residual, value)
if isNotAllowed(next) {
errors.append(.init(reason: "text content is not valid in <\(name)>", at: path))
errors.append(.init(reason: "\(textDetail(residual, value)) in <\(name)>", at: path))
sequenceBroken = true
} else {
residual = next
@ -115,6 +115,31 @@ extension PureXML.Schema.RelaxNGEngine {
return (forceClose(opened), false)
}
/// 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
/// generic message when the content admits no text at all.
private func textDetail(_ pattern: Pattern, _ value: String) -> String {
switch pattern {
case let .data(type):
type.validate(value) ?? "text content '\(value)' is not valid"
case let .value(type, literal):
type.valueMatches(value, literal: literal)
? "text content '\(value)' is not valid"
: "text content '\(value)' must be '\(literal)'"
case let .list(inner):
textDetail(inner, value)
case let .choice(lhs, rhs):
textDetail(isNotAllowed(textDeriv(lhs, value)) ? rhs : lhs, value)
case let .group(lhs, _), let .after(lhs, _):
textDetail(lhs, value)
case let .ref(name):
textDetail(resolve(name), value)
default:
"text content is not allowed"
}
}
/// The content (the `after` left side) an opened element's children must match.
private func contentOf(_ pattern: Pattern) -> Pattern {
switch pattern {

View File

@ -90,6 +90,19 @@ struct RelaxNGLocatedErrorTests {
"""
let found = try reasons(rng, "<n>abc</n>")
#expect(found.contains { $0.contains("<n>") })
// The message quotes the offending value and names the expected datatype.
#expect(found.contains { $0.contains("'abc'") && $0.contains("integer") })
}
@Test("A value mismatch quotes both the actual text and the required literal")
func test_valueMismatchDetail() throws {
let rng = """
<element name="flag" xmlns="http://relaxng.org/ns/structure/1.0">
<value>on</value>
</element>
"""
let found = try reasons(rng, "<flag>off</flag>")
#expect(found.contains { $0.contains("'off'") && $0.contains("'on'") })
}
@Test("The validation() value composes with the framework like the other validators")