diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..fa0a833 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 0c4aab6..0e84d7e 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "CopyableMacro", - targets: ["CopyableMacro"] + targets: ["CopyableMacroMacros"] ), .executable( name: "CopyableMacroClient", diff --git a/Sources/CopyableMacro/CopyableMacro.swift b/Sources/CopyableMacro/CopyableMacro.swift index a394735..aadd576 100644 --- a/Sources/CopyableMacro/CopyableMacro.swift +++ b/Sources/CopyableMacro/CopyableMacro.swift @@ -1,11 +1,26 @@ // The Swift Programming Language // https://docs.swift.org/swift-book -/// A macro that produces both a value and a string containing the -/// source code that generated the value. For example, +/// A macro that adds a `copy()` method into a type bearing the annotation. For example: /// -/// #stringify(x + y) +/// @Copyable +/// struct User { +/// let name: String +/// let age: Int +/// } /// -/// produces a tuple `(x + y, "x + y")`. -@freestanding(expression) -public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "CopyableMacroMacros", type: "StringifyMacro") \ No newline at end of file +/// updates the struct as follows: +/// +/// struct User { +/// let name: String +/// let age: Int +/// +/// func copy(name: String? = nil, age: Int? = nil) -> Self { +/// .init( +/// name: name ?? self.name +/// age: age ?? self.age +/// ) +/// } +/// } +@attached(member, names: named(copy)) +public macro Copyable() = #externalMacro(module: "CopyableMacroMacros", type: "CopyableMacroMacro") diff --git a/Sources/CopyableMacroClient/main.swift b/Sources/CopyableMacroClient/main.swift index 6b0b2ba..3115449 100644 --- a/Sources/CopyableMacroClient/main.swift +++ b/Sources/CopyableMacroClient/main.swift @@ -1,8 +1,30 @@ import CopyableMacro -let a = 17 -let b = 25 +@Copyable +final class Sample { + var x: Int + let y: Double -let (result, code) = #stringify(a + b) + init(x: Int, y: Double) { + self.x = x + self.y = y + } +} -print("The value \(result) was produced by the code \"\(code)\"") +let sample = Sample(x: 1, y: 2) + +@Copyable +struct StructSample { + var firstProp: Bool + let secondProp: String + //let t: String? = nil + var fourth: String { + secondProp + } + let fifth: [Int] + +} + +let structSample = StructSample(firstProp: true, secondProp: "secondProp", fifth: [0, 1]) + +print(sample) diff --git a/Sources/CopyableMacroMacros/CopyableMacroMacro.swift b/Sources/CopyableMacroMacros/CopyableMacroMacro.swift index 4859e60..677b566 100644 --- a/Sources/CopyableMacroMacros/CopyableMacroMacro.swift +++ b/Sources/CopyableMacroMacros/CopyableMacroMacro.swift @@ -3,31 +3,144 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros -/// Implementation of the `stringify` macro, which takes an expression -/// of any type and produces a tuple containing the value of that expression -/// and the source code that produced the value. For example +/// Implementation of the `Copyable` macro, adds a `copy` method to a type. /// -/// #stringify(x + y) +/// For example +/// +/// @Copyable /// /// will expand to /// -/// (x + y, "x + y") -public struct StringifyMacro: ExpressionMacro { +/// ... TODO +public struct CopyableMacroMacro: MemberMacro { + @main + struct CopyableMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + CopyableMacroMacro.self, + ] + } + public static func expansion( - of node: some FreestandingMacroExpansionSyntax, + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext - ) -> ExprSyntax { - guard let argument = node.argumentList.first?.expression else { - fatalError("compiler bug: the macro does not have any arguments") + ) throws -> [DeclSyntax] { + // TODO: Extract the properties from the type--must be a class or struct + //let storedProperties = declaration + // .as(ClassDeclSyntax.self)?.storedProperties() ?? declaration + // .as(StructDeclSyntax.self)?.storedProperties() + + // Extract the properties from the type--must be a struct + guard let storedProperties = declaration + .as(StructDeclSyntax.self)?.storedProperties(), + !storedProperties.isEmpty + else { return [] } + + let funcArguments = storedProperties + .compactMap { property -> (name: String, type: String)? in + guard + // Get the property's name (a.k.a. identifier)... + let patternBinding = property.bindings.first?.as( + PatternBindingSyntax.self + ), + + let name = patternBinding.pattern.as( + IdentifierPatternSyntax.self + )?.identifier, + + // ...and then the property's type... + let type = /*patternBinding.typeAnnotation?.as( + TypeAnnotationSyntax.self + )?.type.as( + IdentifierTypeSyntax.self + )?.name ?? + // ... including if it's an optional type + patternBinding.typeAnnotation?.as( + TypeAnnotationSyntax.self + )?.type.as( + OptionalTypeSyntax.self + )?.wrappedType.as( + IdentifierTypeSyntax.self + )?.name ??*/ + + patternBinding.typeAnnotation?.as(TypeAnnotationSyntax.self)?.trimmed.description.replacingOccurrences(of: "?", with: "") + else { return nil } + + return (name: name.text, type: type) + } + + let funcBody: ExprSyntax = """ + .init( + \(raw: funcArguments.map { "\($0.name): \($0.name) ?? self.\($0.name)" }.joined(separator: ", \n")) + ) + """ + + guard + let funcDeclSyntax = try? FunctionDeclSyntax( + SyntaxNodeString( + stringLiteral: """ + public func copy( + \(funcArguments.map { "\($0.name)\($0.type)? = nil" }.joined(separator: ", \n")) + ) -> Self + """.trimmingCharacters(in: .whitespacesAndNewlines) + ), + bodyBuilder: { + funcBody + } + ), + let finalDeclaration = DeclSyntax(funcDeclSyntax) + else { + return [] } + + return [finalDeclaration] + } + + +} - return "(\(argument), \(literal: argument.description))" +extension VariableDeclSyntax { + /// Check this variable is a stored property + var isStoredProperty: Bool { + guard let binding = bindings.first, + bindings.count == 1, + modifiers.contains(where: { + $0.name == .keyword(.public) + }) || modifiers.isEmpty + else { return false } + + switch binding.accessorBlock?.accessors { + case .none: + return true + + case .accessors(let node): + for accessor in node { + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.willSet), .keyword(.didSet): + // stored properties can have observers + break + default: + // everything else makes it a computed property + return false + } + } + return true + + case .getter: + return false + } } } -@main -struct CopyableMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - StringifyMacro.self, - ] +extension DeclGroupSyntax { + /// Get the stored properties from the declaration based on syntax. + func storedProperties() -> [VariableDeclSyntax] { + memberBlock.members.compactMap { member in + guard let variable = member.decl.as(VariableDeclSyntax.self), + variable.isStoredProperty + else { return nil } + + return variable + } + } } diff --git a/Tests/CopyableMacroTests/CopyableMacroTests.swift b/Tests/CopyableMacroTests/CopyableMacroTests.swift index e98061b..7c81d46 100644 --- a/Tests/CopyableMacroTests/CopyableMacroTests.swift +++ b/Tests/CopyableMacroTests/CopyableMacroTests.swift @@ -7,40 +7,257 @@ import XCTest import CopyableMacroMacros let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, + "Copyable": CopyableMacroMacro.self, ] #endif final class CopyableMacroTests: XCTestCase { - func testMacro() throws { - #if canImport(CopyableMacroMacros) +// func testMacro() throws { +// #if canImport(CopyableMacroMacros) +// assertMacroExpansion( +// """ +// #stringify(a + b) +// """, +// expandedSource: """ +// (a + b, "a + b") +// """, +// macros: testMacros +// ) +// #else +// throw XCTSkip("macros are only supported when running tests for the host platform") +// #endif +// } + + func testMacroForClass() { assertMacroExpansion( """ - #stringify(a + b) + @Copyable + class Sample { + var x: Int + let y: Double + + var myComputedProperty: String { + "hello world" + } + + private var _something: Bool + + var something: Bool { + get { + return _something + } + set { + _something = newValue + } + } + + func sayHi() { + + } + + func sayBye() { } + } """, - expandedSource: """ - (a + b, "a + b") + expandedSource: + """ + + class Sample { + var x: Int + let y: Double + + var myComputedProperty: String { + "hello world" + } + + private var _something: Bool + + var something: Bool { + get { + return _something + } + set { + _something = newValue + } + } + + func sayHi() { + + } + + func sayBye() { } + } """, macros: testMacros ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif } - - func testMacroWithStringLiteral() throws { - #if canImport(CopyableMacroMacros) + + func testMacroForStruct() { + assertMacroExpansion( + """ + struct User { + let name: String + let followers: [String] + } + + @Copyable + struct Sample { + var x: Int + let y: Double + var g: String? = nil + let users: [User] + + var myComputedProperty: String { + "hello world" + } + + private var _something: Bool + + var something: Bool { + get { + return _something + } + set { + _something = newValue + } + } + + func sayHi() { + + } + + func sayBye() { } + } + """, + expandedSource: + """ + struct User { + let name: String + let followers: [String] + } + struct Sample { + var x: Int + let y: Double + var g: String? = nil + let users: [User] + + var myComputedProperty: String { + "hello world" + } + + private var _something: Bool + + var something: Bool { + get { + return _something + } + set { + _something = newValue + } + } + + func sayHi() { + + } + + func sayBye() { } + + public func copy( + x: Int? = nil, + y: Double? = nil, + g: String? = nil, + users: [User]? = nil + ) -> Self { + .init( + x: x ?? self.x, + y: y ?? self.y, + g: g ?? self.g, + users: users ?? self.users + ) + } + } + """, + macros: testMacros + ) + } + + func testLazyPropsAreTreatedProperly() { + assertMacroExpansion( + """ + @Copyable + class Sample { + lazy var x: Int = 1 + let y: Double + } + """, + expandedSource: + """ + + class Sample { + lazy var x: Int = 1 + let y: Double + } + """, + macros: testMacros + ) + } + + func testLetPropertiesWithValuesAreTreatedCorrectly() { + assertMacroExpansion( + """ + @Copyable + class Sample { + let y: Double = 0 + } + """, + expandedSource: + """ + + class Sample { + let y: Double = 0 + } + """, + macros: testMacros + ) + } + + // FIXME: this should treated as a separate edge case + func testDuplicatedInitsAreNotCreated() { assertMacroExpansion( - #""" - #stringify("Hello, \(name)") - """#, - expandedSource: #""" - ("Hello, \(name)", #""Hello, \(name)""#) - """#, + """ + @Copyable + struct Sample { + public init() { + + } + } + """, + expandedSource: + """ + + struct Sample { + public init() { + + } + } + """, + macros: testMacros + ) + } + + // FIXME: this should treated as a separate edge case + func testInitsAreNotCreated() { + assertMacroExpansion( + """ + @Copyable + struct Sample { + } + """, + expandedSource: + """ + struct Sample { + } + """, macros: testMacros ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif } }