diff --git a/CHANGELOG.md b/CHANGELOG.md index abc7141..032535c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b3e622d..2e91349 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Parsing/Reader.swift b/Sources/Parsing/Reader.swift index f7fb4bd..d359107 100644 --- a/Sources/Parsing/Reader.swift +++ b/Sources/Parsing/Reader.swift @@ -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) } } diff --git a/Sources/Parsing/XMLCharacter.swift b/Sources/Parsing/XMLCharacter.swift new file mode 100644 index 0000000..da30e30 --- /dev/null +++ b/Sources/Parsing/XMLCharacter.swift @@ -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 + } + } +} diff --git a/Tests/XMLCharacterTests.swift b/Tests/XMLCharacterTests.swift new file mode 100644 index 0000000..ca8b3a0 --- /dev/null +++ b/Tests/XMLCharacterTests.swift @@ -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") + 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") + } +}