diff --git a/Sources/MemberwiseInit/MemberwiseInit.swift b/Sources/MemberwiseInit/MemberwiseInit.swift index a234a70..73ce0e5 100644 --- a/Sources/MemberwiseInit/MemberwiseInit.swift +++ b/Sources/MemberwiseInit/MemberwiseInit.swift @@ -30,6 +30,27 @@ public macro MemberwiseInit( type: "MemberwiseInitMacro" ) +@attached(member, names: named(init)) +public macro _UncheckedMemberwiseInit( + _deunderscoreParameters: Bool? = nil, + _optionalsDefaultNil: Bool? = nil +) = + #externalMacro( + module: "MemberwiseInitMacros", + type: "UncheckedMemberwiseInitMacro" + ) + +@attached(member, names: named(init)) +public macro _UncheckedMemberwiseInit( + _ accessLevel: AccessLevelConfig, + _deunderscoreParameters: Bool? = nil, + _optionalsDefaultNil: Bool? = nil +) = + #externalMacro( + module: "MemberwiseInitMacros", + type: "UncheckedMemberwiseInitMacro" + ) + // MARK: @Init macro public enum IgnoreConfig { diff --git a/Sources/MemberwiseInitMacros/MacroPlugin.swift b/Sources/MemberwiseInitMacros/MacroPlugin.swift index ad8ecdd..cd7efc9 100644 --- a/Sources/MemberwiseInitMacros/MacroPlugin.swift +++ b/Sources/MemberwiseInitMacros/MacroPlugin.swift @@ -6,5 +6,6 @@ struct MemberwiseInitPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ InitMacro.self, MemberwiseInitMacro.self, + UncheckedMemberwiseInitMacro.self, ] } diff --git a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift index 009f130..ff92961 100644 --- a/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift +++ b/Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift @@ -47,44 +47,14 @@ public struct MemberwiseInitMacro: MemberMacro { ) diagnostics.forEach { context.diagnose($0) } - func formatParameters() -> String { - guard !properties.isEmpty else { return "" } - - return "\n" - + properties - .map { property in - formatParameter( - for: property, - considering: properties, - deunderscoreParameters: deunderscoreParameters, - optionalsDefaultNil: optionalsDefaultNil - ?? defaultOptionalsDefaultNil( - for: property.keywordToken, - initAccessLevel: accessLevel - ) - ) - } - .joined(separator: ",\n") - + "\n" - } - - let formattedInitSignature = "\n\(accessLevel) init(\(formatParameters()))" return [ DeclSyntax( - try InitializerDeclSyntax(SyntaxNodeString(stringLiteral: formattedInitSignature)) { - CodeBlockItemListSyntax( - properties - .map { property in - CodeBlockItemSyntax( - stringLiteral: formatInitializerAssignmentStatement( - for: property, - considering: properties, - deunderscoreParameters: deunderscoreParameters - ) - ) - } - ) - } + MemberwiseInitFormatter.formatInitializer( + properties: properties, + accessLevel: accessLevel, + deunderscoreParameters: deunderscoreParameters, + optionalsDefaultNil: optionalsDefaultNil + ) ) ] } @@ -107,7 +77,7 @@ public struct MemberwiseInitMacro: MemberMacro { return nil } - private static func extractLabeledBoolArgument( + static func extractLabeledBoolArgument( _ label: String, from node: AttributeSyntax ) -> Bool? { @@ -269,7 +239,7 @@ public struct MemberwiseInitMacro: MemberMacro { } } - private static func extractVariableCustomSettings( + static func extractVariableCustomSettings( from variable: VariableDeclSyntax ) -> VariableCustomSettings? { guard let customConfigurationAttribute = variable.customConfigurationAttribute else { @@ -342,7 +312,7 @@ public struct MemberwiseInitMacro: MemberMacro { ) } - private static func defaultOptionalsDefaultNil( + static func defaultOptionalsDefaultNil( for bindingKeyword: TokenKind, initAccessLevel: AccessLevelModifier ) -> Bool { diff --git a/Sources/MemberwiseInitMacros/Macros/Support/MemberwiseInitFormatter.swift b/Sources/MemberwiseInitMacros/Macros/Support/MemberwiseInitFormatter.swift new file mode 100644 index 0000000..6113d00 --- /dev/null +++ b/Sources/MemberwiseInitMacros/Macros/Support/MemberwiseInitFormatter.swift @@ -0,0 +1,105 @@ +import SwiftSyntax +import SwiftSyntaxBuilder + +struct MemberwiseInitFormatter { + static func formatInitializer( + properties: [MemberProperty], + accessLevel: AccessLevelModifier, + deunderscoreParameters: Bool, + optionalsDefaultNil: Bool? + ) -> InitializerDeclSyntax { + let formattedParameters = formatParameters( + properties: properties, + deunderscoreParameters: deunderscoreParameters, + optionalsDefaultNil: optionalsDefaultNil, + accessLevel: accessLevel + ) + + let formattedInitSignature = "\n\(accessLevel) init(\(formattedParameters))" + + return try! InitializerDeclSyntax(SyntaxNodeString(stringLiteral: formattedInitSignature)) { + CodeBlockItemListSyntax( + properties.map { property in + CodeBlockItemSyntax( + stringLiteral: formatInitializerAssignmentStatement( + for: property, + considering: properties, + deunderscoreParameters: deunderscoreParameters + ) + ) + } + ) + } + } + + private static func formatParameters( + properties: [MemberProperty], + deunderscoreParameters: Bool, + optionalsDefaultNil: Bool?, + accessLevel: AccessLevelModifier + ) -> String { + guard !properties.isEmpty else { return "" } + + return "\n" + + properties + .map { property in + formatParameter( + for: property, + considering: properties, + deunderscoreParameters: deunderscoreParameters, + optionalsDefaultNil: optionalsDefaultNil + ?? MemberwiseInitMacro.defaultOptionalsDefaultNil( + for: property.keywordToken, + initAccessLevel: accessLevel + ) + ) + } + .joined(separator: ",\n") + "\n" + } + + private static func formatParameter( + for property: MemberProperty, + considering allProperties: [MemberProperty], + deunderscoreParameters: Bool, + optionalsDefaultNil: Bool + ) -> String { + let defaultValue = + property.initializerValue.map { " = \($0.description)" } + ?? property.customSettings?.defaultValue.map { " = \($0)" } + ?? (optionalsDefaultNil && property.type.isOptionalType ? " = nil" : "") + + let escaping = + (property.customSettings?.forceEscaping ?? false || property.type.isFunctionType) + ? "@escaping " : "" + + let label = property.initParameterLabel( + considering: allProperties, deunderscoreParameters: deunderscoreParameters) + + let parameterName = property.initParameterName( + considering: allProperties, deunderscoreParameters: deunderscoreParameters) + + return "\(label)\(parameterName): \(escaping)\(property.type.description)\(defaultValue)" + } + + private static func formatInitializerAssignmentStatement( + for property: MemberProperty, + considering allProperties: [MemberProperty], + deunderscoreParameters: Bool + ) -> String { + let assignee = + switch property.customSettings?.assignee { + case .none: + "self.\(property.name)" + case .wrapper: + "self._\(property.name)" + case let .raw(assignee): + assignee + } + + let parameterName = property.initParameterName( + considering: allProperties, + deunderscoreParameters: deunderscoreParameters + ) + return "\(assignee) = \(parameterName)" + } +} diff --git a/Sources/MemberwiseInitMacros/Macros/UncheckedMemberwiseInitMacro.swift b/Sources/MemberwiseInitMacros/Macros/UncheckedMemberwiseInitMacro.swift new file mode 100644 index 0000000..eca75f1 --- /dev/null +++ b/Sources/MemberwiseInitMacros/Macros/UncheckedMemberwiseInitMacro.swift @@ -0,0 +1,74 @@ +import SwiftCompilerPlugin +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros + +public struct UncheckedMemberwiseInitMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf decl: D, + in context: C + ) throws -> [SwiftSyntax.DeclSyntax] + where D: DeclGroupSyntax, C: MacroExpansionContext { + guard [SwiftSyntax.SyntaxKind.classDecl, .structDecl, .actorDecl].contains(decl.kind) else { + throw MacroExpansionErrorMessage( + """ + @_UncheckedMemberwiseInit can only be attached to a struct, class, or actor; \ + not to \(decl.descriptiveDeclKind(withArticle: true)). + """ + ) + } + + let accessLevel = + MemberwiseInitMacro.extractConfiguredAccessLevel(from: node) ?? .internal + let optionalsDefaultNil: Bool? = + MemberwiseInitMacro.extractLabeledBoolArgument("_optionalsDefaultNil", from: node) + let deunderscoreParameters: Bool = + MemberwiseInitMacro.extractLabeledBoolArgument("_deunderscoreParameters", from: node) ?? false + + let properties = try collectUncheckedMemberProperties( + from: decl.memberBlock.members + ) + + return [ + DeclSyntax( + MemberwiseInitFormatter.formatInitializer( + properties: properties, + accessLevel: accessLevel, + deunderscoreParameters: deunderscoreParameters, + optionalsDefaultNil: optionalsDefaultNil + ) + ) + ] + } + + private static func collectUncheckedMemberProperties( + from memberBlockItemList: MemberBlockItemListSyntax + ) throws -> [MemberProperty] { + memberBlockItemList.compactMap { member -> MemberProperty? in + guard let variable = member.decl.as(VariableDeclSyntax.self), + !variable.isComputedProperty, + variable.modifiersExclude([.static, .lazy]), + let binding = variable.bindings.first, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + let type = binding.typeAnnotation?.type ?? binding.initializer?.value.inferredTypeSyntax + else { return nil } + + let customSettings = MemberwiseInitMacro.extractVariableCustomSettings(from: variable) + if customSettings?.ignore == true { + return nil + } + + return MemberProperty( + accessLevel: variable.accessLevel, + customSettings: customSettings, + initializerValue: binding.initializer?.value, + keywordToken: variable.bindingSpecifier.tokenKind, + name: name, + type: type.trimmed + ) + } + } +} diff --git a/Tests/MemberwiseInitTests/UncheckedMemberwiseInitTests.swift b/Tests/MemberwiseInitTests/UncheckedMemberwiseInitTests.swift new file mode 100644 index 0000000..8ed0355 --- /dev/null +++ b/Tests/MemberwiseInitTests/UncheckedMemberwiseInitTests.swift @@ -0,0 +1,424 @@ +import MacroTesting +import MemberwiseInitMacros +import SwiftSyntaxMacros +import XCTest + +final class UncheckedMemberwiseInitTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + macros: [ + "_UncheckedMemberwiseInit": UncheckedMemberwiseInitMacro.self + ] + ) { + super.invokeTest() + } + } + + func testBasicStruct() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.internal) + struct S { + var number: Int + let text: String + } + """ + } expansion: { + """ + struct S { + var number: Int + let text: String + + internal init( + number: Int, + text: String + ) { + self.number = number + self.text = text + } + } + """ + } + } + + func testClassWithAccessLevels() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + class C { + private var privateVar: Int + public let publicLet: String + var defaultVar: Double + } + """ + } expansion: { + """ + class C { + private var privateVar: Int + public let publicLet: String + var defaultVar: Double + + public init( + privateVar: Int, + publicLet: String, + defaultVar: Double + ) { + self.privateVar = privateVar + self.publicLet = publicLet + self.defaultVar = defaultVar + } + } + """ + } + } + + func testActorWithComputedProperties() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.internal) + actor A { + var storedProperty: Int + let constant: String + var computedProperty: Double { + get { return Double(storedProperty) } + } + } + """ + } expansion: { + """ + actor A { + var storedProperty: Int + let constant: String + var computedProperty: Double { + get { return Double(storedProperty) } + } + + internal init( + storedProperty: Int, + constant: String + ) { + self.storedProperty = storedProperty + self.constant = constant + } + } + """ + } + } + + func testStructWithStaticAndLazyProperties() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.fileprivate) + struct S { + var normal: Int + static var staticVar: String = "" + lazy var lazyVar: Double = 0.0 + } + """ + } expansion: { + """ + struct S { + var normal: Int + static var staticVar: String = "" + lazy var lazyVar: Double = 0.0 + + fileprivate init( + normal: Int + ) { + self.normal = normal + } + } + """ + } + } + + func testStructWithOptionals() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.internal, _optionalsDefaultNil: true) + struct S { + var optionalInt: Int? + var optionalString: String? + var nonOptional: Double + } + """ + } expansion: { + """ + struct S { + var optionalInt: Int? + var optionalString: String? + var nonOptional: Double + + internal init( + optionalInt: Int? = nil, + optionalString: String? = nil, + nonOptional: Double + ) { + self.optionalInt = optionalInt + self.optionalString = optionalString + self.nonOptional = nonOptional + } + } + """ + } + } + + func testStructWithDeunderscoreParameters() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.internal, _deunderscoreParameters: true) + struct S { + var _internalName: String + var normalName: Int + } + """ + } expansion: { + """ + struct S { + var _internalName: String + var normalName: Int + + internal init( + internalName: String, + normalName: Int + ) { + self._internalName = internalName + self.normalName = normalName + } + } + """ + } + } + + func testDefaultAccessLevelWhenMissing() { + assertMacro { + """ + @_UncheckedMemberwiseInit + struct S { + var value: Int + } + """ + } expansion: { + """ + struct S { + var value: Int + + internal init( + value: Int + ) { + self.value = value + } + } + """ + } + } + + func testInferredTypeFromInitializer() { + assertMacro { + """ + @_UncheckedMemberwiseInit + struct S { + var inferred = 42 + var explicit: Double = 3.14 + } + """ + } expansion: { + """ + struct S { + var inferred = 42 + var explicit: Double = 3.14 + + internal init( + inferred: Int = 42, + explicit: Double = 3.14 + ) { + self.inferred = inferred + self.explicit = explicit + } + } + """ + } + } + + func testErrorOnInvalidDeclaration() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.internal) + enum E { + case a, b, c + } + """ + } diagnostics: { + """ + @_UncheckedMemberwiseInit(.internal) + ┬─────────────────────────────────── + ╰─ 🛑 @_UncheckedMemberwiseInit can only be attached to a struct, class, or actor; not to an enum. + enum E { + case a, b, c + } + """ + } + } + + func testInitIgnore() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + struct S { + @Init(.ignore) var ignored: Int = 42 + var normal: String + } + """ + } expansion: { + """ + struct S { + @Init(.ignore) var ignored: Int = 42 + var normal: String + + public init( + normal: String + ) { + self.normal = normal + } + } + """ + } + } + + func testInitLabel() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + struct S { + @Init(label: "_") var name: String + } + """ + } expansion: { + """ + struct S { + @Init(label: "_") var name: String + + public init( + _ name: String + ) { + self.name = name + } + } + """ + } + } + + func testInitInternalOnPrivateProperty() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + struct S { + @Init(.internal) private var internalProperty: Int + var publicProperty: String + } + """ + } expansion: { + """ + struct S { + @Init(.internal) private var internalProperty: Int + var publicProperty: String + + public init( + internalProperty: Int, + publicProperty: String + ) { + self.internalProperty = internalProperty + self.publicProperty = publicProperty + } + } + """ + } + } + + // NB: Unlike `@MemberwiseInit`, `@_UncheckedMemberwiseInit` naively includes attributed properties by default + func testWithPropertyWrapper() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + struct S { + @Clamping(0...100) var percentage: Int + } + """ + } expansion: { + """ + struct S { + @Clamping(0...100) var percentage: Int + + public init( + percentage: Int + ) { + self.percentage = percentage + } + } + """ + } + } + + func testWithInitIgnoreAndDefaultValue() { + assertMacro { + """ + @_UncheckedMemberwiseInit(.public) + struct S { + @Init(.ignore) var ignored: Int = 42 + var normal: String + var withDefault: Double = 3.14 + } + """ + } expansion: { + """ + struct S { + @Init(.ignore) var ignored: Int = 42 + var normal: String + var withDefault: Double = 3.14 + + public init( + normal: String, + withDefault: Double = 3.14 + ) { + self.normal = normal + self.withDefault = withDefault + } + } + """ + } + } + + func testEscaping() { + assertMacro { + """ + public typealias CompletionHandler = () -> Void + + @_UncheckedMemberwiseInit + struct APIRequest: Sendable { + let onSuccess: (Data) -> Void + let onFailure: @MainActor @Sendable (Error) -> Void + @Init(escaping: true) var customEscaping: CompletionHandler + } + """ + } expansion: { + """ + public typealias CompletionHandler = () -> Void + struct APIRequest: Sendable { + let onSuccess: (Data) -> Void + let onFailure: @MainActor @Sendable (Error) -> Void + @Init(escaping: true) var customEscaping: CompletionHandler + + internal init( + onSuccess: @escaping (Data) -> Void, + onFailure: @escaping @MainActor @Sendable (Error) -> Void, + customEscaping: @escaping CompletionHandler + ) { + self.onSuccess = onSuccess + self.onFailure = onFailure + self.customEscaping = customEscaping + } + } + """ + } + } +}