diff --git a/.swiftlint.yml b/.swiftlint.yml index f035d14..e471693 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -183,11 +183,11 @@ custom_rules: global_actor_attribute_order: name: "Global actor attribute order" message: "Global actor should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor)" + regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor\\s)" sendable_attribute_order: name: "Sendable attribute order" message: "Sendable should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@Sendable)" + regex: "(?-s)(@.+[^,\\s]\\s+@Sendable\\s)" autoclosure_attribute_order: name: "Autoclosure attribute order" message: "Autoclosure should be the last attribute." diff --git a/Package.resolved b/Package.resolved index f1e00b6..d27c3ad 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "562722fee5c55759fdc591bf9e50bbc7e84d94e5f50c2034e511205e38982b5d", + "originHash" : "6091bffc9b91ac475d5e4d5008c7140d158dc19e03b0762a886ebe8d53eb77b0", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "b2671db08bc28ee2336bd33517a524de4abeb92a", - "version" : "1.0.6" + "revision" : "d55ddb9d15a0e266a01b0153db4a3750da7d33b8", + "version" : "2.0.4" } }, { diff --git a/Package.swift b/Package.swift index 3b07436..1491b19 100644 --- a/Package.swift +++ b/Package.swift @@ -23,11 +23,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - from: "1.0.6" + from: "2.0.4" ), .package( url: "https://github.com/swiftlang/swift-syntax", - "600.0.0" ..< "602.0.0" + "600.0.0" ..< "604.0.0" ) ], targets: [ diff --git a/Sources/Publishable/Documentation.docc/Publishable.md b/Sources/Publishable/Documentation.docc/Publishable.md index ad46d0c..4087be9 100644 --- a/Sources/Publishable/Documentation.docc/Publishable.md +++ b/Sources/Publishable/Documentation.docc/Publishable.md @@ -7,6 +7,7 @@ Observe changes to `Observable` types synchronously with `Combine`. ### Making Types Publishable - ``Publishable()`` +- ``Publishable(isolation:)`` - ``Publishable-protocol`` ### Getting Property Publishers diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index cc82b65..26d3655 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Publishable/PropertyPublisher/Publishable.swift @@ -10,10 +10,13 @@ import Observation /// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. /// +/// - Note: This macro infers the global actor isolation of the type and applies it to the generated declarations. +/// If this causes compilation errors, use ``Publishable(isolation:)`` instead. +/// /// - Note: This macro works only with `final` classes to which the `Observable` or `SwiftData.Model` macro has been applied directly. /// -/// The `Publishable` macro adds a new ``Publishable/publisher`` property to your type, -/// which exposes `Combine` publishers for all mutable instance properties - both stored and computed. +/// The `Publishable` macro adds a new `publisher` property to your type, +/// which exposes `Combine` publishers for all mutable or computed instance properties. /// /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. @@ -38,22 +41,44 @@ public macro Publishable() = #externalMacro( type: "PublishableMacro" ) +/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// +/// - Parameter isolation: The global actor to which the type is isolated. If set to `nil`, the generated members are `nonisolated`. +/// To infer isolation automatically, use the ``Publishable()`` macro instead. +/// +/// - Note: This macro works only with `final` classes to which the `Observable` or `SwiftData.Model` macro has been applied directly. +/// +/// The `Publishable` macro adds a new `publisher` property to your type, +/// which exposes `Combine` publishers for all mutable or computed instance properties. +/// +/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. +/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. +/// +/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. +/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. +/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. +/// +@attached( + member, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(Observation) +) +@attached( + extension, + conformances: Publishable +) +public macro Publishable( + isolation: Isolation.Type? +) = #externalMacro( + module: "PublishableMacros", + type: "PublishableMacro" +) + /// A type that can be observed using both the `Observation` and `Combine` frameworks. /// /// You don't need to declare conformance to this protocol yourself. /// It is generated automatically when you apply the ``Publishable()`` macro to your type. /// -public protocol Publishable: AnyObject, Observable { - - /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, - /// containing publishers for all mutable instance properties of the type. - /// - associatedtype PropertyPublisher: AnyPropertyPublisher - - /// An instance that exposes `Combine` publishers for all mutable instance properties of the type. - /// - /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated - /// may result in a crash. Always access it directly through the object that exposes it. - /// - var publisher: PropertyPublisher { get } -} +public protocol Publishable: AnyObject, Observable {} diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 245bf25..4ac7f2a 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -13,49 +13,26 @@ public protocol PublishableObservationRegistrar { associatedtype Object: Publishable, Observable - var underlying: SwiftObservationRegistrar { get } + init() - func publish( + func willSet( _ object: Object, keyPath: KeyPath ) -} - -extension PublishableObservationRegistrar { - public func willSet( + func didSet( _ object: Object, keyPath: KeyPath - ) { - object.publisher.beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - - public func didSet( - _ object: Object, - keyPath: KeyPath - ) { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher.endModifications() - } + ) - public func access( + func access( _ object: Object, keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } + ) - public func withMutation( + func withMutation( of object: Object, keyPath: KeyPath, _ mutation: () throws -> T - ) rethrows -> T { - object.publisher.beginModifications() - let result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher.endModifications() - return result - } + ) rethrows -> T } diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 9c5af0c..71b98bb 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -12,9 +12,13 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList + let explicitGlobalActorIsolation: GlobalActorIsolation? var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) + .init( + accessControlLevel: .init(inheritingDeclaration: .member), + explicitGlobalActorIsolation: explicitGlobalActorIsolation + ) } private var registeredProperties: PropertiesList { @@ -28,11 +32,15 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { struct ObservationRegistrar: PublishableObservationRegistrar { - let underlying = SwiftObservationRegistrar() + private let underlying = SwiftObservationRegistrar() \(publishNewValueFunction()) \(subjectFunctions().formatted()) + + \(publishableObservationRegistrarFunctions()) + + \(assumeIsolatedIfNeededFunction()) } } """ @@ -41,9 +49,9 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private func publishNewValueFunction() -> MemberBlockItemListSyntax { """ - func publish( - _ object: \(trimmedTypeName), - keyPath: KeyPath<\(trimmedTypeName), some Any> + \(inheritedGlobalActorAttribute)func publish( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> ) { \(publishNewValueKeyPathCasting().formatted()) } @@ -54,7 +62,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { for inferredType in registeredProperties.uniqueInferredTypes { """ - if let keyPath = keyPath as? KeyPath<\(trimmedTypeName), \(inferredType)>, + if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, let subject = subject(for: keyPath, on: object) { subject.send(object[keyPath: keyPath]) return @@ -70,9 +78,9 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private func subjectFunctions() -> MemberBlockItemListSyntax { for inferredType in registeredProperties.uniqueInferredTypes { """ - private func subject( - for keyPath: KeyPath<\(trimmedTypeName), \(inferredType)>, - on object: \(trimmedTypeName) + \(inheritedGlobalActorAttribute)private func subject( + for keyPath: KeyPath<\(trimmedType), \(inferredType)>, + on object: \(trimmedType) ) -> PassthroughSubject<\(inferredType), Never>? { \(subjectKeyPathCasting(for: inferredType).formatted()) } @@ -94,4 +102,91 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { return nil """ } + + private func publishableObservationRegistrarFunctions() -> MemberBlockItemListSyntax { + """ + nonisolated func willSet( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher.beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + } + + nonisolated func access( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation( + of object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any>, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher.beginModifications() + result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + + return result + } + """ + } + + private func assumeIsolatedIfNeededFunction() -> MemberBlockItemListSyntax { + if let globalActor = inheritedGlobalActorIsolation?.trimmedType { + // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift + """ + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @\(globalActor) () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try \(globalActor).shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + """ + } else { + """ + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + """ + } + } } diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index fecdf5c..0ac997e 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -12,15 +12,19 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList + let explicitGlobalActorIsolation: GlobalActorIsolation? var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) + .init( + accessControlLevel: .init(inheritingDeclaration: .member), + explicitGlobalActorIsolation: explicitGlobalActorIsolation + ) } func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order [ """ - \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { + \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedType)> { \(deinitializer()) @@ -50,12 +54,15 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.stored.mutable.instance { - let accessControlLevel = property.declaration.accessControlLevel(inheritedBy: .peer, maxAllowed: .public) + let accessControlLevel = property.declaration.inlinableAccessControlLevel( + inheritedBy: .peer, + maxAllowed: .public + ) let name = property.trimmedName let type = property.inferredType """ fileprivate let _\(name) = PassthroughSubject<\(type), Never>() - \(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + \(inheritedGlobalActorAttribute)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { _storedPropertyPublisher(_\(name), for: \\.\(name)) } """ @@ -65,11 +72,14 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.computed.instance { - let accessControlLevel = property.declaration.accessControlLevel(inheritedBy: .peer, maxAllowed: .public) + let accessControlLevel = property.declaration.inlinableAccessControlLevel( + inheritedBy: .peer, + maxAllowed: .public + ) let name = property.trimmedName let type = property.inferredType """ - \(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + \(inheritedGlobalActorAttribute)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { _computedPropertyPublisher(for: \\.\(name)) } """ diff --git a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift index 1e71534..b9ba7b7 100644 --- a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift @@ -20,6 +20,13 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder { func build() -> [DeclSyntax] { [ """ + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// \(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self) """ ] diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Sources/PublishableMacros/Main/PublishableMacro.swift index 81fd3a8..e89879e 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Sources/PublishableMacros/Main/PublishableMacro.swift @@ -31,7 +31,7 @@ public enum PublishableMacro { extension PublishableMacro: MemberMacro { public static func expansion( - of _: AttributeSyntax, + of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo _: [TypeSyntax], in context: some MacroExpansionContext @@ -40,15 +40,30 @@ extension PublishableMacro: MemberMacro { return [] } + let parameterExtractor = ParameterExtractor(from: node) + let explicitGlobalActorIsolation = try parameterExtractor + .globalActorIsolation(withLabel: "isolation") + let properties = PropertiesParser.parse( memberBlock: declaration.memberBlock, in: context ) let builderTypes: [any ClassDeclBuilder] = [ - PublisherDeclBuilder(declaration: declaration, properties: properties), - PropertyPublisherDeclBuilder(declaration: declaration, properties: properties), - ObservationRegistrarDeclBuilder(declaration: declaration, properties: properties) + PublisherDeclBuilder( + declaration: declaration, + properties: properties + ), + PropertyPublisherDeclBuilder( + declaration: declaration, + properties: properties, + explicitGlobalActorIsolation: explicitGlobalActorIsolation + ), + ObservationRegistrarDeclBuilder( + declaration: declaration, + properties: properties, + explicitGlobalActorIsolation: explicitGlobalActorIsolation + ) ] return try builderTypes.flatMap { builderType in diff --git a/Tests/PublishableMacrosTests/MainActorMacroTests.swift b/Tests/PublishableMacrosTests/MainActorMacroTests.swift new file mode 100644 index 0000000..d32c654 --- /dev/null +++ b/Tests/PublishableMacrosTests/MainActorMacroTests.swift @@ -0,0 +1,236 @@ +// +// MainActorMacroTests.swift +// Publishable +// +// Created by Kamil Strzelecki on 24/08/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(PublishableMacros) + import PublishableMacros + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class MainActorMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Publishable": PublishableMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Publishable @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// + public private(set) lazy var publisher = PropertyPublisher(object: self) + + public final class PropertyPublisher: AnyPropertyPublisher { + + deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate let _age = PassthroughSubject() + @MainActor var age: AnyPublisher { + _storedPropertyPublisher(_age, for: \.age) + } + fileprivate let _name = PassthroughSubject() + @MainActor var name: AnyPublisher { + _storedPropertyPublisher(_name, for: \.name) + } + fileprivate let _surname = PassthroughSubject() + @MainActor public var surname: AnyPublisher { + _storedPropertyPublisher(_surname, for: \.surname) + } + + @MainActor internal var fullName: AnyPublisher { + _computedPropertyPublisher(for: \.fullName) + } + @MainActor package var initials: AnyPublisher { + _computedPropertyPublisher(for: \.initials) + } + } + + private enum Observation { + + struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor func publish( + _ object: Person, + keyPath: KeyPath + ) { + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + assertionFailure("Unknown keyPath: \(keyPath)") + } + + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher.beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher.beginModifications() + result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + + extension Person: Publishable { + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 9448f09..98f6690 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -72,6 +72,13 @@ set { _ = newValue } } + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// public private(set) lazy var publisher = PropertyPublisher(object: self) public final class PropertyPublisher: AnyPropertyPublisher { @@ -107,7 +114,7 @@ struct ObservationRegistrar: PublishableObservationRegistrar { - let underlying = SwiftObservationRegistrar() + private let underlying = SwiftObservationRegistrar() func publish( _ object: Person, @@ -147,6 +154,61 @@ } return nil } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher.beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher.beginModifications() + result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } } } } diff --git a/Tests/PublishableTests/Suites/MainActorTests.swift b/Tests/PublishableTests/Suites/MainActorTests.swift new file mode 100644 index 0000000..2d0b783 --- /dev/null +++ b/Tests/PublishableTests/Suites/MainActorTests.swift @@ -0,0 +1,222 @@ +// +// MainActorTests.swift +// Publishable +// +// Created by Kamil Strzelecki on 18/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Publishable +import Foundation +import Testing + +@MainActor +internal struct MainActorTests { + + @Test + func testStoredPropertyPublisher() { + var person: Person? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue: [Void] = [] + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.name + } onChange: { + observationsQueue.append(()) + } + } + + observe() + #expect(publishableQueue.popFirst() == "John") + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() == "Kamil") + #expect(observationsQueue.popFirst() != nil) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func testComputedPropertyPublisher() { + var person: Person? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue: [Void] = [] + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.fullName.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.fullName + } onChange: { + observationsQueue.append(()) + } + } + + observe() + #expect(publishableQueue.popFirst() == "John Doe") + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() == "John Strzelecki") + #expect(observationsQueue.popFirst() != nil) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() == "Kamil Strzelecki") + #expect(observationsQueue.popFirst() != nil) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension MainActorTests { + + @Test + func testWillChangePublisher() { + var person: Person? = .init() + var publishableQueue = [Person]() + nonisolated(unsafe) var observationsQueue: [Void] = [] + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.willChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.age + _ = person?.name + _ = person?.surname + _ = person?.fullName + } onChange: { + observationsQueue.append(()) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func testDidChangePublisher() { + var person: Person? = .init() + var publishableQueue = [Person]() + nonisolated(unsafe) var observationsQueue: [Void] = [] + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.didChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.age + _ = person?.name + _ = person?.surname + _ = person?.fullName + } onChange: { + observationsQueue.append(()) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() != nil) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension MainActorTests { + + @MainActor @Publishable @Observable + public final class Person { + + let id = UUID() + var age = 25 + fileprivate(set) var name = "John" + public var surname = "Doe" + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + } +}