Add @Observable macro for class property mutation tracking

This commit is contained in:
Brendan Szymanski 2026-06-15 17:42:44 -04:00
parent 4ea5ec7efe
commit 0c011eff01
5 changed files with 281 additions and 0 deletions

View file

@ -1,3 +1,4 @@
import CompilerPluginSupport
// swift-tools-version: 6.0 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
@ -11,6 +12,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"), .package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
.package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"),
], ],
targets: [ targets: [
.target( .target(
@ -27,11 +29,20 @@ let package = Package(
name: "LuminateCore", name: "LuminateCore",
dependencies: [ dependencies: [
"LuminateAPI", "LuminateAPI",
"LuminateObservationMacros",
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
.product(name: "SQLite", package: "SQLite.swift"), .product(name: "SQLite", package: "SQLite.swift"),
] ]
), ),
.macro(
name: "LuminateObservationMacros",
dependencies: [
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),
.target( .target(
name: "LuminateDI", name: "LuminateDI",
dependencies: [ dependencies: [
@ -44,6 +55,7 @@ let package = Package(
dependencies: [ dependencies: [
"LuminateCore", "LuminateCore",
"LuminateDI", "LuminateDI",
"LuminateObservationMacros",
.product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"),
] ]
), ),
@ -52,6 +64,7 @@ let package = Package(
dependencies: [ dependencies: [
"LuminateCore", "LuminateCore",
"LuminateDI", "LuminateDI",
"LuminateObservationMacros",
.product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"),
] ]
), ),
@ -60,6 +73,7 @@ let package = Package(
dependencies: [ dependencies: [
"LuminateCore", "LuminateCore",
"LuminateDI", "LuminateDI",
"LuminateObservationMacros",
.product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"),
] ]
), ),
@ -70,6 +84,7 @@ let package = Package(
"LuminateLibrary", "LuminateLibrary",
"LuminatePlayer", "LuminatePlayer",
"LuminateDI", "LuminateDI",
"LuminateObservationMacros",
.product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Adwaita", package: "adwaita-swift"),
.product(name: "Localized", package: "localized"), .product(name: "Localized", package: "localized"),
], ],

View file

@ -15,6 +15,7 @@ struct Luminate: App {
@State private var isLaunchLoading = true @State private var isLaunchLoading = true
init() { init() {
ObservationRegistrar.onChange = { StateManager.updateViews() }
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) { if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
DIContainer.shared.register(\.persistence, value: store) DIContainer.shared.register(\.persistence, value: store)
} }

View file

@ -0,0 +1,99 @@
import Foundation
// MARK: - ObservationRegistrar
/// Manages observation notifications for `@Observable` classes.
///
/// When a tracked property changes, ``didChange()`` triggers the global
/// ``onChange`` handler, which should be wired to the view update system
/// during app initialization.
///
/// ## Wiring
/// Connect the registrar to the view update system during app startup:
/// ```swift
/// ObservationRegistrar.onChange = { StateManager.updateViews() }
/// ```
public class ObservationRegistrar {
/// Global callback invoked when any tracked property changes.
/// Set this during app startup to connect to the view update system.
public static var onChange: (() -> Void)?
public init() {}
/// Notifies the system that a tracked property changed.
/// Triggers the global ``onChange`` handler.
public func didChange() {
Self.onChange?()
}
}
// MARK: - ObservableProtocol
/// Conforming types get automatic property-mutation notifications via ``Observable``.
///
/// All `@Observable` classes automatically conform to this protocol.
/// The ``_$observationRegistrar`` property is added by the `@Observable` macro.
public protocol ObservableProtocol: AnyObject {
/// The observation registrar that tracks property mutations.
var _$observationRegistrar: ObservationRegistrar { get }
}
// MARK: - @Observable Macro Declaration
/// Registers a class for observable property mutation tracking.
///
/// When a property of an observable class is mutated, the framework
/// automatically triggers a view update. Use with ``@State`` in views:
///
/// ```swift
/// @Observable
/// class PlayerViewModel {
/// var title: String = ""
/// var position: TimeInterval = 0
/// let id: UUID = .init()
/// }
///
/// struct PlayerView: View {
/// @State private var model = PlayerViewModel()
/// var view: Body {
/// VStack {
/// Text(model.title)
/// Button("Seek") { model.position += 10 }
/// }
/// }
/// }
/// ```
///
/// The macro:
/// - Adds an ``_$observationRegistrar`` stored property to the class
/// - Adds a stored backing property ``_$<name>`` for each tracked `var`, preserving
/// its type and initial value
/// - Converts each stored `var` property to a computed `get`/`set` pair that
/// reads/writes the ``_$<name>`` backing property and calls
/// ``ObservationRegistrar/didChange()`` on every mutation
///
/// Stored `let` properties and computed properties are left untouched.
///
/// - Important: The registrar must be wired up before use:
/// ```swift
/// ObservationRegistrar.onChange = { StateManager.updateViews() }
/// ```
@attached(member, names: named(_$observationRegistrar), arbitrary)
@attached(memberAttribute)
public macro Observable() =
#externalMacro(
module: "LuminateObservationMacros",
type: "ObservableMacro"
)
/// Internal macro used by ``Observable`` to add accessor observers to stored properties.
///
/// This macro is applied automatically by `@Observable` to each stored `var` property.
/// Do not use directly.
@attached(accessor)
public macro ObservationTracked() =
#externalMacro(
module: "LuminateObservationMacros",
type: "ObservableMacro"
)

View file

@ -0,0 +1,155 @@
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("_$")
}
}
}

View file

@ -0,0 +1,11 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros
/// Compiler plugin that provides the `@Observable` macro implementation.
@main
struct LuminateObservationPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ObservableMacro.self
]
}