Skip to content

Commit

Permalink
Add @_UncheckedMemberwiseInit macro (#36)
Browse files Browse the repository at this point in the history
Introduce a new experimental macro for generating memberwise
initializers with reduced safety checks compared to `@MemberwiseInit`.

Features of `@_UncheckedMemberwiseInit`:
- Include all properties in the initializer, regardless of access level
- Include attributed properties by default (differs from
  `@MemberwiseInit`)
- Allow exposure of lower access level members without per-member
  annotation
- Has the same usage as `@MemberwiseInit`

`@_UncheckedMemberwiseInit` provides a trade-off between ease of use and
compile-time safety, suitable for scenarios where brevity is preferred
over strict access control enforcement. Note that the underscore prefix
indicates this is an experimental feature.

Example:

```swift
@_UnsafeMemberwiseInit(.public)
public struct ViewModel {
  private let title: String
}
```

Yields:

```swift
  public init(title: String) {
    self.title = title
  }
```
  • Loading branch information
gohanlon committed Jul 15, 2024
1 parent 1edc42e commit b15e015
Show file tree
Hide file tree
Showing 6 changed files with 634 additions and 39 deletions.
21 changes: 21 additions & 0 deletions Sources/MemberwiseInit/MemberwiseInit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/MemberwiseInitMacros/MacroPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ struct MemberwiseInitPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
InitMacro.self,
MemberwiseInitMacro.self,
UncheckedMemberwiseInitMacro.self,
]
}
48 changes: 9 additions & 39 deletions Sources/MemberwiseInitMacros/Macros/MemberwiseInitMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
]
}
Expand All @@ -107,7 +77,7 @@ public struct MemberwiseInitMacro: MemberMacro {
return nil
}

private static func extractLabeledBoolArgument(
static func extractLabeledBoolArgument(
_ label: String,
from node: AttributeSyntax
) -> Bool? {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -342,7 +312,7 @@ public struct MemberwiseInitMacro: MemberMacro {
)
}

private static func defaultOptionalsDefaultNil(
static func defaultOptionalsDefaultNil(
for bindingKeyword: TokenKind,
initAccessLevel: AccessLevelModifier
) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import SwiftCompilerPlugin
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacroExpansion
import SwiftSyntaxMacros

public struct UncheckedMemberwiseInitMacro: MemberMacro {
public static func expansion<D, C>(
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
)
}
}
}
Loading

0 comments on commit b15e015

Please sign in to comment.