PureXML/Tests/XSDNameCharacterTests.swift
Mihaela Mihaljevic 2eb9f83958
fix(schema): back XSD \i and \c with the XML 1.0 Appendix B name classes (#378)
XSD 1.0 (Datatypes Appendix F) defines the regex escape \i as the characters
that may begin an XML Name (Letter | '_' | ':') and \c as the characters that
may appear in one (NameChar), citing the XML 1.0 Second Edition productions:
the enumerated, Unicode-2.0-era character classes of XML 1.0 Appendix B
(BaseChar, Ideographic, CombiningChar, Digit, Extender). \i and \c were wired
to PureXML.Parsing.XMLCharacter, which correctly implements the Fifth Edition
Name production used to parse live XML, but the two genuinely differ: the Fifth
Edition admits whole blocks where the Second Edition has gaps, so \c
over-accepted, e.g. the combining mark U+0346 (COMBINING BRIDGE ABOVE), which
Appendix B's CombiningChar excludes (its first range ends at U+0345).

Add XSDNameCharacter, transcribed clean-room from the Appendix B grammar
(identical across editions one through four) and stored as sorted, non-
overlapping code-point ranges with binary-search membership; the parser's own
name check is untouched. The two tables reproduce the W3C XSTS \i/\c
enumerations (reZ005, reZ006: 34516 and 35122 code points) exactly and in both
directions, so the more restrictive definition rejects no spec-valid input:
there is no false-positive surface.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
12 -> 11 (reZ006i, whose value is U+0346, now rejects), both false-positive
buckets at 0. Full suite (1834 tests) green; an adversarial critic brute-forced
the binary search over the whole code-point space (0 mismatches), confirmed the
tables are sorted and non-overlapping and equal the oracle sets, and that the
parser's Fifth Edition path is left intact. Found by triaging the invalid-
instance tail to a single boundary code point (U+0345 valid vs U+0346 invalid)
and reading the XML 1.0 Appendix B grammar.
2026-06-29 07:05:27 +02:00

66 lines
3.0 KiB
Swift

import Testing
@testable import PureXML
/// The XSD `\i`/`\c` regex escapes use the XML 1.0 Second Edition name classes
/// (Appendix B), a fixed Unicode-2.0 repertoire, distinct from the parser's
/// Fifth Edition Name production. These exercise the boundary cases the W3C XSTS
/// `\i`/`\c` tests (reZ005, reZ006) probe and the deliberate divergence from
/// `PureXML.Parsing.XMLCharacter`.
@Suite("XSD \\i / \\c name-character classes (XML 1.0 Appendix B)")
struct XSDNameCharacterTests {
private typealias NameChar = PureXML.Regex.XSDNameCharacter
private func matches(_ pattern: String, _ scalar: UInt32) throws -> Bool {
try PureXML.Regex.matches(pattern, String(#require(Unicode.Scalar(scalar))))
}
@Test("\\c admits the Appendix B CombiningChar range but not the gap above it")
func test_combiningCharBoundary() throws {
// Appendix B CombiningChar begins [#x0300-#x0345]; U+0346 starts the gap.
// XSTS reZ006: U+0345 is a valid \c, U+0346 is not (the failing case).
#expect(NameChar.isNameChar("\u{0345}"))
#expect(!NameChar.isNameChar("\u{0346}"))
#expect(try matches("\\c", 0x0345))
#expect(try !matches("\\c", 0x0346))
// \C is the negation.
#expect(try !matches("\\C", 0x0345))
#expect(try matches("\\C", 0x0346))
}
@Test("\\c and \\i deliberately differ from the parser's Fifth Edition name check")
func test_divergesFromParser() {
// U+0346 is a Fifth Edition name character (the parser accepts it), but not
// an XML 1.0 Second Edition one, so XSD \c must reject it. This is the bug
// that wiring \c to the parser caused.
#expect(PureXML.Parsing.XMLCharacter.isNameChar("\u{0346}"))
#expect(!NameChar.isNameChar("\u{0346}"))
}
@Test("\\i is the name-start class: letters, '_' and ':', not digits or combining marks")
func test_nameStartClass() throws {
#expect(NameChar.isNameStart("a"))
#expect(NameChar.isNameStart("_"))
#expect(NameChar.isNameStart(":"))
// A digit may appear in a Name but may not start one.
#expect(!NameChar.isNameStart("0"))
#expect(NameChar.isNameChar("0"))
// A combining mark is a NameChar but not a name-start.
#expect(!NameChar.isNameStart("\u{0345}"))
#expect(NameChar.isNameChar("\u{0345}"))
// XSTS reZ005 boundary: '[' (U+005B) is neither.
#expect(try !matches("\\i", 0x005B))
#expect(try matches("\\i", 0x0061))
}
@Test("\\c covers the ASCII name punctuation and excludes a clear non-name char")
func test_asciiAndNegative() {
let nameChars: [Unicode.Scalar] = ["A", "z", "0", "9", "-", ".", "_", ":"]
for scalar in nameChars {
#expect(NameChar.isNameChar(scalar), "U+\(String(scalar.value, radix: 16)) is a NameChar")
}
// '+' (U+002B) and ' ' (space) are not NameChars in any edition.
#expect(!NameChar.isNameChar("+"))
#expect(!NameChar.isNameChar(" "))
}
}