diff --git a/Package.swift b/Package.swift index 6f35a0b..4cc75e7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,3 +1,4 @@ +import CompilerPluginSupport // swift-tools-version: 6.0 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-urlsession", from: "1.0.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: [ .target( @@ -27,11 +29,20 @@ let package = Package( name: "LuminateCore", dependencies: [ "LuminateAPI", + "LuminateObservationMacros", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .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( name: "LuminateDI", dependencies: [ @@ -44,6 +55,7 @@ let package = Package( dependencies: [ "LuminateCore", "LuminateDI", + "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), ] ), @@ -52,6 +64,7 @@ let package = Package( dependencies: [ "LuminateCore", "LuminateDI", + "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), ] ), @@ -60,6 +73,7 @@ let package = Package( dependencies: [ "LuminateCore", "LuminateDI", + "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), ] ), @@ -70,6 +84,7 @@ let package = Package( "LuminateLibrary", "LuminatePlayer", "LuminateDI", + "LuminateObservationMacros", .product(name: "Adwaita", package: "adwaita-swift"), .product(name: "Localized", package: "localized"), ], diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 0d72822..ed35e09 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -15,6 +15,7 @@ struct Luminate: App { @State private var isLaunchLoading = true init() { + ObservationRegistrar.onChange = { StateManager.updateViews() } if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) { DIContainer.shared.register(\.persistence, value: store) } diff --git a/Sources/LuminateCore/Observation.swift b/Sources/LuminateCore/Observation.swift new file mode 100644 index 0000000..3d9a164 --- /dev/null +++ b/Sources/LuminateCore/Observation.swift @@ -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 ``_$`` 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 ``_$`` 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" + ) diff --git a/Sources/LuminateObservationMacros/ObservableMacro.swift b/Sources/LuminateObservationMacros/ObservableMacro.swift new file mode 100644 index 0000000..0081167 --- /dev/null +++ b/Sources/LuminateObservationMacros/ObservableMacro.swift @@ -0,0 +1,155 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +/// The `@Observable` macro implementation. +/// +/// Expands in three phases: +/// 1. **MemberMacro**: Adds `_$observationRegistrar` + one stored backing property +/// (`_$`) 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 `_$` 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 `_$` 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 (`_$`) 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("_$") + } + } +} diff --git a/Sources/LuminateObservationMacros/Plugin.swift b/Sources/LuminateObservationMacros/Plugin.swift new file mode 100644 index 0000000..0f441ac --- /dev/null +++ b/Sources/LuminateObservationMacros/Plugin.swift @@ -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 + ] +}