I had a need for a way to clamp property values within some structs so I thought I'd try my hand at a macro. I created a Clamped
macro with a peer
role that adds a private stored property with an underscore prefix. It also has an accessor
role that adds get
and set
accessors to the original stored property. It expands something like this:
struct Celsius {
@Clamped(0...100)
var value: Double
}
into this:
struct Celsius {
var value: Double {
get {
_value
}
set {
if newValue < 0 {
_value = 0
}
else if newValue > 100 {
_value = 100
}
else {
_value = newValue
}
}
}
private var _value: Double
}
When you use the macro, you get an error: "Return from initializer without initializing all stored properties". Makes sense since _value
is uninitialized in the memberwise initializer. Well, it seems that's what those init accessors were created for. I'll just add one in when I create the get
and set
accessors:
...
var value: Double {
@storageRestrictions(initializes: _value)
init(newValue) {
if newValue < 0 {
_value = 0
}
else if newValue > 100 {
_value = 100
}
else {
_value = newValue
}
}
...
But now you get the error: "Expansion of macro 'Clamped' produced an unexpected 'init' accessor". Looking through the documentation and all the examples I can find reveals no way to add in an init accessor as part of a macro. Without that, there's no way to create an initializer with the macro or within the original type. Is this kind of macro possible with the current tools?
For what it's worth, here's the code to generate the expansion. It's probably pretty rough since I'm new to working with swift-syntax
. This is the declaration:
@attached(accessor)
@attached(peer, names: prefixed(_))
public macro Clamped<Bound: Comparable>(_ range: ClosedRange<Bound>) = #externalMacro(
module: "ClampedMacros",
type: "ClampedMacro"
)
The peer
logic:
enum ClampedMacro {}
extension ClampedMacro: PeerMacro {
static func expansion<D: DeclSyntaxProtocol, M: MacroExpansionContext>(
of node: AttributeSyntax,
providingPeersOf declaration: D,
in context: M
) throws -> [DeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first
else { throw ClampedError.notVariable }
guard let id = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { throw ClampedError.notNamed }
let newDecl = VariableDeclSyntax(
attributes: varDecl.attributes.removing(node),
modifiers: [DeclModifierSyntax(name: "private")],
.var,
name: "\(raw: id.prefixed())",
type: binding.typeAnnotation,
initializer: binding.initializer
)
return [DeclSyntax(newDecl)]
}
}
The accessor
logic:
extension ClampedMacro: AccessorMacro {
static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first
else { throw ClampedError.notVariable }
guard let id = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else { throw ClampedError.notNamed }
guard let args = node.arguments?.children(viewMode: .sourceAccurate),
args.count == 1,
let firstArg = args.first,
let exprSyntax = firstArg.as(LabeledExprSyntax.self)?.expression,
let infixSyntax = exprSyntax.as(InfixOperatorExprSyntax.self),
let op = infixSyntax.operator.as(BinaryOperatorExprSyntax.self),
op.operator.text == "..."
else { throw ClampedError.invalidArguments }
let lower = infixSyntax.leftOperand
let upper = infixSyntax.rightOperand
let prefixed = id.prefixed()
return [
"""
@storageRestrictions(initializes: \(raw: prefixed))
init(newValue) {
if newValue < \(lower) {
\(raw: prefixed) = \(lower)
}
else if newValue > \(upper) {
\(raw: prefixed) = \(upper)
}
else {
\(raw: prefixed) = newValue
}
}
""",
"""
get {
\(raw: prefixed)
}
""",
"""
set {
if newValue < \(lower) {
\(raw: prefixed) = \(lower)
}
else if newValue > \(upper) {
\(raw: prefixed) = \(upper)
}
else {
\(raw: prefixed) = newValue
}
}
"""
]
}
}
private extension String {
func prefixed() -> String {
"_" + self
}
}