├── .gitignore ├── LICENSE ├── Package.swift ├── Playgrounds ├── Article.playground │ ├── Contents.swift │ └── contents.xcplayground └── Trivia.playground │ ├── Contents.swift │ ├── Resources │ └── trivia.xml │ └── contents.xcplayground ├── README.md ├── Sources └── SwiftyXMLSequence │ ├── AsyncChunkedByElementSequence.swift │ ├── AsyncCollectElementSequence.swift │ ├── AsyncFilterElementSequence.swift │ ├── AsyncFlatMapParsingEventWithStateSequence.swift │ ├── AsyncLinebreakMappingSequence.swift │ ├── AsyncMapParsingEventWithStateSequence.swift │ ├── AsyncMapWithContextElementSequence.swift │ ├── AsyncSequence+ParsingEvent.swift │ ├── AsyncWhitespaceCollapsingSequence.swift │ ├── AsyncWhitespaceMappingSequence.swift │ ├── Attributes.swift │ ├── CollectElementSequence.swift │ ├── Data+XML.swift │ ├── FilterElementSequence.swift │ ├── HTMLElement.swift │ ├── ParsingError.swift │ ├── ParsingEvent.swift │ ├── ParsingEventDebugFormatter.swift │ ├── PeekingAsyncIterator.swift │ ├── PushParser.swift │ ├── URLSession+XML.swift │ ├── WhitespaceParsingEvent.swift │ ├── WhitespaceSegmentSequence.swift │ └── XMLElement.swift └── Tests └── SwiftyXMLSequenceTests ├── ChunkByElementTests.swift ├── FilterAndCollectTests.swift ├── HTMLTests.swift ├── LinebreakTests.swift ├── Samples ├── sample1.html ├── sample2.html ├── trivia.xml ├── whitespace-collapse-cases.html └── whitespace-collapse.html ├── WhiteSpaceCollapseTests.swift ├── XMLEventSessionTests.swift └── XMLEventTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## macOS 9 | .DS_Store 10 | 11 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 12 | *.xcscmblueprint 13 | *.xccheckout 14 | 15 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 16 | build/ 17 | DerivedData/ 18 | *.moved-aside 19 | *.pbxuser 20 | !default.pbxuser 21 | *.mode1v3 22 | !default.mode1v3 23 | *.mode2v3 24 | !default.mode2v3 25 | *.perspectivev3 26 | !default.perspectivev3 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | 31 | ## App packaging 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | Packages/ 44 | Package.pins 45 | Package.resolved 46 | *.xcodeproj 47 | # 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # 56 | # We recommend against adding the Pods directory to your .gitignore. However 57 | # you should judge for yourself, the pros and cons are mentioned at: 58 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 59 | # 60 | # Pods/ 61 | # 62 | # Add this line if you want to avoid checking in source code from the Xcode workspace 63 | # *.xcworkspace 64 | 65 | # Carthage 66 | # 67 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 68 | # Carthage/Checkouts 69 | 70 | Carthage/Build/ 71 | 72 | # Accio dependency management 73 | Dependencies/ 74 | .accio/ 75 | 76 | # fastlane 77 | # 78 | # It is recommended to not store the screenshots in the git repo. 79 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 80 | # For more information about the recommended setup visit: 81 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 82 | 83 | fastlane/report.xml 84 | fastlane/Preview.html 85 | fastlane/screenshots/**/*.png 86 | fastlane/test_output 87 | 88 | # Code Injection 89 | # 90 | # After new code Injection tools there's a generated folder /iOSInjectionProject 91 | # https://github.com/johnno1962/injectionforxcode 92 | 93 | iOSInjectionProject/ 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sophiestication Software, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftyXMLSequence", 8 | platforms: [ 9 | .iOS(.v18), .macOS(.v15), .watchOS(.v11), .tvOS(.v18) 10 | ], 11 | products: [ 12 | .library( 13 | name: "SwiftyXMLSequence", 14 | targets: ["SwiftyXMLSequence"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"), 18 | .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0") 19 | ], 20 | targets: [ 21 | .target( 22 | name: "SwiftyXMLSequence", 23 | dependencies: [ 24 | .product(name: "Algorithms", package: "swift-algorithms"), 25 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") 26 | ] 27 | ), 28 | .testTarget( 29 | name: "SwiftyXMLSequenceTests", 30 | dependencies: [ 31 | "SwiftyXMLSequence", 32 | .product(name: "Algorithms", package: "swift-algorithms"), 33 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") 34 | ], 35 | resources: [ 36 | .copy("Samples/trivia.xml"), 37 | .copy("Samples/sample1.html"), 38 | .copy("Samples/sample2.html"), 39 | .copy("Samples/whitespace-collapse.html"), 40 | .copy("Samples/whitespace-collapse-cases.html") 41 | ] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /Playgrounds/Article.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import PlaygroundSupport 26 | import SwiftUI 27 | import SwiftyXMLSequence 28 | 29 | PlaygroundPage.current.needsIndefiniteExecution = true 30 | 31 | Task { 32 | do { 33 | let (events, _) = try await URLSession.shared.xml( 34 | HTMLElement.self, 35 | for: URL(string: "https://en.wikipedia.org/api/rest_v1/page/html/Eero_Saarinen")! 36 | ) 37 | 38 | let text = try await events 39 | .filter { element, attributes in 40 | return switch element { 41 | case .style, .link, .table: 42 | false 43 | default: 44 | true 45 | } 46 | } 47 | .filter { element, attributes in 48 | if attributes.class.contains("reference") { return false } 49 | if attributes.class.contains("gallery") { return false } 50 | if attributes.class.contains("infobox") { return false } 51 | if attributes.class.contains("navbox") { return false } 52 | if attributes.class.contains("mw-editsection") { return false } 53 | if attributes.class.contains("mw-cite-backlink") { return false } 54 | 55 | return true 56 | } 57 | .collect { element, attributes in 58 | return switch element { 59 | case .title, .h1, .h2, .h3, .h4, .h5, .h6, .p, .ul, .ol, .li: 60 | true 61 | default: 62 | false 63 | } 64 | } 65 | .map(whitespace: { element, _ in 66 | element.whitespacePolicy 67 | }) 68 | .map(linebreaks: { element, _ in 69 | return switch element { 70 | case .title, .h1, .h2, .h3, .h4, .h5, .h6, .p, .ul, .ol, .li: 71 | "\n \n" 72 | default: 73 | "\n" 74 | } 75 | }) 76 | .collapse() 77 | .flatMap { event in 78 | switch event { 79 | case .begin(let element, _): 80 | switch element { 81 | case .li: 82 | return [.text("- ") , event].async 83 | default: 84 | break 85 | } 86 | default: 87 | break 88 | } 89 | 90 | return [event].async 91 | } 92 | .reduce(into: String()) { @Sendable partialResult, event in 93 | switch event { 94 | case .text(let string): 95 | partialResult.append(string) 96 | break 97 | default: 98 | break 99 | } 100 | } 101 | 102 | let attributedString = AttributedString(text) 103 | 104 | struct PlaygroundView: View { 105 | let attributedString: AttributedString 106 | 107 | var body: some View { 108 | ScrollView { 109 | Text(attributedString) 110 | .fontDesign(.monospaced) 111 | .padding() 112 | } 113 | .frame(width: 640.0) 114 | } 115 | } 116 | 117 | DispatchQueue.main.async { 118 | let view = PlaygroundView(attributedString: attributedString) 119 | PlaygroundPage.current.setLiveView(view) 120 | } 121 | } catch { 122 | print("\(error)") 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Playgrounds/Article.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Playgrounds/Trivia.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import PlaygroundSupport 26 | import SwiftUI 27 | import SwiftyXMLSequence 28 | 29 | PlaygroundPage.current.needsIndefiniteExecution = true 30 | 31 | func bool(from attributes: Attributes) -> Bool { 32 | guard let string = attributes["correct"] else { 33 | return false 34 | } 35 | 36 | guard let value = Bool(string) else { 37 | return false 38 | } 39 | 40 | return value 41 | } 42 | 43 | func url(from attributes: Attributes) -> URL? { 44 | guard let string = attributes["reference"] else { 45 | return nil 46 | } 47 | 48 | return URL(string: string) 49 | } 50 | 51 | enum Element: ElementRepresentable, Equatable { 52 | case trivia 53 | case question 54 | case answer(correct: Bool) 55 | case explaination(reference: URL?) 56 | case custom(String) 57 | 58 | init(element elementName: String, attributes: Attributes) { 59 | switch elementName.lowercased() { 60 | case "trivia": 61 | self = .trivia 62 | case "question": 63 | self = .question 64 | case "answer": 65 | self = .answer(correct: bool(from: attributes)) 66 | case "explanation": 67 | self = .explaination(reference: url(from: attributes)) 68 | default: 69 | self = .custom(elementName) 70 | } 71 | } 72 | } 73 | 74 | extension Element { 75 | var style: AttributeContainer { 76 | var container = AttributeContainer() 77 | 78 | switch self { 79 | case .question: 80 | container.font = .system(size: 18.0).bold() 81 | 82 | case .answer(let correct): 83 | container.font = .system(size: 17.0) 84 | 85 | case .explaination(let reference): 86 | container.font = .system(size: 17.0).italic() 87 | 88 | default: 89 | AttributeContainer() 90 | } 91 | 92 | return container 93 | } 94 | } 95 | 96 | Task { 97 | do { 98 | let (events, _) = try await URLSession.shared.xml( 99 | Element.self, 100 | for: Bundle.main.url(forResource: "trivia", withExtension: "xml")! 101 | ) 102 | 103 | struct ElementStack { 104 | private var stack: [Element] = [] 105 | 106 | mutating func push(_ element: Element) { stack.append(element) } 107 | mutating func pop() { stack.removeLast() } 108 | 109 | var style: AttributeContainer { stack.last?.style ?? AttributeContainer() } 110 | } 111 | 112 | let attributedString = try await events.map(whitespace: { element, attributes in 113 | return switch element { 114 | case .trivia, .custom(_): 115 | .block 116 | default: 117 | .preserve 118 | } 119 | }) 120 | .collapse() 121 | .map(with: ElementStack(), { stack, event in 122 | switch event { 123 | case .begin(let element, let attributes): 124 | stack.push(element) 125 | 126 | return switch element { 127 | case .answer(let correct): 128 | correct ? AttributedString("✓ ") : nil 129 | case .explaination(_): 130 | AttributedString("\n") 131 | default: 132 | nil 133 | } 134 | 135 | case .end(let element): 136 | stack.pop() 137 | 138 | return switch element { 139 | case .answer(_): 140 | AttributedString("\n") 141 | case .question, .explaination(_): 142 | AttributedString("\n\n") 143 | default: 144 | nil 145 | } 146 | 147 | case .text(let string): 148 | return AttributedString(string, attributes: stack.style) 149 | 150 | default: 151 | return nil 152 | } 153 | }) 154 | .compactMap { 155 | $0 156 | } 157 | .reduce(AttributedString()) { @Sendable result, attributedString in 158 | result + attributedString 159 | } 160 | 161 | struct PlaygroundView: View { 162 | let attributedString: AttributedString 163 | 164 | var body: some View { 165 | ScrollView { 166 | Text(attributedString) 167 | .padding() 168 | } 169 | .frame(width: 320.0) 170 | } 171 | } 172 | 173 | DispatchQueue.main.async { 174 | let view = PlaygroundView(attributedString: attributedString) 175 | PlaygroundPage.current.setLiveView(view) 176 | } 177 | } catch { 178 | print("\(error)") 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Playgrounds/Trivia.playground/Resources/trivia.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Which 1990 hit, often associated with the Madchester scene, was performed by The La's? 5 | There She Goes 6 | Step On 7 | Fools Gold 8 | Unbelievable 9 | The La's are best known for their hit "There She Goes", a song that became synonymous with the Madchester scene of the early 90s. 10 | 11 | 12 | 13 | Which influential electronic music duo released the album "Dig Your Own Hole" in 1997? 14 | Daft Punk 15 | Orbital 16 | The Chemical Brothers 17 | Aphex Twin 18 | The Chemical Brothers released "Dig Your Own Hole" in 1997, which was a significant album in the development of the big beat genre. 19 | 20 | 21 | 22 | In 1995, which band released the song "Common People", a defining track of the Britpop era? 23 | Blur 24 | Pulp 25 | Oasis 26 | Supergrass 27 | Pulp released "Common People" in 1995, which became one of the most popular and influential songs of the Britpop movement. 28 | 29 | 30 | 31 | Which artist, known for her ethereal voice and eclectic style, released the album "Debut" in 1993? 32 | Tori Amos 33 | Björk 34 | Kate Bush 35 | Sinead O'Connor 36 | Björk released her album "Debut" in 1993, showcasing her unique voice and innovative approach to music. 37 | 38 | 39 | 40 | Which band released the influential album "Spiderland" in 1991, now considered a touchstone of post-rock? 41 | Godspeed You! Black Emperor 42 | Mogwai 43 | Slint 44 | Tortoise 45 | Slint's album "Spiderland," released in 1991, is considered a seminal work in the post-rock genre. 46 | 47 | 48 | 49 | In 1994, which band released the album "Crooked Rain, Crooked Rain," showcasing their indie rock style? 50 | Pavement 51 | Guided by Voices 52 | Sonic Youth 53 | The Pixies 54 | Pavement released "Crooked Rain, Crooked Rain" in 1994, an album that became a cornerstone of 90s indie rock. 55 | 56 | 57 | 58 | Which band, formed by former members of the band Kyuss, released the album "Rated R" in 2000? 59 | Foo Fighters 60 | Queens of the Stone Age 61 | Eagles of Death Metal 62 | Them Crooked Vultures 63 | Queens of the Stone Age, formed by ex-members of Kyuss, released "Rated R" in 2000, which became a critical success. 64 | 65 | 66 | 67 | Which 1992 album by Rage Against the Machine featured the iconic song "Killing in the Name"? 68 | Rage Against the Machine 69 | Evil Empire 70 | The Battle of Los Angeles 71 | Renegades 72 | Rage Against the Machine's self-titled debut album released in 1992 featured the song "Killing in the Name," which became one of their most famous tracks. 73 | 74 | 75 | 76 | Which influential 1991 album by Nirvana signaled the mainstream breakthrough of grunge music? 77 | In Utero 78 | Bleach 79 | Nevermind 80 | MTV Unplugged in New York 81 | Nirvana's album "Nevermind," released in 1991, was a critical and commercial success and marked the mainstream arrival of grunge music. 82 | 83 | 84 | 85 | In 1997, which artist released the innovative electronica album "OK Computer"? 86 | Radiohead 87 | Blur 88 | Aphex Twin 89 | Massive Attack 90 | Radiohead released "OK Computer" in 1997, an album that was highly influential in the development of electronic and experimental rock. 91 | 92 | 93 | -------------------------------------------------------------------------------- /Playgrounds/Trivia.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swifty-xml-sequence 2 | 3 | swifty-xml-sequence is a lightweight, incremental XML parser built as a Swift wrapper around the libxml2 SAX parser. It is designed for efficient, streaming XML parsing, making it well-suited for very large XML documents from files or network sources. 4 | 5 | ## Features 6 | 7 | - **Incremental Parsing**: Parses XML data as it is streamed, reducing memory overhead. 8 | - **Built on libxml2 SAX Parser**: Uses a fast, low-level parsing engine. 9 | - **Swift Concurrency**: Designed for structured concurrency using `AsyncSequence`. 10 | - **Memory Efficient**: Handles large XML files without loading everything into memory. 11 | - **URLSession Integration**: Simplifies parsing XML from network sources. 12 | 13 | --- 14 | 15 | ## Installation 16 | 17 | ### Swift Package Manager (SPM) 18 | 19 | To integrate `swifty-xml-sequence` into your project, add the package dependency in `Package.swift`: 20 | 21 | ```swift 22 | dependencies: [ 23 | .package(url: "https://github.com/sophiestication/swifty-xml-sequence.git", .upToNextMajor(from: "1.0.0")) 24 | ] 25 | ``` 26 | 27 | ## Example 28 | 29 | For a usage example, check out the Trivia Playground or XMLEventTests contained within the Swift package. 30 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncChunkedByElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | public func chunked( 29 | by matchingElement: @Sendable @escaping ( 30 | _ element: T, 31 | _ attributes: Attributes 32 | ) throws -> Group? 33 | ) async rethrows -> AsyncChunkedByElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return AsyncChunkedByElementSequence( 37 | base: self, 38 | predicate: matchingElement 39 | ) 40 | } 41 | } 42 | 43 | public struct AsyncChunkedByElementSequence: AsyncSequence, Sendable 44 | where Base: AsyncSequence & Sendable, 45 | Base.Element == ParsingEvent, 46 | T: ElementRepresentable 47 | { 48 | private let base: Base 49 | 50 | internal typealias Predicate = @Sendable ( 51 | _ element: T, 52 | _ attributes: Attributes 53 | ) throws -> Group? 54 | 55 | private let predicate: Predicate 56 | 57 | internal init(base: Base, predicate: @escaping Predicate) { 58 | self.base = base 59 | self.predicate = predicate 60 | } 61 | 62 | public func makeAsyncIterator() -> Iterator { 63 | return Iterator(base.makeAsyncIterator(), predicate: predicate) 64 | } 65 | 66 | public struct Iterator: AsyncIteratorProtocol { 67 | public typealias Element = (Group?, [Base.Element]) 68 | private typealias Event = Base.Element 69 | 70 | private var base: PeekingAsyncIterator 71 | private let predicate: Predicate 72 | 73 | private var pending: Element? = nil 74 | 75 | internal init(_ base: Base.AsyncIterator, predicate: @escaping Predicate) { 76 | self.base = PeekingAsyncIterator(base: base) 77 | self.predicate = predicate 78 | } 79 | 80 | public mutating func next() async throws -> Element? { 81 | if let pending { 82 | self.pending = nil 83 | return pending 84 | } 85 | 86 | var chunk: [Event] = [] 87 | 88 | while let event = try await base.peek() { 89 | if case .begin(let element, let attributes) = event { 90 | let group = try predicate(element, attributes) 91 | 92 | if group != nil { 93 | let element = try await nextElement() 94 | 95 | if chunk.isEmpty { 96 | return (group, element) 97 | } else { 98 | pending = (group, element) 99 | return (nil, chunk) 100 | } 101 | } 102 | } 103 | 104 | _ = try await base.next() 105 | chunk.append(event) 106 | } 107 | 108 | if chunk.isEmpty { 109 | return nil 110 | } 111 | 112 | return (nil, chunk) 113 | } 114 | 115 | private mutating func nextElement() async throws -> [Event] { 116 | var element: [Event] = [] 117 | var depth = 0 118 | 119 | while let event = try await base.next() { 120 | element.append(event) 121 | 122 | switch event { 123 | case .begin(_, attributes: _): 124 | depth += 1 125 | case .end(_): 126 | depth -= 1 127 | 128 | if depth <= 0 { 129 | return element 130 | } 131 | default: 132 | break 133 | } 134 | } 135 | 136 | return element 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncCollectElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | public func collect( 29 | _ matching: @Sendable @escaping ( 30 | _ element: T, 31 | _ attributes: Attributes 32 | ) throws -> Bool 33 | ) async rethrows -> AsyncCollectElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return AsyncCollectElementSequence( 37 | base: self, 38 | predicate: matching 39 | ) 40 | } 41 | } 42 | 43 | public struct AsyncCollectElementSequence: AsyncSequence, Sendable 44 | where Base: AsyncSequence & Sendable, 45 | Base.Element == ParsingEvent, 46 | T: ElementRepresentable 47 | { 48 | private let base: Base 49 | 50 | internal typealias Predicate = @Sendable ( 51 | _ element: T, 52 | _ attributes: Attributes 53 | ) throws -> Bool 54 | 55 | private let predicate: Predicate 56 | 57 | internal init(base: Base, predicate: @escaping Predicate) { 58 | self.base = base 59 | self.predicate = predicate 60 | } 61 | 62 | public func makeAsyncIterator() -> Iterator { 63 | return Iterator(base.makeAsyncIterator(), predicate: predicate) 64 | } 65 | 66 | public struct Iterator: AsyncIteratorProtocol { 67 | public typealias Element = ParsingEvent 68 | 69 | private var base: Base.AsyncIterator 70 | private let predicate: Predicate 71 | 72 | internal init(_ base: Base.AsyncIterator, predicate: @escaping Predicate) { 73 | self.base = base 74 | self.predicate = predicate 75 | } 76 | 77 | private var depth = 0 78 | 79 | public mutating func next() async throws -> Element? { 80 | var nextEvent = try await base.next() 81 | 82 | if depth == 0 { 83 | while nextEvent != nil { 84 | if case .begin(let element, let attributes) = nextEvent { 85 | if try predicate(element, attributes) { 86 | depth = 1 87 | return nextEvent 88 | } 89 | } 90 | 91 | nextEvent = try await base.next() 92 | } 93 | } else if let nextEvent { 94 | switch nextEvent { 95 | case .begin(_, attributes: _): 96 | depth += 1 97 | case .end(_): 98 | depth -= 1 99 | default: 100 | break 101 | } 102 | } 103 | 104 | return nextEvent 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncFilterElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | public func filter( 29 | _ isIncluded: @Sendable @escaping ( 30 | _ element: T, 31 | _ attributes: Attributes 32 | ) throws -> Bool 33 | ) async rethrows -> AsyncFilterElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return AsyncFilterElementSequence( 37 | base: self, 38 | predicate: isIncluded 39 | ) 40 | } 41 | } 42 | 43 | public struct AsyncFilterElementSequence: AsyncSequence, Sendable 44 | where Base: AsyncSequence & Sendable, 45 | Base.Element == ParsingEvent, 46 | T: ElementRepresentable 47 | { 48 | private let base: Base 49 | 50 | internal typealias Predicate = @Sendable ( 51 | _ element: T, 52 | _ attributes: Attributes 53 | ) throws -> Bool 54 | 55 | private let predicate: Predicate 56 | 57 | internal init(base: Base, predicate: @escaping Predicate) { 58 | self.base = base 59 | self.predicate = predicate 60 | } 61 | 62 | public func makeAsyncIterator() -> Iterator { 63 | return Iterator(base.makeAsyncIterator(), predicate: predicate) 64 | } 65 | 66 | public struct Iterator: AsyncIteratorProtocol { 67 | public typealias Element = ParsingEvent 68 | 69 | private var base: Base.AsyncIterator 70 | private let predicate: Predicate 71 | 72 | internal init(_ base: Base.AsyncIterator, predicate: @escaping Predicate) { 73 | self.base = base 74 | self.predicate = predicate 75 | } 76 | 77 | public mutating func next() async throws -> Element? { 78 | var depth = 0 79 | 80 | while let nextEvent = try await base.next() { 81 | if depth == 0 { 82 | if case .begin(let element, let attributes) = nextEvent { 83 | if try predicate(element, attributes) == false { 84 | depth = 1 85 | continue 86 | } 87 | } 88 | 89 | return nextEvent 90 | } else { 91 | switch nextEvent { 92 | case .begin(_, attributes: _): 93 | depth += 1 94 | break 95 | case .end(_): 96 | depth -= 1 97 | break 98 | default: 99 | break 100 | } 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncFlatMapParsingEventWithStateSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | public func flatMap( 29 | with initialState: State, 30 | _ transform: @Sendable @escaping ( 31 | _ state: inout State, 32 | _ event: Element 33 | ) throws -> [Result] 34 | ) async rethrows -> AsyncFlatMapParsingEventWithStateSequence 35 | where Element == ParsingEvent 36 | { 37 | return AsyncFlatMapParsingEventWithStateSequence( 38 | base: self, 39 | state: initialState, 40 | transform: transform 41 | ) 42 | } 43 | } 44 | public struct AsyncFlatMapParsingEventWithStateSequence< 45 | Base, T, State, Result 46 | >: AsyncSequence, Sendable 47 | where Base: AsyncSequence & Sendable, 48 | Base.Element == ParsingEvent, 49 | T: ElementRepresentable, 50 | State: Sendable 51 | { 52 | private let base: Base 53 | 54 | internal typealias Transform = @Sendable ( 55 | _ state: inout State, 56 | _ event: Base.Element 57 | ) throws -> [Result] 58 | 59 | private let transform: Transform 60 | private var state: State 61 | 62 | internal init(base: Base, state: State, transform: @escaping Transform) { 63 | self.base = base 64 | self.state = state 65 | self.transform = transform 66 | } 67 | 68 | public func makeAsyncIterator() -> Iterator { 69 | return Iterator(base.makeAsyncIterator(), state: state, transform: transform) 70 | } 71 | 72 | public struct Iterator: AsyncIteratorProtocol { 73 | public typealias Element = Result 74 | 75 | private var base: Base.AsyncIterator 76 | private let transform: Transform 77 | 78 | internal init(_ base: Base.AsyncIterator, state: State, transform: @escaping Transform) { 79 | self.base = base 80 | self.state = state 81 | self.transform = transform 82 | } 83 | 84 | private var state: State 85 | private var result: [Result] = [] 86 | 87 | public mutating func next() async throws -> Element? { 88 | if result.isEmpty { 89 | guard let event = try await base.next() else { 90 | return nil 91 | } 92 | 93 | result = try transform(&state, event) 94 | } 95 | 96 | if result.isEmpty { 97 | return nil 98 | } 99 | 100 | return result.removeFirst() 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncLinebreakMappingSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | public enum LinebreakParsingEvent: Equatable, Sendable 28 | where Element: ElementRepresentable 29 | { 30 | case event(ParsingEvent, WhitespacePolicy) 31 | case whitespace(String, WhitespaceProcessing) 32 | case linebreak(String) 33 | } 34 | 35 | extension AsyncSequence { 36 | public func map( 37 | linebreaks transform: @Sendable @escaping ( 38 | _ element: T, 39 | _ attributes: Attributes 40 | ) -> String 41 | ) async rethrows -> AsyncLinebreakMappingSequence 42 | where Element == WhitespaceParsingEvent 43 | { 44 | return try await AsyncLinebreakMappingSequence(base: self, transform: transform) 45 | } 46 | } 47 | 48 | public struct AsyncLinebreakMappingSequence: AsyncSequence, Sendable 49 | where Base: AsyncSequence & Sendable, 50 | Base.Element == WhitespaceParsingEvent, 51 | T: ElementRepresentable 52 | { 53 | private var base: Base 54 | 55 | internal typealias Transform = @Sendable ( 56 | _ element: T, 57 | _ attributes: Attributes 58 | ) -> String 59 | 60 | private let transform: Transform 61 | 62 | internal init(base: Base, transform: @escaping Transform) async throws { 63 | self.base = base 64 | self.transform = transform 65 | } 66 | 67 | public typealias Element = Iterator.Element 68 | 69 | public func makeAsyncIterator() -> Iterator { 70 | return Iterator(base.makeAsyncIterator(), transform: transform) 71 | } 72 | 73 | public struct Iterator: AsyncIteratorProtocol { 74 | public typealias Element = LinebreakParsingEvent 75 | 76 | private var base: Base.AsyncIterator 77 | private var transform: Transform 78 | 79 | private var preparedInlineText: Bool = false 80 | 81 | private var collectedLinebreakElement: ParsingEvent? = nil 82 | private var pendingLinebreakElement: ParsingEvent? = nil 83 | private var pendingLinebreak: Bool = false 84 | 85 | private var prepared: [Element] = [] 86 | 87 | fileprivate init(_ base: Base.AsyncIterator, transform: @escaping Transform) { 88 | self.base = base 89 | self.transform = transform 90 | } 91 | 92 | public mutating func next() async throws -> Element? { 93 | if prepared.isEmpty == false { 94 | return prepared.removeFirst() 95 | } 96 | 97 | var preparing = true 98 | 99 | while preparing { 100 | let whitespaceEvent = try await base.next() 101 | 102 | switch whitespaceEvent { 103 | case .event(let event, let policy): 104 | switch event { 105 | case .begin(_, _), .end(_): 106 | if policy == .block { 107 | if preparedInlineText { 108 | preparedInlineText = false 109 | 110 | pendingLinebreak = true 111 | pendingLinebreakElement = collectedLinebreakElement 112 | } 113 | 114 | if case .begin(_, _) = event { 115 | collectedLinebreakElement = event 116 | } 117 | } 118 | 119 | yield(whitespaceEvent) 120 | break 121 | 122 | case .text(_): 123 | if pendingLinebreak { 124 | yieldPendingLinebreak() 125 | } 126 | 127 | preparedInlineText = true 128 | yield(whitespaceEvent) 129 | break 130 | 131 | default: 132 | yield(whitespaceEvent) 133 | break 134 | } 135 | break 136 | 137 | case .whitespace(_, _): 138 | yield(whitespaceEvent) 139 | break 140 | 141 | case .none: 142 | pendingLinebreak = false 143 | break 144 | } 145 | 146 | if pendingLinebreak == false { 147 | preparing = false 148 | } 149 | } 150 | 151 | if prepared.isEmpty == false { 152 | return prepared.removeFirst() 153 | } 154 | 155 | return nil 156 | } 157 | 158 | private mutating func yield(_ whitespaceEvent: Element?) { 159 | if let whitespaceEvent { 160 | prepared.append(whitespaceEvent) 161 | } 162 | } 163 | 164 | private mutating func yield(_ whitespaceEvent: Base.Element?) { 165 | guard let whitespaceEvent else { 166 | return 167 | } 168 | 169 | switch whitespaceEvent { 170 | case .event(let event, let policy): 171 | prepared.append(.event(event, policy)) 172 | break 173 | 174 | case .whitespace(let whitespace, let processing): 175 | prepared.append(.whitespace(whitespace, processing)) 176 | break 177 | } 178 | } 179 | 180 | private mutating func yieldPendingLinebreak() { 181 | if pendingLinebreak { 182 | let string = linebreak(for: pendingLinebreakElement) 183 | prepared.insert(.linebreak(string), at: 0) 184 | } 185 | 186 | self.pendingLinebreak = false 187 | self.pendingLinebreakElement = nil 188 | } 189 | 190 | private func linebreak(for event: ParsingEvent?) -> String { 191 | return switch event { 192 | case .begin(let element, let attributes): 193 | transform(element, attributes) 194 | default: 195 | "\n" 196 | } 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncMapParsingEventWithStateSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | public func map( 29 | with initialState: State, 30 | _ transform: @Sendable @escaping ( 31 | _ state: inout State, 32 | _ event: Element 33 | ) throws -> Result 34 | ) async rethrows -> AsyncMapParsingEventWithStateSequence 35 | where Element == ParsingEvent 36 | { 37 | return AsyncMapParsingEventWithStateSequence( 38 | base: self, 39 | state: initialState, 40 | transform: transform 41 | ) 42 | } 43 | } 44 | public struct AsyncMapParsingEventWithStateSequence< 45 | Base, T, State, Result 46 | >: AsyncSequence, Sendable 47 | where Base: AsyncSequence & Sendable, 48 | Base.Element == ParsingEvent, 49 | T: ElementRepresentable, 50 | State: Sendable 51 | { 52 | private let base: Base 53 | 54 | internal typealias Transform = @Sendable ( 55 | _ state: inout State, 56 | _ event: Base.Element 57 | ) throws -> Result 58 | 59 | private let transform: Transform 60 | private var state: State 61 | 62 | internal init(base: Base, state: State, transform: @escaping Transform) { 63 | self.base = base 64 | self.state = state 65 | self.transform = transform 66 | } 67 | 68 | public func makeAsyncIterator() -> Iterator { 69 | return Iterator(base.makeAsyncIterator(), state: state, transform: transform) 70 | } 71 | 72 | public struct Iterator: AsyncIteratorProtocol { 73 | public typealias Element = Result 74 | 75 | private var base: Base.AsyncIterator 76 | private let transform: Transform 77 | 78 | internal init(_ base: Base.AsyncIterator, state: State, transform: @escaping Transform) { 79 | self.base = base 80 | self.state = state 81 | self.transform = transform 82 | } 83 | 84 | private var state: State 85 | 86 | public mutating func next() async throws -> Element? { 87 | guard let event = try await base.next() else { 88 | return nil 89 | } 90 | 91 | return try transform(&state, event) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncMapWithContextElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension AsyncSequence { 28 | internal func mapWithContext( 29 | _ transform: @Sendable @escaping ( 30 | _ context: [ParsingEventMappingContext], 31 | _ event: Element 32 | ) throws -> Result 33 | ) async rethrows -> AsyncMapWithContextElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return AsyncMapWithContextElementSequence( 37 | base: self, 38 | transform: transform 39 | ) 40 | } 41 | } 42 | 43 | internal struct ParsingEventMappingContext < 44 | T, Result 45 | > where T: ElementRepresentable { 46 | var element: T 47 | var attributes: Attributes 48 | var mappedResult: Result 49 | } 50 | 51 | internal struct AsyncMapWithContextElementSequence< 52 | Base, T, Result 53 | >: AsyncSequence, Sendable 54 | where Base: AsyncSequence & Sendable, 55 | Base.Element == ParsingEvent, 56 | T: ElementRepresentable 57 | { 58 | private let base: Base 59 | 60 | internal typealias Transform = @Sendable ( 61 | _ context: [ParsingEventMappingContext], 62 | _ event: Base.Element 63 | ) throws -> Result 64 | 65 | private let transform: Transform 66 | 67 | internal init(base: Base, transform: @escaping Transform) { 68 | self.base = base 69 | self.transform = transform 70 | } 71 | 72 | public func makeAsyncIterator() -> Iterator { 73 | return Iterator(base.makeAsyncIterator(), transform: transform) 74 | } 75 | 76 | public struct Iterator: AsyncIteratorProtocol { 77 | public typealias Element = Result 78 | 79 | private var base: Base.AsyncIterator 80 | private let transform: Transform 81 | 82 | internal init(_ base: Base.AsyncIterator, transform: @escaping Transform) { 83 | self.base = base 84 | self.transform = transform 85 | } 86 | 87 | public typealias Context = ParsingEventMappingContext 88 | private var context: [Context] = [] 89 | 90 | public mutating func next() async throws -> Element? { 91 | guard let event = try await base.next() else { 92 | return nil 93 | } 94 | 95 | let result = try transform(context, event) 96 | 97 | switch event { 98 | case .begin(let element, let attributes): 99 | context.append( 100 | Context(element: element, attributes: attributes, mappedResult: result) 101 | ) 102 | break 103 | 104 | case .end(_): 105 | _ = context.popLast() 106 | break 107 | 108 | default: 109 | break 110 | } 111 | 112 | return result 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncSequence+ParsingEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import AsyncAlgorithms 26 | 27 | extension AsyncSequence { 28 | public func joinAdjacentText( 29 | ) async rethrows -> AsyncThrowingFlatMapSequence< 30 | AsyncChunkedByGroupSequence, 31 | AsyncSyncSequence<[Element]> 32 | > 33 | where Element == ParsingEvent 34 | { 35 | chunked { 36 | if case .text(_) = $0, case .text(_) = $1 { return true } 37 | return false 38 | } 39 | .flatMap { chunk in 40 | if let first = chunk.first, 41 | case .text(_) = first 42 | { 43 | let text = chunk.compactMap { 44 | if case .text(let string) = $0 { return string } 45 | return nil 46 | }.joined() 47 | 48 | let joinedEvent: Element = .text(text) 49 | return [joinedEvent].async 50 | } 51 | 52 | return chunk.async 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncWhitespaceCollapsingSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | import AsyncAlgorithms 27 | 28 | public typealias AsyncWhitespaceCollapsingSequence< 29 | Base: AsyncSequence, 30 | T: ElementRepresentable 31 | > = AsyncThrowingFlatMapSequence< 32 | AsyncChunkedByGroupSequence< 33 | AsyncCompactMapSequence>, 34 | [AsyncCompactMapSequence>.Element] 35 | >, 36 | AsyncSyncSequence<[AsyncCompactMapSequence>.Element]> 37 | > 38 | 39 | extension AsyncSequence { 40 | public func collapse( 41 | ) async rethrows -> AsyncWhitespaceCollapsingSequence 42 | where Element == WhitespaceParsingEvent 43 | { 44 | try await compactMap { 45 | return switch $0 { 46 | case .event(let event, _): 47 | event 48 | case .whitespace(_, let processing): 49 | if processing == .collapse { 50 | .text(" ") 51 | } else { 52 | nil 53 | } 54 | } 55 | } 56 | .joinAdjacentText() 57 | } 58 | 59 | public func collapse( 60 | ) async rethrows -> AsyncWhitespaceCollapsingSequence 61 | where Element == LinebreakParsingEvent 62 | { 63 | try await compactMap { 64 | return switch $0 { 65 | case .event(let event, _): 66 | event 67 | case .whitespace(_, let processing): 68 | if processing == .collapse { 69 | .text(" ") 70 | } else { 71 | nil 72 | } 73 | case .linebreak(let linebreak): 74 | .text(linebreak) 75 | } 76 | } 77 | .joinAdjacentText() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/AsyncWhitespaceMappingSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | import Algorithms 27 | import AsyncAlgorithms 28 | 29 | extension AsyncSequence { 30 | public func map( 31 | whitespace policy: @Sendable @escaping ( 32 | _ element: T, 33 | _ attributes: Attributes 34 | ) -> WhitespacePolicy 35 | ) async rethrows -> AsyncWhitespaceMappingSequence 36 | where Element == ParsingEvent 37 | { 38 | try await AsyncWhitespaceMappingSequence(base: self, policy: policy) 39 | } 40 | } 41 | 42 | public struct AsyncWhitespaceMappingSequence: AsyncSequence, Sendable 43 | where Base: AsyncSequence & Sendable, 44 | Base.Element == ParsingEvent, 45 | T: ElementRepresentable 46 | { 47 | fileprivate typealias PrivateBase = AsyncFlatMapSequence< 48 | AsyncMapWithContextElementSequence< 49 | AsyncThrowingFlatMapSequence< 50 | AsyncChunkedByGroupSequence< 51 | Base, [Base.Element] 52 | >, AsyncSyncSequence<[Base.Element]> 53 | >, T, AsyncWhitespaceMappingSequence.Element 54 | >, AsyncSyncSequence<[AsyncWhitespaceMappingSequence.Element]> 55 | > // ☠️ 56 | private var base: PrivateBase 57 | 58 | internal typealias Policy = @Sendable ( 59 | _ element: T, 60 | _ attributes: Attributes 61 | ) -> WhitespacePolicy 62 | 63 | internal init(base: Base, policy: @escaping Policy) async throws { 64 | self.base = try await base 65 | .joinAdjacentText() 66 | .mapWithContext { (context, event) -> Element in 67 | return switch event { 68 | case .begin(let element, let attributes): 69 | .event(event, policy(element, attributes)) 70 | default: 71 | .event(event, Self.policy(for: context)) 72 | } 73 | } 74 | .flatMap({ whitespaceEvent in 75 | switch whitespaceEvent { 76 | case .event(let event, let policy): 77 | if policy != .preserve { 78 | switch event { 79 | case .text(let string): 80 | return Self.segments(for: string, policy).async 81 | default: 82 | break 83 | } 84 | } 85 | default: 86 | break 87 | } 88 | 89 | return [whitespaceEvent].async 90 | }) 91 | } 92 | 93 | public typealias Element = WhitespaceParsingEvent 94 | 95 | public func makeAsyncIterator() -> Iterator { 96 | return Iterator(base.makeAsyncIterator()) 97 | } 98 | 99 | public struct Iterator: AsyncIteratorProtocol { 100 | public typealias Element = WhitespaceParsingEvent 101 | 102 | private var base: PrivateBase.AsyncIterator 103 | 104 | private var preparedInlineText: Bool = false 105 | private var pendingWhitespace: Element? = nil 106 | private var prepared: [Element] = [] 107 | 108 | fileprivate init(_ base: PrivateBase.AsyncIterator) { 109 | self.base = base 110 | } 111 | 112 | public mutating func next() async throws -> Element? { 113 | if prepared.isEmpty == false { 114 | return prepared.removeFirst() 115 | } 116 | 117 | var preparing = true 118 | 119 | while preparing { 120 | let whitespaceEvent = try await base.next() 121 | 122 | switch whitespaceEvent { 123 | case .event(let event, let policy): 124 | switch event { 125 | case .begin(_, _), .end(_): 126 | if policy == .block { 127 | preparedInlineText = false 128 | yield(pending: .remove) 129 | } 130 | 131 | yield(whitespaceEvent) 132 | break 133 | 134 | case .text(_): 135 | yield(pending: .collapse) 136 | 137 | preparedInlineText = true 138 | yield(whitespaceEvent) 139 | break 140 | 141 | default: 142 | yield(whitespaceEvent) 143 | break 144 | } 145 | break 146 | 147 | case .whitespace(_, _): 148 | if preparedInlineText == true, 149 | pendingWhitespace == nil { 150 | pendingWhitespace = whitespaceEvent 151 | } else { 152 | yield(whitespaceEvent) 153 | } 154 | break 155 | 156 | case .none: 157 | yield(pending: .remove) 158 | break 159 | } 160 | 161 | if pendingWhitespace == nil { 162 | preparing = false 163 | } 164 | } 165 | 166 | if prepared.isEmpty == false { 167 | return prepared.removeFirst() 168 | } 169 | 170 | return nil 171 | } 172 | 173 | private mutating func yield(_ whitespaceEvent: Element?) { 174 | if let whitespaceEvent { 175 | prepared.append(whitespaceEvent) 176 | } 177 | } 178 | 179 | private mutating func yield( 180 | pending processing: WhitespaceProcessing 181 | ) { 182 | guard let pendingWhitespace else { 183 | return 184 | } 185 | 186 | switch pendingWhitespace { 187 | case .whitespace(let whitespace, _): 188 | prepared.insert(.whitespace(whitespace, processing), at: 0) 189 | default: 190 | break 191 | } 192 | 193 | self.pendingWhitespace = nil 194 | } 195 | } 196 | 197 | private static func policy( 198 | for context: [ParsingEventMappingContext] 199 | ) -> WhitespacePolicy { 200 | if let last = context.last { 201 | switch last.mappedResult { 202 | case .event(_, let policy): 203 | return policy 204 | default: 205 | break 206 | } 207 | } 208 | 209 | return .block 210 | } 211 | 212 | private static func segments( 213 | for string: String, 214 | _ policy: WhitespacePolicy 215 | ) -> [Element] { 216 | string 217 | .whitespaceSegments 218 | .map { segment -> Element in 219 | return switch segment { 220 | case .text(let substring): 221 | .event(.text(String(substring)), policy) 222 | case .whitespace(let whitespace, _): 223 | .whitespace(String(whitespace), .remove) 224 | } 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/Attributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | @dynamicMemberLookup 28 | public struct Attributes: Equatable, Sendable, RawRepresentable { 29 | public typealias Element = RawValue.Element 30 | public typealias Index = RawValue.Index 31 | 32 | public var rawValue: [String : String] 33 | 34 | public init(rawValue: RawValue) { 35 | self.rawValue = rawValue 36 | } 37 | 38 | public subscript(dynamicMember key: String) -> String? { 39 | return self[key] 40 | } 41 | 42 | public subscript(key: String) -> String? { 43 | return rawValue.first { $0.key.caseInsensitiveCompare(key) == .orderedSame }?.value 44 | } 45 | } 46 | 47 | extension Attributes: Collection { 48 | public var startIndex: Index { rawValue.startIndex } 49 | public var endIndex: Index { rawValue.endIndex } 50 | 51 | public func index(after i: Index) -> Index { 52 | rawValue.index(after: i) 53 | } 54 | 55 | public subscript(position: Index) -> Element { 56 | rawValue[position] 57 | } 58 | } 59 | 60 | extension Attributes: Identifiable { 61 | public typealias ID = String? 62 | public var id: ID { self["id"] } 63 | } 64 | 65 | extension Attributes { 66 | public var `class`: some Collection { 67 | (self["class"] ?? String()).matches(of: /\S+/).lazy.map(\.output) // 🕶️ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/CollectElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension Sequence { 28 | public func collect( 29 | _ matching: @escaping ( 30 | _ element: T, 31 | _ attributes: Attributes 32 | ) -> Bool 33 | ) -> CollectElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return CollectElementSequence( 37 | base: self, 38 | predicate: matching 39 | ) 40 | } 41 | } 42 | 43 | public struct CollectElementSequence: Sequence 44 | where Base: Sequence, 45 | Base.Element == ParsingEvent, 46 | T: ElementRepresentable 47 | { 48 | private let base: Base 49 | 50 | internal typealias Predicate = ( 51 | _ element: T, 52 | _ attributes: Attributes 53 | ) -> Bool 54 | 55 | private let predicate: Predicate 56 | 57 | internal init(base: Base, predicate: @escaping Predicate) { 58 | self.base = base 59 | self.predicate = predicate 60 | } 61 | 62 | public func makeIterator() -> Iterator { 63 | return Iterator(base.makeIterator(), predicate: predicate) 64 | } 65 | 66 | public struct Iterator: IteratorProtocol { 67 | public typealias Element = ParsingEvent 68 | 69 | private var base: Base.Iterator 70 | private let predicate: Predicate 71 | 72 | internal init(_ base: Base.Iterator, predicate: @escaping Predicate) { 73 | self.base = base 74 | self.predicate = predicate 75 | } 76 | 77 | private var depth = 0 78 | 79 | public mutating func next() -> Element? { 80 | var nextEvent = base.next() 81 | 82 | if depth == 0 { 83 | while nextEvent != nil { 84 | if case .begin(let element, let attributes) = nextEvent { 85 | if predicate(element, attributes) { 86 | depth = 1 87 | return nextEvent 88 | } 89 | } 90 | 91 | nextEvent = base.next() 92 | } 93 | } else if let nextEvent { 94 | switch nextEvent { 95 | case .begin(_, attributes: _): 96 | depth += 1 97 | case .end(_): 98 | depth -= 1 99 | default: 100 | break 101 | } 102 | } 103 | 104 | return nextEvent 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/Data+XML.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | public extension Data { 28 | func xml( 29 | _ elementType: Element.Type = XMLElement.self, 30 | referencing url: URL? = nil 31 | ) throws -> [ParsingEvent] { 32 | try parse(.xml, elementType, referencing: url) 33 | } 34 | 35 | func html( 36 | _ elementType: Element.Type = HTMLElement.self, 37 | referencing url: URL? = nil 38 | ) throws -> [ParsingEvent] { 39 | try parse(.html, elementType, referencing: url) 40 | } 41 | 42 | private func parse( 43 | _ options: PushParser.Options, 44 | _ elementType: Element.Type = XMLElement.self, 45 | referencing url: URL? = nil 46 | ) throws -> [ParsingEvent] { 47 | var xml = [ParsingEvent]() 48 | 49 | var elementStack = [Element]() 50 | 51 | let parser = PushParser( 52 | options: options, 53 | filename: url?.absoluteString, 54 | 55 | startDocument: { 56 | xml.append(.beginDocument) 57 | }, endDocument: { 58 | xml.append(.endDocument) 59 | }, startElement: { elementName, attributes in 60 | let element = Element(element: elementName, attributes: attributes) 61 | 62 | elementStack.append(element) 63 | xml.append(.begin(element, attributes: attributes)) 64 | }, endElement: { 65 | let element = elementStack.removeLast() 66 | xml.append(.end(element)) 67 | }, characters: { string in 68 | xml.append(.text(string)) 69 | } 70 | ) 71 | 72 | try parser.push(self) 73 | try parser.finish() 74 | 75 | return xml 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/FilterElementSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension Sequence { 28 | public func filter( 29 | _ isIncluded: @escaping ( 30 | _ element: T, 31 | _ attributes: Attributes 32 | ) -> Bool 33 | ) -> FilterElementSequence 34 | where Element == ParsingEvent 35 | { 36 | return FilterElementSequence( 37 | base: self, 38 | predicate: isIncluded 39 | ) 40 | } 41 | } 42 | 43 | public struct FilterElementSequence: Sequence 44 | where Base: Sequence, 45 | Base.Element == ParsingEvent, 46 | T: ElementRepresentable 47 | { 48 | private let base: Base 49 | 50 | internal typealias Predicate = ( 51 | _ element: T, 52 | _ attributes: Attributes 53 | ) -> Bool 54 | 55 | private let predicate: Predicate 56 | 57 | internal init(base: Base, predicate: @escaping Predicate) { 58 | self.base = base 59 | self.predicate = predicate 60 | } 61 | 62 | public func makeIterator() -> Iterator { 63 | return Iterator(base.makeIterator(), predicate: predicate) 64 | } 65 | 66 | public struct Iterator: IteratorProtocol { 67 | public typealias Element = ParsingEvent 68 | 69 | private var base: Base.Iterator 70 | private let predicate: Predicate 71 | 72 | internal init(_ base: Base.Iterator, predicate: @escaping Predicate) { 73 | self.base = base 74 | self.predicate = predicate 75 | } 76 | 77 | public mutating func next() -> Element? { 78 | var depth = 0 79 | 80 | while let nextEvent = base.next() { 81 | if depth == 0 { 82 | if case .begin(let element, let attributes) = nextEvent { 83 | if predicate(element, attributes) == false { 84 | depth = 1 85 | continue 86 | } 87 | } 88 | 89 | return nextEvent 90 | } else { 91 | switch nextEvent { 92 | case .begin(_, attributes: _): 93 | depth += 1 94 | break 95 | case .end(_): 96 | depth -= 1 97 | break 98 | default: 99 | break 100 | } 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/HTMLElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | public enum HTMLElement: ElementRepresentable { 28 | case html, head, meta, title, link, style, script, body 29 | 30 | // Sectioning 31 | case div, span, section, article, aside, nav, header, footer, main 32 | 33 | // Text & Formatting 34 | case p, br, hr, figure, figcaption, blockquote, q, cite, code, pre 35 | case abbr, sup, sub, strong, b, i, u, small, mark, time, kbd, samp, `var` 36 | case ruby, rt, rp, bdi, bdo, wbr 37 | 38 | // Headings 39 | case h1, h2, h3, h4, h5, h6 40 | 41 | // Lists 42 | case ul, ol, li, dl, dt, dd 43 | 44 | // Tables 45 | case table, thead, tbody, tfoot, tr, th, td, caption, colgroup, col 46 | 47 | // Forms & Inputs 48 | case form, label, input, textarea, button, select, option, optgroup 49 | case fieldset, legend, datalist, output, progress, meter 50 | 51 | // Interactive Elements 52 | case details, summary, dialog 53 | 54 | // Media 55 | case audio, video, source, track, embed, object, param, iframe, canvas, picture, svg, img 56 | 57 | // Links 58 | case a 59 | 60 | // Custom 61 | case custom(String) 62 | 63 | private static let stringToElement: [String: HTMLElement] = [ 64 | "html": .html, 65 | "head": .head, 66 | "meta": .meta, 67 | "title": .title, 68 | "link": .link, 69 | "style": .style, 70 | "script": .script, 71 | "body": .body, 72 | 73 | "div": .div, 74 | "span": .span, 75 | "section": .section, 76 | "article": .article, 77 | "aside": .aside, 78 | "nav": .nav, 79 | "header": .header, 80 | "footer": .footer, 81 | "main": .main, 82 | 83 | "p": .p, 84 | "br": .br, 85 | "hr": .hr, 86 | "figure": .figure, 87 | "figcaption": .figcaption, 88 | "blockquote": .blockquote, 89 | "q": .q, 90 | "cite": .cite, 91 | "code": .code, 92 | "pre": .pre, 93 | 94 | "abbr": .abbr, 95 | "sup": .sup, 96 | "sub": .sub, 97 | "strong": .strong, 98 | "b": .b, 99 | "i": .i, 100 | "u": .u, 101 | "small": .small, 102 | "mark": .mark, 103 | "time": .time, 104 | "kbd": .kbd, 105 | "samp": .samp, 106 | "var": .var, 107 | "ruby": .ruby, 108 | "rt": .rt, 109 | "rp": .rp, 110 | "bdi": .bdi, 111 | "bdo": .bdo, 112 | "wbr": .wbr, 113 | 114 | "h1": .h1, 115 | "h2": .h2, 116 | "h3": .h3, 117 | "h4": .h4, 118 | "h5": .h5, 119 | "h6": .h6, 120 | 121 | "ul": .ul, 122 | "ol": .ol, 123 | "li": .li, 124 | "dl": .dl, 125 | "dt": .dt, 126 | "dd": .dd, 127 | 128 | "table": .table, 129 | "thead": .thead, 130 | "tbody": .tbody, 131 | "tfoot": .tfoot, 132 | "tr": .tr, 133 | "th": .th, 134 | "td": .td, 135 | "caption": .caption, 136 | "colgroup": .colgroup, 137 | "col": .col, 138 | 139 | "form": .form, 140 | "label": .label, 141 | "input": .input, 142 | "textarea": .textarea, 143 | "button": .button, 144 | "select": .select, 145 | "option": .option, 146 | "optgroup": .optgroup, 147 | "fieldset": .fieldset, 148 | "legend": .legend, 149 | "datalist": .datalist, 150 | "output": .output, 151 | "progress": .progress, 152 | "meter": .meter, 153 | 154 | "details": .details, 155 | "summary": .summary, 156 | "dialog": .dialog, 157 | 158 | "audio": .audio, 159 | "video": .video, 160 | "source": .source, 161 | "track": .track, 162 | "embed": .embed, 163 | "object": .object, 164 | "param": .param, 165 | "iframe": .iframe, 166 | "canvas": .canvas, 167 | "picture": .picture, 168 | "svg": .svg, 169 | "img": .img, 170 | 171 | "a": .a, 172 | ] 173 | 174 | public init(element: String, attributes: Attributes) { 175 | let key = element.lowercased() 176 | self = HTMLElement.stringToElement[key] ?? .custom(element) 177 | } 178 | } 179 | 180 | extension HTMLElement: WhitespaceCollapsing { 181 | public var whitespacePolicy: WhitespacePolicy { 182 | return switch self { 183 | case .wbr, .span, .a, .link, .b, .i, .u, .strong, .small, .mark, 184 | .abbr, .cite, .q, .code, .sup, .sub, .time, .kbd, .samp, .var, 185 | .ruby, .rt, .rp, .bdi, .bdo, .img, .button, .label, 186 | .input, .select, .option, .optgroup, 187 | .output, .progress, .meter, .details, .summary: 188 | .inline 189 | 190 | case .pre, .textarea: 191 | .preserve 192 | 193 | default: 194 | .block 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/ParsingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | import libxml2 27 | 28 | public struct ParsingError: Error { 29 | public let domain: Int 30 | public let code: Int 31 | 32 | public let message: String 33 | 34 | public let filename: String? 35 | public let line: Int 36 | public let column: Int 37 | 38 | init(from xmlError: xmlErrorPtr) { 39 | let error = xmlError.pointee 40 | 41 | self.domain = Int(error.domain) 42 | self.code = Int(error.code) 43 | 44 | self.message = String(cString: error.message) 45 | .trimmingCharacters(in: .whitespacesAndNewlines) 46 | 47 | self.filename = error.file != nil ? 48 | String(cString: error.file) : nil 49 | 50 | self.line = Int(error.line) 51 | self.column = Int(error.line) 52 | } 53 | 54 | init(with errorCode: Int, context: xmlParserCtxtPtr) { 55 | self.domain = Int(XML_FROM_NONE.rawValue) 56 | self.code = errorCode 57 | 58 | self.message = "XML Parsing Error: \(errorCode)" 59 | 60 | if let input = context.pointee.input, 61 | let file = input.pointee.filename { 62 | self.filename = String(cString: file) 63 | } else { 64 | self.filename = nil 65 | } 66 | 67 | self.line = Int(xmlSAX2GetLineNumber(context)) 68 | self.column = Int(xmlSAX2GetColumnNumber(context)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/ParsingEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | public protocol ElementRepresentable: Equatable, Sendable { 28 | init(element: String, attributes: Attributes) 29 | } 30 | 31 | public enum ParsingEvent: Equatable, Sendable 32 | where Element: ElementRepresentable 33 | { 34 | case beginDocument 35 | case endDocument 36 | 37 | case begin(_ element: Element, attributes: Attributes) 38 | case end(_ element: Element) 39 | 40 | case text(String) 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/ParsingEventDebugFormatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | public struct ParsingEventDebugFormatter: Sendable { 26 | func format(_ sequence: S) async throws -> String 27 | where S: AsyncSequence, 28 | S.Element == WhitespaceParsingEvent, 29 | E: ElementRepresentable 30 | { 31 | let array = try await Array(sequence.compactMap { format($0) }) 32 | return array.joined(separator: " ") 33 | } 34 | 35 | func format(_ sequence: S) async throws -> String 36 | where S: AsyncSequence, 37 | S.Element == LinebreakParsingEvent, 38 | E: ElementRepresentable 39 | { 40 | let array = try await Array(sequence.compactMap { format($0) }) 41 | return array.joined(separator: " ") 42 | } 43 | 44 | func format(_ sequence: S) async throws -> String 45 | where S: AsyncSequence, 46 | S.Element == ParsingEvent, 47 | E: ElementRepresentable 48 | { 49 | let array = try await Array(sequence.compactMap { format($0) }) 50 | return array.joined(separator: " ") 51 | } 52 | 53 | func format(_ sequence: S) -> String 54 | where S: Sequence, 55 | S.Element == WhitespaceParsingEvent, 56 | E: ElementRepresentable 57 | { 58 | sequence.compactMap { format($0) }.joined(separator: " ") 59 | } 60 | 61 | func format(_ sequence: S) -> String 62 | where S: Sequence, 63 | S.Element == ParsingEvent, 64 | E: ElementRepresentable 65 | { 66 | sequence.compactMap { format($0) }.joined(separator: " ") 67 | } 68 | 69 | func format(_ event: WhitespaceParsingEvent) -> String? 70 | where E: ElementRepresentable 71 | { 72 | switch event { 73 | case .whitespace(let string, let processing): 74 | return "[\(format(processing)):\(format(whitespace: string))]" 75 | case .event(let event, let policy): 76 | return switch event { 77 | case .begin(_, _): 78 | "[\(format(policy))" 79 | case .end(_): 80 | "\(format(policy))]" 81 | case .text(let string): 82 | "[\(string)]" 83 | default: 84 | nil 85 | } 86 | } 87 | } 88 | 89 | func format(_ event: LinebreakParsingEvent) -> String? 90 | where E: ElementRepresentable 91 | { 92 | switch event { 93 | case .whitespace(let string, let processing): 94 | return "[\(format(processing)):\(format(whitespace: string))]" 95 | case .event(let event, let policy): 96 | return switch event { 97 | case .begin(_, _): 98 | "[\(format(policy))" 99 | case .end(_): 100 | "\(format(policy))]" 101 | case .text(let string): 102 | "[\(string)]" 103 | default: 104 | nil 105 | } 106 | case .linebreak: 107 | return "[↩︎]" 108 | } 109 | } 110 | 111 | func format( 112 | _ event: ParsingEvent 113 | ) -> String? 114 | where E: ElementRepresentable 115 | { 116 | return switch event { 117 | case .begin(let element, _): 118 | "[\(element)" 119 | case .end(let element): 120 | "\(element)]" 121 | case .text(let string): 122 | "[\(string)]" 123 | default: 124 | nil 125 | } 126 | } 127 | 128 | func format(_ policy: WhitespacePolicy) -> String { 129 | return switch policy { 130 | case .inline: 131 | "inline" 132 | case .block: 133 | "block" 134 | case .preserve: 135 | "preserve" 136 | } 137 | } 138 | 139 | func format(_ processing: WhitespaceProcessing) -> String { 140 | return switch processing { 141 | case .collapse: 142 | "collapse" 143 | case .remove: 144 | "remove" 145 | } 146 | } 147 | 148 | func format(whitespace: String) -> String { 149 | whitespace.map { 150 | if $0 == "\t" { return "⇥" } 151 | if $0.isNewline { return "↩︎" } 152 | if $0.isWhitespace { return "·" } 153 | return String($0) 154 | } 155 | .joined() 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/PeekingAsyncIterator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | internal struct PeekingAsyncIterator: AsyncIteratorProtocol 28 | where Base: AsyncIteratorProtocol 29 | { 30 | public typealias Element = Base.Element 31 | 32 | private var base: Base 33 | private var pending: Element? = nil 34 | 35 | internal init(base: Base) { 36 | self.base = base 37 | } 38 | 39 | public mutating func next() async throws -> Element? { 40 | if let pending { 41 | self.pending = nil 42 | return pending 43 | } 44 | 45 | return try await base.next() 46 | } 47 | 48 | public mutating func peek() async throws -> Element? { 49 | if pending == nil { 50 | pending = try await base.next() 51 | } 52 | 53 | return pending 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/PushParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | @preconcurrency import libxml2 27 | 28 | internal final class PushParser { 29 | enum Options { 30 | case xml 31 | case html 32 | } 33 | 34 | private let options: Options 35 | private var parserContext: xmlParserCtxtPtr? = nil 36 | private let suggestedFilename: UnsafePointer? 37 | 38 | private var startDocument: (() -> Void) 39 | private var endDocument: (() -> Void) 40 | 41 | private var startElement: ((_ elementName: String, _ attributes: Attributes) -> Void) 42 | private var endElement: (() -> Void) 43 | 44 | private var characters: ((_ string: String) -> Void) 45 | 46 | init( 47 | options: Options, 48 | 49 | filename: String?, 50 | 51 | startDocument: (@escaping () -> Void), 52 | endDocument: (@escaping () -> Void), 53 | 54 | startElement: (@escaping (_ elementName: String, _ attributes: Attributes) -> Void), 55 | endElement: (@escaping () -> Void), 56 | 57 | characters: (@escaping (_ string: String) -> Void) 58 | ) { 59 | self.options = options 60 | 61 | self.suggestedFilename = Self.suggestedFilename(for: filename) 62 | 63 | self.startDocument = startDocument 64 | self.endDocument = endDocument 65 | 66 | self.startElement = startElement 67 | self.endElement = endElement 68 | 69 | self.characters = characters 70 | } 71 | 72 | deinit { 73 | if let parserContext { 74 | xmlFreeParserCtxt(parserContext) 75 | } 76 | 77 | if let suggestedFilename { 78 | free(UnsafeMutablePointer(mutating: suggestedFilename)) 79 | } 80 | } 81 | 82 | func push(_ data: Data) throws { 83 | let parser = prepared(parserContext) 84 | 85 | try data.withUnsafeBytes { rawBufferPointer in 86 | if let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int8.self) { 87 | let errorCode = xmlParseChunk(parser, buffer, Int32(data.count), 0) 88 | try raiseErrorIfNeeded(for: errorCode, parser) 89 | } 90 | } 91 | } 92 | 93 | func finish() throws { 94 | let parser = prepared(parserContext) 95 | let errorCode = xmlParseChunk(parser, nil, 0, 1) 96 | 97 | try raiseErrorIfNeeded(for: errorCode, parser) 98 | } 99 | 100 | private func prepared(_ parser: xmlParserCtxtPtr?) -> xmlParserCtxtPtr { 101 | guard let preparedContext = self.parserContext else { 102 | let context = Unmanaged 103 | .passUnretained(self) 104 | .toOpaque() 105 | 106 | var handler = xmlSAXHandler() 107 | 108 | handler.startDocument = startDocumentSAX 109 | handler.endDocument = endDocumentSAX 110 | 111 | handler.startElement = startElementSAX 112 | handler.endElement = endElementSAX 113 | 114 | handler.characters = charactersSAX 115 | 116 | switch options { 117 | case .xml: 118 | self.parserContext = xmlCreatePushParserCtxt( 119 | &handler, 120 | context, 121 | nil, 122 | 0, 123 | self.suggestedFilename 124 | ) 125 | 126 | xmlCtxtUseOptions( 127 | self.parserContext, 128 | Int32(XML_PARSE_NONET.rawValue) | 129 | Int32(XML_PARSE_NOENT.rawValue) | 130 | Int32(XML_PARSE_HUGE.rawValue) 131 | ) 132 | 133 | case .html: 134 | self.parserContext = htmlCreatePushParserCtxt( 135 | &handler, 136 | context, 137 | nil, 138 | 0, 139 | self.suggestedFilename, 140 | XML_CHAR_ENCODING_NONE 141 | ) 142 | 143 | htmlCtxtUseOptions( 144 | self.parserContext, 145 | Int32(HTML_PARSE_NONET.rawValue) | 146 | Int32(HTML_PARSE_RECOVER.rawValue) 147 | ) 148 | } 149 | 150 | return self.parserContext! 151 | } 152 | 153 | return preparedContext 154 | } 155 | 156 | private func raiseErrorIfNeeded(for errorCode: Int32, _ parser: xmlParserCtxtPtr) throws { 157 | if errorCode != XML_ERR_NONE.rawValue { 158 | if let lastError = xmlCtxtGetLastError(parser) { 159 | throw ParsingError(from: lastError) 160 | } else { 161 | throw ParsingError(with: Int(errorCode), context: parser) 162 | } 163 | } 164 | } 165 | 166 | private let startDocumentSAX: startDocumentSAXFunc = { context in 167 | guard let parser = parser(from: context) else { return } 168 | parser.startDocument() 169 | } 170 | 171 | private let endDocumentSAX: endDocumentSAXFunc = { context in 172 | guard let parser = parser(from: context) else { return } 173 | parser.endDocument() 174 | } 175 | 176 | private let startElementSAX: startElementSAXFunc = { context, name, attributes in 177 | guard let parser = parser(from: context), 178 | let name = name else { 179 | return 180 | } 181 | 182 | let elementName = String(cString: name) 183 | var attributeDict = Dictionary() 184 | 185 | if let attributes { 186 | var i = 0 187 | 188 | while attributes[i] != nil { 189 | guard let attributeName = attributes[i], 190 | let attributeValue = attributes[i + 1] else { 191 | continue 192 | } 193 | 194 | attributeDict[String(cString: attributeName)] 195 | = String(cString: attributeValue) 196 | i += 2 197 | } 198 | } 199 | 200 | parser.startElement(elementName, Attributes(rawValue: attributeDict)) 201 | } 202 | 203 | private let endElementSAX: endElementSAXFunc = { context, name in 204 | guard let parser = parser(from: context), 205 | let name = name else { 206 | return 207 | } 208 | 209 | // let elementName = String(cString: name) 210 | parser.endElement() 211 | } 212 | 213 | private let charactersSAX: charactersSAXFunc = { context, buffer, bufferSize in 214 | guard let parser = parser(from: context), 215 | let buffer else { 216 | return 217 | } 218 | 219 | if let text = String( 220 | bytes: UnsafeBufferPointer(start: buffer, count: Int(bufferSize)), 221 | encoding: .utf8 222 | ) { 223 | parser.characters(text) 224 | } 225 | } 226 | 227 | private static func parser(from context: UnsafeMutableRawPointer?) -> PushParser? { 228 | guard let context else { 229 | return nil 230 | } 231 | 232 | let parser = Unmanaged 233 | .fromOpaque(context) 234 | .takeUnretainedValue() 235 | 236 | return parser 237 | } 238 | 239 | private static func suggestedFilename(for string: String?) -> UnsafePointer? { 240 | guard let string else { 241 | return nil 242 | } 243 | 244 | let cString = strdup(string) 245 | return UnsafePointer(cString) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/URLSession+XML.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | extension URLSession { 28 | public typealias AsyncXMLParsingEvents = AsyncThrowingStream< 29 | ParsingEvent, 30 | any Error 31 | > where Element: ElementRepresentable 32 | 33 | public func xml( 34 | _ elementType: Element.Type = XMLElement.self, 35 | for url: URL, 36 | delegate: URLSessionTaskDelegate? = nil 37 | ) async throws -> (AsyncXMLParsingEvents, URLResponse) { 38 | try await xml( 39 | elementType, 40 | for: URLRequest(url: url), 41 | delegate: delegate 42 | ) 43 | } 44 | 45 | public func xml( 46 | _ elementType: Element.Type = XMLElement.self, 47 | for request: URLRequest, 48 | delegate: URLSessionTaskDelegate? = nil 49 | ) async throws -> (AsyncXMLParsingEvents, URLResponse) { 50 | var events: AsyncXMLParsingEvents? = nil 51 | let response = try await withCheckedThrowingContinuation { responseContinuation in 52 | events = AsyncXMLParsingEvents { dataContinuation in 53 | let task = self.dataTask(with: request) 54 | 55 | task.delegate = ParsingSessionDelegate( 56 | responseContinuation: responseContinuation, 57 | dataContinuation: dataContinuation, 58 | delegate: delegate 59 | ) 60 | 61 | task.resume() 62 | } 63 | } 64 | 65 | return (events!, response) 66 | } 67 | } 68 | 69 | private final class ParsingSessionDelegate< 70 | Element 71 | >: NSObject, URLSessionDataDelegate, @unchecked Sendable 72 | where Element: ElementRepresentable 73 | { 74 | typealias ResponseContinuation = CheckedContinuation < 75 | URLResponse, 76 | any Error 77 | > 78 | private var responseContinuation: ResponseContinuation? 79 | 80 | typealias DataContinuation = URLSession.AsyncXMLParsingEvents < 81 | Element 82 | >.Continuation 83 | private let dataContinuation: DataContinuation 84 | 85 | private let delegate: URLSessionTaskDelegate? 86 | 87 | private var response: URLResponse? = nil 88 | private var parser: PushParser? = nil 89 | 90 | private var elementStack: [Element] = [] 91 | 92 | init( 93 | responseContinuation: ResponseContinuation, 94 | dataContinuation: DataContinuation, 95 | delegate: URLSessionTaskDelegate? = nil 96 | ) { 97 | self.responseContinuation = responseContinuation 98 | self.dataContinuation = dataContinuation 99 | self.delegate = delegate 100 | } 101 | 102 | func urlSession( 103 | _ session: URLSession, 104 | dataTask: URLSessionDataTask, 105 | didReceive response: URLResponse, 106 | completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void 107 | ) { 108 | self.response = response 109 | 110 | if let continuation = self.responseContinuation { 111 | continuation.resume(returning: response) 112 | self.responseContinuation = nil 113 | } 114 | 115 | if let dataDelegate = delegate as? URLSessionDataDelegate, 116 | dataDelegate.responds(to:#selector(URLSessionDataDelegate.urlSession(_:dataTask:didReceive:completionHandler:))) { 117 | dataDelegate.urlSession?( 118 | session, 119 | dataTask: dataTask, 120 | didReceive: response, 121 | completionHandler: completionHandler 122 | ) 123 | } else { 124 | completionHandler(.allow) 125 | } 126 | } 127 | 128 | func urlSession( 129 | _ session: URLSession, 130 | dataTask: URLSessionDataTask, 131 | didReceive data: Data 132 | ) { 133 | if let dataDelegate = delegate as? URLSessionDataDelegate { 134 | dataDelegate.urlSession?(session, dataTask: dataTask, didReceive: data) 135 | } 136 | 137 | if parser == nil { 138 | parser = makePushParser() 139 | } 140 | 141 | do { 142 | try parser!.push(data) 143 | } catch { 144 | dataContinuation.finish(throwing: error) 145 | } 146 | } 147 | 148 | func urlSession( 149 | _ session: URLSession, 150 | task: URLSessionTask, 151 | didCompleteWithError error: (any Error)? 152 | ) { 153 | if let continuation = self.responseContinuation, 154 | let error { 155 | continuation.resume(throwing: error) 156 | } 157 | 158 | if let error { 159 | dataContinuation.finish(throwing: error) 160 | } else { 161 | do { 162 | if let parser { 163 | try parser.finish() 164 | } 165 | 166 | dataContinuation.finish() 167 | } catch { 168 | dataContinuation.finish(throwing: error) 169 | } 170 | } 171 | 172 | delegate?.urlSession?(session, task: task, didCompleteWithError: error) 173 | } 174 | 175 | override func responds(to aSelector: Selector!) -> Bool { 176 | super.responds(to: aSelector) || delegate?.responds(to: aSelector) == true 177 | } 178 | 179 | override func forwardingTarget(for aSelector: Selector!) -> Any? { 180 | delegate 181 | } 182 | 183 | private func makePushParser() -> PushParser { 184 | let dataContinuation = self.dataContinuation 185 | var elementStack = self.elementStack 186 | 187 | return PushParser( 188 | options: .xml, 189 | 190 | filename: response?.suggestedFilename, 191 | 192 | startDocument: { 193 | dataContinuation.yield(.beginDocument) 194 | }, endDocument: { 195 | dataContinuation.yield(.endDocument) 196 | }, startElement: { elementName, attributes in 197 | let element = Element(element: elementName, attributes: attributes) 198 | elementStack.append(element) 199 | dataContinuation.yield(.begin(element, attributes: attributes)) 200 | }, endElement: { 201 | guard let element = elementStack.popLast() else { return } 202 | dataContinuation.yield(.end(element)) 203 | }, characters: { string in 204 | dataContinuation.yield(.text(string)) 205 | } 206 | ) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/WhitespaceParsingEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Foundation 26 | 27 | public enum WhitespacePolicy: Equatable, Sendable { 28 | case inline 29 | case block 30 | case preserve 31 | } 32 | 33 | public protocol WhitespaceCollapsing { 34 | var whitespacePolicy: WhitespacePolicy { get } 35 | } 36 | 37 | public enum WhitespaceProcessing: Equatable, Sendable { 38 | case collapse 39 | case remove 40 | } 41 | 42 | public enum WhitespaceParsingEvent: Equatable, Sendable 43 | where Element: ElementRepresentable 44 | { 45 | case event(ParsingEvent, WhitespacePolicy) 46 | case whitespace(String, WhitespaceProcessing) 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftyXMLSequence/WhitespaceSegmentSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | internal struct WhitespaceSegmentSequence: Sequence { 26 | let input: String 27 | 28 | enum Location: Equatable { 29 | case start 30 | case between 31 | case end 32 | case single 33 | } 34 | 35 | enum Element: Equatable, CustomDebugStringConvertible, CustomStringConvertible { 36 | case text(Substring) 37 | case whitespace(Substring, Location) 38 | 39 | var description: String { 40 | return switch self { 41 | case .text(let substring): 42 | String(substring) 43 | case .whitespace(let whitespace, _): 44 | String(whitespace) 45 | } 46 | } 47 | 48 | var debugDescription: String { 49 | description 50 | } 51 | } 52 | 53 | func makeIterator() -> Iterator { 54 | Iterator(input: input) 55 | } 56 | 57 | struct Iterator: IteratorProtocol { 58 | private let input: String 59 | private var current: String.Index 60 | 61 | init(input: String) { 62 | self.input = input 63 | self.current = input.startIndex 64 | } 65 | 66 | mutating func next() -> Element? { 67 | guard current < input.endIndex else { return nil } 68 | 69 | if let match = input[current...].firstMatch(of: /(^\s+)|(\s{2,})|(\s+$)/) { 70 | let range = match.range 71 | 72 | if current < range.lowerBound { 73 | let text = input[current..( 37 | _ elementType: Element.Type = XMLElement.self, 38 | for filename: String 39 | ) async throws -> URLSession.AsyncXMLParsingEvents { 40 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 41 | #expect(Bool(false), "Failed to find \(filename).html file.") 42 | throw Error.fileNoSuchFile 43 | } 44 | 45 | let (events, _) = try await URLSession.shared.xml( 46 | Element.self, 47 | for: fileURL 48 | ) 49 | 50 | return events 51 | } 52 | 53 | @Test func chunkByHeadAndSection() async throws { 54 | let groups = try await makeEvents(HTMLElement.self, for: "sample2") 55 | .collect { element, _ in 56 | return switch element { 57 | case .head, .body, .section: 58 | true 59 | default: 60 | false 61 | } 62 | } 63 | .filter { element, _ in 64 | return switch element { 65 | case .style: 66 | false 67 | default: 68 | true 69 | } 70 | } 71 | .filter { element, attributes in 72 | if attributes.class.contains("noprint") { return false } 73 | if attributes.class.contains("mw-ref") { return false } 74 | if attributes.class.contains("reflist") { return false } 75 | if attributes.class.contains("navigation-not-searchable") { return false } 76 | 77 | return true 78 | } 79 | .map(whitespace: { element, attributes in 80 | element.whitespacePolicy 81 | }) 82 | .collapse() 83 | .chunked { element, attributes in 84 | return switch element { 85 | case .head, .section: 86 | element 87 | default: 88 | nil 89 | } 90 | } 91 | .map { 92 | return $0.0 93 | } 94 | 95 | let expectedGroups: [HTMLElement?] = 96 | [ .head, nil ] + 97 | Array(repeating: HTMLElement.section, count: 27) + 98 | [ nil ] 99 | 100 | #expect(try await Array(groups) == expectedGroups) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/FilterAndCollectTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | import AsyncAlgorithms 28 | @testable import SwiftyXMLSequence 29 | 30 | @Suite("XML Element") 31 | struct FilterAndCollectTests { 32 | enum Error: Swift.Error { 33 | case fileNoSuchFile 34 | } 35 | 36 | private func makeEvents( 37 | _ elementType: Element.Type = XMLElement.self, 38 | for filename: String 39 | ) async throws -> URLSession.AsyncXMLParsingEvents { 40 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 41 | #expect(Bool(false), "Failed to find \(filename).html file.") 42 | throw Error.fileNoSuchFile 43 | } 44 | 45 | let (events, _) = try await URLSession.shared.xml( 46 | Element.self, 47 | for: fileURL 48 | ) 49 | 50 | return events 51 | } 52 | 53 | @Test func collectTitle() async throws { 54 | let events = try await Array( 55 | try await makeEvents(HTMLElement.self, for: "sample1") 56 | ) 57 | 58 | let title = events.collect { element, attributes in 59 | return switch element { 60 | case .title: 61 | true 62 | default: 63 | false 64 | } 65 | }.reduce(String()) { partialResult, event in 66 | return switch event { 67 | case .text(let string): 68 | partialResult + string 69 | default: 70 | partialResult 71 | } 72 | } 73 | 74 | #expect(title == "Der Blaue Reiter") 75 | } 76 | 77 | @Test func asyncCollectTitle() async throws { 78 | let title = try await makeEvents(HTMLElement.self, for: "sample1") 79 | .collect { element, attributes in 80 | return switch element { 81 | case .title: 82 | true 83 | default: 84 | false 85 | } 86 | }.reduce(String()) { partialResult, event in 87 | return switch event { 88 | case .text(let string): 89 | partialResult + string 90 | default: 91 | partialResult 92 | } 93 | } 94 | 95 | #expect(title == "Der Blaue Reiter") 96 | } 97 | 98 | @Test func filterSection() async throws { 99 | let events = try await Array( 100 | try await makeEvents(HTMLElement.self, for: "sample1") 101 | ) 102 | 103 | let listItem = events.collect { element, attributes in 104 | return switch element { 105 | case .li: 106 | true 107 | default: 108 | false 109 | } 110 | }.filter { element, attributes in 111 | attributes.id == "mwqA" 112 | }.reduce(String()) { partialResult, event in 113 | return switch event { 114 | case .text(let string): 115 | partialResult + string 116 | default: 117 | partialResult 118 | } 119 | } 120 | 121 | #expect(listItem == "Kandinsky's \"On Stage Composition\"") 122 | } 123 | 124 | @Test func asyncFilterSection() async throws { 125 | let listItem = try await makeEvents(HTMLElement.self, for: "sample1") 126 | .collect { element, attributes in 127 | return switch element { 128 | case .li: 129 | true 130 | default: 131 | false 132 | } 133 | }.filter { element, attributes in 134 | attributes.id == "mwqA" 135 | }.reduce(String()) { partialResult, event in 136 | return switch event { 137 | case .text(let string): 138 | partialResult + string 139 | default: 140 | partialResult 141 | } 142 | } 143 | 144 | #expect(listItem == "Kandinsky's \"On Stage Composition\"") 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/HTMLTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | @testable import SwiftyXMLSequence 28 | 29 | @Suite("HTML Element") 30 | struct HTMLTests { 31 | enum Error: Swift.Error { 32 | case fileNoSuchFile 33 | } 34 | 35 | private func makeEvents( 36 | _ elementType: Element.Type = XMLElement.self, 37 | for filename: String 38 | ) async throws -> URLSession.AsyncXMLParsingEvents { 39 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 40 | #expect(Bool(false), "Failed to find \(filename).html file.") 41 | throw Error.fileNoSuchFile 42 | } 43 | 44 | let (events, _) = try await URLSession.shared.xml( 45 | Element.self, 46 | for: fileURL 47 | ) 48 | 49 | return events 50 | } 51 | 52 | private func makeSample1Events() async throws -> URLSession.AsyncXMLParsingEvents { 53 | try await makeEvents(HTMLElement.self, for: "sample1") 54 | } 55 | 56 | @Test func HTMLElementParsing() async throws { 57 | let events = try await makeSample1Events() 58 | 59 | let sections = try await events.reduce(into: [HTMLElement]()) { result, event in 60 | if case .begin(let element, _) = event, 61 | element == .section 62 | { 63 | result.append(element) 64 | } 65 | } 66 | 67 | #expect(sections.count > 0) 68 | } 69 | 70 | @Test func filterElement() async throws { 71 | let elementId = "mwAQ" 72 | let events = try await makeSample1Events() 73 | 74 | let text = try await events.collect { element, attributes in 75 | attributes.id == elementId 76 | } 77 | .filter { element, attributes in 78 | return switch element { 79 | case .figure, .style: 80 | false 81 | default: 82 | true 83 | } 84 | } 85 | .reduce(String()) { partialResult, event in 86 | return switch event { 87 | case .text(let string): 88 | partialResult.appending(string) 89 | default: 90 | partialResult 91 | } 92 | } 93 | 94 | #expect(text.count > 0) 95 | } 96 | 97 | @Test func parseParagraphText() async throws { 98 | let events = try await makeSample1Events() 99 | 100 | let paragraph = events.drop(while: { event in 101 | if case .begin(let element, let attributes) = event, 102 | element == .p 103 | { 104 | if attributes.id == "mwGQ" { 105 | return false 106 | } 107 | } 108 | 109 | return true 110 | }) 111 | 112 | var text: String = "" 113 | var stack: [HTMLElement] = [] 114 | 115 | for try await event in paragraph { 116 | switch event { 117 | case .begin(let element, _): 118 | stack.append(element) 119 | break 120 | 121 | case .end(_): 122 | _ = stack.popLast() 123 | break 124 | 125 | case .text(let string): 126 | text += string 127 | break 128 | 129 | default: 130 | break 131 | } 132 | 133 | if stack.isEmpty { 134 | break 135 | } 136 | } 137 | 138 | let expectedText = "The artists associated with Der Blaue Reiter were important pioneers of modern art of the 20th century; they formed a loose network of relationships, but not an art group in the narrower sense like Die Brücke (The Bridge) in Dresden. The work of the affiliated artists is assigned to German Expressionism." 139 | 140 | #expect(text == expectedText) 141 | } 142 | 143 | @Test func matchIdentifier() async throws { 144 | let events = try await makeSample1Events() 145 | 146 | let text = try await events.collect { element, attributes in 147 | attributes.id == "mwGQ" 148 | }.reduce(into: String()) { partialResult, event in 149 | if case .text(let string) = event { 150 | partialResult += string 151 | } 152 | } 153 | 154 | let expectedText = "The artists associated with Der Blaue Reiter were important pioneers of modern art of the 20th century; they formed a loose network of relationships, but not an art group in the narrower sense like Die Brücke (The Bridge) in Dresden. The work of the affiliated artists is assigned to German Expressionism." 155 | 156 | #expect(text == expectedText) 157 | } 158 | 159 | @Test func followingElementFiltering() async throws { 160 | let events = try await makeEvents(HTMLElement.self, for: "sample2") 161 | 162 | let text = try await events.collect { element, attributes in 163 | attributes.id == "mwRg" 164 | }.filter { element, attributes in 165 | if attributes.class.contains("mw-ref") { 166 | return false 167 | } 168 | 169 | return true 170 | }.reduce(into: String()) { partialResult, event in 171 | if case .text(let string) = event { 172 | partialResult += string 173 | } 174 | } 175 | 176 | let expectedText = "In the 1930s, during the Great Depression, Art Deco gradually became more subdued. A sleeker form of the style, called Streamline Moderne, appeared in the 1930s, featuring curving forms and smooth, polished surfaces. Art Deco was a truly international style, but its dominance ended with the beginning of World War II and the rise of the strictly functional and unadorned styles of modern architecture and the International Style of architecture that followed." 177 | 178 | #expect(text == expectedText) 179 | } 180 | 181 | private enum MediaWikiElement: ElementRepresentable, Equatable { 182 | case thumbnail(id: String) 183 | case html(HTMLElement) 184 | 185 | init(element: String, attributes: Attributes) { 186 | let html = HTMLElement(element: element, attributes: attributes) 187 | 188 | switch html { 189 | case .figure: 190 | if attributes.typeof == "mw:File/Thumb", 191 | let id = attributes.id { 192 | self = .thumbnail(id: id) 193 | return 194 | } 195 | 196 | default: 197 | break 198 | } 199 | 200 | self = .html(html) 201 | } 202 | } 203 | 204 | @Test func matchThumbnailElements() async throws { 205 | let events = try await makeEvents(MediaWikiElement.self, for: "sample1") 206 | 207 | var foundURLs = [URL]() 208 | 209 | while true { 210 | let element = try await events.collect { element, attributes in 211 | if case .thumbnail(_) = element { 212 | return true 213 | } 214 | 215 | return false 216 | }.reduce(into: [ParsingEvent]()) { 217 | $0.append($1) 218 | } 219 | 220 | guard element.count > 0 else { 221 | break 222 | } 223 | 224 | let urls = element.reduce(into: [URL]()) { partialResult, event in 225 | if case .begin(let element, let attributes) = event, 226 | case .html(let htmlElement) = element, 227 | case .img = htmlElement 228 | { 229 | if let string = attributes.src, 230 | let url = URL(string: string) { 231 | partialResult.append(url) 232 | } 233 | } 234 | } 235 | 236 | foundURLs.append(contentsOf: urls) 237 | } 238 | 239 | #expect(foundURLs.count == 5) 240 | } 241 | 242 | @Test func parseAndCompareHTML() async throws { 243 | await tryAndFailIfNeeded { 244 | let text = try await 245 | " Hello,
World! " 246 | .data(using: .utf8)! 247 | .html() 248 | .async 249 | .map(whitespace: { element, _ in 250 | element.whitespacePolicy 251 | }) 252 | .map(linebreaks: { element, _ in 253 | "\n" 254 | }) 255 | .collapse() 256 | .compactMap { event in 257 | return switch event { 258 | case .text(let string): 259 | string 260 | default: 261 | nil 262 | } 263 | } 264 | .reduce(String()) { partialResult, string in 265 | partialResult + string 266 | } 267 | 268 | let expectedText = "Hello,\nWorld!" 269 | 270 | #expect(text == expectedText) 271 | } 272 | } 273 | 274 | private func tryAndFailIfNeeded(_ action: () async throws -> Void) async { 275 | do { 276 | try await action() 277 | } catch let error as ParsingError { 278 | #expect(Bool(false), "Line \(error.line); Column \(error.column): \(error.message)") 279 | } catch { 280 | #expect(Bool(false), "Error occurred: \(error)") 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/LinebreakTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | @testable import SwiftyXMLSequence 28 | 29 | @Suite("Whitespace Mapping") 30 | struct LinebreakTest { 31 | enum Error: Swift.Error { 32 | case fileNoSuchFile 33 | } 34 | 35 | private func makeEvents( 36 | _ elementType: Element.Type = XMLElement.self, 37 | for filename: String 38 | ) async throws -> URLSession.AsyncXMLParsingEvents { 39 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 40 | #expect(Bool(false), "Failed to find \(filename).html file.") 41 | throw Error.fileNoSuchFile 42 | } 43 | 44 | let (events, _) = try await URLSession.shared.xml( 45 | Element.self, 46 | for: fileURL 47 | ) 48 | 49 | return events 50 | } 51 | 52 | @Test func linebreakMapping() async throws { 53 | let events = try await makeEvents(HTMLElement.self, for: "whitespace-collapse") 54 | 55 | let whitespaceEvents = try await events 56 | .map(whitespace: { element, _ in 57 | element.whitespacePolicy 58 | }) 59 | .map(linebreaks: { _, _ in 60 | "\n" 61 | }) 62 | 63 | let formatter = ParsingEventDebugFormatter() 64 | let debugDescription = try await formatter.format(whitespaceEvents) 65 | 66 | let expectedText = "[block [remove:↩︎····] [block [remove:↩︎········] [block block] [remove:↩︎····] block] [remove:↩︎····] [block [remove:↩︎↩︎↩︎········] [block [remove:·] [Art] [collapse:····] [Deco] [remove:····] [↩︎] block] [inline [Art Deco got its name after the] [remove:····] [↩︎] [block [remove:··] [1925] [remove:···] [↩︎] block] [remove:···] [Exposition] [collapse:·] [inline [remove:·] [internationale] [collapse:······] inline] [remove:·] [des arts décoratifs et industriels modernes] [collapse:↩︎↩︎↩︎········] [(International Exhibition of Modern Decorative and Industrial Arts) held in Paris. Art Deco has its origins in bold geometric forms of the Vienna Secession and Cubism.] [collapse:↩︎↩︎···········] inline] [remove:↩︎↩︎········] [inline [remove:↩︎········] [From its outset, it was influenced] [collapse:····] [by the bright colors of Fauvism and of the Ballets] [collapse:······] [Russes, and the exoticized styles of art from] [collapse:↩︎↩︎·············] [China, Japan, India, Persia, ancient Egypt, and Maya.] inline] [remove:↩︎↩︎↩︎····] block] [remove:↩︎] block]" 67 | 68 | #expect(debugDescription == expectedText) 69 | } 70 | 71 | @Test func checkTextRepresentation() async throws { 72 | let text = try await makeEvents(HTMLElement.self, for: "sample1") 73 | .collect { element, attributes in 74 | return switch element { 75 | case .title, .section: 76 | true 77 | default: 78 | false 79 | } 80 | } 81 | .filter { element, _ in 82 | return switch element { 83 | case .figure, .style: 84 | false 85 | default: 86 | true 87 | } 88 | } 89 | .filter { element, attributes in 90 | if attributes.class.contains("noprint") { return false } 91 | if attributes.class.contains("mw-ref") { return false } 92 | if attributes.class.contains("reflist") { return false } 93 | if attributes.class.contains("navigation-not-searchable") { return false } 94 | 95 | if ["mw6Q", "mw7A", "mwAUU", "mwAWI"].contains(attributes.id) { 96 | return false 97 | } 98 | 99 | return true 100 | } 101 | .map(whitespace: { element, _ in 102 | element.whitespacePolicy 103 | }) 104 | .map(linebreaks: { element, _ in 105 | return switch element { 106 | case .title, .h1, .h2, .h3, .h4, .h5, .h6, .p, .ul, .ol, .li: 107 | "\n \n" 108 | default: 109 | "\n" 110 | } 111 | }) 112 | .collapse() 113 | .flatMap { event in 114 | switch event { 115 | case .begin(let element, _): 116 | switch element { 117 | case .li: 118 | return [.text("- ") , event].async 119 | default: 120 | break 121 | } 122 | default: 123 | break 124 | } 125 | 126 | return [event].async 127 | } 128 | .reduce(into: String()) { partialResult, event in 129 | switch event { 130 | case .text(let string): 131 | partialResult.append(string) 132 | break 133 | default: 134 | break 135 | } 136 | } 137 | 138 | let expectedText = """ 139 | Der Blaue Reiter 140 | 141 | Der Blaue Reiter (The Blue Rider) was a group of artists and a designation by Wassily Kandinsky and Franz Marc for their exhibition and publication activities, in which both artists acted as sole editors in the almanac of the same name (first published in mid-May 1912). The editorial team organized two exhibitions in Munich in 1911 and 1912 to demonstrate their art-theoretical ideas based on the works of art exhibited. Traveling exhibitions in German and other European cities followed. The Blue Rider disbanded at the start of World War I in 1914. 142 | 143 | The artists associated with Der Blaue Reiter were important pioneers of modern art of the 20th century; they formed a loose network of relationships, but not an art group in the narrower sense like Die Brücke (The Bridge) in Dresden. The work of the affiliated artists is assigned to German Expressionism. 144 | 145 | History 146 | 147 | The forerunner of The Blue Rider was the Neue Künstlervereinigung München (N.K.V.M: New Artists' Association Munich), instigated by Marianne von Werefkin, Alexej von Jawlensky, Adolf Erbslöh and German entrepreneur, art collector, aviation pioneer and musician Oscar Wittenstein. The N.K.V.M was co-founded in 1909 and Kandinsky (as its first chairman) organized the exhibitions of 1909 and 1910. Even before the first exhibition, Kandinsky introduced the so-called "four square meter clause" into the statutes of the N.K.V.M due to a difference of opinion with the painter Charles Johann Palmié; this clause would give Kandinsky the lever to leave the N.K.V.M in 1911. 148 | 149 | There were repeated disputes among the conservative forces in the N.K.V.M, which flared up due to Kandinsky's increasingly abstract painting. In December 1911, Kandinsky submitted Composition V for the association's third exhibition, but the jury rejected the painting. In response, Kandinsky, along with Münter, Marc, and others, formed a rival group and quickly organised a parallel exhibition at the same venue, the Thannhauser Gallery, in rooms adjacent to the official show. This breakaway group adopted the name Der Blaue Reiter. 150 | 151 | Years later, Kandinsky recalled anticipating the controversy and having already prepared extensive material for the new group's exhibition: "Our halls were close to the rooms of the NKVM exhibition. It was a sensation. Since I anticipated the 'noise' in good time, I had prepared a wealth of exhibition material for the BR [Blaue Reiter]. So the two exhibitions took place simultaneously. (…) Revenge was sweet!". The exhibition was officially titled the First Exhibition of the Editorial Board of Der Blaue Reiter, reflecting Kandinsky and Marc's plans to publish an art almanac under the same name. 152 | 153 | Kandinsky resigned as chairmanship of the N.K.V.M. on 10 January 1911 but remained in the association as a simple member. His successor was Adolf Erbslöh. In June, Kandinsky developed plans for his activities outside of the N.K.V.M. He intended to publish a "kind of almanac" which could be called Die Kette (The Chain). On 19 June, he pitched his idea to Marc and won him over by offering him the co-editing of the book. 154 | 155 | The name of the movement is the title of a painting that Kandinsky created in 1903, but it is unclear whether it is the origin of the name of the movement as Professor Klaus Lankheit learned that the title of the painting had been overwritten. Kandinsky wrote 20 years later that the name is derived from Marc's enthusiasm for horses and Kandinsky's love of riders, combined with a shared love of the color blue. For Kandinsky, blue was the color of spirituality; the darker the blue, the more it awakened human desire for the eternal (as he wrote in his 1911 book On the Spiritual in Art). 156 | 157 | Within the group, artistic approaches and aims varied from artist to artist; however, the artists shared a common desire to express spiritual truths through their art. They believed in the promotion of modern art; the connection between visual art and music; the spiritual and symbolic associations of color; and a spontaneous, intuitive approach to painting. Members were interested in European medieval art and primitivism, as well as the contemporary, non-figurative art scene in France. As a result of their encounters with Cubist, Fauvist and Rayonist ideas, they moved towards abstraction. 158 | 159 | Der Blaue Reiter organized exhibitions in 1911 and 1912 that toured Germany. They also published an almanac featuring contemporary, primitive and folk art, along with children's paintings. In 1913, they exhibited in the first German Herbstsalon. 160 | 161 | The group was disrupted by the outbreak of the First World War in 1914. Franz Marc and August Macke were killed in combat. Wassily Kandinsky returned to Russia, and Marianne von Werefkin and Alexej von Jawlensky fled to Switzerland. There were also differences in opinion within the group. As a result, Der Blaue Reiter was short-lived, lasting for only three years from 1911 to 1914. 162 | 163 | In 1923, Kandinsky, Feininger, Klee and Alexej von Jawlensky formed the group Die Blaue Vier (The Blue Four) at the instigation of painter and art dealer Galka Scheyer. Scheyer organized Blue Four exhibitions in the United States from 1924 onward. 164 | 165 | An extensive collection of paintings by Der Blaue Reiter is exhibited in the Städtische Galerie in the Lenbachhaus in Munich. 166 | 167 | Almanac 168 | 169 | Conceived in June 1911, Der Blaue Reiter Almanach (The Blue Rider Almanac) was published in early 1912 by Piper in an edition that sold approximately 1100 copies; on 11 May, Franz Marc received the first print. The volume was edited by Kandinsky and Marc; its costs were underwritten by the industrialist and art collector Bernhard Koehler, a relative of Macke. It contained reproductions of more than 140 artworks, and 14 major articles. A second volume was planned, but the start of World War I prevented it. Instead, a second edition of the original was printed in 1914, again by Piper. 170 | 171 | The contents of the Almanac included: 172 | 173 | - Marc's essay "Spiritual Treasures," illustrated with children's drawings, German woodcuts, Chinese paintings, and Pablo Picasso's Woman with Mandolin at the Piano 174 | 175 | - an article by French critic Roger Allard on Cubism 176 | 177 | - Arnold Schoenberg's article "The Relationship to the Text", and a facsimile of his song "Herzgewächse" 178 | 179 | - facsimiles of song settings by Alban Berg and Anton Webern 180 | 181 | - Thomas de Hartmann's essay "Anarchy in Music" 182 | 183 | - an article by Leonid Sabaneyev about Alexander Scriabin 184 | 185 | - an article by Erwin von Busse on Robert Delaunay, illustrated with a print of his The Window on the City 186 | 187 | - an article by Vladimir Burliuk on contemporary Russian art 188 | 189 | - Macke's essay "Masks" 190 | 191 | - Kandinsky's essay "On the Question of Form" 192 | 193 | - Kandinsky's "On Stage Composition" 194 | 195 | - Kandinsky's The Yellow Sound. 196 | 197 | The art reproduced in the Almanac marked a dramatic turn away from a Eurocentric and conventional orientation. The selection was dominated by primitive, folk and children's art, with pieces from the South Pacific and Africa, Japanese drawings, medieval German woodcuts and sculpture, Egyptian puppets, Russian folk art, and Bavarian religious art painted on glass. The five works by Van Gogh, Cézanne, and Gauguin were outnumbered by seven from Henri Rousseau and thirteen from child artists. 198 | 199 | Exhibitions 200 | 201 | First exhibition 202 | 203 | On December 18, 1911, the First exhibition of the editorial board of Der Blaue Reiter (Erste Ausstellung der Redaktion Der Blaue Reiter) opened at the Heinrich Thannhauser's Moderne Galerie in Munich, running through the first days of 1912. 43 works by 14 artists were shown: paintings by Henri Rousseau, Albert Bloch, David Burliuk, Wladimir Burliuk, Heinrich Campendonk, Robert Delaunay, Elisabeth Epstein, Eugen von Kahler, Wassily Kandinsky, August Macke, Franz Marc, Gabriele Münter, Jean Bloé Niestlé and Arnold Schoenberg, and an illustrated catalogue edited. 204 | 205 | From January 1912 through July 1914, the exhibition toured Europe with venues in Cologne, Berlin, Bremen, Hagen, Frankfurt, Hamburg, Budapest, Oslo, Helsinki, Trondheim and Göteborg. 206 | 207 | Second exhibition 208 | 209 | From February 12 through April 2, 1912, the Second exhibition of the editorial board of Der Blaue Reiter (Zweite Ausstellung der Redaktion Der Blaue Reiter) showed works in black-and-white at the New Art Gallery of Hans Goltz (Neue Kunst Hans Goltz) in Munich. 210 | 211 | Other shows 212 | 213 | The artists of Der Blaue Reiter also participated in these other exhibitions: 214 | 215 | - 1912 Sonderbund westdeutscher Kunstfreunde und Künstler exhibition, held in Cologne 216 | 217 | - Erster Deutscher Herbstsalon (organised by Herwarth Walden and his gallery, Der Sturm), held in 1913 in Berlin 218 | """ 219 | 220 | #expect(text == expectedText) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/Samples/trivia.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Which 1990 hit, often associated with the Madchester scene, was performed by The La's? 5 | There She Goes 6 | Step On 7 | Fools Gold 8 | Unbelievable 9 | The La's are best known for their hit "There She Goes", a song that became synonymous with the Madchester scene of the early 90s. 10 | 11 | 12 | 13 | Which influential electronic music duo released the album "Dig Your Own Hole" in 1997? 14 | Daft Punk 15 | Orbital 16 | The Chemical Brothers 17 | Aphex Twin 18 | The Chemical Brothers released "Dig Your Own Hole" in 1997, which was a significant album in the development of the big beat genre. 19 | 20 | 21 | 22 | In 1995, which band released the song "Common People", a defining track of the Britpop era? 23 | Blur 24 | Pulp 25 | Oasis 26 | Supergrass 27 | Pulp released "Common People" in 1995, which became one of the most popular and influential songs of the Britpop movement. 28 | 29 | 30 | 31 | Which artist, known for her ethereal voice and eclectic style, released the album "Debut" in 1993? 32 | Tori Amos 33 | Björk 34 | Kate Bush 35 | Sinead O'Connor 36 | Björk released her album "Debut" in 1993, showcasing her unique voice and innovative approach to music. 37 | 38 | 39 | 40 | Which band released the influential album "Spiderland" in 1991, now considered a touchstone of post-rock? 41 | Godspeed You! Black Emperor 42 | Mogwai 43 | Slint 44 | Tortoise 45 | Slint's album "Spiderland," released in 1991, is considered a seminal work in the post-rock genre. 46 | 47 | 48 | 49 | In 1994, which band released the album "Crooked Rain, Crooked Rain," showcasing their indie rock style? 50 | Pavement 51 | Guided by Voices 52 | Sonic Youth 53 | The Pixies 54 | Pavement released "Crooked Rain, Crooked Rain" in 1994, an album that became a cornerstone of 90s indie rock. 55 | 56 | 57 | 58 | Which band, formed by former members of the band Kyuss, released the album "Rated R" in 2000? 59 | Foo Fighters 60 | Queens of the Stone Age 61 | Eagles of Death Metal 62 | Them Crooked Vultures 63 | Queens of the Stone Age, formed by ex-members of Kyuss, released "Rated R" in 2000, which became a critical success. 64 | 65 | 66 | 67 | Which 1992 album by Rage Against the Machine featured the iconic song "Killing in the Name"? 68 | Rage Against the Machine 69 | Evil Empire 70 | The Battle of Los Angeles 71 | Renegades 72 | Rage Against the Machine's self-titled debut album released in 1992 featured the song "Killing in the Name," which became one of their most famous tracks. 73 | 74 | 75 | 76 | Which influential 1991 album by Nirvana signaled the mainstream breakthrough of grunge music? 77 | In Utero 78 | Bleach 79 | Nevermind 80 | MTV Unplugged in New York 81 | Nirvana's album "Nevermind," released in 1991, was a critical and commercial success and marked the mainstream arrival of grunge music. 82 | 83 | 84 | 85 | In 1997, which artist released the innovative electronica album "OK Computer"? 86 | Radiohead 87 | Blur 88 | Aphex Twin 89 | Massive Attack 90 | Radiohead released "OK Computer" in 1997, an album that was highly influential in the development of electronic and experimental rock. 91 | 92 | 93 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/Samples/whitespace-collapse-cases.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 | 19 |

Hello world!

20 |
Hello world!
21 |

Hello 22 | world!

23 | 24 |
Hello
World
25 |
Hello
World
26 | 27 |
Hello World
28 |
Hello World
29 |
Hello World
30 | 31 |
Hello World
32 |
Hello
World
33 | 34 |
Hello World
35 |
Hello
World
36 | 37 |
Hello
World
38 | 39 |
   Hello   World  
40 |
Hello   World
41 | 42 |
First Second Third
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/Samples/whitespace-collapse.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Art Deco

Art Deco got its name after the
1925
Exposition internationale des arts décoratifs et industriels modernes 10 | 11 | 12 | (International Exhibition of Modern Decorative and Industrial Arts) held in Paris. Art Deco has its origins in bold geometric forms of the Vienna Secession and Cubism. 13 | 14 |
15 | 16 | 17 | From its outset, it was influenced by the bright colors of Fauvism and of the Ballets Russes, and the exoticized styles of art from 18 | 19 | China, Japan, India, Persia, ancient Egypt, and Maya. 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/WhiteSpaceCollapseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | @testable import SwiftyXMLSequence 28 | 29 | @Suite("Whitespace Mapping") 30 | struct WhitespaceMappingTests { 31 | enum Error: Swift.Error { 32 | case fileNoSuchFile 33 | } 34 | 35 | private func makeEvents( 36 | _ elementType: Element.Type = XMLElement.self, 37 | for filename: String 38 | ) async throws -> URLSession.AsyncXMLParsingEvents { 39 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 40 | #expect(Bool(false), "Failed to find \(filename).html file.") 41 | throw Error.fileNoSuchFile 42 | } 43 | 44 | let (events, _) = try await URLSession.shared.xml( 45 | Element.self, 46 | for: fileURL 47 | ) 48 | 49 | return events 50 | } 51 | 52 | @Test func mapping() async throws { 53 | let events = try await makeEvents(HTMLElement.self, for: "whitespace-collapse") 54 | 55 | let whitespaceEvents = try await events.map(whitespace: { element, attributes in 56 | element.whitespacePolicy 57 | }) 58 | 59 | let formatter = ParsingEventDebugFormatter() 60 | let debugDescription = try await formatter.format(whitespaceEvents) 61 | 62 | let expectedText = "[block [remove:↩︎····] [block [remove:↩︎········] [block block] [remove:↩︎····] block] [remove:↩︎····] [block [remove:↩︎↩︎↩︎········] [block [remove:·] [Art] [collapse:····] [Deco] [remove:····] block] [inline [Art Deco got its name after the] [remove:····] [block [remove:··] [1925] [remove:···] block] [remove:···] [Exposition] [collapse:·] [inline [remove:·] [internationale] [collapse:······] inline] [remove:·] [des arts décoratifs et industriels modernes] [collapse:↩︎↩︎↩︎········] [(International Exhibition of Modern Decorative and Industrial Arts) held in Paris. Art Deco has its origins in bold geometric forms of the Vienna Secession and Cubism.] [collapse:↩︎↩︎···········] inline] [remove:↩︎↩︎········] [inline [remove:↩︎········] [From its outset, it was influenced] [collapse:····] [by the bright colors of Fauvism and of the Ballets] [collapse:······] [Russes, and the exoticized styles of art from] [collapse:↩︎↩︎·············] [China, Japan, India, Persia, ancient Egypt, and Maya.] inline] [remove:↩︎↩︎↩︎····] block] [remove:↩︎] block]" 63 | 64 | #expect(debugDescription == expectedText) 65 | } 66 | 67 | @Test func collapsing() async throws { 68 | let events = try await makeEvents(HTMLElement.self, for: "whitespace-collapse") 69 | 70 | let collapsedEvents = try await events.map(whitespace: { element, attributes in 71 | element.whitespacePolicy 72 | }).collapse() 73 | 74 | let formatter = ParsingEventDebugFormatter() 75 | let debugDescription = try await formatter.format(collapsedEvents) 76 | 77 | let expectedText = "[html [head [meta meta] head] [body [h1 [Art Deco] h1] [span [Art Deco got its name after the] [div [1925] div] [Exposition ] [strong [internationale ] strong] [des arts décoratifs et industriels modernes (International Exhibition of Modern Decorative and Industrial Arts) held in Paris. Art Deco has its origins in bold geometric forms of the Vienna Secession and Cubism. ] span] [span [From its outset, it was influenced by the bright colors of Fauvism and of the Ballets Russes, and the exoticized styles of art from China, Japan, India, Persia, ancient Egypt, and Maya.] span] body] html]" 78 | 79 | #expect(debugDescription == expectedText) 80 | } 81 | 82 | @Test func preserving() async throws { 83 | typealias Event = ParsingEvent 84 | typealias WhitespaceEvent = WhitespaceParsingEvent 85 | 86 | let events = try await makeEvents(HTMLElement.self, for: "whitespace-collapse") 87 | 88 | let text = try await events.collect { element, _ in 89 | return switch element { 90 | case .h1: 91 | true 92 | default: 93 | false 94 | } 95 | }.map(whitespace: { element, _ in 96 | .preserve 97 | }) 98 | .collapse() 99 | .reduce(into: String()) { partialResult, event in 100 | switch event { 101 | case .text(let string): 102 | partialResult.append(string) 103 | break 104 | default: 105 | break 106 | } 107 | } 108 | 109 | let expectedText = " Art Deco " 110 | 111 | #expect(text == expectedText) 112 | } 113 | 114 | enum WhitespaceCollapseCase: ElementRepresentable, WhitespaceCollapsing { 115 | case `case`(id: String, expect: String) 116 | case html(HTMLElement) 117 | 118 | init(element: String, attributes: Attributes) { 119 | if attributes.class.contains("case") { 120 | self = .case(id: attributes["id"]!, expect: attributes["expect"]!) 121 | } else { 122 | self = .html(HTMLElement(element: element, attributes: attributes)) 123 | } 124 | } 125 | 126 | var whitespacePolicy: WhitespacePolicy { 127 | return switch self { 128 | case .html(let element): element.whitespacePolicy 129 | default: .block 130 | } 131 | } 132 | } 133 | 134 | @Test func multipleSpaces() async throws { 135 | try await runCase( 136 | named: "test-multiple-spaces", 137 | result: "Hello world!" 138 | ) 139 | } 140 | 141 | @Test func leadingTrailing() async throws { 142 | try await runCase( 143 | named: "test-leading-trailing", 144 | result: "Hello world!" 145 | ) 146 | } 147 | 148 | @Test func afterBlock() async throws { 149 | try await runCase( 150 | named: "test-after-block", 151 | result: "HelloWorld" 152 | ) 153 | } 154 | 155 | @Test func inlineFollow() async throws { 156 | try await runCase( 157 | named: "test-inline-follow", 158 | result: "Hello World" 159 | ) 160 | } 161 | 162 | @Test func inlineStart() async throws { 163 | try await runCase( 164 | named: "test-inline-start", 165 | result: "Hello World" 166 | ) 167 | } 168 | 169 | @Test func inlineSpace() async throws { 170 | try await runCase( 171 | named: "test-inline-space", 172 | result: "Hello World" 173 | ) 174 | } 175 | 176 | @Test func beforeLeadingInline() async throws { 177 | try await runCase( 178 | named: "test-before-leading-inline", 179 | result: "Hello World" 180 | ) 181 | } 182 | 183 | @Test func beforeLeadingBlock() async throws { 184 | try await runCase( 185 | named: "test-before-leading-block", 186 | result: "HelloWorld" 187 | ) 188 | } 189 | 190 | @Test func lineBreak() async throws { 191 | try await runCase( 192 | named: "test-br", 193 | result: "HelloWorld" 194 | ) 195 | } 196 | 197 | @Test func betweenInline() async throws { 198 | try await runCase( 199 | named: "test-between-inline", 200 | result: "Hello World" 201 | ) 202 | } 203 | 204 | @Test func inlineSequence() async throws { 205 | try await runCase( 206 | named: "test-inline-sequence", 207 | result: "First Second Third" 208 | ) 209 | } 210 | 211 | private func runCase(named: String, result: String) async throws { 212 | let string = try await makeEvents( 213 | WhitespaceCollapseCase.self, 214 | for: "whitespace-collapse-cases" 215 | ) 216 | .collect { element, attributes in 217 | guard let id = attributes["id"] else { return false } 218 | return id == named 219 | } 220 | .filter({ event in 221 | switch event { 222 | case .begin(let element, _), .end(let element): 223 | switch element { 224 | case .html(_): 225 | return true 226 | default: 227 | return false 228 | } 229 | case .text(_): 230 | return true 231 | default: 232 | return false 233 | } 234 | }) 235 | .map(whitespace: { element, attributes in 236 | element.whitespacePolicy 237 | }) 238 | .collapse() 239 | .reduce(String()) { partialResult, event in 240 | switch event { 241 | case .text(let string): 242 | return partialResult + string 243 | default: 244 | break 245 | } 246 | 247 | return partialResult 248 | } 249 | 250 | #expect(string == result) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/XMLEventSessionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2025 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | @testable import SwiftyXMLSequence 28 | 29 | @Suite("URL Session") 30 | final class XMLEventSessionTests: NSObject, URLSessionDataDelegate, @unchecked Sendable { 31 | private var receivedDidCompleteWithError = false 32 | private var receivedDidReceiveResponse = false 33 | private var receivedDidReceiveData = false 34 | 35 | @Test func receiveDelegateCalls() async throws { 36 | let filename = "sample1" 37 | 38 | guard let fileURL = Bundle.module.url(forResource: filename, withExtension: "html") else { 39 | #expect(Bool(false), "Failed to find \(filename).html file.") 40 | return 41 | } 42 | 43 | let (events, response) = try await URLSession.shared.xml( 44 | HTMLElement.self, 45 | for: fileURL, 46 | delegate: self 47 | ) 48 | 49 | let title = try await events.collect { element, attributes in 50 | return switch element { 51 | case .title: true 52 | default: false 53 | } 54 | } 55 | .reduce(into: String()) { partialResult, event in 56 | switch event { 57 | case .text(let string): 58 | partialResult.append(string) 59 | break 60 | default: 61 | break 62 | } 63 | } 64 | 65 | #expect(receivedDidCompleteWithError) 66 | #expect(receivedDidReceiveResponse) 67 | #expect(receivedDidReceiveData) 68 | 69 | #expect(title == "Der Blaue Reiter") 70 | 71 | #expect(response.suggestedFilename! == "\(filename).html") 72 | } 73 | 74 | func urlSession( 75 | _ session: URLSession, 76 | task: URLSessionTask, 77 | didCompleteWithError error: (any Error)? 78 | ) { 79 | receivedDidCompleteWithError = true 80 | } 81 | 82 | func urlSession( 83 | _ session: URLSession, 84 | dataTask: URLSessionDataTask, 85 | didReceive response: URLResponse, 86 | completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void 87 | ) { 88 | receivedDidReceiveResponse = true 89 | completionHandler(.allow) 90 | } 91 | 92 | func urlSession( 93 | _ session: URLSession, 94 | dataTask: URLSessionDataTask, 95 | didReceive data: Data 96 | ) { 97 | receivedDidReceiveData = true 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Tests/SwiftyXMLSequenceTests/XMLEventTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MIT License 3 | // 4 | // Copyright (c) 2023 Sophiestication Software, Inc. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | // 24 | 25 | import Testing 26 | import Foundation 27 | @testable import SwiftyXMLSequence 28 | 29 | @Suite("Parsing Event") 30 | final class XMLEventTests { 31 | typealias XMLElement = SwiftyXMLSequence.XMLElement 32 | typealias XMLParsingEvent = ParsingEvent 33 | 34 | var session: URLSession! 35 | var triviaFileURL: URL! 36 | 37 | init() throws { 38 | session = URLSession(configuration: .default) 39 | 40 | guard let fileURL = Bundle.module.url(forResource: "trivia", withExtension: "xml") else { 41 | #expect(Bool(false), "Failed to find trivia.xml file."); return 42 | } 43 | triviaFileURL = fileURL 44 | } 45 | 46 | @Test func loadAndParseWithURLSession() async { 47 | await tryAndFailIfNeeded { 48 | let (events, _) = try await session.xml(for: triviaFileURL) 49 | let events2 = makeXMLParserStream(for: triviaFileURL) 50 | 51 | let equalSequences = await isEqual(events, events2) 52 | #expect(equalSequences == true) 53 | } 54 | } 55 | 56 | @Test func loadAndParseData() async { 57 | await tryAndFailIfNeeded { 58 | let events = try Data( 59 | contentsOf: triviaFileURL, 60 | options: [.mappedIfSafe] 61 | ) 62 | .xml(referencing: triviaFileURL) 63 | .async 64 | let events2 = makeXMLParserStream(for: triviaFileURL) 65 | 66 | let equalSequences = await isEqual(events, events2) 67 | #expect(equalSequences == true) 68 | } 69 | } 70 | 71 | private func isEqual( 72 | _ sequence1: S1, 73 | _ sequence2: S2 74 | ) async -> Bool 75 | where S1.Element == S2.Element, S1.Element: Equatable { 76 | var iterator1 = sequence1.makeAsyncIterator() 77 | var iterator2 = sequence2.makeAsyncIterator() 78 | 79 | while let element1 = try? await iterator1.next(), 80 | let element2 = try? await iterator2.next() { 81 | if element1 != element2 { 82 | return false 83 | } 84 | } 85 | 86 | let nextElement1 = try? await iterator1.next() 87 | let nextElement2 = try? await iterator2.next() 88 | 89 | if nextElement1 != nextElement2 { 90 | return false 91 | } 92 | 93 | return true 94 | } 95 | 96 | private func tryAndFailIfNeeded(_ action: () async throws -> Void) async { 97 | do { 98 | try await action() 99 | } catch let error as ParsingError { 100 | #expect(Bool(false), "Line \(error.line); Column \(error.column): \(error.message)") 101 | } catch { 102 | #expect(Bool(false), "Error occurred: \(error)") 103 | } 104 | } 105 | 106 | private func makeXMLParserStream(for url: URL) -> AsyncThrowingStream { 107 | return AsyncThrowingStream { continuation in 108 | let delegate = XMLParserDelegate({ event in 109 | continuation.yield(event) 110 | }) 111 | 112 | guard let parser = XMLParser(contentsOf: url) else { 113 | continuation.finish() 114 | return 115 | } 116 | 117 | parser.delegate = delegate 118 | 119 | parser.parse() 120 | continuation.finish(throwing: parser.parserError) 121 | } 122 | } 123 | 124 | private class XMLParserDelegate: NSObject, Foundation.XMLParserDelegate { 125 | private let yield: (XMLParsingEvent) -> Void 126 | private var elementStack: [XMLElement] = [] 127 | 128 | init(_ yield: (@escaping (XMLParsingEvent) -> Void)) { 129 | self.yield = yield 130 | } 131 | 132 | func parserDidStartDocument(_ parser: XMLParser) { 133 | yield(.beginDocument) 134 | } 135 | 136 | func parserDidEndDocument(_ parser: XMLParser) { 137 | yield(.endDocument) 138 | } 139 | 140 | func parser(_ parser: XMLParser, foundCharacters string: String) { 141 | yield(.text(string)) 142 | } 143 | 144 | func parser( 145 | _ parser: XMLParser, 146 | didStartElement elementName: String, 147 | namespaceURI: String?, 148 | qualifiedName qName: String?, 149 | attributes attributeDict: [String : String] 150 | ) { 151 | let attributes = Attributes(rawValue: attributeDict) 152 | let element = XMLElement( 153 | element: elementName, 154 | attributes: attributes 155 | ) 156 | 157 | elementStack.append(element) 158 | 159 | yield(.begin(element, attributes: attributes)) 160 | } 161 | 162 | func parser( 163 | _ parser: XMLParser, 164 | didEndElement elementName: String, 165 | namespaceURI: String?, 166 | qualifiedName qName: String? 167 | ) { 168 | if let element = elementStack.popLast() { 169 | yield(.end(element)) 170 | } 171 | } 172 | } 173 | } 174 | --------------------------------------------------------------------------------