From 5360bf83f5e4027a834b0388b16d745749a7dac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Franze=CC=81n?= Date: Tue, 11 Feb 2025 21:50:47 +0100 Subject: [PATCH] Add codable macros --- Package.resolved | 15 ++ Package.swift | 15 +- Sources/Nodal/Macros.swift | 278 +++++++++++++++++++++++ Sources/NodalMacros/NodalMacros.swift | 263 +++++++++++++++++++++ Sources/Tests/CodableProtocolTests.swift | 13 +- Sources/Tests/MacroTests.swift | 27 +++ 6 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/Nodal/Macros.swift create mode 100644 Sources/NodalMacros/NodalMacros.swift create mode 100644 Sources/Tests/MacroTests.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..3900ade --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "d9d82074354a27184266acc79b18fde440069c2523ee2d38ccf322b68c2e6cd8", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 075f6f7..db6c5d1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,17 @@ // swift-tools-version: 6.0 import PackageDescription +import CompilerPluginSupport let package = Package( name: "Nodal", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], products: [ .library(name: "Nodal", targets: ["Nodal"]), ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest") + ], targets: [ .target( name: "pugixml", @@ -22,7 +27,15 @@ let package = Package( ), .target( name: "Nodal", - dependencies: ["pugixml", "Bridge"], + dependencies: ["pugixml", "Bridge", "NodalMacros"], + swiftSettings: [.interoperabilityMode(.Cxx)] + ), + .macro( + name: "NodalMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], swiftSettings: [.interoperabilityMode(.Cxx)] ), .testTarget( diff --git a/Sources/Nodal/Macros.swift b/Sources/Nodal/Macros.swift new file mode 100644 index 0000000..9475ed0 --- /dev/null +++ b/Sources/Nodal/Macros.swift @@ -0,0 +1,278 @@ +import Foundation + +/// Automatically provides `XMLElementCodable` conformance to a type. +/// +/// This macro generates both an initializer (`init(from element: Node)`) and an encoding method (`encode(to element: Node)`) +/// to allow seamless conversion between Swift types and XML elements. +/// +/// ## Example Usage +/// ```swift +/// @XMLCodable +/// struct Person { +/// @Attribute var name: String +/// @Attribute var age: Int +/// @Element var address: Address +/// } +/// ``` +/// This expands to: +/// ```swift +/// struct Person: XMLElementCodable { +/// let name: String +/// let age: Int +/// let address: Address +/// +/// init(from element: Node) throws { ... } +/// func encode(to element: Node) { ... } +/// } +/// ``` +/// +/// - SeeAlso: `@XMLEncodable`, `@XMLDecodable` +@attached(member, names: named(init), named(encode)) +@attached(extension, conformances: XMLElementCodable) +public macro XMLCodable() = #externalMacro(module: "NodalMacros", type: "XMLCodableMacro") + +/// Automatically provides `XMLElementEncodable` conformance to a type. +/// +/// This macro generates an `encode(to element: Node)` method, allowing the type to be serialized into XML. +/// +/// ## Example Usage +/// ```swift +/// @XMLEncodable +/// struct Animal { +/// @Attribute var species: String +/// @Element var habitat: Habitat +/// } +/// ``` +/// This expands to: +/// ```swift +/// struct Animal: XMLElementEncodable { +/// let species: String +/// let habitat: Habitat +/// +/// func encode(to element: Node) { ... } +/// } +/// ``` +/// +/// - SeeAlso: `@XMLDecodable`, `@XMLCodable` +@attached(member, names: named(init), named(encode)) +@attached(extension, conformances: XMLElementEncodable) +public macro XMLEncodable() = #externalMacro(module: "NodalMacros", type: "XMLEncodableMacro") + +/// Automatically provides `XMLElementDecodable` conformance to a type. +/// +/// This macro generates an `init(from element: Node)` initializer, allowing the type to be constructed from XML. +/// +/// ## Example Usage +/// ```swift +/// @XMLDecodable +/// struct Animal { +/// @Attribute var species: String +/// @Element var habitat: Habitat +/// } +/// ``` +/// This expands to: +/// ```swift +/// struct Animal: XMLElementDecodable { +/// let species: String +/// let habitat: Habitat +/// +/// init(from element: Node) throws { ... } +/// } +/// ``` +/// +/// - SeeAlso: `@XMLEncodable`, `@XMLCodable` +@attached(member, names: named(init), named(encode)) +@attached(extension, conformances: XMLElementDecodable) +public macro XMLDecodable() = #externalMacro(module: "NodalMacros", type: "XMLDecodableMacro") + + +/// Marks a property as an XML attribute. +/// +/// Any property not marked as `@Element` or `@TextContent` is *automatically assumed* to be an attribute. +/// However, this macro can be used to explicitly define an attribute, rename it, or specify a namespace. +/// +/// ## Example Usage +/// +/// **Basic Attribute** +/// ```swift +/// @Attribute var animalName: String +/// ``` +/// Expands to: +/// ```xml +/// +/// ``` +/// +/// **Custom Attribute Name** +/// ```swift +/// @Attribute("animal") var animalName: String +/// ``` +/// Expands to: +/// ```xml +/// +/// ``` +/// +/// **Namespaced Attribute** +/// ```swift +/// @Attribute("animal", namespace: "http://example.com") var animalName: String +/// ``` +/// Expands to (assuming the namespace is bound to the "ex" prefix): +/// ```xml +/// +/// ``` +/// +/// - SeeAlso: `@Element`, `@TextContent` +@attached(peer) +public macro Attribute() = #externalMacro(module: "NodalMacros", type: "MarkerMacro") + +@attached(peer) +public macro Attribute( + _ name: any AttributeName +) = #externalMacro(module: "NodalMacros", type: "MarkerMacro") + +@attached(peer) +public macro Attribute( + _ localName: String, + namespace: String? +) = #externalMacro(module: "NodalMacros", type: "MarkerMacro") + + +/// Marks a property as an XML element. +/// +/// Properties marked with `@Element` are serialized as *child elements* of the XML node. +/// These properties are of a type that conforms to `XMLElementCodable`. +/// +/// ## Example Usage +/// +/// **Single Nested Element** +/// ```swift +/// struct Habitat: XMLElementCodable { +/// @Attribute var climate: String +/// } +/// +/// struct Animal: XMLElementCodable { +/// @Element var habitat: Habitat +/// } +/// ``` +/// Expands to: +/// ```xml +/// +/// +/// +/// ``` +/// +/// **Custom Element Name** +/// ```swift +/// struct Animal: XMLElementCodable { +/// @Element("home") var habitat: Habitat +/// } +/// ``` +/// Expands to: +/// ```xml +/// +/// +/// +/// ``` +/// +/// **Namespaced Element** +/// ```swift +/// struct Animal: XMLElementCodable { +/// @Element("habitat", namespace: "http://example.com") var habitat: Habitat +/// } +/// ``` +/// Expands to (assuming the namespace is bound to the "ex" prefix): +/// ```xml +/// +/// +/// +/// ``` +/// +/// **Array of Nested Elements (Flat Structure)** +/// ```swift +/// struct Zoo: XMLElementCodable { +/// @Element var animals: [Animal] +/// } +/// ``` +/// Expands to: +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// **Using `containedIn` for Arrays (Wrapper Element)** +/// ```swift +/// struct Zoo: XMLElementCodable { +/// @Element("animal", containedIn: "animals") var animals: [Animal] +/// } +/// ``` +/// Expands to: +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// **Important Notes:** +/// - **Element properties should conform to `XMLElementCodable`.** +/// - **Use `containedIn` for arrays if you need a wrapper element.** +/// +/// - SeeAlso: `@Attribute`, `@TextContent` +@attached(peer) +public macro Element( + _ name: (any ElementName)? = nil, + namespace: String? = nil, + containedIn: (any ElementName)? = nil +) = #externalMacro(module: "NodalMacros", type: "MarkerMacro") + +/// Marks a property as the *text content* of an XML element. +/// +/// A property marked with `@TextContent` is serialized as **the text inside the element**, rather than an attribute or child element. +/// A struct *can only have one* `@TextContent` property, and can not co-exist with `@Element` properties. +/// +/// ## Example Usage +/// +/// **Basic Text Content** +/// ```swift +/// @TextContent var content: String +/// ``` +/// Expands to: +/// ```xml +/// Hello, World! +/// ``` +/// +/// **With Attributes** +/// ```swift +/// struct Message { +/// @Attribute var sender: String +/// @TextContent var content: String +/// } +/// ``` +/// Expands to: +/// ```xml +/// Hello, Bob! +/// ``` +/// +/// **Invalid Usage: Multiple `@TextContent`** +/// ```swift +/// struct Invalid { +/// @TextContent var text1: String +/// @TextContent var text2: String // ❌ Error: Only one `@TextContent` is allowed. +/// } +/// ``` +/// +/// - SeeAlso: `@Element`, `@Attribute` +/// +@attached(peer) +public macro TextContent() = #externalMacro(module: "NodalMacros", type: "MarkerMacro") diff --git a/Sources/NodalMacros/NodalMacros.swift b/Sources/NodalMacros/NodalMacros.swift new file mode 100644 index 0000000..83ab694 --- /dev/null +++ b/Sources/NodalMacros/NodalMacros.swift @@ -0,0 +1,263 @@ +import Foundation + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics + +@main +struct NodalMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + XMLEncodableMacro.self, + XMLDecodableMacro.self, + XMLCodableMacro.self, + MarkerMacro.self, + ] +} + +/// Macro for automatically generating `XMLElementEncodable` conformance. +public struct XMLEncodableMacro: MemberMacro, ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + var encodeStatements: [String] = [] + + var hasAddedElements = false + var hasAddedTextContent = false + + for property in declaration.variableDeclarations { + guard let binding = property.bindings.first, + let propertyName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { continue } + + if let macro = property.attribute(named: "Element") { + var elementName = macro.firstMacroArgument ?? "\"" + propertyName + "\"" + let containedIn = macro.macroArgument(named: "containedIn") + let containedInPart = containedIn.map { ", containedIn: \($0)" } ?? "" + + if let namespace = macro.macroArgument(named: "namespace") { + elementName = "ExpandedName(namespaceName: \(namespace), localName: \(elementName))" + } + + guard !hasAddedTextContent else { + throw MacroError.conflictingChildProperties + } + + encodeStatements.append("element.encode(\(propertyName), elementName: \(elementName)\(containedInPart))") + hasAddedElements = true + + } else if property.attribute(named: "TextContent") != nil { + guard !hasAddedElements else { + throw MacroError.conflictingChildProperties + } + guard !hasAddedTextContent else { + throw MacroError.multipleTextContentProperties + } + + encodeStatements.append("element.setContent(\(propertyName))") + hasAddedTextContent = true + + } else { + var attributeName = "\"" + propertyName + "\"" + if let macro = property.attribute(named: "Attribute") { + if let firstArg = macro.firstMacroArgument { + attributeName = firstArg + } + + if let namespace = macro.macroArgument(named: "namespace") { + attributeName = "ExpandedName(namespaceName: \(namespace), localName: \(attributeName))" + } + } + + encodeStatements.append("element.setValue(\(propertyName), forAttribute: \(attributeName))") + } + } + + return [ + DeclSyntax(""" + func encode(to element: Node) { + \(raw: encodeStatements.joined(separator: "\n ")) + } + """) + ] + } + + /// Adds an extension to conform to `XMLElementCodable`. + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + return try [ + ExtensionDeclSyntax("extension \(type.trimmed): XMLElementEncodable {}") + ] + } +} + + +/// Macro for automatically generating `XMLElementDecodable` conformance. +public struct XMLDecodableMacro: MemberMacro, ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + var initStatements: [String] = [] + + var hasAddedElements = false + var hasAddedTextContent = false + + for property in declaration.variableDeclarations { + guard let binding = property.bindings.first, + let propertyName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { continue } + + if let macro = property.attribute(named: "Element") { + var elementName = macro.firstMacroArgument ?? "\"" + propertyName + "\"" + let containedIn = macro.macroArgument(named: "containedIn") + let containedInPart = containedIn.map { ", containedIn: \($0)" } ?? "" + + if let namespace = macro.macroArgument(named: "namespace") { + elementName = "ExpandedName(namespaceName: \(namespace), localName: \(elementName))" + } + + guard !hasAddedTextContent else { + throw MacroError.conflictingChildProperties + } + + initStatements.append("\(propertyName) = try element.decode(elementName: \(elementName)\(containedInPart))") + hasAddedElements = true + + } else if property.attribute(named: "TextContent") != nil { + guard !hasAddedElements else { + throw MacroError.conflictingChildProperties + } + guard !hasAddedTextContent else { + throw MacroError.multipleTextContentProperties + } + + initStatements.append("\(propertyName) = try element.content()") + hasAddedTextContent = true + + } else { + var attributeName = "\"" + propertyName + "\"" + if let macro = property.attribute(named: "Attribute") { + if let firstArg = macro.firstMacroArgument { + attributeName = firstArg + } + + if let namespace = macro.macroArgument(named: "namespace") { + attributeName = "ExpandedName(namespaceName: \(namespace), localName: \(attributeName))" + } + } + + initStatements.append("\(propertyName) = try element.value(forAttribute: \(attributeName))") + } + } + + return [ + DeclSyntax(""" + init(from element: Node) throws { + \(raw: initStatements.joined(separator: "\n ")) + } + """), + ] + } + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + return try [ + ExtensionDeclSyntax("extension \(type.trimmed): XMLElementDecodable {}") + ] + } +} + + +/// Macro combining XMLEncodableMacro + XMLDecodableMacro +public struct XMLCodableMacro: MemberMacro, ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + try XMLEncodableMacro.expansion(of: node, providingMembersOf: declaration, in: context) + + XMLDecodableMacro.expansion(of: node, providingMembersOf: declaration, in: context) + } + + /// Adds an extension to conform to `XMLElementCodable`. + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + try XMLEncodableMacro.expansion(of: node, attachedTo: declaration, providingExtensionsOf: type, conformingTo: protocols, in: context) + + XMLDecodableMacro.expansion(of: node, attachedTo: declaration, providingExtensionsOf: type, conformingTo: protocols, in: context) + } +} + +extension DeclGroupSyntax { + var variableDeclarations: [VariableDeclSyntax] { + memberBlock.members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + } +} + +extension AttributeSyntax { + var firstMacroArgument: String? { + arguments?.as(LabeledExprListSyntax.self)?.first?.expression.description + } + + func macroArgument(named label: String) -> String? { + arguments?.as(LabeledExprListSyntax.self)?.first { + $0.label?.text == label + }?.expression.description + } +} + +extension VariableDeclSyntax { + var attributeNames: [String] { + attributes.compactMap { $0.as(AttributeSyntax.self) }.map(\.attributeName.description) + } + + func attribute(named label: String) -> AttributeSyntax? { + attributes.compactMap { $0.as(AttributeSyntax.self) }.first { + $0.attributeName.description.trimmingCharacters(in: .whitespaces) == label + } + } +} + +public struct MarkerMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + return [] + } +} + +public enum MacroError: Error, DiagnosticMessage { + case conflictingChildProperties + case multipleTextContentProperties + + public var message: String { + switch self { + case .conflictingChildProperties: + "There can be either one or more @Element properties OR a single @TextContent property, not both" + case .multipleTextContentProperties: + "There can only be a single @TextContent property" + } + } + + public var severity: SwiftDiagnostics.DiagnosticSeverity { .error } + public var diagnosticID: SwiftDiagnostics.MessageID { .init(domain: "NodalMacros", id: "\(Self.self)") } +} diff --git a/Sources/Tests/CodableProtocolTests.swift b/Sources/Tests/CodableProtocolTests.swift index d518e07..6650c0d 100644 --- a/Sources/Tests/CodableProtocolTests.swift +++ b/Sources/Tests/CodableProtocolTests.swift @@ -31,9 +31,9 @@ struct CodableProtocolTests { struct Vehicle: XMLElementCodable, Equatable { var make: String var model: String - var year: Int + var year: Int? - init(make: String, model: String, year: Int) { + init(make: String, model: String, year: Int? = nil) { self.make = make self.model = model self.year = year @@ -42,13 +42,13 @@ struct CodableProtocolTests { init(from element: Node) throws { make = try element.value(forAttribute: "make") model = try element.value(forAttribute: "model") - year = try element.value(forAttribute: "year") + year = try element.value(forAttribute: ExpandedName(namespaceName: "foo", localName: "year")) } func encode(to element: Node) { element.setValue(make, forAttribute: "make") element.setValue(model, forAttribute: "model") - element.setValue(year, forAttribute: "year") + element.setValue(year, forAttribute: ExpandedName(namespaceName: "foo", localName: "year")) } } @@ -69,14 +69,14 @@ struct CodableProtocolTests { name = try element.value(forAttribute: "name") age = try element.value(forAttribute: "age") vehicles = try element.decode(elementName: "vehicle", containedIn: "vehicles") - primaryVehicle = try element.decode(elementName: "primaryvehicle") + primaryVehicle = try element.decode(elementName: ExpandedName(namespaceName: "foo", localName: "primary")) } func encode(to element: Node) { element.setValue(name, forAttribute: "name") element.setValue(age, forAttribute: "age") element.encode(vehicles, elementName: "vehicle", containedIn: "vehicles") - element.encode(primaryVehicle, elementName: "primaryvehicle") + element.encode(primaryVehicle, elementName: ExpandedName(namespaceName: "foo", localName: "primary")) } } @@ -119,6 +119,7 @@ struct CodableProtocolTests { let doc = Document() let root = doc.makeDocumentElement(name: "directory") + root.declareNamespace("foo", forPrefix: "f") directory.encode(to: root) print(try doc.xmlString()) diff --git a/Sources/Tests/MacroTests.swift b/Sources/Tests/MacroTests.swift new file mode 100644 index 0000000..a8785f9 --- /dev/null +++ b/Sources/Tests/MacroTests.swift @@ -0,0 +1,27 @@ +@testable import Nodal +import Testing + +struct MacroTests { + // These types exist to make sure the macro code compiles + + @XMLCodable + struct Vehicle { + let make: String + @Attribute let model: String + @Attribute(ExpandedName(namespaceName: "foo", localName: "year")) let year: Int? + @TextContent let content: String + } + + @XMLCodable + struct Person { + let name: String + @Element("vehicle", containedIn: "vehicles") let vehicles: [Vehicle] + let age: Int + @Element("primaryvehicle", namespace: "sdf") let primaryVehicle: Vehicle? + } + + @XMLCodable + struct Root { + @Element let people: [Person] + } +}