diff --git a/Sources/HTML/HTMLDocumentBuilder.swift b/Sources/HTML/HTMLDocumentBuilder.swift index 3cbba6c..bee4d16 100644 --- a/Sources/HTML/HTMLDocumentBuilder.swift +++ b/Sources/HTML/HTMLDocumentBuilder.swift @@ -1,9 +1,3 @@ -/// The foreign-content namespaces HTML5 switches into. File-scope and private. -private enum ForeignNamespace { - static let svg = "http://www.w3.org/2000/svg" - static let mathml = "http://www.w3.org/1998/Math/MathML" -} - /// The HTML5 tree-construction insertion modes this builder implements: the /// document-structure subset that establishes the implied `html`, `head`, and /// `body`. File-scope and private. @@ -239,23 +233,6 @@ final class HTMLDocument { node.name?.localName.lowercased() ?? "" } - /// Tags that close an open `` that an opening table tag should close - /// (a select inside a table), or nil when there is none. - private func selectInTableToClose(for name: String) -> Int? { - guard Self.closesSelectInTable.contains(name), - let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1, - openBody[..= 1 { openBody.removeLast(openBody.count - selectIndex) @@ -288,61 +265,6 @@ final class HTMLDocument { private func bodyInsert(_ node: PureXML.Model.TreeNode) { openBody.last?.append(node) } - - private func bodyEnsureTableContext(for name: String) { - let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"] - guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return } - switch name { - case "tr" where context == "table": - bodyPushImplied("tbody") - case "td", "th": - if context == "table" { - bodyPushImplied("tbody") - } - if context == "table" || ["tbody", "thead", "tfoot"].contains(context) { - bodyPushImplied("tr") - } - default: - break - } - } - - private func bodyPushImplied(_ name: String) { - let element = PureXML.Model.TreeNode.element(name) - openBody.last?.append(element) - openBody.append(element) - } - - /// Restores SVG attribute names to their canonical camel case. - private func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] { - guard namespace == ForeignNamespace.svg else { return attributes } - return attributes.map { attribute in - guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute } - return Attribute(adjusted, attribute.value) - } - } - - /// The foreign-content namespace for a body element: SVG/MathML on entry, the - /// nearest open foreign ancestor's, or HTML (nil) inside an integration point. - private func bodyForeignNamespace(for name: String) -> String? { - if name == "svg" { - return ForeignNamespace.svg - } - if name == "math" { - return ForeignNamespace.mathml - } - for node in openBody.reversed() { - guard let namespace = node.name?.namespaceURI else { continue } - if Self.svgIntegrationPoints.contains(tagName(node)) { - return nil - } - return namespace - } - return nil - } - - /// The SVG HTML integration points: their content is parsed as HTML. - private static let svgIntegrationPoints: Set = ["foreignobject", "desc", "title"] } extension HTMLDocument { diff --git a/Sources/HTML/HTMLDocumentTableAndForeign.swift b/Sources/HTML/HTMLDocumentTableAndForeign.swift new file mode 100644 index 0000000..319b311 --- /dev/null +++ b/Sources/HTML/HTMLDocumentTableAndForeign.swift @@ -0,0 +1,75 @@ +/// Table-context and foreign-content (SVG/MathML) helpers, split out so +/// HTMLDocument's own body stays within the type-body length budget. +extension HTMLDocument { + /// Tags that close an open `` that an opening table tag should close + /// (a select inside a table), or nil when there is none. + func selectInTableToClose(for name: String) -> Int? { + guard Self.closesSelectInTable.contains(name), + let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1, + openBody[.. [Attribute] { + guard namespace == ForeignNamespace.svg else { return attributes } + return attributes.map { attribute in + guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute } + return Attribute(adjusted, attribute.value) + } + } + + /// The foreign-content namespace for a body element: SVG/MathML on entry, the + /// nearest open foreign ancestor's, or HTML (nil) inside an integration point. + func bodyForeignNamespace(for name: String) -> String? { + if name == "svg" { + return ForeignNamespace.svg + } + if name == "math" { + return ForeignNamespace.mathml + } + for node in openBody.reversed() { + guard let namespace = node.name?.namespaceURI else { continue } + if Self.svgIntegrationPoints.contains(tagName(node)) { + return nil + } + return namespace + } + return nil + } +} diff --git a/Sources/HTML/HTMLForeignNamespace.swift b/Sources/HTML/HTMLForeignNamespace.swift new file mode 100644 index 0000000..73aa834 --- /dev/null +++ b/Sources/HTML/HTMLForeignNamespace.swift @@ -0,0 +1,5 @@ +/// The foreign-content namespaces HTML5 switches into. +enum ForeignNamespace { + static let svg = "http://www.w3.org/2000/svg" + static let mathml = "http://www.w3.org/1998/Math/MathML" +} diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift index 2584652..9483371 100644 --- a/Sources/Parsing/EventReader.swift +++ b/Sources/Parsing/EventReader.swift @@ -211,101 +211,6 @@ public extension PureXML.Parsing { return .startElement(name: resolved.name, attributes: resolved.attributes) } - /// Returns the next text event, or nil after splicing: a reference to - /// an entity whose replacement (directly or transitively) contains - /// markup is validated, budgeted, and pushed back into the reader so - /// it is reparsed as content (4.4.2 Included), elements in replacement - /// text become elements in the stream, not character data. - private mutating func scanText() throws -> Event? { - let mark = reader.mark - var raw = "" - var length = 0 - // Whether any '&' was seen: without one the text has no references - // and no markup-entity boundary, so the whole decode/split/findings - // pass is skipped. The scanners visit every byte already, so this - // costs nothing beyond a compare they were doing anyway. - var sawAmpersand = false - while true { - // Bulk byte runs first: plain ASCII content (the bulk of any - // document) skips the per-character machinery entirely; the - // run never spans '<', CR, ']', or invalid bytes, so the - // character path below keeps exact error marks. - if let run = reader.contentRunBytes(sawAmpersand: &sawAmpersand) { - length += run.utf8.count - try checkContent(length, mark) - raw += run - continue - } - // Detect the closing '<' on the byte path so the buffer stays - // empty: a buffered '<' would force the next element's dispatch, - // matches(), and name scan onto the slow Character path. - if reader.peekByte() == 0x3C { - break - } - guard let character = reader.peek(), character != "<" else { break } - length += 1 - try checkContent(length, mark) - if character == ">", raw.hasSuffix("]]") { - throw ParseError.cdataCloseInContent(reader.mark) - } - guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else { - throw ParseError.invalidCharacter(reader.mark) - } - if character == "&" { - sawAmpersand = true - } - raw.append(character) - reader.advance() - } - guard sawAmpersand else { return .characters(raw) } - let split = EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities) - if let split { - let replacement = try EntityDecoder.includeForContent( - split.name, - entities: referencableEntities, - budget: &entityBudget, - at: mark, - ) - reader.inject(replacement + split.remainder) - let prefix = try decodeReferences(split.prefix, at: mark) - return prefix.isEmpty ? nil : .characters(prefix) - } - let decoded = try decodeReferences(raw, at: mark) - recordReferenceContentFindings(raw: raw, decoded: decoded) - return .characters(decoded) - } - - mutating func scanProcessingInstruction() throws -> (target: String, data: String) { - let mark = reader.mark - reader.consume("` is not well-formed. - if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") { - throw ParseError.unexpectedCharacter(next, reader.mark) - } - reader.skipSpace() - var data = "" - var length = 0 - while !reader.matches("?>") { - guard let character = reader.advance() else { - throw ParseError.unterminatedTag(mark) - } - length += 1 - try checkContent(length, mark) - guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else { - throw ParseError.invalidCharacter(reader.mark) - } - data.append(character) - } - reader.consume("?>") - return (target.description, data) - } - mutating func scanName() throws -> PureXML.Model.QualifiedName { let mark = reader.mark // Fast path: an all-ASCII name scanned directly from the bytes, with diff --git a/Sources/Parsing/EventReaderText.swift b/Sources/Parsing/EventReaderText.swift new file mode 100644 index 0000000..a9e9985 --- /dev/null +++ b/Sources/Parsing/EventReaderText.swift @@ -0,0 +1,98 @@ +/// Text and processing-instruction scanning, split from the reader body to +/// keep it under the length caps. Behavior is part of EventReader proper. +extension PureXML.Parsing.EventReader { + /// Returns the next text event, or nil after splicing: a reference to + /// an entity whose replacement (directly or transitively) contains + /// markup is validated, budgeted, and pushed back into the reader so + /// it is reparsed as content (4.4.2 Included), elements in replacement + /// text become elements in the stream, not character data. + mutating func scanText() throws -> Event? { + let mark = reader.mark + var raw = "" + var length = 0 + // Whether any '&' was seen: without one the text has no references + // and no markup-entity boundary, so the whole decode/split/findings + // pass is skipped. The scanners visit every byte already, so this + // costs nothing beyond a compare they were doing anyway. + var sawAmpersand = false + while true { + // Bulk byte runs first: plain ASCII content (the bulk of any + // document) skips the per-character machinery entirely; the + // run never spans '<', CR, ']', or invalid bytes, so the + // character path below keeps exact error marks. + if let run = reader.contentRunBytes(sawAmpersand: &sawAmpersand) { + length += run.utf8.count + try checkContent(length, mark) + raw += run + continue + } + // Detect the closing '<' on the byte path so the buffer stays + // empty: a buffered '<' would force the next element's dispatch, + // matches(), and name scan onto the slow Character path. + if reader.peekByte() == 0x3C { + break + } + guard let character = reader.peek(), character != "<" else { break } + length += 1 + try checkContent(length, mark) + if character == ">", raw.hasSuffix("]]") { + throw ParseError.cdataCloseInContent(reader.mark) + } + guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else { + throw ParseError.invalidCharacter(reader.mark) + } + if character == "&" { + sawAmpersand = true + } + raw.append(character) + reader.advance() + } + guard sawAmpersand else { return .characters(raw) } + let split = EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities) + if let split { + let replacement = try EntityDecoder.includeForContent( + split.name, + entities: referencableEntities, + budget: &entityBudget, + at: mark, + ) + reader.inject(replacement + split.remainder) + let prefix = try decodeReferences(split.prefix, at: mark) + return prefix.isEmpty ? nil : .characters(prefix) + } + let decoded = try decodeReferences(raw, at: mark) + recordReferenceContentFindings(raw: raw, decoded: decoded) + return .characters(decoded) + } + + mutating func scanProcessingInstruction() throws -> (target: String, data: String) { + let mark = reader.mark + reader.consume("` is not well-formed. + if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") { + throw ParseError.unexpectedCharacter(next, reader.mark) + } + reader.skipSpace() + var data = "" + var length = 0 + while !reader.matches("?>") { + guard let character = reader.advance() else { + throw ParseError.unterminatedTag(mark) + } + length += 1 + try checkContent(length, mark) + guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else { + throw ParseError.invalidCharacter(reader.mark) + } + data.append(character) + } + reader.consume("?>") + return (target.description, data) + } +} diff --git a/Sources/Parsing/StreamingDecoder.swift b/Sources/Parsing/StreamingDecoder.swift index 1d5f4eb..49f36f8 100644 --- a/Sources/Parsing/StreamingDecoder.swift +++ b/Sources/Parsing/StreamingDecoder.swift @@ -47,152 +47,6 @@ extension PureXML.Parsing { } } - /// Streams one character from a multi-byte CJK encoding. - private mutating func nextCJK(_ encoding: InputEncoding) -> Character? { - switch encoding { - case .shiftJIS: nextShiftJIS() - case .eucJP: nextEUCJP() - case .eucKR: nextEUCKR() - case .gbk: nextGBK() - case .gb18030: nextGB18030() - case .big5: nextBig5() - case .iso2022jp: nextISO2022JP() - case .eucTW: nextEUCTW() - default: nextUTF8() - } - } - - /// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail. - private mutating func nextGBK() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if lead <= 0x7F { - return Character(Unicode.Scalar(lead)) - } - if lead == 0x80 { - return "\u{20AC}" - } - guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } - guard let trail = nextByte() else { finished = true - return Self.replacement - } - return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement - } - - /// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte - /// sequence when the lead is followed by a digit, or a two-byte GBK pair. - private mutating func nextGB18030() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if lead <= 0x7F { - return Character(Unicode.Scalar(lead)) - } - if lead == 0x80 { - return "\u{20AC}" - } - guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } - guard let second = nextByte() else { finished = true - return Self.replacement - } - if (0x30 ... 0x39).contains(second) { - guard let third = nextByte(), let fourth = nextByte() else { finished = true - return Self.replacement - } - guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement } - let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth) - return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement - } - return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement - } - - /// Streams one Big5 character: ASCII, or a lead+trail through the index - /// (a two-scalar combining sequence stays one grapheme, one `Character`). - private mutating func nextBig5() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if lead <= 0x7F { - return Character(Unicode.Scalar(lead)) - } - guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } - guard let trail = nextByte() else { finished = true - return Self.replacement - } - guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else { - return Self.replacement - } - return Character(String(String.UnicodeScalarView(sequence))) - } - - /// Streams one EUC-KR character: ASCII, or a lead+trail through CP949. - private mutating func nextEUCKR() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if lead <= 0x7F { - return Character(Unicode.Scalar(lead)) - } - guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } - guard let trail = nextByte() else { finished = true - return Self.replacement - } - return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement - } - - /// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana), - /// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208. - private mutating func nextEUCJP() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if lead <= 0x7F { - return Character(Unicode.Scalar(lead)) - } - if lead == 0x8E { - guard let byte = nextByte() else { finished = true - return Self.replacement - } - guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement } - return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}") - } - if lead == 0x8F { - guard let mid = nextByte(), let trail = nextByte() else { finished = true - return Self.replacement - } - return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement - } - guard let trail = nextByte() else { finished = true - return Self.replacement - } - return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement - } - - /// Streams one Shift-JIS character: a single byte (ASCII or half-width - /// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208. - private mutating func nextShiftJIS() -> Character? { - guard let lead = nextByte() else { - finished = true - return nil - } - if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) { - return Character(single) - } - guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement } - guard let trail = nextByte() else { - finished = true - return Self.replacement - } - guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement } - return Character(scalar) - } - private mutating func nextUTF32(bigEndian: Bool) -> Character? { var ordered: [UInt32] = [] for _ in 0 ..< 4 { @@ -247,7 +101,7 @@ extension PureXML.Parsing { return pull() } - private mutating func nextUTF8() -> Character? { + mutating func nextUTF8() -> Character? { guard let first = nextByte() else { finished = true return nil diff --git a/Sources/Parsing/StreamingDecoderCJK.swift b/Sources/Parsing/StreamingDecoderCJK.swift new file mode 100644 index 0000000..410907f --- /dev/null +++ b/Sources/Parsing/StreamingDecoderCJK.swift @@ -0,0 +1,147 @@ +extension PureXML.Parsing.StreamingDecoder { + /// Streams one character from a multi-byte CJK encoding. + mutating func nextCJK(_ encoding: PureXML.Parsing.InputEncoding) -> Character? { + switch encoding { + case .shiftJIS: nextShiftJIS() + case .eucJP: nextEUCJP() + case .eucKR: nextEUCKR() + case .gbk: nextGBK() + case .gb18030: nextGB18030() + case .big5: nextBig5() + case .iso2022jp: nextISO2022JP() + case .eucTW: nextEUCTW() + default: nextUTF8() + } + } + + /// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail. + private mutating func nextGBK() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if lead <= 0x7F { + return Character(Unicode.Scalar(lead)) + } + if lead == 0x80 { + return "\u{20AC}" + } + guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } + guard let trail = nextByte() else { finished = true + return Self.replacement + } + return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement + } + + /// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte + /// sequence when the lead is followed by a digit, or a two-byte GBK pair. + private mutating func nextGB18030() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if lead <= 0x7F { + return Character(Unicode.Scalar(lead)) + } + if lead == 0x80 { + return "\u{20AC}" + } + guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } + guard let second = nextByte() else { finished = true + return Self.replacement + } + if (0x30 ... 0x39).contains(second) { + guard let third = nextByte(), let fourth = nextByte() else { finished = true + return Self.replacement + } + guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement } + let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth) + return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement + } + return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement + } + + /// Streams one Big5 character: ASCII, or a lead+trail through the index + /// (a two-scalar combining sequence stays one grapheme, one `Character`). + private mutating func nextBig5() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if lead <= 0x7F { + return Character(Unicode.Scalar(lead)) + } + guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } + guard let trail = nextByte() else { finished = true + return Self.replacement + } + guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else { + return Self.replacement + } + return Character(String(String.UnicodeScalarView(sequence))) + } + + /// Streams one EUC-KR character: ASCII, or a lead+trail through CP949. + private mutating func nextEUCKR() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if lead <= 0x7F { + return Character(Unicode.Scalar(lead)) + } + guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement } + guard let trail = nextByte() else { finished = true + return Self.replacement + } + return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement + } + + /// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana), + /// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208. + private mutating func nextEUCJP() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if lead <= 0x7F { + return Character(Unicode.Scalar(lead)) + } + if lead == 0x8E { + guard let byte = nextByte() else { finished = true + return Self.replacement + } + guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement } + return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}") + } + if lead == 0x8F { + guard let mid = nextByte(), let trail = nextByte() else { finished = true + return Self.replacement + } + return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement + } + guard let trail = nextByte() else { finished = true + return Self.replacement + } + return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement + } + + /// Streams one Shift-JIS character: a single byte (ASCII or half-width + /// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208. + private mutating func nextShiftJIS() -> Character? { + guard let lead = nextByte() else { + finished = true + return nil + } + if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) { + return Character(single) + } + guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement } + guard let trail = nextByte() else { + finished = true + return Self.replacement + } + guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement } + return Character(scalar) + } +} diff --git a/Sources/Schema/RelaxNGDerivative.swift b/Sources/Schema/RelaxNGDerivative.swift index 30d39b2..2aed0bf 100644 --- a/Sources/Schema/RelaxNGDerivative.swift +++ b/Sources/Schema/RelaxNGDerivative.swift @@ -284,7 +284,7 @@ extension PureXML.Schema.RelaxNGEngine { } } - private func endTagDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + func endTagDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { switch pattern { case let .choice(lhs, rhs): choice(endTagDeriv(lhs), endTagDeriv(rhs)) case let .after(lhs, rhs): nullable(lhs) ? rhs : .notAllowed @@ -319,7 +319,7 @@ extension PureXML.Schema.RelaxNGEngine { return result } - private static func isWhitespace(_ string: String) -> Bool { + static func isWhitespace(_ string: String) -> Bool { string.allSatisfy(\.isWhitespace) } @@ -342,92 +342,3 @@ extension PureXML.Schema.RelaxNGEngine { return counts } } - -/// One open element while streaming a RELAX NG derivative: whether it has had an -/// element child yet, and the text buffered since the last structural event. The -/// `after`-threaded pattern is the validation stack; this frame only carries the -/// bookkeeping `childrenDeriv` needs to treat ignorable whitespace correctly. -private struct RelaxNGStreamFrame { - var hadElementChild = false - var pendingText = "" - /// The namespace bindings in scope at this element, for QName values. - var env: [String: String] = [:] -} - -public extension PureXML.Schema { - /// The residual derivative plus per-element whitespace bookkeeping carried - /// across the events of a streamed RELAX NG validation. - struct RelaxNGStreamingState { - var current: PureXML.Schema.Pattern - fileprivate var frames: [RelaxNGStreamFrame] - } -} - -extension PureXML.Schema.RelaxNGEngine { - /// The initial streaming state for a start pattern, with a document-level frame. - func streamingStart(_ start: PureXML.Schema.Pattern) -> PureXML.Schema.RelaxNGStreamingState { - PureXML.Schema.RelaxNGStreamingState(current: start, frames: [RelaxNGStreamFrame(env: Self.rootEnvironment)]) - } - - /// Derives the state by one parse event, the streaming form of `childDeriv`. - /// Text is buffered and applied at structural boundaries so ignorable - /// whitespace is treated exactly as the tree walk's `childrenDeriv` does. - func streamingConsume(_ event: PureXML.Parsing.Event, into state: inout PureXML.Schema.RelaxNGStreamingState) { - switch event { - case let .startElement(name, attributes): - flushText(&state, closing: false) - if !state.frames.isEmpty { - state.frames[state.frames.count - 1].hadElementChild = true - } - var env = state.frames.last?.env ?? Self.rootEnvironment - for attribute in attributes where Self.isNamespaceDeclaration(attribute) { - env[attribute.name.prefix == "xmlns" ? attribute.name.localName : ""] = attribute.value - } - let opened = startTagOpenDeriv(state.current, name) - let attributed = attributes - .filter { !Self.isNamespaceDeclaration($0) } - .reduce(opened) { attributeDeriv($0, $1, env: env) } - state.current = startTagCloseDeriv(attributed) - state.frames.append(RelaxNGStreamFrame(env: env)) - case let .characters(text), let .cdata(text): - if !state.frames.isEmpty { - state.frames[state.frames.count - 1].pendingText += text - } - case .endElement: - flushText(&state, closing: true) - state.current = endTagDeriv(state.current) - if !state.frames.isEmpty { - state.frames.removeLast() - } - case .comment, .processingInstruction: - break - } - } - - /// Whether the streamed document satisfied the schema (the residual is nullable). - func streamingValid(_ state: PureXML.Schema.RelaxNGStreamingState) -> Bool { - nullable(state.current) - } - - private func flushText(_ state: inout PureXML.Schema.RelaxNGStreamingState, closing: Bool) { - guard let index = state.frames.indices.last else { return } - let text = state.frames[index].pendingText - let env = state.frames[index].env - guard !text.isEmpty else { - // An empty element is optional empty text (the tree walk's rule), - // so a data/value pattern admitting "" matches . - if closing, !state.frames[index].hadElementChild { - state.current = choice(state.current, textDeriv(state.current, "", env: env)) - } - return - } - state.frames[index].pendingText = "" - if !Self.isWhitespace(text) { - state.current = textDeriv(state.current, text, env: env) - } else if closing, !state.frames[index].hadElementChild { - // A lone whitespace text child is optional, the `childrenDeriv` rule. - state.current = choice(state.current, textDeriv(state.current, text, env: env)) - } - // Whitespace among element siblings is ignorable and skipped. - } -} diff --git a/Sources/Schema/RelaxNGStreaming.swift b/Sources/Schema/RelaxNGStreaming.swift new file mode 100644 index 0000000..f413038 --- /dev/null +++ b/Sources/Schema/RelaxNGStreaming.swift @@ -0,0 +1,91 @@ +// Streaming RELAX NG validation, split out so RelaxNGDerivative.swift stays +// within the file length budget. + +/// One open element while streaming a RELAX NG derivative: whether it has had an +/// element child yet, and the text buffered since the last structural event. The +/// `after`-threaded pattern is the validation stack; this frame only carries the +/// bookkeeping `childrenDeriv` needs to treat ignorable whitespace correctly. +private struct RelaxNGStreamFrame { + var hadElementChild = false + var pendingText = "" + /// The namespace bindings in scope at this element, for QName values. + var env: [String: String] = [:] +} + +public extension PureXML.Schema { + /// The residual derivative plus per-element whitespace bookkeeping carried + /// across the events of a streamed RELAX NG validation. + struct RelaxNGStreamingState { + var current: PureXML.Schema.Pattern + fileprivate var frames: [RelaxNGStreamFrame] + } +} + +extension PureXML.Schema.RelaxNGEngine { + /// The initial streaming state for a start pattern, with a document-level frame. + func streamingStart(_ start: PureXML.Schema.Pattern) -> PureXML.Schema.RelaxNGStreamingState { + PureXML.Schema.RelaxNGStreamingState(current: start, frames: [RelaxNGStreamFrame(env: Self.rootEnvironment)]) + } + + /// Derives the state by one parse event, the streaming form of `childDeriv`. + /// Text is buffered and applied at structural boundaries so ignorable + /// whitespace is treated exactly as the tree walk's `childrenDeriv` does. + func streamingConsume(_ event: PureXML.Parsing.Event, into state: inout PureXML.Schema.RelaxNGStreamingState) { + switch event { + case let .startElement(name, attributes): + flushText(&state, closing: false) + if !state.frames.isEmpty { + state.frames[state.frames.count - 1].hadElementChild = true + } + var env = state.frames.last?.env ?? Self.rootEnvironment + for attribute in attributes where Self.isNamespaceDeclaration(attribute) { + env[attribute.name.prefix == "xmlns" ? attribute.name.localName : ""] = attribute.value + } + let opened = startTagOpenDeriv(state.current, name) + let attributed = attributes + .filter { !Self.isNamespaceDeclaration($0) } + .reduce(opened) { attributeDeriv($0, $1, env: env) } + state.current = startTagCloseDeriv(attributed) + state.frames.append(RelaxNGStreamFrame(env: env)) + case let .characters(text), let .cdata(text): + if !state.frames.isEmpty { + state.frames[state.frames.count - 1].pendingText += text + } + case .endElement: + flushText(&state, closing: true) + state.current = endTagDeriv(state.current) + if !state.frames.isEmpty { + state.frames.removeLast() + } + case .comment, .processingInstruction: + break + } + } + + /// Whether the streamed document satisfied the schema (the residual is nullable). + func streamingValid(_ state: PureXML.Schema.RelaxNGStreamingState) -> Bool { + nullable(state.current) + } + + private func flushText(_ state: inout PureXML.Schema.RelaxNGStreamingState, closing: Bool) { + guard let index = state.frames.indices.last else { return } + let text = state.frames[index].pendingText + let env = state.frames[index].env + guard !text.isEmpty else { + // An empty element is optional empty text (the tree walk's rule), + // so a data/value pattern admitting "" matches . + if closing, !state.frames[index].hadElementChild { + state.current = choice(state.current, textDeriv(state.current, "", env: env)) + } + return + } + state.frames[index].pendingText = "" + if !Self.isWhitespace(text) { + state.current = textDeriv(state.current, text, env: env) + } else if closing, !state.frames[index].hadElementChild { + // A lone whitespace text child is optional, the `childrenDeriv` rule. + state.current = choice(state.current, textDeriv(state.current, text, env: env)) + } + // Whitespace among element siblings is ignorable and skipped. + } +} diff --git a/Sources/Schema/SchemaReferences.swift b/Sources/Schema/SchemaReferences.swift index 7eb9196..e36d48f 100644 --- a/Sources/Schema/SchemaReferences.swift +++ b/Sources/Schema/SchemaReferences.swift @@ -232,176 +232,4 @@ extension PureXML.Schema.XSDParser { } return findings } - - static func simpleTypeBaseNotComplexFindings(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { - var findings: [PureXML.Schema.SchemaLocatedFinding] = [] - let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) - let targetNamespace = context.targetNamespace - - func isComplex(_ qname: String) -> Bool { - let trimmed = qname.trimmingXMLWhitespace() - let prefix = PureXML.Schema.XSDNode.prefix(trimmed) - let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] - let local = PureXML.Schema.XSDNode.stripPrefix(trimmed) - if uri == xsdNamespace { - if targetNamespace == xsdNamespace { - return local == "anyType" || context.complexTypeNodes[local] != nil - } else { - return local == "anyType" - } - } - if uri == targetNamespace || uri == nil || uri == "" { - return context.complexTypeNodes[local] != nil - } - return false - } - - func check(_ node: XSDTree) { - let local = PureXML.Schema.XSDNode.localName(node) - if local == "appinfo" || local == "documentation" { - return - } - - if node.name?.namespaceURI == xsdNamespace { - switch local { - case "restriction": - Self.checkRestrictionUnderSimpleType(node, isComplex: isComplex, findings: &findings) - case "list": - Self.checkList(node, isComplex: isComplex, findings: &findings) - case "union": - Self.checkUnion(node, isComplex: isComplex, findings: &findings) - default: - break - } - } - - for child in PureXML.Schema.XSDNode.elementChildren(node) { - check(child) - } - } - check(schema) - return findings - } - - private static func checkRestrictionUnderSimpleType(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { - guard let parent = node.parent, parent.name?.namespaceURI == xsdNamespace, PureXML.Schema.XSDNode.localName(parent) == "simpleType" else { return } - if let base = PureXML.Schema.XSDNode.attribute(node, "base"), isComplex(base) { - findings.append(PureXML.Schema.SchemaLocatedFinding( - reason: "base type '\(base)' of simpleType restriction must be a simple type, not a complex type", - node: node, - )) - } - } - - private static func checkList(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { - if let itemType = PureXML.Schema.XSDNode.attribute(node, "itemType"), isComplex(itemType) { - findings.append(PureXML.Schema.SchemaLocatedFinding( - reason: "itemType '\(itemType)' of list must be a simple type, not a complex type", - node: node, - )) - } - } - - private static func checkUnion(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { - if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { - for token in members.split(whereSeparator: \.isWhitespace) where isComplex(String(token)) { - findings.append(PureXML.Schema.SchemaLocatedFinding( - reason: "memberType '\(token)' of union must be a simple type, not a complex type", - node: node, - )) - } - } - } - - private static func xsdNamespaceReferenceFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { - let targetNamespace = PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") - if targetNamespace == xsdNamespace { - return [] - } - var findings: [PureXML.Schema.SchemaLocatedFinding] = [] - let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) - - func isUndeclaredBuiltin(_ qname: String) -> Bool { - let prefix = PureXML.Schema.XSDNode.prefix(qname) - let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] - if uri == xsdNamespace { - let localName = PureXML.Schema.XSDNode.stripPrefix(qname) - return !referenceBuiltins.contains(localName) - } - return false - } - - func check(_ node: XSDTree) { - let local = PureXML.Schema.XSDNode.localName(node) - if local == "appinfo" || local == "documentation" { - return - } - - if node.name?.namespaceURI == xsdNamespace { - for attribute in ["type", "base", "itemType"] { - if let qname = PureXML.Schema.XSDNode.attribute(node, attribute), isUndeclaredBuiltin(qname) { - findings.append(PureXML.Schema.SchemaLocatedFinding(reason: "\(attribute) references undeclared type '\(qname)'", node: node)) - } - } - if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { - for token in members.split(whereSeparator: \.isWhitespace) { - let qname = String(token) - if isUndeclaredBuiltin(qname) { - findings.append(PureXML.Schema.SchemaLocatedFinding(reason: "memberTypes references undeclared type '\(qname)'", node: node)) - } - } - } - } - - for child in PureXML.Schema.XSDNode.elementChildren(node) { - check(child) - } - } - check(schema) - return findings - } - - private static func simpleContentBaseFindings(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { - var findings: [PureXML.Schema.SchemaLocatedFinding] = [] - let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) - let targetNamespace = context.targetNamespace - - func check(_ node: XSDTree) { - let local = PureXML.Schema.XSDNode.localName(node) - if local == "appinfo" || local == "documentation" { - return - } - - let isSimpleContentRestriction = node.name?.namespaceURI == xsdNamespace && local == "restriction" - && node.parent?.name?.namespaceURI == xsdNamespace - && node.parent.flatMap { PureXML.Schema.XSDNode.localName($0) } == "simpleContent" - if isSimpleContentRestriction { - if let base = PureXML.Schema.XSDNode.attribute(node, "base") { - let prefix = PureXML.Schema.XSDNode.prefix(base) - let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] - let localPart = PureXML.Schema.XSDNode.stripPrefix(base) - if uri == targetNamespace || uri == nil || uri == "" { - if let baseNode = context.complexTypeNodes[localPart] { - let children = PureXML.Schema.XSDNode.elementChildren(baseNode) - let hasSimpleContent = children.contains { child in - child.name?.namespaceURI == xsdNamespace && PureXML.Schema.XSDNode.localName(child) == "simpleContent" - } - if !hasSimpleContent { - findings.append(PureXML.Schema.SchemaLocatedFinding( - reason: "base type '\(base)' of simpleContent restriction must be a simple type or complex type with simpleContent", - node: node, - )) - } - } - } - } - } - - for child in PureXML.Schema.XSDNode.elementChildren(node) { - check(child) - } - } - check(schema) - return findings - } } diff --git a/Sources/Schema/SchemaSimpleContentCompatibility.swift b/Sources/Schema/SchemaSimpleContentCompatibility.swift new file mode 100644 index 0000000..94c6c8c --- /dev/null +++ b/Sources/Schema/SchemaSimpleContentCompatibility.swift @@ -0,0 +1,176 @@ +/// Type-compatibility findings for simpleType derivations and simpleContent +/// restrictions, split out so SchemaReferences.swift stays within the file +/// length budget. +extension PureXML.Schema.XSDParser { + static func simpleTypeBaseNotComplexFindings(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) + let targetNamespace = context.targetNamespace + + func isComplex(_ qname: String) -> Bool { + let trimmed = qname.trimmingXMLWhitespace() + let prefix = PureXML.Schema.XSDNode.prefix(trimmed) + let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] + let local = PureXML.Schema.XSDNode.stripPrefix(trimmed) + if uri == xsdNamespace { + if targetNamespace == xsdNamespace { + return local == "anyType" || context.complexTypeNodes[local] != nil + } else { + return local == "anyType" + } + } + if uri == targetNamespace || uri == nil || uri == "" { + return context.complexTypeNodes[local] != nil + } + return false + } + + func check(_ node: XSDTree) { + let local = PureXML.Schema.XSDNode.localName(node) + if local == "appinfo" || local == "documentation" { + return + } + + if node.name?.namespaceURI == xsdNamespace { + switch local { + case "restriction": + Self.checkRestrictionUnderSimpleType(node, isComplex: isComplex, findings: &findings) + case "list": + Self.checkList(node, isComplex: isComplex, findings: &findings) + case "union": + Self.checkUnion(node, isComplex: isComplex, findings: &findings) + default: + break + } + } + + for child in PureXML.Schema.XSDNode.elementChildren(node) { + check(child) + } + } + check(schema) + return findings + } + + private static func checkRestrictionUnderSimpleType(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { + guard let parent = node.parent, parent.name?.namespaceURI == xsdNamespace, PureXML.Schema.XSDNode.localName(parent) == "simpleType" else { return } + if let base = PureXML.Schema.XSDNode.attribute(node, "base"), isComplex(base) { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "base type '\(base)' of simpleType restriction must be a simple type, not a complex type", + node: node, + )) + } + } + + private static func checkList(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { + if let itemType = PureXML.Schema.XSDNode.attribute(node, "itemType"), isComplex(itemType) { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "itemType '\(itemType)' of list must be a simple type, not a complex type", + node: node, + )) + } + } + + private static func checkUnion(_ node: XSDTree, isComplex: (String) -> Bool, findings: inout [PureXML.Schema.SchemaLocatedFinding]) { + if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { + for token in members.split(whereSeparator: \.isWhitespace) where isComplex(String(token)) { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "memberType '\(token)' of union must be a simple type, not a complex type", + node: node, + )) + } + } + } + + static func xsdNamespaceReferenceFindings(_ schema: XSDTree) -> [PureXML.Schema.SchemaLocatedFinding] { + let targetNamespace = PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") + if targetNamespace == xsdNamespace { + return [] + } + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) + + func isUndeclaredBuiltin(_ qname: String) -> Bool { + let prefix = PureXML.Schema.XSDNode.prefix(qname) + let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] + if uri == xsdNamespace { + let localName = PureXML.Schema.XSDNode.stripPrefix(qname) + return !referenceBuiltins.contains(localName) + } + return false + } + + func check(_ node: XSDTree) { + let local = PureXML.Schema.XSDNode.localName(node) + if local == "appinfo" || local == "documentation" { + return + } + + if node.name?.namespaceURI == xsdNamespace { + for attribute in ["type", "base", "itemType"] { + if let qname = PureXML.Schema.XSDNode.attribute(node, attribute), isUndeclaredBuiltin(qname) { + findings.append(PureXML.Schema.SchemaLocatedFinding(reason: "\(attribute) references undeclared type '\(qname)'", node: node)) + } + } + if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { + for token in members.split(whereSeparator: \.isWhitespace) { + let qname = String(token) + if isUndeclaredBuiltin(qname) { + findings.append(PureXML.Schema.SchemaLocatedFinding(reason: "memberTypes references undeclared type '\(qname)'", node: node)) + } + } + } + } + + for child in PureXML.Schema.XSDNode.elementChildren(node) { + check(child) + } + } + check(schema) + return findings + } + + static func simpleContentBaseFindings(_ schema: XSDTree, in context: PureXML.Schema.XSDContext) -> [PureXML.Schema.SchemaLocatedFinding] { + var findings: [PureXML.Schema.SchemaLocatedFinding] = [] + let bindings = PureXML.Schema.XSDNode.namespaceBindings(of: schema) + let targetNamespace = context.targetNamespace + + func check(_ node: XSDTree) { + let local = PureXML.Schema.XSDNode.localName(node) + if local == "appinfo" || local == "documentation" { + return + } + + let isSimpleContentRestriction = node.name?.namespaceURI == xsdNamespace && local == "restriction" + && node.parent?.name?.namespaceURI == xsdNamespace + && node.parent.flatMap { PureXML.Schema.XSDNode.localName($0) } == "simpleContent" + if isSimpleContentRestriction { + if let base = PureXML.Schema.XSDNode.attribute(node, "base") { + let prefix = PureXML.Schema.XSDNode.prefix(base) + let uri = prefix.flatMap { bindings[$0] } ?? bindings[""] + let localPart = PureXML.Schema.XSDNode.stripPrefix(base) + if uri == targetNamespace || uri == nil || uri == "" { + if let baseNode = context.complexTypeNodes[localPart] { + let children = PureXML.Schema.XSDNode.elementChildren(baseNode) + let hasSimpleContent = children.contains { child in + child.name?.namespaceURI == xsdNamespace && PureXML.Schema.XSDNode.localName(child) == "simpleContent" + } + if !hasSimpleContent { + findings.append(PureXML.Schema.SchemaLocatedFinding( + reason: "base type '\(base)' of simpleContent restriction must be a simple type or complex type with simpleContent", + node: node, + )) + } + } + } + } + } + + for child in PureXML.Schema.XSDNode.elementChildren(node) { + check(child) + } + } + check(schema) + return findings + } +} diff --git a/Sources/Schema/SchemaSimpleTypeFacetChecks.swift b/Sources/Schema/SchemaSimpleTypeFacetChecks.swift new file mode 100644 index 0000000..c11ed9a --- /dev/null +++ b/Sources/Schema/SchemaSimpleTypeFacetChecks.swift @@ -0,0 +1,137 @@ +/// The per-facet validation checks, split out so SimpleType's own body stays +/// within the type-body length budget. +extension PureXML.Schema.SimpleType { + private func boundsError(_ value: String, _: PureXML.Schema.Primitive) -> String? { + let (lower, upper) = base.bounds + guard lower != nil || upper != nil, let decimal = PureXML.Schema.DecimalValue(value, allowFraction: false) else { + return nil + } + if let lower, decimal < lower { + return "\(value) is below the minimum for \(base.rawValue)" + } + if let upper, decimal > upper { + return "\(value) is above the maximum for \(base.rawValue)" + } + return nil + } + + private func lengthError(_ value: String, _ primitive: PureXML.Schema.Primitive) -> String? { + // length/minLength/maxLength measure characters, octets, or list + // items depending on the type; for QName the spec leaves the unit + // unspecified (XSD 1.0 Datatypes 4.3.1), so like Xerces and the XSTS + // NIST oracle we do not constrain QName values by length. + if case .qName = primitive { + return nil + } + if case .notation = primitive { + return nil + } + let length = primitive.measuredLength(value) + if let exact = facets.length, length != exact { + return "length \(length) is not \(exact)" + } + if let minimum = facets.minLength, length < minimum { + return "length \(length) is below \(minimum)" + } + if let maximum = facets.maxLength, length > maximum { + return "length \(length) is above \(maximum)" + } + return nil + } + + private func digitsError(_ value: String) -> String? { + guard facets.totalDigits != nil || facets.fractionDigits != nil, + let decimal = PureXML.Schema.DecimalValue(value, allowFraction: true) else { return nil } + if let total = facets.totalDigits, decimal.totalDigits > total { + return "more than \(total) total digits" + } + if let fraction = facets.fractionDigits, decimal.fractionDigits.count > fraction { + return "more than \(fraction) fraction digits" + } + return nil + } + + func patternError(_ value: String) -> String? { + for group in facets.patternGroups { + let matched = group.contains { pattern in + guard let regex = try? PureXML.Regex.Pattern(pattern) else { return false } + return regex.matches(value) + } + guard matched else { + let label = group.joined(separator: "|") + return "'\(value)' does not match pattern '\(label)'" + } + } + return nil + } + + private func enumerationError(_ value: String, _ primitive: PureXML.Schema.Primitive) -> String? { + guard let enumeration = facets.enumeration else { return nil } + let whiteSpace = facets.whiteSpace ?? base.whiteSpace + let matched = enumeration.contains { candidate in + let normalized = Self.process(candidate, whiteSpace: whiteSpace) + if let lhs = primitive.ordered(value), let rhs = primitive.ordered(normalized) { + return lhs == rhs + } + return normalized == value + } + return matched ? nil : "'\(value)' is not in the enumeration" + } + + private func rangeError(_ value: String, _ primitive: PureXML.Schema.Primitive) -> String? { + if case .duration = primitive { + return durationRangeError(value) + } + guard let actual = primitive.ordered(value) else { return nil } + if let bound = facets.minInclusive, let limit = primitive.ordered(bound), actual < limit { + return "\(value) is below the inclusive minimum" + } + if let bound = facets.maxInclusive, let limit = primitive.ordered(bound), actual > limit { + return "\(value) is above the inclusive maximum" + } + if let bound = facets.minExclusive, let limit = primitive.ordered(bound), !(limit < actual) { + return "\(value) is not above the exclusive minimum" + } + if let bound = facets.maxExclusive, let limit = primitive.ordered(bound), !(actual < limit) { + return "\(value) is not below the exclusive maximum" + } + return nil + } + + /// Ordering facets on `xs:duration`, which is a partial order: a value + /// that is incomparable to the bound does not satisfy it (it is not in + /// the ordered range), so an incomparable result is a violation. + private func durationRangeError(_ value: String) -> String? { + guard let actual = PureXML.Schema.DurationValue(value) else { return nil } + func order(_ bound: String?) -> PureXML.Schema.DurationOrder? { + bound.flatMap(PureXML.Schema.DurationValue.init).map(actual.compare(to:)) + } + if let order = order(facets.minInclusive), order == .lessThan || order == .incomparable { + return "\(value) is below the inclusive minimum" + } + if let order = order(facets.maxInclusive), order == .greaterThan || order == .incomparable { + return "\(value) is above the inclusive maximum" + } + if let order = order(facets.minExclusive), order != .greaterThan { + return "\(value) is not above the exclusive minimum" + } + if let order = order(facets.maxExclusive), order != .lessThan { + return "\(value) is not below the exclusive maximum" + } + return nil + } + + func validateAtomic(_ lexical: String) -> String? { + let value = Self.process(lexical, whiteSpace: facets.whiteSpace ?? base.whiteSpace) + let primitive = base.primitive + guard primitive.isValid(value) else { + return "'\(lexical)' is not a valid \(base.rawValue)" + } + return boundsError(value, primitive) + ?? lengthError(value, primitive) + ?? digitsError(value) + ?? patternError(value) + ?? enumerationError(value, primitive) + ?? rangeError(value, primitive) + } +} diff --git a/Sources/Schema/SimpleType.swift b/Sources/Schema/SimpleType.swift index b3cc7c3..a10312f 100644 --- a/Sources/Schema/SimpleType.swift +++ b/Sources/Schema/SimpleType.swift @@ -239,20 +239,6 @@ public extension PureXML.Schema { return nil } - private func validateAtomic(_ lexical: String) -> String? { - let value = Self.process(lexical, whiteSpace: facets.whiteSpace ?? base.whiteSpace) - let primitive = base.primitive - guard primitive.isValid(value) else { - return "'\(lexical)' is not a valid \(base.rawValue)" - } - return boundsError(value, primitive) - ?? lengthError(value, primitive) - ?? digitsError(value) - ?? patternError(value) - ?? enumerationError(value, primitive) - ?? rangeError(value, primitive) - } - // MARK: Whitespace static func process(_ value: String, whiteSpace: WhiteSpace) -> String { @@ -266,128 +252,6 @@ public extension PureXML.Schema { return String(replaced).split(separator: " ").joined(separator: " ") } } - - // MARK: Facet checks - - private func boundsError(_ value: String, _: Primitive) -> String? { - let (lower, upper) = base.bounds - guard lower != nil || upper != nil, let decimal = DecimalValue(value, allowFraction: false) else { - return nil - } - if let lower, decimal < lower { - return "\(value) is below the minimum for \(base.rawValue)" - } - if let upper, decimal > upper { - return "\(value) is above the maximum for \(base.rawValue)" - } - return nil - } - - private func lengthError(_ value: String, _ primitive: Primitive) -> String? { - // length/minLength/maxLength measure characters, octets, or list - // items depending on the type; for QName the spec leaves the unit - // unspecified (XSD 1.0 Datatypes 4.3.1), so like Xerces and the XSTS - // NIST oracle we do not constrain QName values by length. - if case .qName = primitive { - return nil - } - if case .notation = primitive { - return nil - } - let length = primitive.measuredLength(value) - if let exact = facets.length, length != exact { - return "length \(length) is not \(exact)" - } - if let minimum = facets.minLength, length < minimum { - return "length \(length) is below \(minimum)" - } - if let maximum = facets.maxLength, length > maximum { - return "length \(length) is above \(maximum)" - } - return nil - } - - private func digitsError(_ value: String) -> String? { - guard facets.totalDigits != nil || facets.fractionDigits != nil, - let decimal = DecimalValue(value, allowFraction: true) else { return nil } - if let total = facets.totalDigits, decimal.totalDigits > total { - return "more than \(total) total digits" - } - if let fraction = facets.fractionDigits, decimal.fractionDigits.count > fraction { - return "more than \(fraction) fraction digits" - } - return nil - } - - private func patternError(_ value: String) -> String? { - for group in facets.patternGroups { - let matched = group.contains { pattern in - guard let regex = try? PureXML.Regex.Pattern(pattern) else { return false } - return regex.matches(value) - } - guard matched else { - let label = group.joined(separator: "|") - return "'\(value)' does not match pattern '\(label)'" - } - } - return nil - } - - private func enumerationError(_ value: String, _ primitive: Primitive) -> String? { - guard let enumeration = facets.enumeration else { return nil } - let whiteSpace = facets.whiteSpace ?? base.whiteSpace - let matched = enumeration.contains { candidate in - let normalized = Self.process(candidate, whiteSpace: whiteSpace) - if let lhs = primitive.ordered(value), let rhs = primitive.ordered(normalized) { - return lhs == rhs - } - return normalized == value - } - return matched ? nil : "'\(value)' is not in the enumeration" - } - - private func rangeError(_ value: String, _ primitive: Primitive) -> String? { - if case .duration = primitive { - return durationRangeError(value) - } - guard let actual = primitive.ordered(value) else { return nil } - if let bound = facets.minInclusive, let limit = primitive.ordered(bound), actual < limit { - return "\(value) is below the inclusive minimum" - } - if let bound = facets.maxInclusive, let limit = primitive.ordered(bound), actual > limit { - return "\(value) is above the inclusive maximum" - } - if let bound = facets.minExclusive, let limit = primitive.ordered(bound), !(limit < actual) { - return "\(value) is not above the exclusive minimum" - } - if let bound = facets.maxExclusive, let limit = primitive.ordered(bound), !(actual < limit) { - return "\(value) is not below the exclusive maximum" - } - return nil - } - - /// Ordering facets on `xs:duration`, which is a partial order: a value - /// that is incomparable to the bound does not satisfy it (it is not in - /// the ordered range), so an incomparable result is a violation. - private func durationRangeError(_ value: String) -> String? { - guard let actual = DurationValue(value) else { return nil } - func order(_ bound: String?) -> DurationOrder? { - bound.flatMap(DurationValue.init).map(actual.compare(to:)) - } - if let order = order(facets.minInclusive), order == .lessThan || order == .incomparable { - return "\(value) is below the inclusive minimum" - } - if let order = order(facets.maxInclusive), order == .greaterThan || order == .incomparable { - return "\(value) is above the inclusive maximum" - } - if let order = order(facets.minExclusive), order != .greaterThan { - return "\(value) is not above the exclusive minimum" - } - if let order = order(facets.maxExclusive), order != .lessThan { - return "\(value) is not below the exclusive maximum" - } - return nil - } } /// Validates `value` against a built-in type with no extra facets. diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 60ccfa8..3ee660b 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -389,31 +389,3 @@ extension PureXML.XSLT { } } } - -extension PureXML.Emitting.Options { - /// These options with an `xsl:output`'s settings layered over them. Only an - /// explicit setting overrides; unspecified `xsl:output` attributes leave the - /// caller's corresponding option untouched. - func applying(_ output: PureXML.XSLT.Output) -> Self { - var copy = self - if let indent = output.indent { - copy.prettyPrint = indent - // Xalan-style indentation: children on their own lines with no - // leading spaces (indent-amount 0), the conformance golds' form. - copy.indent = "" - } - if let omit = output.omitXMLDeclaration { - copy.includeXMLDeclaration = !omit - } - if let encoding = output.encoding { - copy.encodingName = encoding - } - if let version = output.version { - copy.xmlVersion = version - } - if let standalone = output.standalone { - copy.standalone = standalone - } - return copy - } -} diff --git a/Sources/XSLT/XSLTOutputOptions.swift b/Sources/XSLT/XSLTOutputOptions.swift new file mode 100644 index 0000000..a3685ad --- /dev/null +++ b/Sources/XSLT/XSLTOutputOptions.swift @@ -0,0 +1,27 @@ +extension PureXML.Emitting.Options { + /// These options with an `xsl:output`'s settings layered over them. Only an + /// explicit setting overrides; unspecified `xsl:output` attributes leave the + /// caller's corresponding option untouched. + func applying(_ output: PureXML.XSLT.Output) -> Self { + var copy = self + if let indent = output.indent { + copy.prettyPrint = indent + // Xalan-style indentation: children on their own lines with no + // leading spaces (indent-amount 0), the conformance golds' form. + copy.indent = "" + } + if let omit = output.omitXMLDeclaration { + copy.includeXMLDeclaration = !omit + } + if let encoding = output.encoding { + copy.encodingName = encoding + } + if let version = output.version { + copy.xmlVersion = version + } + if let standalone = output.standalone { + copy.standalone = standalone + } + return copy + } +} diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 8038c41..afd12dc 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -223,7 +223,7 @@ extension PureXML.XSLT { ) } - private static func withParameters(_ node: XSLTTree) -> [Binding] { + static func withParameters(_ node: XSLTTree) -> [Binding] { XSLTNode.children(node, named: "with-param").map(binding) } @@ -247,145 +247,5 @@ extension PureXML.XSLT { } return 0 } - - // MARK: Bodies and instructions - - static func body(_ node: XSLTTree) -> [Instruction] { - // Adjacent text and CDATA children are one text node in the data - // model, so a whitespace-only run is dropped (XSLT 1.0 3.4) only when - // the WHOLE coalesced run is whitespace: " " keeps its - // spaces because the run "x" surrounds them with non-whitespace. - // `xml:space="preserve"` in scope keeps a whitespace-only run too. - let preserve = preservesWhitespace(node) - var result: [Instruction] = [] - var run = "" - func flush() { - if !run.isEmpty, preserve || run.unicodeScalars.contains(where: { !PureXML.Parsing.XMLCharacter.isWhitespace($0) }) { - result.append(.literalText(run)) - } - run = "" - } - for child in node.children { - if child.kind == .text || child.kind == .cdata { - run += child.value - } else { - flush() - if let instruction = instruction(child) { - result.append(instruction) - } - } - } - flush() - return result - } - - static func instruction(_ node: XSLTTree) -> Instruction? { - switch node.kind { - case .text, .cdata: - let value = node.value - return value.allSatisfy { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" } ? nil : .literalText(value) - case .element: - return XSLTNode.isXSL(node) ? xslInstruction(node) : literalElement(node) - default: - return nil - } - } - - private static func xslInstruction(_ node: XSLTTree) -> Instruction? { - if let known = simpleInstruction(node) ?? structuralInstruction(node) { - return known - } - // An unrecognized XSLT element instantiates its xsl:fallback children - // (forwards-compatible processing); with none, it is dropped. - let fallback = XSLTNode.children(node, named: "fallback").flatMap(body) - return fallback.isEmpty ? nil : .fallback(body: fallback) - } - - private static func simpleInstruction(_ node: XSLTTree) -> Instruction? { - switch XSLTNode.localName(node) { - case "value-of": .valueOf( - select: XSLTNode.attribute(node, "select") ?? "", - raw: XSLTNode.attribute(node, "disable-output-escaping") == "yes", - ) - case "apply-templates": .applyTemplates( - select: XSLTNode.attribute(node, "select"), - mode: expandedMode(node), - sorts: sorts(node), - parameters: withParameters(node), - ) - case "copy-of": .copyOf(select: XSLTNode.attribute(node, "select") ?? "") - case "call-template": .callTemplate( - name: expandedDeclaredName(node), - parameters: withParameters(node), - ) - case "text": .literalText( - XSLTNode.attribute(node, "disable-output-escaping") == "yes" - ? PureXML.XSLT.RawText.marked(node.stringValue) - : node.stringValue, - ) - case "variable", "param": variable(node) - case "copy": .copy(useAttributeSets: useAttributeSets(node), body: body(node)) - case "message": .message(terminate: XSLTNode.attribute(node, "terminate") == "yes", body: body(node)) - case "apply-imports": .applyImports - default: nil - } - } - - private static func variable(_ node: XSLTTree) -> Instruction { - .variable(name: expandedDeclaredName(node), select: XSLTNode.attribute(node, "select"), body: body(node)) - } - - static func choose(_ node: XSLTTree) -> Instruction { - let whens = XSLTNode.children(node, named: "when").map { branch in - Branch(test: XSLTNode.attribute(branch, "test") ?? "", body: body(branch)) - } - let otherwise = XSLTNode.children(node, named: "otherwise").first.map(body) ?? [] - return .choose(whens: whens, otherwise: otherwise) - } - - private static func literalElement(_ node: XSLTTree) -> Instruction { - guard let name = node.name else { return .literalText("") } - // xmlns declarations and the special xsl:* attributes (use-attribute-sets, - // version, exclude-result-prefixes …) are not copied to the output. - let attributes = node.attributes - .filter { $0.name.prefix != "xmlns" && !($0.name.prefix == nil && $0.name.localName == "xmlns") && $0.name.prefix != "xsl" } - .map { LiteralAttribute(name: $0.name, value: valueTemplate($0.value)) } - return .literalElement( - name: name, - attributes: attributes, - namespaces: copiedNamespaces(node), - useAttributeSets: useAttributeSets(node), - body: body(node), - ) - } - - /// The whitespace-separated names of `[xsl:]use-attribute-sets` on `node`. - static func useAttributeSets(_ node: XSLTTree) -> [String] { - // Each referenced set is keyed by expanded QName, matching the - // expansion applied to the attribute-set declaration's name. - (XSLTNode.attribute(node, "use-attribute-sets") ?? "") - .split(whereSeparator: \.isWhitespace) - .map { expandedQName(String($0), at: node) } - } - - private static func caseOrder(_ node: XSLTTree) -> PureXML.XSLT.CaseOrder? { - switch XSLTNode.attribute(node, "case-order") { - case "upper-first": .upperFirst - case "lower-first": .lowerFirst - default: nil - } - } - - static func sorts(_ node: XSLTTree) -> [Sort] { - XSLTNode.children(node, named: "sort").map { sort in - Sort( - select: XSLTNode.attribute(sort, "select") ?? ".", - descending: XSLTNode.attribute(sort, "order") == "descending", - numeric: XSLTNode.attribute(sort, "data-type") == "number", - caseOrder: caseOrder(sort), - lang: XSLTNode.attribute(sort, "lang").map(valueTemplate), - ) - } - } } } diff --git a/Sources/XSLT/XSLTParserInstructions.swift b/Sources/XSLT/XSLTParserInstructions.swift new file mode 100644 index 0000000..3a7733a --- /dev/null +++ b/Sources/XSLT/XSLTParserInstructions.swift @@ -0,0 +1,143 @@ +/// Instruction and template-body compilation, split out so the parser enum +/// stays within the type-body and file length budgets. +extension PureXML.XSLT.XSLTParser { + // MARK: Bodies and instructions + + static func body(_ node: XSLTTree) -> [PureXML.XSLT.Instruction] { + // Adjacent text and CDATA children are one text node in the data + // model, so a whitespace-only run is dropped (XSLT 1.0 3.4) only when + // the WHOLE coalesced run is whitespace: " " keeps its + // spaces because the run "x" surrounds them with non-whitespace. + // `xml:space="preserve"` in scope keeps a whitespace-only run too. + let preserve = preservesWhitespace(node) + var result: [PureXML.XSLT.Instruction] = [] + var run = "" + func flush() { + if !run.isEmpty, preserve || run.unicodeScalars.contains(where: { !PureXML.Parsing.XMLCharacter.isWhitespace($0) }) { + result.append(.literalText(run)) + } + run = "" + } + for child in node.children { + if child.kind == .text || child.kind == .cdata { + run += child.value + } else { + flush() + if let instruction = instruction(child) { + result.append(instruction) + } + } + } + flush() + return result + } + + static func instruction(_ node: XSLTTree) -> PureXML.XSLT.Instruction? { + switch node.kind { + case .text, .cdata: + let value = node.value + return value.allSatisfy { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" } ? nil : .literalText(value) + case .element: + return XSLTNode.isXSL(node) ? xslInstruction(node) : literalElement(node) + default: + return nil + } + } + + private static func xslInstruction(_ node: XSLTTree) -> PureXML.XSLT.Instruction? { + if let known = simpleInstruction(node) ?? structuralInstruction(node) { + return known + } + // An unrecognized XSLT element instantiates its xsl:fallback children + // (forwards-compatible processing); with none, it is dropped. + let fallback = XSLTNode.children(node, named: "fallback").flatMap(body) + return fallback.isEmpty ? nil : .fallback(body: fallback) + } + + private static func simpleInstruction(_ node: XSLTTree) -> PureXML.XSLT.Instruction? { + switch XSLTNode.localName(node) { + case "value-of": .valueOf( + select: XSLTNode.attribute(node, "select") ?? "", + raw: XSLTNode.attribute(node, "disable-output-escaping") == "yes", + ) + case "apply-templates": .applyTemplates( + select: XSLTNode.attribute(node, "select"), + mode: expandedMode(node), + sorts: sorts(node), + parameters: withParameters(node), + ) + case "copy-of": .copyOf(select: XSLTNode.attribute(node, "select") ?? "") + case "call-template": .callTemplate( + name: expandedDeclaredName(node), + parameters: withParameters(node), + ) + case "text": .literalText( + XSLTNode.attribute(node, "disable-output-escaping") == "yes" + ? PureXML.XSLT.RawText.marked(node.stringValue) + : node.stringValue, + ) + case "variable", "param": variable(node) + case "copy": .copy(useAttributeSets: useAttributeSets(node), body: body(node)) + case "message": .message(terminate: XSLTNode.attribute(node, "terminate") == "yes", body: body(node)) + case "apply-imports": .applyImports + default: nil + } + } + + static func variable(_ node: XSLTTree) -> PureXML.XSLT.Instruction { + .variable(name: expandedDeclaredName(node), select: XSLTNode.attribute(node, "select"), body: body(node)) + } + + static func choose(_ node: XSLTTree) -> PureXML.XSLT.Instruction { + let whens = XSLTNode.children(node, named: "when").map { branch in + PureXML.XSLT.Branch(test: XSLTNode.attribute(branch, "test") ?? "", body: body(branch)) + } + let otherwise = XSLTNode.children(node, named: "otherwise").first.map(body) ?? [] + return .choose(whens: whens, otherwise: otherwise) + } + + private static func literalElement(_ node: XSLTTree) -> PureXML.XSLT.Instruction { + guard let name = node.name else { return .literalText("") } + // xmlns declarations and the special xsl:* attributes (use-attribute-sets, + // version, exclude-result-prefixes …) are not copied to the output. + let attributes = node.attributes + .filter { $0.name.prefix != "xmlns" && !($0.name.prefix == nil && $0.name.localName == "xmlns") && $0.name.prefix != "xsl" } + .map { PureXML.XSLT.LiteralAttribute(name: $0.name, value: valueTemplate($0.value)) } + return .literalElement( + name: name, + attributes: attributes, + namespaces: copiedNamespaces(node), + useAttributeSets: useAttributeSets(node), + body: body(node), + ) + } + + /// The whitespace-separated names of `[xsl:]use-attribute-sets` on `node`. + static func useAttributeSets(_ node: XSLTTree) -> [String] { + // Each referenced set is keyed by expanded QName, matching the + // expansion applied to the attribute-set declaration's name. + (XSLTNode.attribute(node, "use-attribute-sets") ?? "") + .split(whereSeparator: \.isWhitespace) + .map { expandedQName(String($0), at: node) } + } + + private static func caseOrder(_ node: XSLTTree) -> PureXML.XSLT.CaseOrder? { + switch XSLTNode.attribute(node, "case-order") { + case "upper-first": .upperFirst + case "lower-first": .lowerFirst + default: nil + } + } + + static func sorts(_ node: XSLTTree) -> [PureXML.XSLT.Sort] { + XSLTNode.children(node, named: "sort").map { sort in + PureXML.XSLT.Sort( + select: XSLTNode.attribute(sort, "select") ?? ".", + descending: XSLTNode.attribute(sort, "order") == "descending", + numeric: XSLTNode.attribute(sort, "data-type") == "number", + caseOrder: caseOrder(sort), + lang: XSLTNode.attribute(sort, "lang").map(valueTemplate), + ) + } + } +}