Inserting init accessor with macro

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
  }
}
1 Like