XSD 1.0 fixes the regular-expression repertoire at Unicode 3.2 (Datatypes
Appendix F), where U+1369..U+1371 (ETHIOPIC DIGIT ONE..NINE) are general
category Nd, each carrying a decimal value; a later Unicode reclassified them
to No. XSDCategory derives \d (== \p{Nd}) from the running platform's category,
pinned to 3.2 by an assigned-in-3.2 gate plus a per-code-point override table,
but the table omitted these nine, so \d used the modern No: a valid \d-constrained
Ethiopic digit was wrongly rejected (cardinal-rule false positive) and a
\D-constrained one wrongly accepted.
Pin the nine to Nd. This is the complete set of code points that were Nd at 3.2
and are not today; the reverse direction is empty (every later-Nd point was
unassigned at 3.2 and already reports Cn), verified by diffing the 3.2 and current
Unicode Nd sets. \w is unaffected: an Ethiopic numeral is category N either way,
neither punctuation, separator, nor other.
Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
14 -> 12 (reT17, reT38, the \D cases, now reject), and the matching \d-valid cases
reS17/reS38/reZ004 no longer need a suspect-test exclusion and pass as ordinary
agreements, with both false-positive buckets at 0. Full suite (1830 tests) green;
an adversarial over-rejection critic re-derived the Nd delta bidirectionally and
confirmed no false-positive surface. A regression-pinning test exercises the
production regex path, including the boundary that U+1372 (No even at 3.2) is
not \d.
142 lines
6.0 KiB
Swift
142 lines
6.0 KiB
Swift
import Testing
|
|
@testable import PureXML
|
|
|
|
@Suite("Regex Unicode categories and class subtraction")
|
|
struct RegexCategoryTests {
|
|
private func matches(_ pattern: String, _ string: String) throws -> Bool {
|
|
try PureXML.Regex.matches(pattern, string)
|
|
}
|
|
|
|
@Test("\\p{L} matches a letter group and rejects non-letters")
|
|
func test_letterGroup() throws {
|
|
#expect(try matches("\\p{L}+", "abcÅ"))
|
|
#expect(try !matches("\\p{L}+", "ab1"))
|
|
}
|
|
|
|
@Test("\\p{Lu} and \\p{Ll} distinguish case")
|
|
func test_caseCategories() throws {
|
|
#expect(try matches("\\p{Lu}\\p{Ll}+", "Hello"))
|
|
#expect(try !matches("\\p{Lu}\\p{Ll}+", "hello"))
|
|
}
|
|
|
|
@Test("\\p{Nd} matches decimal digits")
|
|
func test_decimalNumber() throws {
|
|
#expect(try matches("\\p{Nd}+", "2026"))
|
|
#expect(try !matches("\\p{Nd}+", "12a"))
|
|
}
|
|
|
|
@Test("Ethiopic digits are Nd at the pinned Unicode 3.2 repertoire")
|
|
func test_ethiopicDigitsAreNd() throws {
|
|
// U+1369..U+1371 (ETHIOPIC DIGIT ONE..NINE) were Nd at Unicode 3.2 (each
|
|
// had a decimal value) and were reclassified to No later. XSD pins the
|
|
// repertoire at 3.2, so `\d` (== \p{Nd}) must match them and `\D` must not
|
|
// (XSTS reS17/reS38 valid, reT17/reT38 invalid; reZ004 the full \d set).
|
|
for value: UInt32 in 0x1369 ... 0x1371 {
|
|
let text = try String(#require(Unicode.Scalar(value)))
|
|
let matchesDigit = try matches("\\d", text)
|
|
let matchesNd = try matches("\\p{Nd}", text)
|
|
let matchesNonDigit = try matches("\\D", text)
|
|
let matchesNo = try matches("\\p{No}", text)
|
|
#expect(matchesDigit, "U+\(String(value, radix: 16)) must match \\d")
|
|
#expect(matchesNd)
|
|
#expect(!matchesNonDigit)
|
|
#expect(!matchesNo)
|
|
}
|
|
// The override is scoped to the unit digits: U+1372 (ETHIOPIC NUMBER TEN)
|
|
// was No even at 3.2, so it is not a `\d` and is a `\p{No}`.
|
|
let ten = "\u{1372}"
|
|
#expect(try !matches("\\d", ten))
|
|
#expect(try matches("\\p{No}", ten))
|
|
}
|
|
|
|
@Test("\\P{...} negates a category")
|
|
func test_negatedCategory() throws {
|
|
#expect(try matches("\\P{L}+", "123 !"))
|
|
#expect(try !matches("\\P{L}+", "12a"))
|
|
}
|
|
|
|
@Test("A category inside a character class composes with ranges")
|
|
func test_categoryInClass() throws {
|
|
// A letter or an underscore.
|
|
#expect(try matches("[\\p{L}_]+", "a_b"))
|
|
#expect(try !matches("[\\p{L}_]+", "a-b"))
|
|
}
|
|
|
|
@Test("\\p{IsBasicLatin} tests a Unicode block")
|
|
func test_block() throws {
|
|
#expect(try matches("\\p{IsBasicLatin}+", "abc"))
|
|
#expect(try !matches("\\p{IsBasicLatin}+", "abç"))
|
|
}
|
|
|
|
@Test("\\p{IsArabic} tests the Arabic block")
|
|
func test_arabicBlock() throws {
|
|
#expect(try matches("\\p{IsArabic}+", "\u{0627}\u{0628}"))
|
|
// Adjacent Arabic block scalars that Swift fuses into one grapheme cluster.
|
|
#expect(try matches("\\p{IsArabic}+", "\u{0600}\u{0601}"))
|
|
// Base letter plus combining mark: two XSD characters, one grapheme.
|
|
#expect(try matches("\\p{IsArabic}+", "\u{0627}\u{064B}"))
|
|
#expect(try !matches("\\p{IsArabic}+", "abc"))
|
|
}
|
|
|
|
@Test("An unknown category name is a compile error")
|
|
func test_unknownCategory() {
|
|
#expect(throws: PureXML.Regex.RegexError.invalidProperty) {
|
|
_ = try PureXML.Regex.Pattern("\\p{Zz}")
|
|
}
|
|
// An unknown Is<Block> name is rejected too (the block set is complete).
|
|
#expect(throws: PureXML.Regex.RegexError.invalidProperty) {
|
|
_ = try PureXML.Regex.Pattern("\\p{IsaA0-a9}")
|
|
}
|
|
}
|
|
|
|
@Test("Supplementary-plane Is<Block> names match beyond the BMP")
|
|
func test_supplementaryBlock() throws {
|
|
// U+1D11E (musical symbol G clef) is in MusicalSymbols (1D100..1D1FF).
|
|
#expect(try matches("\\p{IsMusicalSymbols}+", "\u{1D11E}"))
|
|
#expect(try !matches("\\p{IsMusicalSymbols}+", "a"))
|
|
// U+10300 (Old Italic letter A) is in OldItalic (10300..1032F).
|
|
#expect(try matches("\\p{IsOldItalic}", "\u{10300}"))
|
|
}
|
|
|
|
@Test("Surrogate Is<Block> names compile but match no scalar")
|
|
func test_surrogateBlockMatchesNothing() throws {
|
|
// Lone surrogates are not Unicode scalars, so the block matches nothing,
|
|
// but the name is valid XSD so the pattern still compiles (no false reject).
|
|
#expect(try !matches("\\p{IsHighSurrogates}", "a"))
|
|
#expect(try !matches("\\p{IsLowSurrogates}+", "\u{1D11E}"))
|
|
}
|
|
|
|
@Test("Character-class subtraction removes the subtrahend")
|
|
func test_subtraction() throws {
|
|
#expect(try matches("[a-z-[aeiou]]+", "bcdfg"))
|
|
#expect(try !matches("[a-z-[aeiou]]+", "bce"))
|
|
}
|
|
|
|
@Test("A negated class with subtraction excludes both the base and the subtrahend")
|
|
func test_negatedSubtraction() throws {
|
|
// `[^cde-[ag]]` is (not c/d/e) minus a/g, i.e. excludes {a,c,d,e,g}
|
|
// (XSD charClassSub: the difference of the NEGATED group and the
|
|
// subtrahend). XSTS RegexTest_430: `agbfxyzga` contains a and g, so it
|
|
// must NOT match `[^cde-[ag]]+`.
|
|
#expect(try !matches("[^cde-[ag]]+", "agbfxyzga"))
|
|
#expect(try matches("[^cde-[ag]]+", "bfxyz")) // none of a,c,d,e,g
|
|
#expect(try !matches("[^cde-[ag]]+", "a")) // a is subtracted out
|
|
#expect(try !matches("[^cde-[ag]]+", "c")) // c is in the negated base
|
|
#expect(try matches("[^cde-[ag]]+", "b")) // b is admitted
|
|
}
|
|
|
|
@Test("Subtraction nests")
|
|
func test_nestedSubtraction() throws {
|
|
// a-z, minus (b-d minus c): so b and d are removed, c stays.
|
|
#expect(try matches("[a-z-[b-d-[c]]]+", "acez"))
|
|
#expect(try !matches("[a-z-[b-d-[c]]]+", "abz"))
|
|
}
|
|
|
|
@Test("Subtraction composes with a category subtrahend")
|
|
func test_subtractCategory() throws {
|
|
// Any letter that is not lowercase.
|
|
#expect(try matches("[\\p{L}-[\\p{Ll}]]+", "ABC"))
|
|
#expect(try !matches("[\\p{L}-[\\p{Ll}]]+", "ABc"))
|
|
}
|
|
}
|