PureXML/Sources/Parsing/XMLDeclaration.swift
Mihaela Mihaljevic dd0ff5d086 feat(parsing): fourth W3C-suite strictness pass, declaration grammar
Make the DTD declaration grammar strict: <!ENTITY> requires its whitespace
(after the keyword, around %, after the name), a real name (not #DEFAULT),
nothing but '>' after the value, NDATA preceded by whitespace and forbidden
on parameter entities; entity-value literals are validated (XML Chars only,
every & a complete reference); SYSTEM/PUBLIC external identifiers parse
strictly (PUBLIC needs both literals for entities and the DOCTYPE, public-only
stays legal for notations) with PubidChar validation; <!ATTLIST> bodies are
validated against XML 1.0 productions 52-60 via a new DTDAttListGrammar
(spacing, attribute types, enumerations, quoted defaults); the reserved xml
PI target is rejected inside the subset. Also: strict UTF-8 decoding (no
silent replacement of malformed sequences), XML Char checks in attribute
values and CDATA, the declaration's version/encoding value grammar, and
required whitespace between pseudo-attributes. The strict machinery lives in
DoctypeScannerStrict/DTDAttListGrammar to stay under the length caps.

W3C xmltest: not-wf/sa 162/186 rejected (from 129; 80 at first contact),
valid/sa 118/119, zero false positives across all 846 tests at every step.
The remaining 24-case baseline is exactly: the entity replacement-text class
(19) and the deliberate internal-subset extensions this package supports as
features (conditional sections, PEs in the internal subset: 5).

Part of #120, #105
2026-06-10 05:34:11 +02:00

80 lines
3.5 KiB
Swift

public extension PureXML.Parsing {
/// The XML declaration `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
/// at the very start of a document: its version, the declared encoding name (if
/// any), and the standalone document declaration (if any).
struct XMLDeclaration: Equatable, Sendable {
public var version: String?
public var encoding: String?
public var standalone: Bool?
public init(version: String? = nil, encoding: String? = nil, standalone: Bool? = nil) {
self.version = version
self.encoding = encoding
self.standalone = standalone
}
/// Parses the pseudo-attributes that follow `<?xml` in an XML declaration.
/// The grammar fixes their order (`version`, then optional `encoding`, then
/// optional `standalone`) and `standalone` must be `yes` or `no`. Returns
/// nil when the text violates that grammar.
static func parse(_ data: String) -> XMLDeclaration? {
var scanner = PseudoAttributeScanner(data)
let pairs = scanner.scan()
guard let pairs, !pairs.isEmpty else { return nil }
let names = pairs.map(\.name)
guard names.first == "version", isOrdered(names) else { return nil }
var declaration = XMLDeclaration()
for pair in pairs {
switch pair.name {
case "version":
guard isValidVersion(pair.value) else { return nil }
declaration.version = pair.value
case "encoding":
guard isValidEncodingName(pair.value) else { return nil }
declaration.encoding = pair.value
case "standalone":
guard let flag = standaloneFlag(pair.value) else { return nil }
declaration.standalone = flag
default: return nil
}
}
return declaration
}
/// Whether the pseudo-attribute names appear in the grammar's fixed order
/// with no repeats: version, then encoding, then standalone.
private static func isOrdered(_ names: [String]) -> Bool {
let rank = ["version": 0, "encoding": 1, "standalone": 2]
var last = -1
for name in names {
guard let position = rank[name], position > last else { return false }
last = position
}
return true
}
/// `VersionNum ::= '1.' [0-9]+`, exactly (no surrounding whitespace).
private static func isValidVersion(_ value: String) -> Bool {
guard value.hasPrefix("1.") else { return false }
let digits = value.dropFirst(2)
return !digits.isEmpty && digits.allSatisfy { $0.isASCII && $0.isNumber }
}
/// `EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')*`.
private static func isValidEncodingName(_ value: String) -> Bool {
guard let first = value.first, first.isASCII, first.isLetter else { return false }
return value.dropFirst().allSatisfy { character in
character.isASCII && (character.isLetter || character.isNumber || character == "." || character == "_" || character == "-")
}
}
private static func standaloneFlag(_ value: String) -> Bool? {
switch value {
case "yes": true
case "no": false
default: nil
}
}
}
}