Skip to content

Commit 2cc00d8

Browse files
Replace SWXMLHash with internal XML parser
Remove the SWXMLHash dependency and implement an internal XML parsing/deserialization subsystem. Added XMLHash (SAX-based parser), XMLIndexer, XMLNode and XMLDeserialization protocols (XMLObjectDeserialization / XMLValueDeserialization) with primitive conformances, enabling parsing and typed value extraction. Updated XMLDeserializer to call XMLHash.lazy and propagate parsing errors (using try), removed SWXMLHash imports, and adjusted tests (TestPerson and XMLDeserializerTests) to import Kite. Also removed the Package.resolved and deleted the external package entry from Package.swift.
1 parent 41e386a commit 2cc00d8

File tree

9 files changed

+270
-32
lines changed

9 files changed

+270
-32
lines changed

Package.resolved

Lines changed: 0 additions & 15 deletions
This file was deleted.

Package.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,9 @@ let package = Package(
1010
name: "Kite",
1111
targets: ["Kite"])
1212
],
13-
dependencies: [
14-
.package(url: "https://github.com/drmohundro/SWXMLHash.git", exact: "8.1.1")
15-
],
1613
targets: [
1714
.target(
18-
name: "Kite",
19-
dependencies: [
20-
.product(name: "SWXMLHash", package: "SWXMLHash")
21-
]
15+
name: "Kite"
2216
),
2317
.testTarget(
2418
name: "KiteTests",

Sources/Kite/Deserializers/XMLDeserializer/XMLDeserializer.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Foundation
2-
import SWXMLHash
32

43
public enum XMLDeserializerError: LocalizedError {
54
case xmlDeserializationFailed(String)
@@ -35,15 +34,15 @@ extension XMLDeserializer where T == Data {
3534
extension XMLDeserializer where T == XMLIndexer {
3635
public init() {
3736
self.transform = { xmlData in
38-
XMLHash.lazy(xmlData)
37+
try XMLHash.lazy(xmlData)
3938
}
4039
}
4140
}
4241

4342
extension XMLDeserializer where T: XMLObjectDeserialization {
4443
public init() {
4544
self.transform = { xmlData in
46-
let xml = XMLHash.lazy(xmlData)
45+
let xml = try XMLHash.lazy(xmlData)
4746
guard let rootElement = xml.documentRootElement else {
4847
throw XMLDeserializerError.xmlDeserializationFailed("XML document contains no element nodes.")
4948
}
@@ -54,7 +53,7 @@ extension XMLDeserializer where T: XMLObjectDeserialization {
5453
public static func singleObjectDeserializer(keyPath path: String...) -> XMLDeserializer<T> {
5554
XMLDeserializer<T>(
5655
transform: { xmlData in
57-
let xml = XMLHash.lazy(xmlData)
56+
let xml = try XMLHash.lazy(xmlData)
5857
return try xml[path].value()
5958
}
6059
)
@@ -63,7 +62,7 @@ extension XMLDeserializer where T: XMLObjectDeserialization {
6362
public static func collectionDeserializer(keyPath path: String...) -> XMLDeserializer<[T]> {
6463
XMLDeserializer<[T]>(
6564
transform: { xmlData in
66-
let xml = XMLHash.lazy(xmlData)
65+
let xml = try XMLHash.lazy(xmlData)
6766
return try xml[path].value()
6867
}
6968
)

Sources/Kite/Extensions/XMLIndexer+Extensions.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import Foundation
2-
import SWXMLHash
3-
41
extension XMLIndexer {
52
subscript(keys: [String]) -> XMLIndexer {
63
keys.reduce(self) { current, key in
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Foundation
2+
3+
// MARK: - XMLObjectDeserialization
4+
5+
/// Conforming types can be constructed from an ``XMLIndexer`` node.
6+
public protocol XMLObjectDeserialization {
7+
static func deserialize(_ node: XMLIndexer) throws -> Self
8+
}
9+
10+
// MARK: - XMLValueDeserialization
11+
12+
/// Conforming types can be constructed from the text content of an ``XMLIndexer`` leaf node.
13+
public protocol XMLValueDeserialization {
14+
static func deserialize(_ node: XMLIndexer) throws -> Self
15+
}
16+
17+
// MARK: - Primitive conformances
18+
19+
extension String: XMLValueDeserialization {
20+
public static func deserialize(_ node: XMLIndexer) throws -> String {
21+
guard case .element(let n) = node.backing, let text = n.text else {
22+
throw XMLDeserializerError.xmlDeserializationFailed("Expected text content for String.")
23+
}
24+
return text
25+
}
26+
}
27+
28+
extension Int: XMLValueDeserialization {
29+
public static func deserialize(_ node: XMLIndexer) throws -> Int {
30+
guard case .element(let n) = node.backing,
31+
let text = n.text,
32+
let value = Int(text.trimmingCharacters(in: .whitespaces)) else {
33+
throw XMLDeserializerError.xmlDeserializationFailed("Cannot convert to Int.")
34+
}
35+
return value
36+
}
37+
}
38+
39+
extension Double: XMLValueDeserialization {
40+
public static func deserialize(_ node: XMLIndexer) throws -> Double {
41+
guard case .element(let n) = node.backing,
42+
let text = n.text,
43+
let value = Double(text.trimmingCharacters(in: .whitespaces)) else {
44+
throw XMLDeserializerError.xmlDeserializationFailed("Cannot convert to Double.")
45+
}
46+
return value
47+
}
48+
}
49+
50+
extension Float: XMLValueDeserialization {
51+
public static func deserialize(_ node: XMLIndexer) throws -> Float {
52+
guard case .element(let n) = node.backing,
53+
let text = n.text,
54+
let value = Float(text.trimmingCharacters(in: .whitespaces)) else {
55+
throw XMLDeserializerError.xmlDeserializationFailed("Cannot convert to Float.")
56+
}
57+
return value
58+
}
59+
}
60+
61+
extension Bool: XMLValueDeserialization {
62+
public static func deserialize(_ node: XMLIndexer) throws -> Bool {
63+
guard case .element(let n) = node.backing, let text = n.text else {
64+
throw XMLDeserializerError.xmlDeserializationFailed("Expected text content for Bool.")
65+
}
66+
switch text.trimmingCharacters(in: .whitespaces).lowercased() {
67+
case "true", "yes", "1": return true
68+
case "false", "no", "0": return false
69+
default:
70+
throw XMLDeserializerError.xmlDeserializationFailed("Cannot convert '\(text)' to Bool.")
71+
}
72+
}
73+
}

Sources/Kite/XML/XMLHash.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
#if canImport(FoundationXML)
3+
import FoundationXML
4+
#endif
5+
6+
/// Entry point for parsing XML data into an ``XMLIndexer``.
7+
public enum XMLHash {
8+
/// Parses `data` and returns a document-level ``XMLIndexer``.
9+
///
10+
/// - Throws: The underlying `XMLParser` error for structurally malformed documents
11+
/// (e.g. mismatched tags). Documents that are syntactically valid but contain no
12+
/// element nodes (e.g. comment-only) return an empty document indexer instead,
13+
/// allowing callers to throw a more descriptive error.
14+
public static func lazy(_ data: Data) throws -> XMLIndexer {
15+
let handler = SAXHandler()
16+
let parser = XMLParser(data: data)
17+
parser.delegate = handler
18+
let success = parser.parse()
19+
20+
if !success && !handler.hadElementStart {
21+
// Parse "failed" only because there are no element nodes at all
22+
// (e.g. comment-only, or prolog-only document). Return an empty document
23+
// so callers can surface a meaningful XMLDeserializerError.
24+
return XMLIndexer(.document([]))
25+
}
26+
27+
if !success, let error = handler.parseError ?? parser.parserError {
28+
// Structural error: mismatched tags, encoding issues, etc.
29+
throw error
30+
}
31+
32+
return XMLIndexer(.document(handler.roots))
33+
}
34+
}
35+
36+
// MARK: - SAX parser delegate
37+
38+
private final class SAXHandler: NSObject, XMLParserDelegate, @unchecked Sendable {
39+
private(set) var roots: [XMLNode] = []
40+
private(set) var parseError: Error?
41+
private(set) var hadElementStart = false
42+
43+
// Each stack frame: (element name, attributes, accumulated children, accumulated text)
44+
private var stack: [(name: String, attributes: [String: String], children: [XMLNode], text: String)] = []
45+
46+
func parser(
47+
_ parser: XMLParser,
48+
didStartElement elementName: String,
49+
namespaceURI: String?,
50+
qualifiedName qName: String?,
51+
attributes attributeDict: [String: String] = [:]
52+
) {
53+
hadElementStart = true
54+
stack.append((name: elementName, attributes: attributeDict, children: [], text: ""))
55+
}
56+
57+
func parser(_ parser: XMLParser, foundCharacters string: String) {
58+
guard !stack.isEmpty else { return }
59+
stack[stack.count - 1].text += string
60+
}
61+
62+
func parser(
63+
_ parser: XMLParser,
64+
didEndElement elementName: String,
65+
namespaceURI: String?,
66+
qualifiedName qName: String?
67+
) {
68+
guard let top = stack.popLast() else { return }
69+
let trimmed = top.text.trimmingCharacters(in: .whitespacesAndNewlines)
70+
let node = XMLNode(
71+
name: top.name,
72+
text: trimmed.isEmpty ? nil : trimmed,
73+
attributes: top.attributes,
74+
children: top.children
75+
)
76+
if stack.isEmpty {
77+
roots.append(node)
78+
} else {
79+
stack[stack.count - 1].children.append(node)
80+
}
81+
}
82+
83+
func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
84+
self.parseError = parseError
85+
}
86+
}

Sources/Kite/XML/XMLIndexer.swift

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// MARK: - Internal XML node
2+
3+
struct XMLNode: Sendable {
4+
let name: String
5+
let text: String?
6+
let attributes: [String: String]
7+
let children: [XMLNode]
8+
}
9+
10+
// MARK: - Internal backing
11+
12+
enum XMLIndexerBacking: Sendable {
13+
case document([XMLNode])
14+
case element(XMLNode)
15+
case list([XMLNode])
16+
case notFound(key: String)
17+
}
18+
19+
// MARK: - Public XMLIndexer
20+
21+
/// Represents a position in a parsed XML document.
22+
///
23+
/// Navigate the tree with subscript notation and extract typed values with ``value()``.
24+
public struct XMLIndexer: Sendable {
25+
let backing: XMLIndexerBacking
26+
27+
init(_ backing: XMLIndexerBacking) {
28+
self.backing = backing
29+
}
30+
31+
// MARK: Subscript
32+
33+
/// Returns a child indexer matching `key`.
34+
public subscript(key: String) -> XMLIndexer {
35+
let matches: [XMLNode]
36+
switch backing {
37+
case .document(let roots):
38+
matches = roots.filter { $0.name == key }
39+
case .element(let node):
40+
matches = node.children.filter { $0.name == key }
41+
case .list(let nodes):
42+
matches = nodes.flatMap { $0.children.filter { $0.name == key } }
43+
case .notFound:
44+
return self
45+
}
46+
return XMLIndexer(nodes: matches, key: key)
47+
}
48+
49+
// MARK: Children
50+
51+
/// All direct children as individual indexers.
52+
public var children: [XMLIndexer] {
53+
switch backing {
54+
case .document(let roots): return roots.map { XMLIndexer(.element($0)) }
55+
case .element(let node): return node.children.map { XMLIndexer(.element($0)) }
56+
case .list(let nodes): return nodes.map { XMLIndexer(.element($0)) }
57+
case .notFound: return []
58+
}
59+
}
60+
61+
// MARK: Private helpers
62+
63+
private init(nodes: [XMLNode], key: String) {
64+
switch nodes.count {
65+
case 0: self.init(.notFound(key: key))
66+
case 1: self.init(.element(nodes[0]))
67+
default: self.init(.list(nodes))
68+
}
69+
}
70+
}
71+
72+
// MARK: - value() overloads
73+
74+
extension XMLIndexer {
75+
/// Deserializes this node as a single `XMLObjectDeserialization` value.
76+
public func value<T: XMLObjectDeserialization>() throws -> T {
77+
switch backing {
78+
case .element(let node):
79+
return try T.deserialize(XMLIndexer(.element(node)))
80+
case .notFound(let key):
81+
throw XMLDeserializerError.xmlDeserializationFailed("Element not found: \(key)")
82+
case .list:
83+
throw XMLDeserializerError.xmlDeserializationFailed("Expected a single element, found a list.")
84+
case .document:
85+
throw XMLDeserializerError.xmlDeserializationFailed("Cannot deserialize a document node as an object.")
86+
}
87+
}
88+
89+
/// Deserializes this node as an array of `XMLObjectDeserialization` values.
90+
public func value<T: XMLObjectDeserialization>() throws -> [T] {
91+
switch backing {
92+
case .list(let nodes):
93+
return try nodes.map { try T.deserialize(XMLIndexer(.element($0))) }
94+
case .element(let node):
95+
return try [T.deserialize(XMLIndexer(.element(node)))]
96+
case .notFound, .document:
97+
return []
98+
}
99+
}
100+
101+
/// Deserializes this node's text content as a `XMLValueDeserialization` primitive.
102+
public func value<T: XMLValueDeserialization>() throws -> T {
103+
try T.deserialize(self)
104+
}
105+
}

Tests/KiteTests/Mocks/Models/TestPerson.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Created by Artem Kalinovsky on 11.03.2025.
66
//
77

8-
import SWXMLHash
8+
import Kite
99

1010
struct TestPerson: Codable, Equatable, XMLObjectDeserialization {
1111
let name: String

Tests/KiteTests/XMLDeserializerTests/XMLDeserializerTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
//
77

88
import Testing
9-
import SWXMLHash
109
import Kite
1110

1211
@Suite("XMLDeserializerTests")

0 commit comments

Comments
 (0)