Add @Observable macro for class property mutation tracking
This commit is contained in:
parent
4ea5ec7efe
commit
0c011eff01
5 changed files with 281 additions and 0 deletions
|
|
@ -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"),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
Sources/LuminateCore/Observation.swift
Normal file
99
Sources/LuminateCore/Observation.swift
Normal 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"
|
||||||
|
)
|
||||||
155
Sources/LuminateObservationMacros/ObservableMacro.swift
Normal file
155
Sources/LuminateObservationMacros/ObservableMacro.swift
Normal 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("_$")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Sources/LuminateObservationMacros/Plugin.swift
Normal file
11
Sources/LuminateObservationMacros/Plugin.swift
Normal 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
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue