Skip to content

Commit bb1cdfc

Browse files
committed
Merge branch 'dev'
2 parents cc04b75 + 27a28c9 commit bb1cdfc

38 files changed

+809
-679
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Add Nodal as a dependency in your `Package.swift`:
1212

1313
```swift
1414
dependencies: [
15-
.package(url: "https://github.com/tomasf/Nodal.git", .upToNextMinor(from: "0.1.0"))
15+
.package(url: "https://github.com/tomasf/Nodal.git", .upToNextMinor(from: "0.2.0"))
1616
]
1717
```
1818

Sources/Nodal/Document/Document+Input.swift

+12-19
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ public extension Document {
1212
/// - Note: This initializer parses the entire string and builds the corresponding document tree.
1313
convenience init(string: String, options: ParseOptions = .default) throws(ParseError) {
1414
self.init()
15-
let result = pugiDocument.load_string(string, options.rawValue)
16-
if result.status != pugi.status_ok {
17-
throw ParseError(result)
18-
}
15+
try finishSetup(withResult: pugiDocument.load_string(string, options.rawValue))
1916
}
2017

2118
/// Creates an XML document by parsing the given data.
@@ -29,12 +26,9 @@ public extension Document {
2926
/// - Note: This initializer parses the data and builds the corresponding document tree.
3027
convenience init(data: Data, encoding: String.Encoding? = nil, options: ParseOptions = .default) throws(ParseError) {
3128
self.init()
32-
let result = data.withUnsafeBytes { bufferPointer in
29+
try finishSetup(withResult: data.withUnsafeBytes { bufferPointer in
3330
pugiDocument.load_buffer(bufferPointer.baseAddress, bufferPointer.count, options.rawValue, encoding?.pugiEncoding ?? pugi.encoding_auto)
34-
}
35-
if result.status != pugi.status_ok {
36-
throw ParseError(result)
37-
}
31+
})
3832
}
3933

4034
/// Creates an XML document by loading and parsing the content of a file at the specified URL.
@@ -48,18 +42,17 @@ public extension Document {
4842
/// - Note: This initializer reads the file from the provided URL and builds the corresponding document tree.
4943
convenience init(url fileURL: URL, encoding: String.Encoding? = nil, options: ParseOptions = .default) throws(ParseError) {
5044
self.init()
51-
let result = fileURL.withUnsafeFileSystemRepresentation { path in
45+
try finishSetup(withResult: fileURL.withUnsafeFileSystemRepresentation { path in
5246
pugiDocument.load_file(path, options.rawValue, encoding?.pugiEncoding ?? pugi.encoding_auto)
53-
}
54-
if result.status != pugi.status_ok {
55-
throw ParseError(result)
56-
}
47+
})
5748
}
49+
}
5850

59-
/// Creates a new, empty XML document.
60-
///
61-
/// - Note: This initializer creates a document with no content. Elements can be added manually using the API.
62-
convenience init() {
63-
self.init(owningDocument: nil, node: .init())
51+
internal extension Document {
52+
func finishSetup(withResult parseResult: pugi.xml_parse_result) throws(ParseError) {
53+
if parseResult.status != pugi.status_ok {
54+
throw ParseError(parseResult)
55+
}
56+
rebuildNamespaceDeclarationCache()
6457
}
6558
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import Foundation
2+
import pugixml
3+
4+
public extension Document {
5+
/// A set of namespace names that are referenced in the document but have not been declared.
6+
///
7+
/// - Note: This property helps identify undeclared namespaces that need to be resolved to generate XML output.
8+
var undeclaredNamespaceNames: Set<String> {
9+
Set(pendingNamespaceRecords.flatMap(\.value.namespaceNames))
10+
}
11+
}
12+
13+
internal extension Document {
14+
typealias Prefix = NamespaceDeclaration.Prefix
15+
16+
static let xmlNamespace = (prefix: Prefix("xml"), name: "http://www.w3.org/XML/1998/namespace")
17+
static let xmlnsNamespace = (prefix: Prefix("xmlns"), name: "http://www.w3.org/2000/xmlns/")
18+
19+
struct NamespaceDeclaration {
20+
var node: pugi.xml_node
21+
var prefix: Prefix
22+
var namespaceName: String
23+
24+
enum Prefix: Hashable {
25+
case defaultNamespace
26+
case named (String)
27+
28+
init(_ string: String?) {
29+
self = if let string { .named(string) } else { .defaultNamespace }
30+
}
31+
32+
var string: String? {
33+
switch self {
34+
case .named (let string): string
35+
case .defaultNamespace: nil
36+
}
37+
}
38+
}
39+
}
40+
41+
func namespaceDeclarationCount(for node: Node? = nil) -> Int {
42+
namespaceDeclarationsByPrefix.reduce(0) { result, item in
43+
result + item.value.filter { if let node { $0.node == node.node } else { true } }.count
44+
}
45+
}
46+
47+
func namespaceName(forPrefix prefix: Prefix, in element: pugi.xml_node) -> String? {
48+
if prefix == Self.xmlNamespace.prefix { return Self.xmlNamespace.name }
49+
if prefix == Self.xmlnsNamespace.prefix { return Self.xmlnsNamespace.name }
50+
51+
guard let candidates = namespaceDeclarationsByPrefix[prefix] else {
52+
return nil
53+
}
54+
55+
// Optimization: If the only candidate is the root element, then it's guaranteed to be right
56+
if candidates.count == 1, candidates[0].node == pugiDocument.documentElement {
57+
return candidates[0].namespaceName
58+
}
59+
60+
var node = element
61+
while(!node.empty()) {
62+
for candidate in candidates where candidate.node == node {
63+
return candidate.namespaceName
64+
}
65+
node = node.parent()
66+
}
67+
return nil
68+
}
69+
70+
func namespacePrefix(forName name: String, in element: pugi.xml_node) -> Prefix? {
71+
if name == Self.xmlNamespace.name { return Self.xmlNamespace.prefix }
72+
if name == Self.xmlnsNamespace.name { return Self.xmlnsNamespace.prefix }
73+
74+
guard let candidates = namespaceDeclarationsByName[name], !candidates.isEmpty else {
75+
return nil
76+
}
77+
78+
// Optimization: If the only candidate is the root element, and the found prefix
79+
// is the only declaration for that prefix (no shadowing), then we've found our match
80+
if candidates.count == 1, candidates[0].node == pugiDocument.documentElement {
81+
let prefix = candidates[0].prefix
82+
if namespaceDeclarationsByPrefix[prefix]?.count == 1 {
83+
return candidates[0].prefix
84+
}
85+
}
86+
87+
var node = element
88+
while(!node.empty()) {
89+
for candidate in candidates where candidate.node == node {
90+
if namespaceName(forPrefix: candidate.prefix, in: element) == name {
91+
return candidate.prefix
92+
}
93+
}
94+
node = node.parent()
95+
}
96+
return nil
97+
}
98+
99+
func resetNamespaceDeclarationCache() {
100+
namespaceDeclarationsByName = [:]
101+
namespaceDeclarationsByPrefix = [:]
102+
}
103+
104+
private func addNamespaceDeclarations(for node: pugi.xml_node) {
105+
for attribute in node.attributes {
106+
let name = attribute.name()!
107+
guard strncmp(name, "xmlns", 5) == 0 else { continue }
108+
109+
let (prefix, localName) = name.qualifiedNameParts
110+
let declaration = NamespaceDeclaration(
111+
node: node,
112+
prefix: prefix == nil ? .defaultNamespace : .named(localName),
113+
namespaceName: String(cString: attribute.value())
114+
)
115+
namespaceDeclarationsByName[declaration.namespaceName, default: []].append(declaration)
116+
namespaceDeclarationsByPrefix[declaration.prefix, default: []].append(declaration)
117+
}
118+
}
119+
120+
private func removeNamespaceDeclarations(for nodes: Set<pugi.xml_node>) {
121+
namespaceDeclarationsByName = namespaceDeclarationsByName.mapValues {
122+
$0.filter { !nodes.contains($0.node) }
123+
}
124+
namespaceDeclarationsByPrefix = namespaceDeclarationsByPrefix.mapValues {
125+
$0.filter { !nodes.contains($0.node) }
126+
}
127+
}
128+
129+
func removeNamespaceDeclarations(for tree: pugi.xml_node, excludingTarget: Bool = false) {
130+
let descendants = Set(tree.descendants.filter { $0.type() == pugi.node_element && (!excludingTarget || $0 != tree) })
131+
removeNamespaceDeclarations(for: descendants)
132+
}
133+
134+
func rebuildNamespaceDeclarationCache(for element: Node) {
135+
removeNamespaceDeclarations(for: [element.node])
136+
addNamespaceDeclarations(for: element.node)
137+
}
138+
139+
func rebuildNamespaceDeclarationCache() {
140+
resetNamespaceDeclarationCache()
141+
142+
for node in pugiDocument.documentElement.descendants {
143+
guard node.type() == pugi.node_element else { continue }
144+
addNamespaceDeclarations(for: node)
145+
}
146+
}
147+
148+
func declaredNamespacesDidChange(for element: Node) {
149+
rebuildNamespaceDeclarationCache(for: element)
150+
for (element, record) in pendingNameRecords(forDescendantsOf: element) {
151+
if record.attemptResolution(for: element, in: self) {
152+
removePendingNameRecord(for: element)
153+
}
154+
}
155+
}
156+
}

Sources/Nodal/Document/Document+NodeObjects.swift

-41
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import Foundation
2+
import pugixml
3+
import Bridge
4+
5+
internal extension Document {
6+
private func save(encoding: String.Encoding = .utf8,
7+
options: OutputOptions = .default,
8+
indentation: String = .fourSpaces,
9+
output: @escaping (Data) -> ()
10+
) throws(OutputError) {
11+
let undeclared = undeclaredNamespaceNames
12+
guard undeclared.isEmpty else {
13+
throw .undeclaredNamespaces(undeclared)
14+
}
15+
16+
xml_document_save_with_block(pugiDocument, indentation, options.rawValue, encoding.pugiEncoding) { buffer, length in
17+
guard let buffer else { return }
18+
output(Data(bytes: buffer, count: length))
19+
}
20+
}
21+
}
22+
23+
public extension Document {
24+
/// Saves the XML document to a specified file URL with the given encoding and options.
25+
///
26+
/// - Parameters:
27+
/// - fileURL: The location where the XML document should be saved.
28+
/// - encoding: The string encoding to use for the file. Defaults to `.utf8`.
29+
/// - options: The options for XML output formatting. Defaults to `.default`.
30+
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
31+
/// - Throws: `OutputError` if the document contains undeclared namespaces or if an error occurs during serialization.
32+
/// Also throws any file-related errors encountered while saving to the provided URL.
33+
func save(
34+
to fileURL: URL,
35+
encoding: String.Encoding = .utf8,
36+
options: OutputOptions = .default,
37+
indentation: String = .fourSpaces
38+
) throws {
39+
FileManager().createFile(atPath: fileURL.path, contents: nil)
40+
let fileHandle = try FileHandle(forWritingTo: fileURL)
41+
fileHandle.truncateFile(atOffset: 0)
42+
defer { fileHandle.closeFile() }
43+
44+
try save(encoding: encoding, options: options, indentation: indentation) { chunk in
45+
fileHandle.write(chunk)
46+
}
47+
}
48+
49+
/// Generates the XML data representation of the document with specified options.
50+
///
51+
/// - Parameters:
52+
/// - encoding: The string encoding to use for the output. Defaults to `.utf8`.
53+
/// - options: The options for XML output formatting. Defaults to `.default`.
54+
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
55+
/// - Returns: A `Data` object containing the serialized XML representation of the document.
56+
/// - Throws: `OutputError` if the document contains undeclared namespaces or if an error occurs during serialization.
57+
func xmlData(
58+
encoding: String.Encoding = .utf8,
59+
options: OutputOptions = .default,
60+
indentation: String = .fourSpaces
61+
) throws(OutputError) -> Data {
62+
var data = Data()
63+
try save(encoding: encoding, options: options, indentation: indentation) { chunk in
64+
data.append(chunk)
65+
}
66+
return data
67+
}
68+
69+
/// Generates the XML string representation of the document with specified options.
70+
///
71+
/// - Parameters:
72+
/// - options: The options for XML output formatting. Defaults to `.default`.
73+
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
74+
/// - Returns: A string containing the serialized XML representation of the document.
75+
func xmlString(options: OutputOptions = .default, indentation: String = .fourSpaces) throws -> String {
76+
String(data: try xmlData(encoding: .utf8, options: options, indentation: indentation), encoding: .utf8) ?? ""
77+
}
78+
}

Sources/Nodal/Document/Document+PendingNameRecords.swift

+11-9
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import pugixml
33

44
internal extension Document {
5-
func pendingNameRecord(for element: Element) -> PendingNameRecord? {
5+
func pendingNameRecord(for element: Node) -> PendingNameRecord? {
66
pendingNamespaceRecords[element.nodePointer]
77
}
88

@@ -14,23 +14,25 @@ internal extension Document {
1414
return name
1515
}
1616

17+
let (prefix, localName) = elementNode.name().qualifiedNameParts
18+
1719
return ExpandedName(
18-
namespaceName: elementNode.namespaceName(for: qName.qNamePrefix),
19-
localName: qName.qNameLocalName
20+
namespaceName: namespaceName(forPrefix: .init(prefix), in: elementNode),
21+
localName: localName
2022
)
2123
}
2224

23-
func addPendingNameRecord(for element: Element) -> PendingNameRecord {
25+
func addPendingNameRecord(for element: Node) -> PendingNameRecord {
2426
let record = PendingNameRecord(element: element)
2527
pendingNamespaceRecords[element.nodePointer] = record
2628
return record
2729
}
2830

29-
func removePendingNameRecord(for element: Element) {
30-
pendingNamespaceRecords[element.nodePointer] = nil
31+
func removePendingNameRecord(for element: pugi.xml_node) {
32+
pendingNamespaceRecords[element.internal_object()] = nil
3133
}
3234

33-
func removePendingNameRecords(withinTree ancestor: Element, excludingTarget: Bool = false) {
35+
func removePendingNameRecords(withinTree ancestor: Node, excludingTarget: Bool = false) {
3436
let nodePointer = ancestor.nodePointer
3537
let keys = pendingNamespaceRecords.filter { node, record in
3638
if excludingTarget && node == nodePointer {
@@ -42,9 +44,9 @@ internal extension Document {
4244
for key in keys { pendingNamespaceRecords[key] = nil }
4345
}
4446

45-
func pendingNameRecords(forDescendantsOf parent: Node) -> [(Element, PendingNameRecord)] {
47+
func pendingNameRecords(forDescendantsOf parent: Node) -> [(pugi.xml_node, PendingNameRecord)] {
4648
pendingNamespaceRecords.compactMap {
47-
$1.belongsToTree(parent) ? (element(for: .init($0)), $1) : nil
49+
$1.belongsToTree(parent) ? (.init($0), $1) : nil
4850
}
4951
}
5052

0 commit comments

Comments
 (0)