fix(regex): use the XSD definition of \w and \W

\w was the Perl word class (alphabetic, decimal, underscore). XSD 1.0 Datatypes
F.1 defines \w as [#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}], so symbols and marks are
word characters and the connector `_` (Pc) is not; \W is its complement and had
wrongly matched symbols. \w is now computed as "not punctuation, separator, or
other" via the category matcher.

XSTS: invalid instances accepted 231 to 210. The internal regex fixtures that
assumed `_` is a word character encoded Perl semantics and were corrected to the
XSD definition (symbols are word chars, `_` is not), confirmed against the XSTS
reV cases.

Refs #147, #129.
This commit is contained in:
Mihaela Mihaljevic 2026-06-13 18:25:08 +02:00
parent 47e20ff90b
commit 07d3ddfa97
5 changed files with 15 additions and 4 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- The regex `\w` and `\W` escapes now follow the XSD definition (#147). `\w` was the Perl class (alphabetic, decimal, underscore); XSD defines it as `[#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}]`, so symbols and marks are word characters and the connector `_` (Pc) is not. `\W` correspondingly matched symbols it should not. XSTS: invalid instances accepted 231 to 210. (Internal regex fixtures that assumed `_` was a word character were corrected.)
- `block="substitution"` on a substitution-group head is now enforced (#147). The substitution filter only dropped members whose type derived from the head's type by a blocked method (`extension`/`restriction`); the `substitution` keyword, which forbids substitution-group substitution outright regardless of the member's type, was ignored, so a same-type member still substituted under a substitution block. XSTS: invalid instances accepted 238 to 231, valid schemas rejected 75 to 74.
- Element `default` and `fixed` values are honored on empty elements (#147). An empty element with a value constraint takes that value, so the empty content is valid even when the type would reject the empty string; the constraint value (not the empty string) is what is validated against the type, including any `xsi:type` override. A restriction's `enumeration` now also replaces the inherited enumeration rather than unioning with it (the new set is the value space; `pattern` still accumulates and is ANDed), which a value-constraint case under `xsi:type` exposed. XSTS: valid instances rejected 395 to 369, invalid instances accepted 240 to 238.
- Element-level `block` is now enforced against `xsi:type` substitution (#147). The `block` on an element declaration (distinct from `block` on its type) was parsed but never threaded to the instance validator, so a document substituting a blocked-derivation type via `xsi:type` was wrongly accepted. The element block is now carried through to `blockedSubstitutionError`, which also resolves the element-ref type indirection (`element:foo` to its concrete type) so the check fires for referenced children, not only the root. XSTS: invalid instances accepted 261 to 240.

View File

@ -35,7 +35,10 @@ extension PureXML.Regex {
switch self {
case .digit: scalar.properties.numericType == .decimal
case .space: scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
case .word: scalar.properties.isAlphabetic || scalar.properties.numericType == .decimal || scalar == "_"
// XSD \w is [#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}]: everything except
// punctuation, separators, and other. So symbols and marks are word
// characters, and `_` (Pc) is not, unlike the Perl definition.
case .word: !(CategoryMatcher.matches("P", scalar) || CategoryMatcher.matches("Z", scalar) || CategoryMatcher.matches("C", scalar))
case .nameStart: PureXML.Parsing.XMLCharacter.isNameStart(scalar)
case .nameChar: PureXML.Parsing.XMLCharacter.isNameChar(scalar)
case .anyButLineBreak: scalar != "\n" && scalar != "\r"

View File

@ -21,7 +21,9 @@ struct ConformanceRegexTests {
// Character-class escapes.
PatternSpec(name: "digits-ok", pattern: "\\d{3}", value: "123", valid: true),
PatternSpec(name: "digits-fail", pattern: "\\d{3}", value: "12a", valid: false),
PatternSpec(name: "word-chars", pattern: "\\w+", value: "a_1", valid: true),
// XSD \w excludes punctuation, so the connector `_` (Pc) is not a word char.
PatternSpec(name: "word-chars", pattern: "\\w+", value: "abc1", valid: true),
PatternSpec(name: "word-chars-no-underscore", pattern: "\\w", value: "_", valid: false),
PatternSpec(name: "whitespace-escape", pattern: "a\\sb", value: "a b", valid: true),
// Unicode categories and blocks.
PatternSpec(name: "category-letters", pattern: "\\p{L}+", value: "abcé", valid: true),

View File

@ -74,7 +74,12 @@ struct RegexTests {
func test_classEscapes() throws {
#expect(try matches("\\d{4}", "2026"))
#expect(try !matches("\\d{4}", "20x6"))
#expect(try matches("\\w+", "a_1"))
#expect(try matches("\\w+", "abc1"))
// XSD \w is the complement of \p{P}\p{Z}\p{C}: symbols and marks are word
// characters, while the connector `_` (Pc) and whitespace are not.
#expect(try matches("\\w", "+"))
#expect(try !matches("\\w", "_"))
#expect(try !matches("\\w", " "))
#expect(try matches("a\\sb", "a b"))
#expect(try matches("\\D+", "abc"))
}

View File

@ -31,7 +31,7 @@ struct XSTSSuiteTests {
private let knownSchemaValidRejected = 74
private let knownSchemaInvalidAccepted = 2467
private let knownInstanceValidRejected = 369
private let knownInstanceInvalidAccepted = 231
private let knownInstanceInvalidAccepted = 210
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {