Repository: gohanlon/swift-memberwise-init-macro (swift)

Issue:
Add `@Init(assignee:type:)` to (awkwardly) support property wrappers
MemberwiseInit can be made to support property wrappers like SwiftUI's `@Binding` with the addition of two new arguments:

```swift
import SwiftUI

@MemberwiseInit
struct CounterView: View {
  @Init(assignee: "self._count", type: Binding<Int>)
  @Binding var count: Int
}
```

With that, MemberwiseInit can provide the expected initializer for a `@Binding`-wrapped property:

```swift
internal init(
  property: Binding<Int>
) {
  self._count = count
}
```

This expressiveness may also support the use of other framework-vended property wrappers, custom property wrappers and macros.

Pull request:
Resolves #11.

Patch:
diff --git a/README.md b/README.md
index 79fbf5f..0445a28 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Informed by explicit developer cues, MemberwiseInit can more often automatically
   * [Infer type from property initialization expressions](#infer-type-from-property-initialization-expressions)
   * [Explicitly ignore properties](#explicitly-ignore-properties)
   * [Attributed properties are ignored by default, but includable](#attributed-properties-are-ignored-by-default-but-includable)
+  * [Support for property wrappers](#support-for-property-wrappers)
   * [Automatic `@escaping` for closure types (usually)](#automatic-escaping-for-closure-types-usually)
   * [Experimental: Deunderscore parameter names](#experimental-deunderscore-parameter-names)
   * [Experimental: Defaulting optionals to nil](#experimental-defaulting-optionals-to-nil)
@@ -99,7 +100,7 @@ To use MemberwiseInit:
 
 ## Quick reference
 
-MemberwiseInit includes two autocomplete-friendly macros:
+MemberwiseInit includes two macros:
 
 ### `@MemberwiseInit`
 
@@ -145,6 +146,20 @@ Attach to member property declarations of a struct, actor, or class that `@Membe
 * `@Init(.public, label: String)`
   <br> Custom labels can be combined with all other behaviors.
 
+* `@Init(assignee: String)`
+  <br> Override the target property to which the initializer argument should be assigned. By default, a property named “property” has the assignee `self.property`, as demonstrated in `self.property = property`.
+
+* `@Init(type: Any.Type)`
+  <br> Override the type of the argument in the initializer.
+
+* `@Init(assignee: String, type: Any.Type)`
+  <br> Combine `assignee` and `type` to support usage with property wrappers:
+
+  ```swift
+  @Init(assignee: "self._isOn", type: Binding<Bool>)
+  @Binding var isOn = true
+  ```
+
 ## Features and limitations
 
 ### Custom `init` parameter labels
@@ -381,6 +396,22 @@ From here, you have two alternatives:
    }
    ```
 
+### Support for property wrappers
+
+Combine `@Init(assignee:)` and `@Init(type)` to support property wrappers. For example, here’s a simple usage with SwiftUI’s `@Binding`:
+
+```swift
+import SwiftUI
+
+@MemberwiseInit
+struct CounterView: View {
+  @Init(assignee: “self._count”, type: Binding<Int>)
+  @Binding var count = 0
+
+  var body: some View { … }
+}
+```
+
 ### Automatic `@escaping` for closure types (usually)
 
 MemberwiseInit automatically marks closures in initializer parameters as `@escaping`. If using a typealias for a closure, explicitly annotate the property with `@Init(.escaping)`.
diff --git a/Sources/MemberwiseInit/MemberwiseInit.swift b/Sources/MemberwiseInit/MemberwiseInit.swift
index e346f6c..4ef082e 100644
--- a/Sources/MemberwiseInit/MemberwiseInit.swift
+++ b/Sources/MemberwiseInit/MemberwiseInit.swift
@@ -11,8 +11,8 @@ public enum AccessLevelConfig {
 
 @attached(member, names: named(init))
 public macro MemberwiseInit(
-  _ accessLevel: AccessLevelConfig = .internal,
-  _deunderscoreParameters: Bool = false,
+  _ accessLevel: AccessLevelConfig? = nil,
+  _deunderscoreParameters: Bool? = nil,
   _optionalsDefaultNil: Bool? = nil
 ) =
   #externalMacro(
@@ -30,16 +30,13 @@ public enum IgnoreConfig {
   case ignore
 }
 
-@attached(peer)
-public macro Init() =
-  #externalMacro(
-    module: "MemberwiseInitMacros",
-    type: "InitMacro"
-  )
-
 @attached(peer)
 public macro Init(
-  _ ignore: IgnoreConfig
+  _ accessLevel: AccessLevelConfig? = nil,
+  assignee: String? = nil,
+  escaping: Bool? = nil,
+  label: String? = nil,
+  type: Any.Type? = nil
 ) =
   #externalMacro(
     module: "MemberwiseInitMacros",
@@ -48,27 +45,20 @@ public macro Init(
 
 @attached(peer)
 public macro Init(
-  label: String
+  _ ignore: IgnoreConfig
 ) =
   #externalMacro(
     module: "MemberwiseInitMacros",
     type: "InitMacro"
   )
 
-@attached(peer)
-public macro Init(
-  _ accessLevel: AccessLevelConfig,
-  _ escaping: EscapingConfig,
-  label: String? = nil
-) =
-  #externalMacro(
-    module: "MemberwiseInitMacros",
-    type: "InitMacro"
-  )
+// MARK: - Deprecated
 
+// Deprecated; remove in 1.0
 @attached(peer)
 public macro Init(
   _ accessLevel: AccessLevelConfig,
+  _ escaping: EscapingConfig,
   label: String? = nil
 ) =
   #externalMacro(
@@ -76,6 +66,7 @@ public macro Init(
     type: "InitMacro"
   )
 
+// Deprecated; remove in 1.0
 @attached(peer)
 public macro Init(
   _ escaping: EscapingConfig,
diff --git a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
index a3b6ae7..8cb5336 100644
--- a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
+++ b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
@@ -152,16 +152,17 @@ public struct MemberwiseInitMacro: MemberMacro {
             }
 
             let type =
-              binding.typeAnnotation?.type
+              customSettings?.type
+              ?? binding.typeAnnotation?.type
               ?? binding.initializer?.value.inferredTypeSyntax
               ?? acc.typeFromTrailingBinding
 
             acc.bindings.append(
               PropertyBinding(
                 accessLevel: variable.accessLevel,
-                adoptedType: type,
                 binding: binding,
                 customSettings: customSettings,
+                effectiveType: type,
                 keywordToken: variable.bindingSpecifier.tokenKind
               )
             )
@@ -181,9 +182,7 @@ public struct MemberwiseInitMacro: MemberMacro {
         if propertyBinding.isComputedProperty || propertyBinding.isPreinitializedLet {
           return (properties, diagnostics)
         }
-        if propertyBinding.isPreinitializedVarWithoutType,
-          propertyBinding.initializer?.inferredTypeSyntax == nil
-        {
+        if propertyBinding.isPreinitializedVarWithoutType {
           return (
             properties,
             diagnostics + [propertyBinding.diagnostic(message: .missingTypeForVarProperty)]
@@ -280,24 +279,36 @@ public struct MemberwiseInitMacro: MemberMacro {
 
     let configuredIgnore = configuredValues.contains("ignore")
     let configuredForceEscaping = configuredValues.contains("escaping")
+
     let configuredAccessLevel =
       configuredValues
       .compactMap(AccessLevelModifier.init(rawValue:))
       .first
+
     let configuredLabel =
       memberConfiguration
       .first(where: { $0.label?.text == "label" })?
       .expression
-      .as(StringLiteralExprSyntax.self)?
-      .segments
-      .trimmedDescription
-      .trimmingCharacters(in: .whitespacesAndNewlines)
+      .trimmedStringLiteral
+
+    let configuredType =
+      memberConfiguration
+      .first(where: { $0.label?.text == "type" })?
+      .expression
+
+    let configuredAssignee =
+      memberConfiguration
+      .first(where: { $0.label?.text == "assignee" })?
+      .expression
+      .trimmedStringLiteral
 
     return PropertyCustomSettings(
       accessLevel: configuredAccessLevel,
+      assignee: configuredAssignee,
       forceEscaping: configuredForceEscaping,
       ignore: configuredIgnore,
       label: configuredLabel,
+      type: configuredType.map { TypeSyntax(stringLiteral: $0.trimmedDescription) },
       _syntaxNode: memberConfiguration
     )
   }
@@ -340,15 +351,22 @@ public struct MemberwiseInitMacro: MemberMacro {
     considering allProperties: [MemberProperty],
     deunderscoreParameters: Bool
   ) -> String {
-    "self.\(property.name) = \(property.initParameterName(considering: allProperties, deunderscoreParameters: deunderscoreParameters))"
+    let assignee = property.customSettings?.assignee ?? "self.\(property.name)"
+    let parameterName = property.initParameterName(
+      considering: allProperties,
+      deunderscoreParameters: deunderscoreParameters
+    )
+    return "\(assignee) = \(parameterName)"
   }
 }
 
 private struct PropertyCustomSettings: Equatable {
   let accessLevel: AccessLevelModifier?
+  let assignee: String?
   let forceEscaping: Bool
   let ignore: Bool
   let label: String?
+  let type: TypeSyntax?
   let _syntaxNode: LabeledExprListSyntax
 
   func diagnostic(message: MemberwiseInitMacroDiagnostic) -> Diagnostic {
@@ -380,14 +398,14 @@ private struct PropertyBinding {
   // Or, store `binding` and add a bunch of computed properties.
   init(
     accessLevel: AccessLevelModifier,
-    adoptedType: TypeSyntax?,
     binding: PatternBindingSyntax,
     customSettings: PropertyCustomSettings?,
+    effectiveType: TypeSyntax?,
     keywordToken: TokenKind
   ) {
     self.accessLevel = accessLevel
     self.customSettings = customSettings
-    self.effectiveType = binding.typeAnnotation?.type ?? adoptedType
+    self.effectiveType = effectiveType
     self.initializer = binding.initializer?.trimmed.value
     self.isComputedProperty = binding.isComputedProperty
     self.isTuplePattern = binding.pattern.isTuplePattern
@@ -398,8 +416,9 @@ private struct PropertyBinding {
 
   var isPreinitializedVarWithoutType: Bool {
     self.initializer != nil
-      && self.effectiveType == nil
       && self.keywordToken == .keyword(.var)
+      && self.effectiveType == nil
+      && self.initializer?.inferredTypeSyntax == nil
   }
 
   var isPreinitializedLet: Bool {
diff --git a/Sources/MemberwiseInitMacros/Macros/Support/SyntaxHelpers.swift b/Sources/MemberwiseInitMacros/Macros/Support/SyntaxHelpers.swift
index 23c6bcc..4f07162 100644
--- a/Sources/MemberwiseInitMacros/Macros/Support/SyntaxHelpers.swift
+++ b/Sources/MemberwiseInitMacros/Macros/Support/SyntaxHelpers.swift
@@ -59,3 +59,12 @@ extension PatternSyntax {
     self.as(TuplePatternSyntax.self) != nil
   }
 }
+
+extension ExprSyntax {
+  var trimmedStringLiteral: String? {
+    self.as(StringLiteralExprSyntax.self)?
+      .segments
+      .trimmedDescription
+      .trimmingCharacters(in: .whitespacesAndNewlines)
+  }
+}


Test patch:
diff --git a/Tests/MemberwiseInitTests/MemberwiseInitTests.swift b/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
index 7e9d000..763bd9a 100644
--- a/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
+++ b/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
@@ -20,6 +20,101 @@ final class MemberwiseInitTests: XCTestCase {
     }
   }
 
+  // MARK: - Test custom type
+
+  func testCustomType() {
+    assertMacro {
+      """
+      @MemberwiseInit
+      struct S {
+        @Init(type: Q) var type: T
+      }
+      """
+    } expansion: {
+      """
+      struct S {
+        var type: T
+
+        internal init(
+          type: Q
+        ) {
+          self.type = type
+        }
+      }
+      """
+    }
+  }
+
+  func testCustomType_GenericExpression() {
+    assertMacro {
+      """
+      @MemberwiseInit
+      struct S {
+        @Init(type: Q<R>) var type: T
+      }
+      """
+    } expansion: {
+      """
+      struct S {
+        var type: T
+
+        internal init(
+          type: Q<R>
+        ) {
+          self.type = type
+        }
+      }
+      """
+    }
+  }
+
+  // TODO: Add fix-it diagnostic when provided type is a Metatype
+  //  func testCustomType_MetatypeFailsWithDiagnostic() {
+  //    assertMacro(record: true) {
+  //      """
+  //      @MemberwiseInit
+  //      struct S {
+  //        @Init(type: Q.self) var type: T
+  //      }
+  //      """
+  //    } diagnostics: {
+  //      """
+  //      @MemberwiseInit
+  //      struct S {
+  //        @Init(type: Q.self) var type: T
+  //            ┬─────────────────
+  //            ╰─ 🛑 Invalid use of metatype 'Q.self'. Expected a type, not its metatype.
+  //            ╰─ 🛑 Remove '.self'; type is expected, not a metatype.
+  //      }
+  //      """
+  //    }
+  //  }
+
+  // MARK: - Test custom assign
+
+  func testCustomAssign() {
+    assertMacro {
+      """
+      @MemberwiseInit
+      struct S {
+        @Init(assignee: "self._type") var type: T
+      }
+      """
+    } expansion: {
+      """
+      struct S {
+        var type: T
+
+        internal init(
+          type: T
+        ) {
+          self._type = type
+        }
+      }
+      """
+    }
+  }
+
   // MARK: - Test simple usage
 
   // NB: Redundant to AccessLevelTests but handy to have here, too.