Merge epic/35-char-validity: spec-exact character classification (#35)
This commit is contained in:
commit
07c4a84fac
@ -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