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

Issue:
Optional `var` properties should default to `nil` for non-public initializers
### Description

MemberwiseInit's behavior doesn't match Swift's memberwise init:

```swift
struct S {
  var v: String?
}
_ = S()

@MemberwiseInit
struct M {
  var v: String?
}
_ = M()  // 🛑 Compiler error: Missing argument for parameter 'v' in call
```

MemberwiseInit provides this initializer:

```swift
internal init(
  v: String
) {
  self.v = v
}
```

but, to match Swift's memberwise init, MemberwiseInit should provide:

```swift
internal init(
  v: String? = nil
) {
  self.v = v
}
```

(Thanks, @AndreiChenchik!)

### Checklist

- [X] If possible, I've reproduced the issue using the `main` branch of this package.
- [X] This issue hasn't been addressed in an [existing GitHub issue](https://github.com/gohanlon/swift-memberwise-init-macro/issues) or [discussion](https://github.com/gohanlon/swift-memberwise-init-macro/discussions).

### swift-memberwise-init-macro version information

0.1.0

Pull request description:
Fixes #2.

Patch:
diff --git a/README.md b/README.md
index fc51870..f9cf112 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ A Swift Macro for enhanced automatic memberwise initializers, greatly reducing m
 
 Informed by explicit developer cues, MemberwiseInit can more often automatically provide your intended memberwise `init`, while following the same safe-by-default semantics underlying [Swift’s memberwise initializers][swifts-memberwise-init].
 
-> :warning: **Important**<br>
+> [!IMPORTANT]
 > `@MemberwiseInit` is a Swift Macro requiring **swift-tools-version: 5.9** or later (**Xcode 15** onwards).
 
 * [Quick start](#quick-start)
@@ -114,7 +114,7 @@ Attach to struct, actor *(experimental)*, or class *(experimental)*.
   <br> Drop underscore prefix from generated `init` parameter names, unless doing so would result in a naming conflict. (Ignored properties won’t contribute to conflicts.)
 
 * `@MemberwiseInit(_optionalsDefaultNil: true)` *(experimental)*
-  <br> Give all optional fields a default `init` parameter value of `nil`.
+  <br> When set to `true`, give all optional properties a default `init` parameter value of `nil`. For non-public initializers, optional `var` properties default to `nil` unless this parameter is explicitly set to `false`.
 
 ### `@Init`
 
@@ -489,14 +489,25 @@ public init(
 
 ### Experimental: Defaulting optionals to nil
 
-`@MemberwiseInit(_optionalsDefaultNil: true)` automatically defaults all optional fields to `nil` in its initializer, trading off compile-time guidance.
+Use `@MemberwiseInit(_optionalsDefaultNil: Bool)` to explicitly control whether optional properties are defaulted to `nil` in the provided initializer:
+
+* Set `_optionalsDefaultNil: true` to default all optional properties to `nil`, trading off compile-time guidance.
+* Set `_optionalsDefaultNil: false` to ensure that MemberwiseInit never defaults optional properties to `nil`.
+
+The default behavior of MemberwiseInit regarding optional properties aligns with Swift’s memberwise initializer:
+
+* For non-public initializers, `var` optional properties automatically default to `nil`.
+* For public initializers, MemberwiseInit follows Swift’s cautious approach to public APIs by requiring all parameters explicitly, including optionals, unless `_optionalsDefaultNil` is set to `true`.
+* `let` optional properties are never automatically defaulted to `nil`. Setting `_optionalsDefaultNil` to `true` is the only way to cause them to default to `nil`.
 
 > **Note**
-> `@Init(default:)` is a planned future enhancement to generally specify default values, and is expected to supersede `_optionalsDefaultNil` due to its improved safety.
+> `@Init(default:)` is a planned future enhancement to generally specify default values, and will be a safer, more explicit alternative to `_optionalsDefaultNil`.
 
 #### Explanation
 
-This feature eases instantiation for types with numerous optional fields, like `Codable` structs that mirror loosely structured HTTP APIs. However, it has a drawback: when properties change, the compiler won’t flag outdated instantiations, risking unintended `nil` assignments and potential runtime errors.
+With `_optionalsDefaultNil`, you gain control over a default behavior of Swift’s memberwise init. And, it allows you to explicitly opt-in to your public initializer defaulting optional properties to `nil`.
+
+Easing instantiation is the primary purpose of `_optionalsDefaultNil`, and is especially useful when your types mirror a loosely structured external dependency, e.g. `Codable` structs that mirror HTTP APIs. However, `_optionalsDefaultNil` has a drawback: when properties change, the compiler won’t flag outdated instantiations, risking unintended `nil` assignments and potential runtime errors.
 
 In Swift:
 
diff --git a/Sources/MemberwiseInit/MemberwiseInit.swift b/Sources/MemberwiseInit/MemberwiseInit.swift
index 5d7ac6e..18edce9 100644
--- a/Sources/MemberwiseInit/MemberwiseInit.swift
+++ b/Sources/MemberwiseInit/MemberwiseInit.swift
@@ -12,7 +12,7 @@ public enum AccessLevelConfig {
 public macro MemberwiseInit(
   _ accessLevel: AccessLevelConfig = .internal,
   _deunderscoreParameters: Bool = false,
-  _optionalsDefaultNil: Bool = false
+  _optionalsDefaultNil: Bool? = nil
 ) =
   #externalMacro(
     module: "MemberwiseInitMacros",
diff --git a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
index f903677..cd61d20 100644
--- a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
+++ b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
@@ -25,8 +25,8 @@ public struct MemberwiseInitMacro: MemberMacro {
       throw MemberwiseInitMacroDiagnostic.invalidDeclarationKind(decl)
     }
     let configuredAccessLevel: AccessLevelModifier? = extractConfiguredAccessLevel(from: node)
-    let optionalsDefaultNil: Bool =
-      extractLabeledBoolArgument("_optionalsDefaultNil", from: node) ?? false
+    let optionalsDefaultNil: Bool? =
+      extractLabeledBoolArgument("_optionalsDefaultNil", from: node)
     let deunderscoreParameters: Bool =
       extractLabeledBoolArgument("_deunderscoreParameters", from: node) ?? false
 
@@ -51,6 +51,10 @@ public struct MemberwiseInitMacro: MemberMacro {
             considering: properties,
             deunderscoreParameters: deunderscoreParameters,
             optionalsDefaultNil: optionalsDefaultNil
+              ?? defaultOptionalsDefaultNil(
+                for: property.keywordToken,
+                initAccessLevel: accessLevel
+              )
           )
         }
         .joined(separator: ",\n")
@@ -290,6 +294,19 @@ public struct MemberwiseInitMacro: MemberMacro {
     )
   }
 
+  private static func defaultOptionalsDefaultNil(
+    for bindingKeyword: TokenKind,
+    initAccessLevel: AccessLevelModifier
+  ) -> Bool {
+    guard bindingKeyword == .keyword(.var) else { return false }
+    return switch initAccessLevel {
+    case .private, .fileprivate, .internal:
+      true
+    case .public, .open:
+      false
+    }
+  }
+
   private static func formatParameter(
     for property: MemberProperty,
     considering allProperties: [MemberProperty],


Test patch:
diff --git a/Tests/MemberwiseInitTests/MemberwiseInitTests.swift b/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
index 64258d6..0b53e62 100644
--- a/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
+++ b/Tests/MemberwiseInitTests/MemberwiseInitTests.swift
@@ -84,29 +84,6 @@ final class MemberwiseInitTests: XCTestCase {
     }
   }
 
-  func testOptionlProperty() {
-    assertMacro {
-      """
-      @MemberwiseInit
-      struct Person {
-        let nickname: String?
-      }
-      """
-    } expansion: {
-      """
-      struct Person {
-        let nickname: String?
-
-        internal init(
-          nickname: String?
-        ) {
-          self.nickname = nickname
-        }
-      }
-      """
-    }
-  }
-
   // MARK: - Test assignment variations
 
   func testVarProperty() {
@@ -1825,7 +1802,128 @@ final class MemberwiseInitTests: XCTestCase {
 
   // MARK: - Test _optionalsDefaultNil (experimental)
 
-  func testOptionalsDefaultNilFalse_AssignsNoDefaultToOptional() {
+  func testOptionalLetProperty_InternalInitNoDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(.public)
+      public struct Person {
+        let nickname: String?
+      }
+      """
+    } expansion: {
+      """
+      public struct Person {
+        let nickname: String?
+
+        internal init(
+          nickname: String?
+        ) {
+          self.nickname = nickname
+        }
+      }
+      """
+    }
+  }
+
+  func testOptionalLetProperty_PublicInitNoDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(.public)
+      public struct Person {
+        public let nickname: String?
+      }
+      """
+    } expansion: {
+      """
+      public struct Person {
+        public let nickname: String?
+
+        public init(
+          nickname: String?
+        ) {
+          self.nickname = nickname
+        }
+      }
+      """
+    }
+  }
+
+  // NB: Swift's memberwise init defaults optional vars to nil, which seems reasonable considering
+  // it only provides non-public initializers. Swift will never default lets to nil, however.
+  // I assume that automatically assigning lets precludes uncommon init flows where you'd want to
+  // assign the constant some other way.
+  func testOptionalVarProperty_InternalInitWithDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(.public)
+      public struct Person {
+        var nickname: String?
+      }
+      """
+    } expansion: {
+      """
+      public struct Person {
+        var nickname: String?
+
+        internal init(
+          nickname: String? = nil
+        ) {
+          self.nickname = nickname
+        }
+      }
+      """
+    }
+  }
+
+  func testOptionalVarProperty_PublicInitNoDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(.public)
+      public struct Person {
+        public var nickname: String?
+      }
+      """
+    } expansion: {
+      """
+      public struct Person {
+        public var nickname: String?
+
+        public init(
+          nickname: String?
+        ) {
+          self.nickname = nickname
+        }
+      }
+      """
+    }
+  }
+
+  // NB: With MemberwiseInit, Swift's default behavior can be disabled.
+  func testOptionalVar_OptionalsDefaultNilFalse_InternalInitNoDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(_optionalsDefaultNil: false)
+      struct Product {
+        var discountCode: String?
+      }
+      """
+    } expansion: {
+      """
+      struct Product {
+        var discountCode: String?
+
+        internal init(
+          discountCode: String?
+        ) {
+          self.discountCode = discountCode
+        }
+      }
+      """
+    }
+  }
+
+  // NB: Confirms that `_optionalsDefaultNil: false` for optional let has no effect.
+  func testOptionalLet_OptionalsDefaultNilFalse_InternalIntiNoDefault() {
     assertMacro {
       """
       @MemberwiseInit(_optionalsDefaultNil: false)
@@ -1848,7 +1946,7 @@ final class MemberwiseInitTests: XCTestCase {
     }
   }
 
-  func testOptionalsDefaultNilTrue_ParameterValueDefaultsNil() {
+  func testOptionalLet_OptionalsDefaultNilTrue_InternalInitWithDefault() {
     assertMacro {
       """
       @MemberwiseInit(_optionalsDefaultNil: true)
@@ -1871,6 +1969,29 @@ final class MemberwiseInitTests: XCTestCase {
     }
   }
 
+  func testOptionalVar_OptionalsDefaultNilTrue_PublicInitWithDefault() {
+    assertMacro {
+      """
+      @MemberwiseInit(.public, _optionalsDefaultNil: true)
+      public struct Person {
+        public var nickname: String?
+      }
+      """
+    } expansion: {
+      """
+      public struct Person {
+        public var nickname: String?
+
+        public init(
+          nickname: String? = nil
+        ) {
+          self.nickname = nickname
+        }
+      }
+      """
+    }
+  }
+
   // MARK: - Test complex usage
 
   func testNestedStructs() {
@@ -1924,16 +2045,16 @@ final class MemberwiseInitTests: XCTestCase {
     assertMacro {
       """
       @MemberwiseInit(.public, _deunderscoreParameters: true)
-      @MemberwiseInit(.internal)
-      @MemberwiseInit(.private, _optionalsDefaultNil: true)
+      @MemberwiseInit(.internal, _optionalsDefaultNil: false)
+      @MemberwiseInit(.private)
       public struct Person {
-        @Init(.public) let _name: String?
+        @Init(.public) var _name: String?
       }
       """
     } expansion: {
       """
       public struct Person {
-        let _name: String?
+        var _name: String?
 
         public init(
           name: String?