feat(parsing): spec-exact XML character classification (chvalid parity)

Add PureXML.Parsing.XMLCharacter as the single source of truth for character
classes, implementing the exact XML 1.0 (Fifth Edition) Char, NameStartChar, and
NameChar productions as public scalar predicates, plus isValidName. The scanner's
name recognition now delegates to these exact ranges (replacing a loose isLetter
check) and handles precomposed and combining-mark names. Closes #35.
Reimplemented from the XML grammar; no upstream code copied. 4 tests; 98 total.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 02:52:42 +02:00
parent 96d240d715
commit c82fef085e
5 changed files with 147 additions and 7 deletions

View File

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Spec-exact XML character classification (`PureXML.Parsing.XMLCharacter`, the
libxml2 `chvalid.h` parity): the XML 1.0 (Fifth Edition) `Char`,
`NameStartChar`, and `NameChar` productions as public predicates over Unicode
scalars, plus `isValidName(_:)`. The scanner's name recognition now uses these
exact ranges (replacing a loose `isLetter` check) from a single source of truth,
and handles both precomposed and combining-mark names.
- Full encoding support. Byte input now decodes UTF-32 (both byte orders, BOM or
sniffed) and the single-byte encodings ISO-8859-1 and Windows-1252 selected by
the XML declaration's `encoding` name, in addition to UTF-8 and UTF-16. The

View File

@ -65,7 +65,7 @@ flowchart TB
Writer["#32 xmlTextWriter"]:::done
Reader["#33 xmlTextReader with validation"]:::epic
Save["#34 Serialization option parity"]:::epic
Chars["#35 Character and name validity"]:::epic
Chars["#35 Character and name validity"]:::done
Core --> Push
Core --> Sax
Core --> Html

View File

@ -101,19 +101,23 @@ extension StringProtocol {
}
extension Character {
private typealias XML = PureXML.Parsing.XMLCharacter
/// XML S production: space, tab, carriage return, line feed.
var isXMLWhitespace: Bool {
self == " " || self == "\t" || self == "\r" || self == "\n"
unicodeScalars.count == 1 && unicodeScalars.first.map(XML.isWhitespace) == true
}
/// A character allowed to start an XML name (a practical subset of the spec
/// NameStartChar production: letters, underscore, and colon).
/// Whether this character may start an XML name. A grapheme qualifies when its
/// first scalar is a NameStartChar and any trailing scalars (combining marks)
/// are NameChar.
var isXMLNameStart: Bool {
isLetter || self == "_" || self == ":"
guard let first = unicodeScalars.first, XML.isNameStart(first) else { return false }
return unicodeScalars.dropFirst().allSatisfy(XML.isNameChar)
}
/// A character allowed within an XML name after the first.
/// Whether this character may continue an XML name (every scalar a NameChar).
var isXMLNameContinuation: Bool {
isXMLNameStart || isNumber || self == "-" || self == "."
!unicodeScalars.isEmpty && unicodeScalars.allSatisfy(XML.isNameChar)
}
}

View File

@ -0,0 +1,79 @@
public extension PureXML.Parsing {
/// Character classification per the XML 1.0 (Fifth Edition) grammar, the
/// single source of truth for what bytes are legal where. Predicates operate
/// on Unicode scalars, since the grammar productions are defined over code
/// points, not graphemes.
enum XMLCharacter {
/// `S`: white space (space, tab, carriage return, line feed).
public static func isWhitespace(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x20, 0x09, 0x0D, 0x0A: true
default: false
}
}
/// `Char`: any legal XML character.
public static func isChar(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x09, 0x0A, 0x0D,
0x20 ... 0xD7FF,
0xE000 ... 0xFFFD,
0x10000 ... 0x10FFFF:
true
default:
false
}
}
/// `NameStartChar`: the first character of a name.
public static func isNameStart(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x3A, // ":"
0x41 ... 0x5A, // A-Z
0x5F, // "_"
0x61 ... 0x7A, // a-z
0xC0 ... 0xD6,
0xD8 ... 0xF6,
0xF8 ... 0x2FF,
0x370 ... 0x37D,
0x37F ... 0x1FFF,
0x200C ... 0x200D,
0x2070 ... 0x218F,
0x2C00 ... 0x2FEF,
0x3001 ... 0xD7FF,
0xF900 ... 0xFDCF,
0xFDF0 ... 0xFFFD,
0x10000 ... 0xEFFFF:
true
default:
false
}
}
/// `NameChar`: a name character after the first.
public static func isNameChar(_ scalar: Unicode.Scalar) -> Bool {
if isNameStart(scalar) { return true }
switch scalar.value {
case 0x2D, // "-"
0x2E, // "."
0x30 ... 0x39, // 0-9
0xB7,
0x300 ... 0x36F,
0x203F ... 0x2040:
return true
default:
return false
}
}
/// `Name`: a NameStartChar followed by zero or more NameChar.
public static func isValidName(_ name: String) -> Bool {
var scalars = name.unicodeScalars.makeIterator()
guard let first = scalars.next(), isNameStart(first) else { return false }
while let scalar = scalars.next() {
if !isNameChar(scalar) { return false }
}
return true
}
}
}

View File

@ -0,0 +1,51 @@
@testable import PureXML
import Testing
@Suite("XML characters")
struct XMLCharacterTests {
private func scalar(_ value: UInt32) -> Unicode.Scalar {
Unicode.Scalar(value) ?? " "
}
@Test("isChar follows the Char production")
func test_isChar() {
#expect(PureXML.Parsing.XMLCharacter.isChar(scalar(0x09))) // tab
#expect(PureXML.Parsing.XMLCharacter.isChar(scalar(0x41))) // A
#expect(!PureXML.Parsing.XMLCharacter.isChar(scalar(0x01))) // control
#expect(!PureXML.Parsing.XMLCharacter.isChar(scalar(0xFFFE))) // non-character
}
@Test("Name start excludes name-only characters; name char includes them")
func test_nameClasses() {
let kind = PureXML.Parsing.XMLCharacter.self
#expect(kind.isNameStart(scalar(0x03B1))) // Greek alpha
#expect(!kind.isNameStart(scalar(0x30))) // digit
#expect(!kind.isNameStart(scalar(0x0301))) // combining acute
#expect(!kind.isNameStart(scalar(0x00AA))) // ordinal: a letter, but outside NameStartChar
#expect(kind.isNameChar(scalar(0x30))) // digit
#expect(kind.isNameChar(scalar(0x0301))) // combining acute
#expect(kind.isNameChar(scalar(0xB7))) // middle dot
#expect(!kind.isNameChar(scalar(0x20))) // space
}
@Test("isValidName accepts well-formed names and rejects the rest")
func test_isValidName() {
for name in ["foo", "foo-bar", "ns:local", "_x1", "\u{00E9}l", "\u{03B1}\u{0301}"] {
#expect(PureXML.Parsing.XMLCharacter.isValidName(name), "\(name)")
}
for name in ["", "1abc", "-x", ".x", "a b", "a\u{0001}b"] {
#expect(!PureXML.Parsing.XMLCharacter.isValidName(name), "\(name)")
}
}
@Test("The parser accepts a non-ASCII element name")
func test_unicodeElementName() throws {
let node = try PureXML.parse("<\u{03B1}>x</\u{03B1}>")
guard case let .document(children) = node, case let .element(element) = children.first else {
Issue.record("expected a document with a root element")
return
}
#expect(element.name.localName == "\u{03B1}")
#expect(element.text == "x")
}
}