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:
parent
96d240d715
commit
c82fef085e
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
79
Sources/Parsing/XMLCharacter.swift
Normal file
79
Sources/Parsing/XMLCharacter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Tests/XMLCharacterTests.swift
Normal file
51
Tests/XMLCharacterTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user