├── .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 |
20 |
21 |
23 |
24 |
25 |
26 |
27 | Hello World
28 | Hello World
29 | Hello World
30 |
31 | Hello World
32 |
33 |
34 | Hello World
35 |
36 |
37 | Hello
World
38 |
39 |
40 |
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 |
--------------------------------------------------------------------------------