Luminate/Sources/LuminateObservationMacros/ObservableMacro.swift

162 lines
5.8 KiB
Swift

//
// ObservableMacro.swift
// LuminateObservationMacros
//
// Created by Brendan Szymanski on 6/15/26.
//
import SwiftSyntax
import SwiftSyntaxMacros
/// The `@Observable` macro implementation.
///
/// Expands in three phases:
/// 1. **MemberMacro**: Adds `_$observationRegistrar` + one stored backing property
/// (`_$<name>`) per tracked `var`, preserving type and initial value.
/// 2. **MemberAttributeMacro**: Applies `@ObservationTracked` to each stored `var`.
/// 3. **AccessorMacro**: Wraps each tracked property with `get`/`set` that read/write
/// the `_$<name>` backing property and call `_$observationRegistrar.didChange()` on
/// every set. No `init` accessor needed the backing stored property handles
/// the initial value naturally.
public enum ObservableMacro: MemberMacro, MemberAttributeMacro, AccessorMacro {
// MARK: - MemberMacro
/// Adds the observation registrar and a stored backing property for each
/// tracked `var`, preserving the original type and initializer expression.
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
var members: [DeclSyntax] = [
"""
public var _$observationRegistrar: ObservationRegistrar = .init()
"""
]
for member in declaration.memberBlock.members {
guard let variableDecl = member.decl.as(VariableDeclSyntax.self),
variableDecl.isStoredVar,
!variableDecl.isObservationSynthesized
else {
continue
}
// For each binding in the declaration, synthesize a backing property
for binding in variableDecl.bindings {
guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else {
continue
}
let backingName = "_$" + name
let initializer = binding.initializer?.trimmedDescription ?? ""
// Include type annotation if present, otherwise let Swift infer from initializer
if let typeAnnotation = binding.typeAnnotation {
let type = typeAnnotation.type.trimmedDescription
if initializer.isEmpty {
members.append("internal var \(raw: backingName): \(raw: type)")
} else {
members.append(
"internal var \(raw: backingName): \(raw: type) \(raw: initializer)")
}
} else if !initializer.isEmpty {
members.append("internal var \(raw: backingName) \(raw: initializer)")
}
// If neither type annotation nor initializer, skip (would be a compile error in the original code anyway)
}
}
return members
}
// MARK: - MemberAttributeMacro
/// Adds `@ObservationTracked` to each stored `var` property (excluding synthesized backing members).
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingAttributesFor member: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AttributeSyntax] {
guard let variableDecl = member.as(VariableDeclSyntax.self),
variableDecl.isStoredVar,
!variableDecl.isObservationSynthesized,
!variableDecl.isBackingProperty
else {
return []
}
return [
AttributeSyntax(stringLiteral: "@ObservationTracked")
]
}
// MARK: - AccessorMacro
/// Wraps the property with `get`/`set` that read from / write to `_$<name>` backing property
/// and call `_$observationRegistrar.didChange()` on every set.
public 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,
let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
else {
return []
}
let backingName = "_$" + name
return [
"""
get {
\(raw: backingName)
}
""",
"""
set {
\(raw: backingName) = newValue
_$observationRegistrar.didChange()
}
""",
]
}
}
// MARK: - VariableDeclSyntax Helpers
extension VariableDeclSyntax {
/// Whether this declaration is a stored `var` (not `let`, not computed).
var isStoredVar: Bool {
guard bindingSpecifier.tokenKind == .keyword(.var) else {
return false
}
return bindings.allSatisfy { $0.accessorBlock == nil }
}
/// Whether this declaration was synthesized by `@Observable` (`_$observationRegistrar`).
var isObservationSynthesized: Bool {
bindings.contains { binding in
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
return false
}
return pattern.identifier.text == "_$observationRegistrar"
}
}
/// Whether this declaration is a backing property (`_$<name>`) synthesized by `@Observable`.
var isBackingProperty: Bool {
bindings.allSatisfy { binding in
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
return false
}
return pattern.identifier.text.hasPrefix("_$")
}
}
}