Skip to content

Commit

Permalink
Add XMLElementCodable
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasf committed Feb 10, 2025
1 parent 6de41cb commit a93f371
Show file tree
Hide file tree
Showing 12 changed files with 541 additions and 389 deletions.
131 changes: 104 additions & 27 deletions Sources/Nodal/Node/Node+Attributes.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import Foundation
import pugixml

internal extension Node {
func value(forAttribute name: String) -> String? {
let attribute = node.attribute(name)
return attribute.empty() ? nil : String(cString: attribute.value())
}

func setValue(_ value: String?, forAttribute name: String) {
var node = node
var attr = node.attribute(name)
if attr.empty() {
if value != nil {
attr = node.append_attribute(name)
attr.set_value(value)
}
} else {
if let value {
attr.set_value(value)
} else {
node.remove_attribute(attr)
}
}
if name.hasPrefix("xmlns") {
document.declaredNamespacesDidChange(for: self)
}
}
}

public extension Node {
/// A Boolean value indicating whether this node type supports attributes.
///
Expand Down Expand Up @@ -49,42 +76,92 @@ public extension Node {
}
}
}
}

/// Accesses the value of a specific attribute by its qualified name.
public extension Node {
/// The attributes of this element in document order, represented as an array of expanded names and their corresponding values.
///
/// - Parameter name: The qualified name of the attribute to access.
/// - Returns: The value of the attribute if it exists, or `nil` if the attribute is not present.
/// - Returns: An array of tuples where each tuple contains an `ExpandedName` and the corresponding attribute value.
///
/// - Example:
/// ```swift
/// let element = ...
/// element["id"] = "12345" // Sets the "id" attribute
/// let idValue = element["id"] // Retrieves the value of the "id" attribute
/// element["class"] = nil // Removes the "class" attribute
/// ```
subscript(attribute name: String) -> String? {
/// - Note: Setting this property replaces all existing attributes with the new ones, preserving the specified order.
var orderedAttributes: [(name: ExpandedName, value: String)] {
get {
let attribute = node.attribute(name)
return attribute.empty() ? nil : String(cString: attribute.value())
return node.attributes.map {(
ExpandedName(effectiveQualifiedAttributeName: String(cString: $0.name()), in: self),
String(cString: $0.value())
)}
}
nonmutating set {
var node = node
var attr = node.attribute(name)
if attr.empty() {
if newValue != nil {
attr = node.append_attribute(name)
attr.set_value(newValue)
}
} else {
if let newValue {
attr.set_value(newValue)
} else {
node.remove_attribute(attr)
}
node.remove_attributes()
for (name, value) in newValue {
let qName = name.requestQualifiedAttributeName(for: self)
var attr = node.append_attribute(qName)
attr.set_value(value)
}
if name.hasPrefix("xmlns") {
document.declaredNamespacesDidChange(for: self)
}
}

/// The attributes of this element as a dictionary, where the keys are `ExpandedName` objects and the values are their corresponding attribute values.
///
/// - Returns: A dictionary of attributes keyed by their expanded names.
///
/// - Note: Setting this property replaces all existing attributes with the new ones. The order of attributes is determined by the dictionary's order.
var namespacedAttributes: [ExpandedName: String] {
get { Dictionary(orderedAttributes) { $1 } }
nonmutating set { orderedAttributes = newValue.map { ($0, $1) } }
}

/// Accesses the value of an attribute by its name.
///
/// - Parameter name: The name of the attribute to access; either a `String` or an `ExpandedName`.
/// - Returns: The value of the attribute if it exists, or `nil` if no such attribute is found.
///
/// - Note: When setting an attribute with an expanded name, its namespace is resolved based on the current scope. If `nil` is assigned, the attribute is removed.
///
/// - Example:
/// ```swift
/// let name = ExpandedName(namespaceName: "http://example.com", localName: "attribute")
/// element[attribute: name] = "value" // Adds or updates the attribute
/// let value = element[attribute: name] // Retrieves the value
/// element[attribute: name] = nil // Removes the attribute
/// ```
subscript(attribute name: AttributeName) -> String? {
get {
if let qName = name.qualifiedName(in: self) {
return value(forAttribute: qName)
} else {
return nil
}
}
nonmutating set {
let qName = name.requestQualifiedName(in: self)
setValue(newValue, forAttribute: qName)
}
}

/// Accesses the value of an attribute by its local name and optional namespace URI.
///
/// - Parameters:
/// - localName: The local name of the attribute to access.
/// - namespaceURI: The namespace name of the attribute, or `nil` if the attribute is not namespaced.
/// - Returns: The value of the attribute if it exists, or `nil` if no such attribute is found.
///
/// - Note: This subscript allows convenient access to attributes by specifying both the local name and namespace.
///
/// - Example:
/// ```swift
/// element[attribute: "id", namespaceName: nil] = "123" // Sets an attribute with no namespace
/// element[attribute: "name", namespaceName: "http://example.com"] = "example" // Sets a namespaced attribute
/// let value = element[attribute: "name", namespaceName: "http://example.com"] // Retrieves the value
/// element[attribute: "name", namespaceName: "http://example.com"] = nil // Removes the attribute
/// ```
subscript(attribute localName: String, namespaceName namespaceURI: String?) -> String? {
get {
self[attribute: ExpandedName(namespaceName: namespaceURI, localName: localName)]
}
nonmutating set {
self[attribute: ExpandedName(namespaceName: namespaceURI, localName: localName)] = newValue
}
}
}
59 changes: 11 additions & 48 deletions Sources/Nodal/Node/Node+Elements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,21 @@ public extension Node {

/// Retrieves the first child element with the specified name.
///
/// - Parameter name: The qualified name of the child element to retrieve.
/// - Parameter name: The name of the child element to retrieve; either a `String` or an `ExpandedName`.
/// - Returns: The first child element with the specified name, or `nil` if no such element exists.
subscript(element name: String) -> Node? {
node.children.first {
$0.type() == pugi.node_element && String(cString: $0.name()) == name
subscript(element name: any ElementName) -> Node? {
node.children.lazy.first {
$0.type() == pugi.node_element && name.matches(node: $0, in: document)
}?.wrapped(in: document)
}

/// Retrieves all child elements with the specified name.
///
/// - Parameter name: The qualified name of the child elements to retrieve.
/// - Parameter name: The name of the child elements to retrieve; either a `String` or an `ExpandedName`.
/// - Returns: An array of child elements with the specified name.
subscript(elements name: String) -> [Node] {
subscript(elements name: any ElementName) -> [Node] {
node.children.lazy.filter {
$0.type() == pugi.node_element && String(cString: $0.name()) == name
}.map { $0.wrapped(in: document) }
}

/// Retrieves all child elements matching the specified expanded name.
///
/// - Parameter targetName: The expanded name (including local name and optional namespace) of the elements to retrieve.
/// - Returns: An array of child elements matching the expanded name.
subscript(elements targetName: ExpandedName) -> [Node] {
return node.children.lazy.filter {
$0.type() == pugi.node_element
&& String(cString: $0.name()).hasSuffix(targetName.localName)
&& self.document.expandedName(for: $0) == targetName
$0.type() == pugi.node_element && name.matches(node: $0, in: document)
}.map { $0.wrapped(in: document) }
}

Expand All @@ -59,18 +47,6 @@ public extension Node {
self[elements: ExpandedName(namespaceName: namespaceURI, localName: localName)]
}

/// Retrieves the first child element matching the specified expanded name.
///
/// - Parameter targetName: The expanded name (including local name and optional namespace) of the element to retrieve.
/// - Returns: The first child element matching the expanded name, or `nil` if no such element exists.
subscript(element targetName: ExpandedName) -> Node? {
node.children.lazy.first {
$0.type() == pugi.node_element
&& String(cString: $0.name()).hasSuffix(targetName.localName)
&& document.expandedName(for: $0) == targetName
}?.wrapped(in: document)
}

/// Retrieves the first child element with the specified local name and namespace URI.
///
/// - Parameters:
Expand All @@ -83,33 +59,20 @@ public extension Node {
}

public extension Node {
/// Adds a new child element with the specified qualified name to this element at the given position.
/// Adds a new child element with the specified name to this element at the given position.
///
/// - Parameters:
/// - name: The qualified name of the new element.
/// - name: The name of the new element; either a `String` or an `ExpandedName`.
/// - position: The position where the new child element should be inserted. Defaults to `.last`, adding the element as the last child of this element.
/// - Returns: The newly created child element.
@discardableResult
func addElement(_ name: String, at position: Position = .last) -> Node {
func addElement(_ name: any ElementName, at position: Position = .last) -> Node {
precondition(canContainChildren(ofKind: .element), "This kind of node can't contain elements")
let element = document.node(for: node.addChild(kind: pugi.node_element, at: position))
element.name = name
element.name = name.requestQualifiedName(for: element)
return element
}

/// Adds a new child element with the specified expanded name to this element at the given position.
///
/// - Parameters:
/// - name: The expanded name of the new element, including the local name and an optional namespace.
/// - position: The position where the new child element should be inserted. Defaults to `.last`, adding the element as the last child of this element.
/// - Returns: The newly created child element.
@discardableResult
func addElement(_ name: ExpandedName, at position: Position = .last) -> Node {
let child = addElement("", at: position)
child.expandedName = name
return child
}

/// Adds a new child element with the specified local name and optional namespace URI to this element at the given position.
///
/// - Parameters:
Expand Down
59 changes: 59 additions & 0 deletions Sources/Nodal/Node/Node+Names.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import pugixml

public protocol ElementName: Sendable {
func requestQualifiedName(for node: Node) -> String
func matches(node: pugi.xml_node, in document: Document) -> Bool
}

extension String: ElementName {
public func requestQualifiedName(for node: Node) -> String {
self
}
public func matches(node: pugi.xml_node, in document: Document) -> Bool {
String(cString: node.name()) == self
}
}

extension ExpandedName: ElementName {
public func requestQualifiedName(for node: Node) -> String {
requestQualifiedElementName(for: node)
}

public func matches(node: pugi.xml_node, in document: Document) -> Bool {
String(cString: node.name()).hasSuffix(localName) && document.expandedName(for: node) == self
}
}


public protocol AttributeName: Sendable {
func requestQualifiedName(in node: Node) -> String
func qualifiedName(in: Node) -> String?
}

extension String: AttributeName {
public func requestQualifiedName(in node: Node) -> String {
self
}

public func qualifiedName(in: Node) -> String? {
self
}
}

extension ExpandedName: AttributeName {
public func requestQualifiedName(in node: Node) -> String {
requestQualifiedAttributeName(for: node)
}

public func qualifiedName(in node: Node) -> String? {
if let match = qualifiedAttributeName(in: node) {
return match
} else if let placeholder = node.pendingNameRecord?.attributes[self] {
// Namespace not in scope; try pending placeholder
return placeholder
} else {
return nil
}
}
}
Loading

0 comments on commit a93f371

Please sign in to comment.