Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasf committed Feb 7, 2025
2 parents cc04b75 + 27a28c9 commit bb1cdfc
Show file tree
Hide file tree
Showing 38 changed files with 809 additions and 679 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Add Nodal as a dependency in your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/tomasf/Nodal.git", .upToNextMinor(from: "0.1.0"))
.package(url: "https://github.com/tomasf/Nodal.git", .upToNextMinor(from: "0.2.0"))
]
```

Expand Down
File renamed without changes.
31 changes: 12 additions & 19 deletions Sources/Nodal/Document/Document+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ public extension Document {
/// - Note: This initializer parses the entire string and builds the corresponding document tree.
convenience init(string: String, options: ParseOptions = .default) throws(ParseError) {
self.init()
let result = pugiDocument.load_string(string, options.rawValue)
if result.status != pugi.status_ok {
throw ParseError(result)
}
try finishSetup(withResult: pugiDocument.load_string(string, options.rawValue))
}

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

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

/// Creates a new, empty XML document.
///
/// - Note: This initializer creates a document with no content. Elements can be added manually using the API.
convenience init() {
self.init(owningDocument: nil, node: .init())
internal extension Document {
func finishSetup(withResult parseResult: pugi.xml_parse_result) throws(ParseError) {
if parseResult.status != pugi.status_ok {
throw ParseError(parseResult)
}
rebuildNamespaceDeclarationCache()
}
}
156 changes: 156 additions & 0 deletions Sources/Nodal/Document/Document+Namespaces.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation
import pugixml

public extension Document {
/// A set of namespace names that are referenced in the document but have not been declared.
///
/// - Note: This property helps identify undeclared namespaces that need to be resolved to generate XML output.
var undeclaredNamespaceNames: Set<String> {
Set(pendingNamespaceRecords.flatMap(\.value.namespaceNames))
}
}

internal extension Document {
typealias Prefix = NamespaceDeclaration.Prefix

static let xmlNamespace = (prefix: Prefix("xml"), name: "http://www.w3.org/XML/1998/namespace")
static let xmlnsNamespace = (prefix: Prefix("xmlns"), name: "http://www.w3.org/2000/xmlns/")

struct NamespaceDeclaration {
var node: pugi.xml_node
var prefix: Prefix
var namespaceName: String

enum Prefix: Hashable {
case defaultNamespace
case named (String)

init(_ string: String?) {
self = if let string { .named(string) } else { .defaultNamespace }
}

var string: String? {
switch self {
case .named (let string): string
case .defaultNamespace: nil
}
}
}
}

func namespaceDeclarationCount(for node: Node? = nil) -> Int {
namespaceDeclarationsByPrefix.reduce(0) { result, item in
result + item.value.filter { if let node { $0.node == node.node } else { true } }.count
}
}

func namespaceName(forPrefix prefix: Prefix, in element: pugi.xml_node) -> String? {
if prefix == Self.xmlNamespace.prefix { return Self.xmlNamespace.name }
if prefix == Self.xmlnsNamespace.prefix { return Self.xmlnsNamespace.name }

guard let candidates = namespaceDeclarationsByPrefix[prefix] else {
return nil
}

// Optimization: If the only candidate is the root element, then it's guaranteed to be right
if candidates.count == 1, candidates[0].node == pugiDocument.documentElement {
return candidates[0].namespaceName
}

var node = element
while(!node.empty()) {
for candidate in candidates where candidate.node == node {
return candidate.namespaceName
}
node = node.parent()
}
return nil
}

func namespacePrefix(forName name: String, in element: pugi.xml_node) -> Prefix? {
if name == Self.xmlNamespace.name { return Self.xmlNamespace.prefix }
if name == Self.xmlnsNamespace.name { return Self.xmlnsNamespace.prefix }

guard let candidates = namespaceDeclarationsByName[name], !candidates.isEmpty else {
return nil
}

// Optimization: If the only candidate is the root element, and the found prefix
// is the only declaration for that prefix (no shadowing), then we've found our match
if candidates.count == 1, candidates[0].node == pugiDocument.documentElement {
let prefix = candidates[0].prefix
if namespaceDeclarationsByPrefix[prefix]?.count == 1 {
return candidates[0].prefix
}
}

var node = element
while(!node.empty()) {
for candidate in candidates where candidate.node == node {
if namespaceName(forPrefix: candidate.prefix, in: element) == name {
return candidate.prefix
}
}
node = node.parent()
}
return nil
}

func resetNamespaceDeclarationCache() {
namespaceDeclarationsByName = [:]
namespaceDeclarationsByPrefix = [:]
}

private func addNamespaceDeclarations(for node: pugi.xml_node) {
for attribute in node.attributes {
let name = attribute.name()!
guard strncmp(name, "xmlns", 5) == 0 else { continue }

let (prefix, localName) = name.qualifiedNameParts
let declaration = NamespaceDeclaration(
node: node,
prefix: prefix == nil ? .defaultNamespace : .named(localName),
namespaceName: String(cString: attribute.value())
)
namespaceDeclarationsByName[declaration.namespaceName, default: []].append(declaration)
namespaceDeclarationsByPrefix[declaration.prefix, default: []].append(declaration)
}
}

private func removeNamespaceDeclarations(for nodes: Set<pugi.xml_node>) {
namespaceDeclarationsByName = namespaceDeclarationsByName.mapValues {
$0.filter { !nodes.contains($0.node) }
}
namespaceDeclarationsByPrefix = namespaceDeclarationsByPrefix.mapValues {
$0.filter { !nodes.contains($0.node) }
}
}

func removeNamespaceDeclarations(for tree: pugi.xml_node, excludingTarget: Bool = false) {
let descendants = Set(tree.descendants.filter { $0.type() == pugi.node_element && (!excludingTarget || $0 != tree) })
removeNamespaceDeclarations(for: descendants)
}

func rebuildNamespaceDeclarationCache(for element: Node) {
removeNamespaceDeclarations(for: [element.node])
addNamespaceDeclarations(for: element.node)
}

func rebuildNamespaceDeclarationCache() {
resetNamespaceDeclarationCache()

for node in pugiDocument.documentElement.descendants {
guard node.type() == pugi.node_element else { continue }
addNamespaceDeclarations(for: node)
}
}

func declaredNamespacesDidChange(for element: Node) {
rebuildNamespaceDeclarationCache(for: element)
for (element, record) in pendingNameRecords(forDescendantsOf: element) {
if record.attemptResolution(for: element, in: self) {
removePendingNameRecord(for: element)
}
}
}
}
41 changes: 0 additions & 41 deletions Sources/Nodal/Document/Document+NodeObjects.swift

This file was deleted.

78 changes: 78 additions & 0 deletions Sources/Nodal/Document/Document+Output.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
import pugixml
import Bridge

internal extension Document {
private func save(encoding: String.Encoding = .utf8,
options: OutputOptions = .default,
indentation: String = .fourSpaces,
output: @escaping (Data) -> ()
) throws(OutputError) {
let undeclared = undeclaredNamespaceNames
guard undeclared.isEmpty else {
throw .undeclaredNamespaces(undeclared)
}

xml_document_save_with_block(pugiDocument, indentation, options.rawValue, encoding.pugiEncoding) { buffer, length in
guard let buffer else { return }
output(Data(bytes: buffer, count: length))
}
}
}

public extension Document {
/// Saves the XML document to a specified file URL with the given encoding and options.
///
/// - Parameters:
/// - fileURL: The location where the XML document should be saved.
/// - encoding: The string encoding to use for the file. Defaults to `.utf8`.
/// - options: The options for XML output formatting. Defaults to `.default`.
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
/// - Throws: `OutputError` if the document contains undeclared namespaces or if an error occurs during serialization.
/// Also throws any file-related errors encountered while saving to the provided URL.
func save(
to fileURL: URL,
encoding: String.Encoding = .utf8,
options: OutputOptions = .default,
indentation: String = .fourSpaces
) throws {
FileManager().createFile(atPath: fileURL.path, contents: nil)
let fileHandle = try FileHandle(forWritingTo: fileURL)
fileHandle.truncateFile(atOffset: 0)
defer { fileHandle.closeFile() }

try save(encoding: encoding, options: options, indentation: indentation) { chunk in
fileHandle.write(chunk)
}
}

/// Generates the XML data representation of the document with specified options.
///
/// - Parameters:
/// - encoding: The string encoding to use for the output. Defaults to `.utf8`.
/// - options: The options for XML output formatting. Defaults to `.default`.
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
/// - Returns: A `Data` object containing the serialized XML representation of the document.
/// - Throws: `OutputError` if the document contains undeclared namespaces or if an error occurs during serialization.
func xmlData(
encoding: String.Encoding = .utf8,
options: OutputOptions = .default,
indentation: String = .fourSpaces
) throws(OutputError) -> Data {
var data = Data()
try save(encoding: encoding, options: options, indentation: indentation) { chunk in
data.append(chunk)
}
return data
}

/// Generates the XML string representation of the document with specified options.
///
/// - Parameters:
/// - options: The options for XML output formatting. Defaults to `.default`.
/// - indentation: The string to use for indentation in the XML output. Defaults to `.fourSpaces`.
/// - Returns: A string containing the serialized XML representation of the document.
func xmlString(options: OutputOptions = .default, indentation: String = .fourSpaces) throws -> String {
String(data: try xmlData(encoding: .utf8, options: options, indentation: indentation), encoding: .utf8) ?? ""
}
}
20 changes: 11 additions & 9 deletions Sources/Nodal/Document/Document+PendingNameRecords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import pugixml

internal extension Document {
func pendingNameRecord(for element: Element) -> PendingNameRecord? {
func pendingNameRecord(for element: Node) -> PendingNameRecord? {
pendingNamespaceRecords[element.nodePointer]
}

Expand All @@ -14,23 +14,25 @@ internal extension Document {
return name
}

let (prefix, localName) = elementNode.name().qualifiedNameParts

return ExpandedName(
namespaceName: elementNode.namespaceName(for: qName.qNamePrefix),
localName: qName.qNameLocalName
namespaceName: namespaceName(forPrefix: .init(prefix), in: elementNode),
localName: localName
)
}

func addPendingNameRecord(for element: Element) -> PendingNameRecord {
func addPendingNameRecord(for element: Node) -> PendingNameRecord {
let record = PendingNameRecord(element: element)
pendingNamespaceRecords[element.nodePointer] = record
return record
}

func removePendingNameRecord(for element: Element) {
pendingNamespaceRecords[element.nodePointer] = nil
func removePendingNameRecord(for element: pugi.xml_node) {
pendingNamespaceRecords[element.internal_object()] = nil
}

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

func pendingNameRecords(forDescendantsOf parent: Node) -> [(Element, PendingNameRecord)] {
func pendingNameRecords(forDescendantsOf parent: Node) -> [(pugi.xml_node, PendingNameRecord)] {
pendingNamespaceRecords.compactMap {
$1.belongsToTree(parent) ? (element(for: .init($0)), $1) : nil
$1.belongsToTree(parent) ? (.init($0), $1) : nil
}
}

Expand Down
Loading

0 comments on commit bb1cdfc

Please sign in to comment.