Skip to content

Commit

Permalink
Add @_UncheckedMemberwiseInit macro (#36)
Browse files Browse the repository at this point in the history
A new experimental macro for generating memberwise initializers without
the safety checks present in the standard `MemberwiseInit` macro. It
simplifies usage by allowing exposure of lower access level members in
initializers without per-member annotation, at the cost of losing some
compile-time checks.
  • Loading branch information
gohanlon committed Jul 14, 2024
1 parent 1edc42e commit f6fc281
Show file tree
Hide file tree
Showing 6 changed files with 442 additions and 38 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,
]
}
46 changes: 8 additions & 38 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 @@ -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,72 @@
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 configuredAccessLevel =
MemberwiseInitMacro.extractConfiguredAccessLevel(from: node) ?? .internal
let optionalsDefaultNil: Bool? =
MemberwiseInitMacro.extractLabeledBoolArgument("_optionalsDefaultNil", from: node)
let deunderscoreParameters: Bool =
MemberwiseInitMacro.extractLabeledBoolArgument("_deunderscoreParameters", from: node) ?? false

let accessLevel = configuredAccessLevel
let properties = try collectUncheckedMemberProperties(
from: decl.memberBlock.members,
targetAccessLevel: accessLevel
)

return [
DeclSyntax(
MemberwiseInitFormatter.formatInitializer(
properties: properties,
accessLevel: accessLevel,
deunderscoreParameters: deunderscoreParameters,
optionalsDefaultNil: optionalsDefaultNil
)
)
]
}

private static func collectUncheckedMemberProperties(
from memberBlockItemList: MemberBlockItemListSyntax,
targetAccessLevel: AccessLevelModifier
) 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 }

return MemberProperty(
accessLevel: variable.accessLevel,
customSettings: nil,
initializerValue: binding.initializer?.value,
keywordToken: variable.bindingSpecifier.tokenKind,
name: name,
type: type.trimmed
)
}
}
}
Loading

0 comments on commit f6fc281

Please sign in to comment.