PureXML/Sources/Regex/RegexCategory.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

176 lines
8.8 KiB
Swift

extension PureXML.Regex {
/// A `\p{...}` / `\P{...}` Unicode category or block test. A category maps to
/// the scalar's Unicode general category through Swift's own `generalCategory`,
/// so no character tables are vendored; an `Is<Block>` name tests a contiguous
/// Unicode block. An unknown name is rejected at compile time.
struct CategoryPredicate: Equatable, Sendable {
let name: String
let negated: Bool
init?(name: String, negated: Bool) {
guard CategoryMatcher.isKnown(name) else { return nil }
self.name = name
self.negated = negated
}
func contains(_ scalar: Unicode.Scalar) -> Bool {
let hit = CategoryMatcher.matches(name, scalar)
return negated ? !hit : hit
}
}
/// Resolves `\p{...}` names: a one-letter general-category group (`L`, `N`, ...),
/// a two-letter category (`Lu`, `Nd`, ...), or an `Is<Block>` block name.
enum CategoryMatcher {
static func isKnown(_ name: String) -> Bool {
if name.hasPrefix("Is") {
return blocks[String(name.dropFirst(2))] != nil
}
return groups.contains(name) || codeValues.contains(name)
}
static func matches(_ name: String, _ scalar: Unicode.Scalar) -> Bool {
let code = XSDCategory.generalCategoryCode(scalar)
if name.hasPrefix("Is") {
guard let range = blocks[String(name.dropFirst(2))] else { return false }
return range.contains(scalar.value)
}
return name.count == 1 ? code.hasPrefix(name) : code == name
}
private static let groups: Set<String> = ["L", "M", "N", "P", "S", "Z", "C"]
private static let codeValues = Set(codes.values)
private static let codes: [Unicode.GeneralCategory: String] = [
.uppercaseLetter: "Lu", .lowercaseLetter: "Ll", .titlecaseLetter: "Lt",
.modifierLetter: "Lm", .otherLetter: "Lo",
.nonspacingMark: "Mn", .spacingMark: "Mc", .enclosingMark: "Me",
.decimalNumber: "Nd", .letterNumber: "Nl", .otherNumber: "No",
.connectorPunctuation: "Pc", .dashPunctuation: "Pd", .openPunctuation: "Ps",
.closePunctuation: "Pe", .initialPunctuation: "Pi", .finalPunctuation: "Pf",
.otherPunctuation: "Po",
.mathSymbol: "Sm", .currencySymbol: "Sc", .modifierSymbol: "Sk", .otherSymbol: "So",
.spaceSeparator: "Zs", .lineSeparator: "Zl", .paragraphSeparator: "Zp",
.control: "Cc", .format: "Cf", .surrogate: "Cs", .privateUse: "Co", .unassigned: "Cn",
]
/// The complete set of Unicode block names XSD 1.0 admits in
/// `\p{Is<Block>}` (the Unicode 3.1 blocks the regular-expression appendix
/// fixes), each mapped to its code-point range. The set is exhaustive: an
/// `Is<Block>` name outside it is no XSD block and is rejected at compile
/// time (`isMalformedProperty` is gone; the predicate's presence here is the
/// sole authority), so a pattern never matches against a fabricated range.
/// XSD 1.0 freezes this list at Unicode 3.1, so a block introduced later
/// (e.g. `CyrillicSupplement`) is by design not an XSD 1.0 block and is
/// rejected, even though some processors accept any `Is[A-Za-z0-9-]+`
/// leniently. This is a deliberate spec-strict choice, not an oversight.
/// The three surrogate blocks are listed so a pattern naming them still
/// compiles, but their ranges hold no scalar value, so they match nothing.
/// `Specials` uses the main `FFF0..FFFD` range (the spec's separate `FEFF`
/// half is the byte-order mark, not matched here). `GreekandCoptic` is the
/// later Unicode rename of `Greek`, kept as a lenient alias.
private static let blocks: [String: ClosedRange<UInt32>] = [
"BasicLatin": 0x0000 ... 0x007F,
"Latin-1Supplement": 0x0080 ... 0x00FF,
"LatinExtended-A": 0x0100 ... 0x017F,
"LatinExtended-B": 0x0180 ... 0x024F,
"IPAExtensions": 0x0250 ... 0x02AF,
"SpacingModifierLetters": 0x02B0 ... 0x02FF,
"CombiningDiacriticalMarks": 0x0300 ... 0x036F,
"Greek": 0x0370 ... 0x03FF,
"GreekandCoptic": 0x0370 ... 0x03FF,
"Cyrillic": 0x0400 ... 0x04FF,
"Armenian": 0x0530 ... 0x058F,
"Hebrew": 0x0590 ... 0x05FF,
"Arabic": 0x0600 ... 0x06FF,
"Syriac": 0x0700 ... 0x074F,
"Thaana": 0x0780 ... 0x07BF,
"Devanagari": 0x0900 ... 0x097F,
"Bengali": 0x0980 ... 0x09FF,
"Gurmukhi": 0x0A00 ... 0x0A7F,
"Gujarati": 0x0A80 ... 0x0AFF,
"Oriya": 0x0B00 ... 0x0B7F,
"Tamil": 0x0B80 ... 0x0BFF,
"Telugu": 0x0C00 ... 0x0C7F,
"Kannada": 0x0C80 ... 0x0CFF,
"Malayalam": 0x0D00 ... 0x0D7F,
"Sinhala": 0x0D80 ... 0x0DFF,
"Thai": 0x0E00 ... 0x0E7F,
"Lao": 0x0E80 ... 0x0EFF,
"Tibetan": 0x0F00 ... 0x0FFF,
"Myanmar": 0x1000 ... 0x109F,
"Georgian": 0x10A0 ... 0x10FF,
"HangulJamo": 0x1100 ... 0x11FF,
"Ethiopic": 0x1200 ... 0x137F,
"Cherokee": 0x13A0 ... 0x13FF,
"UnifiedCanadianAboriginalSyllabics": 0x1400 ... 0x167F,
"Ogham": 0x1680 ... 0x169F,
"Runic": 0x16A0 ... 0x16FF,
"Khmer": 0x1780 ... 0x17FF,
"Mongolian": 0x1800 ... 0x18AF,
"LatinExtendedAdditional": 0x1E00 ... 0x1EFF,
"GreekExtended": 0x1F00 ... 0x1FFF,
"GeneralPunctuation": 0x2000 ... 0x206F,
"SuperscriptsandSubscripts": 0x2070 ... 0x209F,
"CurrencySymbols": 0x20A0 ... 0x20CF,
"CombiningMarksforSymbols": 0x20D0 ... 0x20FF,
"LetterlikeSymbols": 0x2100 ... 0x214F,
"NumberForms": 0x2150 ... 0x218F,
"Arrows": 0x2190 ... 0x21FF,
"MathematicalOperators": 0x2200 ... 0x22FF,
"MiscellaneousTechnical": 0x2300 ... 0x23FF,
"ControlPictures": 0x2400 ... 0x243F,
"OpticalCharacterRecognition": 0x2440 ... 0x245F,
"EnclosedAlphanumerics": 0x2460 ... 0x24FF,
"BoxDrawing": 0x2500 ... 0x257F,
"BlockElements": 0x2580 ... 0x259F,
"GeometricShapes": 0x25A0 ... 0x25FF,
"MiscellaneousSymbols": 0x2600 ... 0x26FF,
"Dingbats": 0x2700 ... 0x27BF,
"BraillePatterns": 0x2800 ... 0x28FF,
"CJKRadicalsSupplement": 0x2E80 ... 0x2EFF,
"KangxiRadicals": 0x2F00 ... 0x2FDF,
"IdeographicDescriptionCharacters": 0x2FF0 ... 0x2FFF,
"CJKSymbolsandPunctuation": 0x3000 ... 0x303F,
"Hiragana": 0x3040 ... 0x309F,
"Katakana": 0x30A0 ... 0x30FF,
"Bopomofo": 0x3100 ... 0x312F,
"HangulCompatibilityJamo": 0x3130 ... 0x318F,
"Kanbun": 0x3190 ... 0x319F,
"BopomofoExtended": 0x31A0 ... 0x31BF,
"EnclosedCJKLettersandMonths": 0x3200 ... 0x32FF,
"CJKCompatibility": 0x3300 ... 0x33FF,
"CJKUnifiedIdeographsExtensionA": 0x3400 ... 0x4DB5,
"CJKUnifiedIdeographs": 0x4E00 ... 0x9FFF,
"YiSyllables": 0xA000 ... 0xA48F,
"YiRadicals": 0xA490 ... 0xA4CF,
"HangulSyllables": 0xAC00 ... 0xD7A3,
"PrivateUse": 0xE000 ... 0xF8FF,
"CJKCompatibilityIdeographs": 0xF900 ... 0xFAFF,
"AlphabeticPresentationForms": 0xFB00 ... 0xFB4F,
"ArabicPresentationForms-A": 0xFB50 ... 0xFDFF,
"CombiningHalfMarks": 0xFE20 ... 0xFE2F,
"CJKCompatibilityForms": 0xFE30 ... 0xFE4F,
"SmallFormVariants": 0xFE50 ... 0xFE6F,
"ArabicPresentationForms-B": 0xFE70 ... 0xFEFF,
"HalfwidthandFullwidthForms": 0xFF00 ... 0xFFEF,
"Specials": 0xFFF0 ... 0xFFFD,
// Surrogate blocks: valid XSD block names whose ranges hold no scalar
// value (lone surrogates are not Unicode scalars), so they match nothing.
"HighSurrogates": 0xD800 ... 0xDB7F,
"HighPrivateUseSurrogates": 0xDB80 ... 0xDBFF,
"LowSurrogates": 0xDC00 ... 0xDFFF,
// Supplementary-plane blocks (Unicode 3.1, beyond the BMP).
"OldItalic": 0x10300 ... 0x1032F,
"Gothic": 0x10330 ... 0x1034F,
"Deseret": 0x10400 ... 0x1044F,
"ByzantineMusicalSymbols": 0x1D000 ... 0x1D0FF,
"MusicalSymbols": 0x1D100 ... 0x1D1FF,
"MathematicalAlphanumericSymbols": 0x1D400 ... 0x1D7FF,
"CJKUnifiedIdeographsExtensionB": 0x20000 ... 0x2A6D6,
"CJKCompatibilityIdeographsSupplement": 0x2F800 ... 0x2FA1F,
"Tags": 0xE0000 ... 0xE007F,
]
}
}