Luminate/Sources/LuminateObservationMacros/ObservableMacro.swift

176 lines
6.5 KiB
Swift

//
// ObservableMacro.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
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("_$")
}
}
}