From 835dc26fb1c8dc9f521e46fe5c3c74bc4ac9680a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 24 Aug 2025 21:26:43 +0200 Subject: [PATCH 01/39] Added global actor isolation support --- .swiftlint.yml | 4 +- Package.resolved | 6 +- Package.swift | 4 +- .../Documentation.docc/Publishable.md | 1 + .../PropertyPublisher/Publishable.swift | 57 +++-- .../PublishableObservationRegistrar.swift | 39 +-- .../ObservationRegistrarDeclBuilder.swift | 113 ++++++++- .../PropertyPublisherDeclBuilder.swift | 22 +- .../Builders/PublisherDeclBuilder.swift | 7 + .../Main/PublishableMacro.swift | 23 +- .../MainActorMacroTests.swift | 236 ++++++++++++++++++ .../PublishableMacroTests.swift | 64 ++++- .../Suites/MainActorTests.swift | 222 ++++++++++++++++ 13 files changed, 724 insertions(+), 74 deletions(-) create mode 100644 Tests/PublishableMacrosTests/MainActorMacroTests.swift create mode 100644 Tests/PublishableTests/Suites/MainActorTests.swift 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 } + } + } +} From f28a7e4a1d16f91b22087cb348b937e401b3b8f2 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:20:25 +0100 Subject: [PATCH 02/39] - --- .mise.toml | 4 +- .swift-version | 2 +- .swiftformat | 209 ++++++++++-------- .swiftlint.yml | 16 +- Package.resolved | 9 +- Package.swift | 16 +- README.md | 3 +- .../AnyPropertyPublisher.swift | 4 +- .../PropertyPublisher/Publishable.swift | 15 +- .../ObservationRegistrarDeclBuilder.swift | 19 +- .../PropertyPublisherDeclBuilder.swift | 17 +- .../Builders/PublisherDeclBuilder.swift | 6 +- .../Main/PublishableMacro.swift | 50 ++++- .../MainActorMacroTests.swift | 7 +- .../PublishableMacroTests.swift | 3 +- .../Suites/AnyPropertyPublisherTests.swift | 6 +- .../Suites/SwiftDataTests.swift | 8 +- 17 files changed, 231 insertions(+), 163 deletions(-) diff --git a/.mise.toml b/.mise.toml index 513f2cf..7811bca 100644 --- a/.mise.toml +++ b/.mise.toml @@ -5,8 +5,8 @@ swiftlint = '~/.local/bin/mise x -- swiftlint' swiftformat = '~/.local/bin/mise x -- swiftformat' [tools] -swiftlint = "0.58.2" -swiftformat = "0.55.5" +swiftlint = "0.62.2" +swiftformat = "0.58.5" [tasks.lint] description = 'Run all linters' diff --git a/.swift-version b/.swift-version index 5049538..913671c 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -6.0 \ No newline at end of file +6.2 \ No newline at end of file diff --git a/.swiftformat b/.swiftformat index 25c4fbb..85fe404 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,106 +1,125 @@ --acronyms ID,URL,UUID --allman false ---anonymousforeach convert ---assetliterals visual-width ---asynccapturing ---beforemarks ---binarygrouping 4,8 ---callsiteparen default ---categorymark "MARK: %c" ---classthreshold 0 ---closingparen default ---closurevoid remove ---commas inline ---complexattrs prev-line ---computedvarattrs prev-line ---condassignment always ---conflictmarkers reject ---dateformat system ---decimalgrouping 3,6 ---doccomments before-declarations ---elseposition same-line ---emptybraces no-space ---enumnamespaces always ---enumthreshold 0 ---exponentcase lowercase ---exponentgrouping disabled ---extensionacl on-declarations ---extensionlength 0 ---extensionmark "MARK: - %t + %c" ---fractiongrouping disabled +--allow-partial-wrapping true +--anonymous-for-each convert +--asset-literals visual-width +--async-capturing +--before-marks +--binary-grouping 4,8 +--blank-line-after-switch-case multiline-only +--call-site-paren default +--category-mark "MARK: %c" +--class-threshold 0 +--closing-paren default +--closure-void remove +--complex-attributes prev-line +--computed-var-attributes prev-line +--conditional-assignment always +--conflict-markers reject +--date-format system +--decimal-grouping 3,6 +--default-test-suite-attributes +--doc-comments before-declarations +--else-position same-line +--empty-braces no-space +--enum-namespaces always +--enum-threshold 0 +--equatable-macro none +--exponent-case lowercase +--exponent-grouping disabled +--extension-acl on-declarations +--extension-mark "MARK: - %t + %c" +--extension-threshold 0 +--file-macro "#file" +--fraction-grouping disabled --fragment false ---funcattributes prev-line ---generictypes ---groupblanklines true ---groupedextension "MARK: %c" ---guardelse next-line +--func-attributes prev-line +--generic-types +--group-blank-lines true +--grouped-extension "MARK: %c" +--guard-else next-line --header ignore ---hexgrouping 4,8 ---hexliteralcase uppercase +--hex-grouping 4,8 +--hex-literal-case uppercase --ifdef indent ---importgrouping testable-first +--import-grouping testable-first --indent 4 ---indentcase false ---indentstrings false ---inferredtypes always ---initcodernil false ---inlinedforeach ignore +--indent-case false +--indent-strings false +--inferred-types always +--init-coder-nil false --lifecycle ---lineaftermarks true +--line-after-marks true +--line-between-guards false --linebreaks lf ---markcategories true ---markextensions always ---marktypes always ---maxwidth none ---modifierorder ---nevertrailing ---nilinit remove ---noncomplexattrs ---nospaceoperators ---nowrapoperators ---octalgrouping 4,8 ---operatorfunc spaced ---organizationmode visibility ---organizetypes actor,class,enum,struct ---patternlet hoist ---preservedecls ---preservedsymbols Package ---propertytypes inferred +--mark-categories true +--mark-class-threshold 0 +--mark-enum-threshold 0 +--mark-extension-threshold 0 +--mark-extensions always +--mark-struct-threshold 0 +--mark-types always +--markdown-files ignore +--max-width none +--modifier-order +--never-trailing +--nil-init remove +--no-space-operators +--no-wrap-operators +--non-complex-attributes +--octal-grouping 4,8 +--operator-func spaced +--organization-mode visibility +--organize-types actor,class,enum,struct +--pattern-let hoist +--preserve-acronyms +--preserve-decls +--preserved-property-types Package +--property-types inferred --ranges spaced +--redundant-async always +--redundant-throws always --self init-only ---selfrequired ---semicolons inline ---shortoptionals always ---smarttabs enabled ---someany true ---sortedpatterns ---storedvarattrs prev-line ---stripunusedargs always ---structthreshold 0 ---tabwidth unspecified ---throwcapturing +--self-required +--semicolons inline-only +--short-optionals always +--single-line-for-each ignore +--smart-tabs enabled +--some-any true +--sort-swiftui-properties none +--sorted-patterns +--stored-var-attributes prev-line +--strip-unused-args always +--struct-threshold 0 +--tab-width unspecified +--throw-capturing --timezone system ---trailingclosures ---trimwhitespace always ---typeattributes prev-line ---typeblanklines preserve ---typedelimiter space-after ---typemark "MARK: - %t" ---typemarks ---typeorder ---visibilitymarks ---visibilityorder ---voidtype void ---wraparguments before-first ---wrapcollections before-first ---wrapconditions after-first ---wrapeffects preserve ---wrapenumcases always ---wrapparameters before-first ---wrapreturntype preserve ---wrapternary before-operators ---wraptypealiases after-first ---xcodeindentation enabled ---yodaswap always ---disable enumNamespaces,fileHeader,headerFileName,redundantInternal,wrap,wrapMultilineStatementBraces,wrapSingleLineComments ---enable acronyms,blankLinesBetweenImports,blockComments,docComments,isEmpty,propertyTypes,redundantProperty,sortSwitchCases,unusedPrivateDeclarations,wrapConditionalBodies,wrapEnumCases +--trailing-closures +--trailing-commas never +--trim-whitespace always +--type-attributes prev-line +--type-blank-lines preserve +--type-body-marks preserve +--type-delimiter space-after +--type-mark "MARK: - %t" +--type-marks +--type-order +--url-macro none +--visibility-marks +--visibility-order +--void-type Void +--wrap-arguments before-first +--wrap-collections before-first +--wrap-conditions after-first +--wrap-effects preserve +--wrap-enum-cases always +--wrap-parameters before-first +--wrap-return-type preserve +--wrap-string-interpolation default +--wrap-ternary before-operators +--wrap-type-aliases after-first +--xcode-indentation enabled +--xctest-symbols +--yoda-swap always +--disable fileHeader,headerFileName,redundantInternal,wrap,wrapMultilineStatementBraces,wrapSingleLineComments +--enable acronyms,blankLinesBetweenImports,blockComments,docComments,emptyExtensions,environmentEntry,isEmpty,noForceTryInTests,noForceUnwrapInTests,noGuardInTests,propertyTypes,redundantAsync,redundantMemberwiseInit,redundantProperty,redundantThrows,singlePropertyPerLine,sortSwitchCases,unusedPrivateDeclarations,wrapConditionalBodies,wrapEnumCases,wrapMultilineFunctionChains diff --git a/.swiftlint.yml b/.swiftlint.yml index e471693..1b923f0 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -7,7 +7,7 @@ opt_in_rules: - accessibility_trait_for_button - anonymous_argument_in_multiline_closure - array_init - # async_without_await - not recognized + - async_without_await # attributes # balanced_xctest_lifecycle # closure_body_length @@ -21,7 +21,7 @@ opt_in_rules: - contains_over_first_not_nil - contains_over_range_nil_comparison # contrasted_opening_brace - # convenience_type - not working with Testing framework + # convenience_type - direct_return - discarded_notification_center_observer - discouraged_assert @@ -56,6 +56,7 @@ opt_in_rules: - identical_operands - implicit_return # implicitly_unwrapped_optional + # incompatible_concurrency_annotation # indentation_width - joined_default_parameter - last_where @@ -66,7 +67,7 @@ opt_in_rules: - local_doc_comment - lower_acl_than_parent # missing_docs - # modifier_order + - modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_function_chains @@ -90,6 +91,8 @@ opt_in_rules: - override_in_extension - pattern_matching_keywords - period_spacing + - prefer_asset_symbols + - prefer_condition_list - prefer_key_path # prefer_nimble - prefer_self_in_static_references @@ -123,7 +126,7 @@ opt_in_rules: - static_operator # strict_fileprivate - strong_iboutlet - - superfluous_else + # superfluous_else - switch_case_on_newline - test_case_accessibility - toggle_bool @@ -164,7 +167,7 @@ file_length: warning: 500 identifier_name: - excluded: [id, x, y, z] + excluded: [id, ui, x, y, z, dx, dy, dz] line_length: ignores_comments: true @@ -172,6 +175,9 @@ line_length: nesting: type_level: 2 +no_magic_numbers: + allowed_numbers: [0.0, 1.0, 2.0, 100.0] + type_name: allowed_symbols: ["_"] max_length: 50 diff --git a/Package.resolved b/Package.resolved index d27c3ad..d550944 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "6091bffc9b91ac475d5e4d5008c7140d158dc19e03b0762a886ebe8d53eb77b0", + "originHash" : "49eb36f5c481140e675566892d53be0c7c4435b2be449b8f8e957fe8d851b19c", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "d55ddb9d15a0e266a01b0153db4a3750da7d33b8", - "version" : "2.0.4" + "revision" : "99f04db9fefe7faa666721c1484197d0a2bd0e15" } }, { @@ -15,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } } ], diff --git a/Package.swift b/Package.swift index 1491b19..0b7dfbd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport @@ -23,11 +23,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - from: "2.0.4" + revision: "99f04db9fefe7faa666721c1484197d0a2bd0e15" ), .package( url: "https://github.com/swiftlang/swift-syntax", - "600.0.0" ..< "604.0.0" + "602.0.0" ..< "603.0.0" ) ], targets: [ @@ -57,7 +57,11 @@ let package = Package( dependencies: [ "PublishableMacros", .product( - name: "SwiftSyntaxMacrosTestSupport", + name: "PrincipleMacrosTestSupport", + package: "PrincipleMacros" + ), + .product( + name: "SwiftCompilerPlugin", package: "swift-syntax" ) ] @@ -68,6 +72,8 @@ let package = Package( for target in package.targets { target.swiftSettings = (target.swiftSettings ?? []) + [ .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny") + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] } diff --git a/README.md b/README.md index 08dcc0f..a50d46e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. This is where `Publishable` comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you take advantage of the latest -`Observable` features, while processing changes synchronously when needed. It even works with the `SwiftData.Model` macro! +`Observable` features, while processing changes synchronously when needed. It integrates just as smoothly with the `SwiftData.Model` macro +and can be extended to support other macros built on `Observation`. ```swift import Publishable diff --git a/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift b/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift index ba73ed8..0702658 100644 --- a/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift +++ b/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift @@ -10,8 +10,8 @@ import Combine /// An object that exposes `Combine` publishers for ``willChange`` and ``didChange`` events. /// -/// Subclasses of this class are generated by the ``Publishable()`` macro, -/// and provide publishers for all mutable instance properties of the type the macro is applied to. +/// Subclasses of this class are generated by the ``Publishable()`` macro, and provide publishers +/// for all mutable or computed instance properties of the type the macro is applied to. /// open class AnyPropertyPublisher { diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index 26d3655..66de6ae 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Publishable/PropertyPublisher/Publishable.swift @@ -81,4 +81,17 @@ public macro Publishable( /// 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 {} +public protocol Publishable: AnyObject, Observable { + + /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, + /// containing publishers for all mutable or computed instance properties of the type. + /// + associatedtype PropertyPublisher: AnyPropertyPublisher + + /// An instance that exposes `Combine` publishers for all mutable or computed 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 } +} diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 71b98bb..08dc160 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -12,14 +12,11 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList - let explicitGlobalActorIsolation: GlobalActorIsolation? + let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? - var settings: DeclBuilderSettings { - .init( - accessControlLevel: .init(inheritingDeclaration: .member), - explicitGlobalActorIsolation: explicitGlobalActorIsolation - ) - } + let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( + inheritingDeclaration: .member + ) private var registeredProperties: PropertiesList { properties.stored.mutable.instance @@ -30,7 +27,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { """ private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -49,7 +46,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private func publishNewValueFunction() -> MemberBlockItemListSyntax { """ - \(inheritedGlobalActorAttribute)func publish( + \(inheritedGlobalActorIsolation)func publish( _ object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any> ) { @@ -78,7 +75,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private func subjectFunctions() -> MemberBlockItemListSyntax { for inferredType in registeredProperties.uniqueInferredTypes { """ - \(inheritedGlobalActorAttribute)private func subject( + \(inheritedGlobalActorIsolation)private func subject( for keyPath: KeyPath<\(trimmedType), \(inferredType)>, on object: \(trimmedType) ) -> PassthroughSubject<\(inferredType), Never>? { @@ -157,7 +154,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { } private func assumeIsolatedIfNeededFunction() -> MemberBlockItemListSyntax { - if let globalActor = inheritedGlobalActorIsolation?.trimmedType { + if let globalActor = inheritedGlobalActorIsolation?.standardizedType { // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift """ private nonisolated func assumeIsolatedIfNeeded( diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index 0ac997e..9715f66 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -12,14 +12,11 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList - let explicitGlobalActorIsolation: GlobalActorIsolation? + let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? - var settings: DeclBuilderSettings { - .init( - accessControlLevel: .init(inheritingDeclaration: .member), - explicitGlobalActorIsolation: explicitGlobalActorIsolation - ) - } + let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( + inheritingDeclaration: .member + ) func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order [ @@ -54,6 +51,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.stored.mutable.instance { + let globalActor = inheritedGlobalActorIsolation let accessControlLevel = property.declaration.inlinableAccessControlLevel( inheritedBy: .peer, maxAllowed: .public @@ -62,7 +60,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { let type = property.inferredType """ fileprivate let _\(name) = PassthroughSubject<\(type), Never>() - \(inheritedGlobalActorAttribute)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { _storedPropertyPublisher(_\(name), for: \\.\(name)) } """ @@ -72,6 +70,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.computed.instance { + let globalActor = inheritedGlobalActorIsolation let accessControlLevel = property.declaration.inlinableAccessControlLevel( inheritedBy: .peer, maxAllowed: .public @@ -79,7 +78,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { let name = property.trimmedName let type = property.inferredType """ - \(inheritedGlobalActorAttribute)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { _computedPropertyPublisher(for: \\.\(name)) } """ diff --git a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift index b9ba7b7..22ae3e7 100644 --- a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift @@ -13,9 +13,9 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList - var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) - } + let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( + inheritingDeclaration: .member + ) func build() -> [DeclSyntax] { [ diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Sources/PublishableMacros/Main/PublishableMacro.swift index e89879e..41e6002 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Sources/PublishableMacros/Main/PublishableMacro.swift @@ -40,10 +40,7 @@ extension PublishableMacro: MemberMacro { return [] } - let parameterExtractor = ParameterExtractor(from: node) - let explicitGlobalActorIsolation = try parameterExtractor - .globalActorIsolation(withLabel: "isolation") - + let parameters = try Parameters(from: node) let properties = PropertiesParser.parse( memberBlock: declaration.memberBlock, in: context @@ -57,12 +54,12 @@ extension PublishableMacro: MemberMacro { PropertyPublisherDeclBuilder( declaration: declaration, properties: properties, - explicitGlobalActorIsolation: explicitGlobalActorIsolation + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( declaration: declaration, properties: properties, - explicitGlobalActorIsolation: explicitGlobalActorIsolation + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ) ] @@ -75,22 +72,42 @@ extension PublishableMacro: MemberMacro { extension PublishableMacro: ExtensionMacro { public static func expansion( - of _: AttributeSyntax, + of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard validate(declaration, in: context) != nil else { + guard let declaration = validate(declaration, in: context) else { return [] } + let parameters = try Parameters(from: node) + let globalActorIsolation = GlobalActorIsolation.resolved( + for: declaration, + preferred: parameters.preferredGlobalActorIsolation + ) + + let attributes: AttributeListSyntax = if let globalActorIsolation { + [.attribute(globalActorIsolation.standardizedAttribute)] + } else { + [] + } + return [ .init( extendedType: type, inheritanceClause: .init( inheritedTypes: [ - .init(type: IdentifierTypeSyntax(name: "Publishable")) + InheritedTypeSyntax( + type: AttributedTypeSyntax( + specifiers: [], + attributes: attributes, + baseType: IdentifierTypeSyntax( + name: "Publishable" + ) + ) + ) ] ), memberBlock: "{}" @@ -98,3 +115,18 @@ extension PublishableMacro: ExtensionMacro { ] } } + +extension PublishableMacro { + + private struct Parameters { + + let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? + + init(from node: AttributeSyntax) throws { + let extractor = ParameterExtractor(from: node) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation( + withLabel: "isolation" + ) + } + } +} diff --git a/Tests/PublishableMacrosTests/MainActorMacroTests.swift b/Tests/PublishableMacrosTests/MainActorMacroTests.swift index d32c654..d6fa05a 100644 --- a/Tests/PublishableMacrosTests/MainActorMacroTests.swift +++ b/Tests/PublishableMacrosTests/MainActorMacroTests.swift @@ -7,9 +7,8 @@ // #if canImport(PublishableMacros) + import PrincipleMacrosTestSupport import PublishableMacros - import SwiftSyntaxMacros - import SwiftSyntaxMacrosTestSupport import XCTest internal final class MainActorMacroTests: XCTestCase { @@ -112,7 +111,7 @@ private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -226,7 +225,7 @@ } } - extension Person: Publishable { + extension Person: @MainActor Publishable { } """#, macros: macros diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 98f6690..23c4cb0 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -7,9 +7,8 @@ // #if canImport(PublishableMacros) + import PrincipleMacrosTestSupport import PublishableMacros - import SwiftSyntaxMacros - import SwiftSyntaxMacrosTestSupport import XCTest internal final class PublishableMacroTests: XCTestCase { diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift index 82cca83..4dd501e 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift @@ -148,8 +148,7 @@ extension AnyPropertyPublisherTests { object?.storedProperty = 0 #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() != nil) - observe() + #expect(observationsQueue.popFirst() == nil) object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) @@ -193,8 +192,7 @@ extension AnyPropertyPublisherTests { object?.storedProperty = 0 #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() != nil) - observe() + #expect(observationsQueue.popFirst() == nil) object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/PublishableTests/Suites/SwiftDataTests.swift index 57c4413..46b65c4 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/PublishableTests/Suites/SwiftDataTests.swift @@ -14,7 +14,7 @@ import Testing internal struct SwiftDataTests { @Test - func testStoredPropertyPublisher() { + func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -54,7 +54,7 @@ internal struct SwiftDataTests { } @Test - func testComputedPropertyPublisher() { + func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -102,7 +102,7 @@ internal struct SwiftDataTests { extension SwiftDataTests { @Test - func testWillChangePublisher() { + func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -151,7 +151,7 @@ extension SwiftDataTests { } @Test - func testDidChangePublisher() { + func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] From 944eb6b14e35c4ee1d71f901379cacec3021d8fb Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:20:25 +0100 Subject: [PATCH 03/39] [SwiftFormat] Applied formatting --- .../Suites/AnyPropertyPublisherTests.swift | 16 ++++++++-------- .../PublishableTests/Suites/SwiftDataTests.swift | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift index 4dd501e..b4c9714 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift @@ -25,10 +25,10 @@ internal struct AnyPropertyPublisherTests { } @Test - func testNonEquatableStoredPropertyPublisher() { + func nonEquatableStoredPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -65,10 +65,10 @@ internal struct AnyPropertyPublisherTests { } @Test - func testNonEquatableComputedPropertyPublisher() { + func nonEquatableComputedPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -119,10 +119,10 @@ extension AnyPropertyPublisherTests { } @Test - func testEquatableStoredPropertyPublisher() { + func equatableStoredPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -163,10 +163,10 @@ extension AnyPropertyPublisherTests { } @Test - func testEquatableComputedPropertyPublisher() { + func equatableComputedPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/PublishableTests/Suites/SwiftDataTests.swift index 46b65c4..b13a1c2 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/PublishableTests/Suites/SwiftDataTests.swift @@ -17,7 +17,7 @@ internal struct SwiftDataTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -57,7 +57,7 @@ internal struct SwiftDataTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -105,7 +105,7 @@ extension SwiftDataTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -154,7 +154,7 @@ extension SwiftDataTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -203,11 +203,11 @@ extension SwiftDataTests { extension SwiftDataTests { @Publishable @Model - public final class Person { + final class Person { var age: Int fileprivate(set) var name: String - public var surname: String + var surname: String internal var fullName: String { "\(name) \(surname)" From 376961e6b09cab83941a9da8b68204a96992c6bc Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:24:08 +0100 Subject: [PATCH 04/39] - --- .../Suites/AnyPropertyPublisherTests.swift | 8 ++++---- Tests/PublishableTests/Suites/MainActorTests.swift | 8 ++++---- Tests/PublishableTests/Suites/ObservableTests.swift | 8 ++++---- Tests/PublishableTests/Suites/SwiftDataTests.swift | 12 ++++++------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift index b4c9714..022b0a2 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift @@ -28,7 +28,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableStoredPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -68,7 +68,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableComputedPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -122,7 +122,7 @@ extension AnyPropertyPublisherTests { func equatableStoredPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -166,7 +166,7 @@ extension AnyPropertyPublisherTests { func equatableComputedPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( diff --git a/Tests/PublishableTests/Suites/MainActorTests.swift b/Tests/PublishableTests/Suites/MainActorTests.swift index 2d0b783..0c28d43 100644 --- a/Tests/PublishableTests/Suites/MainActorTests.swift +++ b/Tests/PublishableTests/Suites/MainActorTests.swift @@ -14,7 +14,7 @@ import Testing internal struct MainActorTests { @Test - func testStoredPropertyPublisher() { + func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -54,7 +54,7 @@ internal struct MainActorTests { } @Test - func testComputedPropertyPublisher() { + func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -102,7 +102,7 @@ internal struct MainActorTests { extension MainActorTests { @Test - func testWillChangePublisher() { + func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -151,7 +151,7 @@ extension MainActorTests { } @Test - func testDidChangePublisher() { + func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] diff --git a/Tests/PublishableTests/Suites/ObservableTests.swift b/Tests/PublishableTests/Suites/ObservableTests.swift index fd30783..aaaff32 100644 --- a/Tests/PublishableTests/Suites/ObservableTests.swift +++ b/Tests/PublishableTests/Suites/ObservableTests.swift @@ -13,7 +13,7 @@ import Testing internal struct ObservableTests { @Test - func testStoredPropertyPublisher() { + func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -53,7 +53,7 @@ internal struct ObservableTests { } @Test - func testComputedPropertyPublisher() { + func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -101,7 +101,7 @@ internal struct ObservableTests { extension ObservableTests { @Test - func testWillChangePublisher() { + func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] @@ -150,7 +150,7 @@ extension ObservableTests { } @Test - func testDidChangePublisher() { + func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue: [Void] = [] diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/PublishableTests/Suites/SwiftDataTests.swift index b13a1c2..46b65c4 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/PublishableTests/Suites/SwiftDataTests.swift @@ -17,7 +17,7 @@ internal struct SwiftDataTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -57,7 +57,7 @@ internal struct SwiftDataTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -105,7 +105,7 @@ extension SwiftDataTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -154,7 +154,7 @@ extension SwiftDataTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue: [Void] = [] var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -203,11 +203,11 @@ extension SwiftDataTests { extension SwiftDataTests { @Publishable @Model - final class Person { + public final class Person { var age: Int fileprivate(set) var name: String - var surname: String + public var surname: String internal var fullName: String { "\(name) \(surname)" From 90e125b9105484a0d9532a339c3a173960119311 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:24:08 +0100 Subject: [PATCH 05/39] [SwiftFormat] Applied formatting --- .../Suites/AnyPropertyPublisherTests.swift | 8 ++++---- Tests/PublishableTests/Suites/MainActorTests.swift | 12 ++++++------ Tests/PublishableTests/Suites/ObservableTests.swift | 12 ++++++------ Tests/PublishableTests/Suites/SwiftDataTests.swift | 12 ++++++------ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift index 022b0a2..b4c9714 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift @@ -28,7 +28,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableStoredPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -68,7 +68,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableComputedPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -122,7 +122,7 @@ extension AnyPropertyPublisherTests { func equatableStoredPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -166,7 +166,7 @@ extension AnyPropertyPublisherTests { func equatableComputedPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( diff --git a/Tests/PublishableTests/Suites/MainActorTests.swift b/Tests/PublishableTests/Suites/MainActorTests.swift index 0c28d43..9200933 100644 --- a/Tests/PublishableTests/Suites/MainActorTests.swift +++ b/Tests/PublishableTests/Suites/MainActorTests.swift @@ -17,7 +17,7 @@ internal struct MainActorTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -57,7 +57,7 @@ internal struct MainActorTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -105,7 +105,7 @@ extension MainActorTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -154,7 +154,7 @@ extension MainActorTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -203,12 +203,12 @@ extension MainActorTests { extension MainActorTests { @MainActor @Publishable @Observable - public final class Person { + final class Person { let id = UUID() var age = 25 fileprivate(set) var name = "John" - public var surname = "Doe" + var surname = "Doe" internal var fullName: String { "\(name) \(surname)" diff --git a/Tests/PublishableTests/Suites/ObservableTests.swift b/Tests/PublishableTests/Suites/ObservableTests.swift index aaaff32..9a639d3 100644 --- a/Tests/PublishableTests/Suites/ObservableTests.swift +++ b/Tests/PublishableTests/Suites/ObservableTests.swift @@ -16,7 +16,7 @@ internal struct ObservableTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -56,7 +56,7 @@ internal struct ObservableTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -104,7 +104,7 @@ extension ObservableTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -153,7 +153,7 @@ extension ObservableTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -202,12 +202,12 @@ extension ObservableTests { extension ObservableTests { @Publishable @Observable - public final class Person { + final class Person { let id = UUID() var age = 25 fileprivate(set) var name = "John" - public var surname = "Doe" + var surname = "Doe" internal var fullName: String { "\(name) \(surname)" diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/PublishableTests/Suites/SwiftDataTests.swift index 46b65c4..b13a1c2 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/PublishableTests/Suites/SwiftDataTests.swift @@ -17,7 +17,7 @@ internal struct SwiftDataTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -57,7 +57,7 @@ internal struct SwiftDataTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -105,7 +105,7 @@ extension SwiftDataTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -154,7 +154,7 @@ extension SwiftDataTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Void]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -203,11 +203,11 @@ extension SwiftDataTests { extension SwiftDataTests { @Publishable @Model - public final class Person { + final class Person { var age: Int fileprivate(set) var name: String - public var surname: String + var surname: String internal var fullName: String { "\(name) \(surname)" From 2fd6d695df5f1e7e252f1afd5aeca8528466a71a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:27:28 +0100 Subject: [PATCH 06/39] - --- .../Suites/AnyPropertyPublisherTests.swift | 24 ++++++------- .../Suites/MainActorTests.swift | 34 +++++++++---------- .../Suites/ObservableTests.swift | 34 +++++++++---------- .../Suites/SwiftDataTests.swift | 34 +++++++++---------- 4 files changed, 63 insertions(+), 63 deletions(-) diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift index b4c9714..30efc63 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift @@ -28,7 +28,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableStoredPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -40,7 +40,7 @@ internal struct AnyPropertyPublisherTests { withObservationTracking { _ = object?.storedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -54,7 +54,7 @@ internal struct AnyPropertyPublisherTests { object?.storedProperty = NonEquatableStruct() #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -68,7 +68,7 @@ internal struct AnyPropertyPublisherTests { func nonEquatableComputedPropertyPublisher() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -80,7 +80,7 @@ internal struct AnyPropertyPublisherTests { withObservationTracking { _ = object?.computedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -94,7 +94,7 @@ internal struct AnyPropertyPublisherTests { object?.storedProperty = NonEquatableStruct() #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -122,7 +122,7 @@ extension AnyPropertyPublisherTests { func equatableStoredPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -134,7 +134,7 @@ extension AnyPropertyPublisherTests { withObservationTracking { _ = object?.storedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -152,7 +152,7 @@ extension AnyPropertyPublisherTests { object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -166,7 +166,7 @@ extension AnyPropertyPublisherTests { func equatableComputedPropertyPublisher() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -178,7 +178,7 @@ extension AnyPropertyPublisherTests { withObservationTracking { _ = object?.computedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -196,7 +196,7 @@ extension AnyPropertyPublisherTests { object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil diff --git a/Tests/PublishableTests/Suites/MainActorTests.swift b/Tests/PublishableTests/Suites/MainActorTests.swift index 9200933..01ae373 100644 --- a/Tests/PublishableTests/Suites/MainActorTests.swift +++ b/Tests/PublishableTests/Suites/MainActorTests.swift @@ -17,7 +17,7 @@ internal struct MainActorTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -29,7 +29,7 @@ internal struct MainActorTests { withObservationTracking { _ = person?.name } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -43,7 +43,7 @@ internal struct MainActorTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -57,7 +57,7 @@ internal struct MainActorTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -69,7 +69,7 @@ internal struct MainActorTests { withObservationTracking { _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -79,7 +79,7 @@ internal struct MainActorTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 @@ -88,7 +88,7 @@ internal struct MainActorTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -105,7 +105,7 @@ extension MainActorTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -120,7 +120,7 @@ extension MainActorTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -130,17 +130,17 @@ extension MainActorTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -154,7 +154,7 @@ extension MainActorTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -169,7 +169,7 @@ extension MainActorTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -179,17 +179,17 @@ extension MainActorTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil diff --git a/Tests/PublishableTests/Suites/ObservableTests.swift b/Tests/PublishableTests/Suites/ObservableTests.swift index 9a639d3..d8d916d 100644 --- a/Tests/PublishableTests/Suites/ObservableTests.swift +++ b/Tests/PublishableTests/Suites/ObservableTests.swift @@ -16,7 +16,7 @@ internal struct ObservableTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -28,7 +28,7 @@ internal struct ObservableTests { withObservationTracking { _ = person?.name } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -42,7 +42,7 @@ internal struct ObservableTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -56,7 +56,7 @@ internal struct ObservableTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -68,7 +68,7 @@ internal struct ObservableTests { withObservationTracking { _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -78,7 +78,7 @@ internal struct ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 @@ -87,7 +87,7 @@ internal struct ObservableTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -104,7 +104,7 @@ extension ObservableTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -119,7 +119,7 @@ extension ObservableTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -129,17 +129,17 @@ extension ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -153,7 +153,7 @@ extension ObservableTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -168,7 +168,7 @@ extension ObservableTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -178,17 +178,17 @@ extension ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/PublishableTests/Suites/SwiftDataTests.swift index b13a1c2..663b3db 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/PublishableTests/Suites/SwiftDataTests.swift @@ -17,7 +17,7 @@ internal struct SwiftDataTests { func storedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -29,7 +29,7 @@ internal struct SwiftDataTests { withObservationTracking { _ = person?.name } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -43,7 +43,7 @@ internal struct SwiftDataTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -57,7 +57,7 @@ internal struct SwiftDataTests { func computedPropertyPublisher() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -69,7 +69,7 @@ internal struct SwiftDataTests { withObservationTracking { _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -79,7 +79,7 @@ internal struct SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 @@ -88,7 +88,7 @@ internal struct SwiftDataTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -105,7 +105,7 @@ extension SwiftDataTests { func willChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -120,7 +120,7 @@ extension SwiftDataTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -130,17 +130,17 @@ extension SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -154,7 +154,7 @@ extension SwiftDataTests { func didChangePublisher() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Void]() + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -169,7 +169,7 @@ extension SwiftDataTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -179,17 +179,17 @@ extension SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil From b06e67f68e27a6af63feb6372b9cf62955d59b8f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Thu, 13 Nov 2025 09:28:13 +0100 Subject: [PATCH 07/39] - --- .../Builders/PropertyPublisherDeclBuilder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index 9715f66..4b73b43 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -18,7 +18,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { inheritingDeclaration: .member ) - func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order + func build() -> [DeclSyntax] { [ """ \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedType)> { From 90a9f9e794c6fea7546b9fb11633727ddfeffaa5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 14 Nov 2025 12:57:25 +0100 Subject: [PATCH 08/39] - --- Package.resolved | 4 ++-- Package.swift | 2 +- .../ObservationRegistrarDeclBuilder.swift | 6 +----- .../Builders/PropertyPublisherDeclBuilder.swift | 16 +++------------- .../Builders/PublisherDeclBuilder.swift | 6 +----- .../MainActorMacroTests.swift | 6 +++--- .../PublishableMacroTests.swift | 6 +++--- 7 files changed, 14 insertions(+), 32 deletions(-) diff --git a/Package.resolved b/Package.resolved index d550944..b0b0997 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "49eb36f5c481140e675566892d53be0c7c4435b2be449b8f8e957fe8d851b19c", + "originHash" : "cba26b03befa9cef8ad9a03f41fd3a83c1b365772eb6fd756bea0f68e337fb7b", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "99f04db9fefe7faa666721c1484197d0a2bd0e15" + "revision" : "e7336abe981c31deaebde62c5dd216fb88029f90" } }, { diff --git a/Package.swift b/Package.swift index 0b7dfbd..eee659e 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - revision: "99f04db9fefe7faa666721c1484197d0a2bd0e15" + revision: "e7336abe981c31deaebde62c5dd216fb88029f90" ), .package( url: "https://github.com/swiftlang/swift-syntax", diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 08dc160..5b0b0f0 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -8,16 +8,12 @@ import PrincipleMacros -internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { +internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? - let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( - inheritingDeclaration: .member - ) - private var registeredProperties: PropertiesList { properties.stored.mutable.instance } diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index 4b73b43..7789775 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -8,16 +8,12 @@ import PrincipleMacros -internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { +internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? - let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( - inheritingDeclaration: .member - ) - func build() -> [DeclSyntax] { [ """ @@ -52,10 +48,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.stored.mutable.instance { let globalActor = inheritedGlobalActorIsolation - let accessControlLevel = property.declaration.inlinableAccessControlLevel( - inheritedBy: .peer, - maxAllowed: .public - ) + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType """ @@ -71,10 +64,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.computed.instance { let globalActor = inheritedGlobalActorIsolation - let accessControlLevel = property.declaration.inlinableAccessControlLevel( - inheritedBy: .peer, - maxAllowed: .public - ) + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType """ diff --git a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift index 22ae3e7..2aea0ca 100644 --- a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift @@ -8,15 +8,11 @@ import PrincipleMacros -internal struct PublisherDeclBuilder: ClassDeclBuilder { +internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList - let accessControlLevelInheritanceSettings = AccessControlLevelInheritanceSettings( - inheritingDeclaration: .member - ) - func build() -> [DeclSyntax] { [ """ diff --git a/Tests/PublishableMacrosTests/MainActorMacroTests.swift b/Tests/PublishableMacrosTests/MainActorMacroTests.swift index d6fa05a..05eff91 100644 --- a/Tests/PublishableMacrosTests/MainActorMacroTests.swift +++ b/Tests/PublishableMacrosTests/MainActorMacroTests.swift @@ -39,7 +39,7 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } @@ -66,7 +66,7 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } @@ -104,7 +104,7 @@ @MainActor internal var fullName: AnyPublisher { _computedPropertyPublisher(for: \.fullName) } - @MainActor package var initials: AnyPublisher { + @MainActor fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } } diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 23c4cb0..d305e11 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -39,7 +39,7 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } @@ -66,7 +66,7 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } @@ -104,7 +104,7 @@ internal var fullName: AnyPublisher { _computedPropertyPublisher(for: \.fullName) } - package var initials: AnyPublisher { + fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } } From 3b76e94b8383fd2cc1860f97226e2977400c07af Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 14 Nov 2025 17:20:22 +0100 Subject: [PATCH 09/39] Rename --- Package.resolved | 4 ++-- Package.swift | 22 +++++++++---------- README.md | 2 +- .../Documentation.docc/Publishable.md | 0 Sources/{Publishable => Relay}/Imports.swift | 0 .../Publishable}/AnyPropertyPublisher.swift | 0 .../Publishable}/Publishable.swift | 4 ++-- .../PublishableObservationRegistrar.swift | 0 .../SwiftObservationRegistrar.swift | 0 .../Main/RelayPlugin.swift} | 4 ++-- .../ObservationRegistrarDeclBuilder.swift | 4 ++-- .../PropertyPublisherDeclBuilder.swift | 2 +- .../Publishable}/PublishableMacro.swift | 15 +++---------- .../Publishable}/PublisherDeclBuilder.swift | 0 .../MainActorPublishableMacroTests.swift} | 8 +++---- .../Publishable}/PublishableMacroTests.swift | 4 ++-- .../Helpers/Array+PopFirst.swift | 0 .../AnyPropertyPublisherTests.swift | 2 +- .../MainActorPublishableTests.swift} | 10 ++++----- .../ObservationPublishableTests.swift} | 10 ++++----- .../SwiftDataPublishableTests.swift} | 10 ++++----- 21 files changed, 46 insertions(+), 55 deletions(-) rename Sources/{Publishable => Relay}/Documentation.docc/Publishable.md (100%) rename Sources/{Publishable => Relay}/Imports.swift (100%) rename Sources/{Publishable/PropertyPublisher => Relay/Publishable}/AnyPropertyPublisher.swift (100%) rename Sources/{Publishable/PropertyPublisher => Relay/Publishable}/Publishable.swift (98%) rename Sources/{Publishable/Registrars => Relay/Publishable}/PublishableObservationRegistrar.swift (100%) rename Sources/{Publishable/Registrars => Relay/Publishable}/SwiftObservationRegistrar.swift (100%) rename Sources/{PublishableMacros/Main/PublishablePlugin.swift => RelayMacros/Main/RelayPlugin.swift} (77%) rename Sources/{PublishableMacros/Builders => RelayMacros/Publishable}/ObservationRegistrarDeclBuilder.swift (98%) rename Sources/{PublishableMacros/Builders => RelayMacros/Publishable}/PropertyPublisherDeclBuilder.swift (97%) rename Sources/{PublishableMacros/Main => RelayMacros/Publishable}/PublishableMacro.swift (87%) rename Sources/{PublishableMacros/Builders => RelayMacros/Publishable}/PublisherDeclBuilder.swift (100%) rename Tests/{PublishableMacrosTests/MainActorMacroTests.swift => RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift} (98%) rename Tests/{PublishableMacrosTests => RelayMacrosTests/Publishable}/PublishableMacroTests.swift (99%) rename Tests/{PublishableTests => RelayTests}/Helpers/Array+PopFirst.swift (100%) rename Tests/{PublishableTests/Suites => RelayTests/Publishable}/AnyPropertyPublisherTests.swift (99%) rename Tests/{PublishableTests/Suites/MainActorTests.swift => RelayTests/Publishable/MainActorPublishableTests.swift} (97%) rename Tests/{PublishableTests/Suites/ObservableTests.swift => RelayTests/Publishable/ObservationPublishableTests.swift} (97%) rename Tests/{PublishableTests/Suites/SwiftDataTests.swift => RelayTests/Publishable/SwiftDataPublishableTests.swift} (97%) diff --git a/Package.resolved b/Package.resolved index b0b0997..fcc7122 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "cba26b03befa9cef8ad9a03f41fd3a83c1b365772eb6fd756bea0f68e337fb7b", + "originHash" : "c7f470c434b63b5457085b32585e613e796b42f710570891a11544ef6760a02c", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "e7336abe981c31deaebde62c5dd216fb88029f90" + "revision" : "0fed1881daa3a2a8f6ebe606f17e5b5416369f6f" } }, { diff --git a/Package.swift b/Package.swift index eee659e..4ef7d02 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import CompilerPluginSupport import PackageDescription let package = Package( - name: "Publishable", + name: "Relay", platforms: [ .macOS(.v14), .macCatalyst(.v17), @@ -16,14 +16,14 @@ let package = Package( ], products: [ .library( - name: "Publishable", - targets: ["Publishable"] + name: "Relay", + targets: ["Relay"] ) ], dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - revision: "e7336abe981c31deaebde62c5dd216fb88029f90" + revision: "0fed1881daa3a2a8f6ebe606f17e5b5416369f6f" ), .package( url: "https://github.com/swiftlang/swift-syntax", @@ -32,15 +32,15 @@ let package = Package( ], targets: [ .target( - name: "Publishable", - dependencies: ["PublishableMacros"] + name: "Relay", + dependencies: ["RelayMacros"] ), .testTarget( - name: "PublishableTests", - dependencies: ["Publishable"] + name: "RelayTests", + dependencies: ["Relay"] ), .macro( - name: "PublishableMacros", + name: "RelayMacros", dependencies: [ .product( name: "PrincipleMacros", @@ -53,9 +53,9 @@ let package = Package( ] ), .testTarget( - name: "PublishableMacrosTests", + name: "RelayMacrosTests", dependencies: [ - "PublishableMacros", + "RelayMacros", .product( name: "PrincipleMacrosTestSupport", package: "PrincipleMacros" diff --git a/README.md b/README.md index a50d46e..d45d503 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This is where `Publishable` comes in. It allows `Observation` and `Combine` to c and can be extended to support other macros built on `Observation`. ```swift -import Publishable +import Relay @Publishable @Observable final class Person { diff --git a/Sources/Publishable/Documentation.docc/Publishable.md b/Sources/Relay/Documentation.docc/Publishable.md similarity index 100% rename from Sources/Publishable/Documentation.docc/Publishable.md rename to Sources/Relay/Documentation.docc/Publishable.md diff --git a/Sources/Publishable/Imports.swift b/Sources/Relay/Imports.swift similarity index 100% rename from Sources/Publishable/Imports.swift rename to Sources/Relay/Imports.swift diff --git a/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift similarity index 100% rename from Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift rename to Sources/Relay/Publishable/AnyPropertyPublisher.swift diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Relay/Publishable/Publishable.swift similarity index 98% rename from Sources/Publishable/PropertyPublisher/Publishable.swift rename to Sources/Relay/Publishable/Publishable.swift index 66de6ae..785e49d 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Relay/Publishable/Publishable.swift @@ -37,7 +37,7 @@ import Observation conformances: Publishable ) public macro Publishable() = #externalMacro( - module: "PublishableMacros", + module: "RelayMacros", type: "PublishableMacro" ) @@ -72,7 +72,7 @@ public macro Publishable() = #externalMacro( public macro Publishable( isolation: Isolation.Type? ) = #externalMacro( - module: "PublishableMacros", + module: "RelayMacros", type: "PublishableMacro" ) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Relay/Publishable/PublishableObservationRegistrar.swift similarity index 100% rename from Sources/Publishable/Registrars/PublishableObservationRegistrar.swift rename to Sources/Relay/Publishable/PublishableObservationRegistrar.swift diff --git a/Sources/Publishable/Registrars/SwiftObservationRegistrar.swift b/Sources/Relay/Publishable/SwiftObservationRegistrar.swift similarity index 100% rename from Sources/Publishable/Registrars/SwiftObservationRegistrar.swift rename to Sources/Relay/Publishable/SwiftObservationRegistrar.swift diff --git a/Sources/PublishableMacros/Main/PublishablePlugin.swift b/Sources/RelayMacros/Main/RelayPlugin.swift similarity index 77% rename from Sources/PublishableMacros/Main/PublishablePlugin.swift rename to Sources/RelayMacros/Main/RelayPlugin.swift index 3f15e31..dfb4174 100644 --- a/Sources/PublishableMacros/Main/PublishablePlugin.swift +++ b/Sources/RelayMacros/Main/RelayPlugin.swift @@ -1,5 +1,5 @@ // -// PublishablePlugin.swift +// RelayPlugin.swift // Publishable // // Created by Kamil Strzelecki on 11/01/2025. @@ -10,7 +10,7 @@ import PrincipleMacros import SwiftCompilerPlugin @main -internal struct PublishablePlugin: CompilerPlugin { +internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ PublishableMacro.self diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift similarity index 98% rename from Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift rename to Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index 5b0b0f0..aca59e8 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -12,7 +12,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin let declaration: ClassDeclSyntax let properties: PropertiesList - let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? + let preferredGlobalActorIsolation: GlobalActorIsolation? private var registeredProperties: PropertiesList { properties.stored.mutable.instance @@ -150,7 +150,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin } private func assumeIsolatedIfNeededFunction() -> MemberBlockItemListSyntax { - if let globalActor = inheritedGlobalActorIsolation?.standardizedType { + if let globalActor = inheritedGlobalActorIsolation?.standardizedIsolationType { // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift """ private nonisolated func assumeIsolatedIfNeeded( diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift similarity index 97% rename from Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift rename to Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index 7789775..5ca134e 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -12,7 +12,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList - let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? + let preferredGlobalActorIsolation: GlobalActorIsolation? func build() -> [DeclSyntax] { [ diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Sources/RelayMacros/Publishable/PublishableMacro.swift similarity index 87% rename from Sources/PublishableMacros/Main/PublishableMacro.swift rename to Sources/RelayMacros/Publishable/PublishableMacro.swift index 41e6002..da008e6 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Sources/RelayMacros/Publishable/PublishableMacro.swift @@ -88,12 +88,6 @@ extension PublishableMacro: ExtensionMacro { preferred: parameters.preferredGlobalActorIsolation ) - let attributes: AttributeListSyntax = if let globalActorIsolation { - [.attribute(globalActorIsolation.standardizedAttribute)] - } else { - [] - } - return [ .init( extendedType: type, @@ -101,11 +95,8 @@ extension PublishableMacro: ExtensionMacro { inheritedTypes: [ InheritedTypeSyntax( type: AttributedTypeSyntax( - specifiers: [], - attributes: attributes, - baseType: IdentifierTypeSyntax( - name: "Publishable" - ) + globalActorIsolation: globalActorIsolation, + baseType: IdentifierTypeSyntax(name: "Publishable") ) ) ] @@ -120,7 +111,7 @@ extension PublishableMacro { private struct Parameters { - let preferredGlobalActorIsolation: ExplicitGlobalActorIsolation? + let preferredGlobalActorIsolation: GlobalActorIsolation? init(from node: AttributeSyntax) throws { let extractor = ParameterExtractor(from: node) diff --git a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift b/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift similarity index 100% rename from Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift rename to Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift diff --git a/Tests/PublishableMacrosTests/MainActorMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift similarity index 98% rename from Tests/PublishableMacrosTests/MainActorMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 05eff91..2b303df 100644 --- a/Tests/PublishableMacrosTests/MainActorMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -1,17 +1,17 @@ // -// MainActorMacroTests.swift +// MainActorPublishableMacroTests.swift // Publishable // // Created by Kamil Strzelecki on 24/08/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -#if canImport(PublishableMacros) +#if canImport(RelayMacros) import PrincipleMacrosTestSupport - import PublishableMacros + import RelayMacros import XCTest - internal final class MainActorMacroTests: XCTestCase { + internal final class MainActorPublishableMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Publishable": PublishableMacro.self diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift similarity index 99% rename from Tests/PublishableMacrosTests/PublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index d305e11..df46e13 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -6,9 +6,9 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -#if canImport(PublishableMacros) +#if canImport(RelayMacros) import PrincipleMacrosTestSupport - import PublishableMacros + import RelayMacros import XCTest internal final class PublishableMacroTests: XCTestCase { diff --git a/Tests/PublishableTests/Helpers/Array+PopFirst.swift b/Tests/RelayTests/Helpers/Array+PopFirst.swift similarity index 100% rename from Tests/PublishableTests/Helpers/Array+PopFirst.swift rename to Tests/RelayTests/Helpers/Array+PopFirst.swift diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift similarity index 99% rename from Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift rename to Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index 30efc63..224b2f1 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable +@testable import Relay import Testing internal struct AnyPropertyPublisherTests { diff --git a/Tests/PublishableTests/Suites/MainActorTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift similarity index 97% rename from Tests/PublishableTests/Suites/MainActorTests.swift rename to Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 01ae373..4f607a7 100644 --- a/Tests/PublishableTests/Suites/MainActorTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -1,17 +1,17 @@ // -// MainActorTests.swift +// MainActorPublishableTests.swift // Publishable // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable +@testable import Relay import Foundation import Testing @MainActor -internal struct MainActorTests { +internal struct MainActorPublishableTests { @Test func storedPropertyPublisher() { @@ -99,7 +99,7 @@ internal struct MainActorTests { } } -extension MainActorTests { +extension MainActorPublishableTests { @Test func willChangePublisher() { @@ -200,7 +200,7 @@ extension MainActorTests { } } -extension MainActorTests { +extension MainActorPublishableTests { @MainActor @Publishable @Observable final class Person { diff --git a/Tests/PublishableTests/Suites/ObservableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift similarity index 97% rename from Tests/PublishableTests/Suites/ObservableTests.swift rename to Tests/RelayTests/Publishable/ObservationPublishableTests.swift index d8d916d..71bf639 100644 --- a/Tests/PublishableTests/Suites/ObservableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -1,16 +1,16 @@ // -// ObservableTests.swift +// ObservationPublishableTests.swift // Publishable // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable +@testable import Relay import Foundation import Testing -internal struct ObservableTests { +internal struct ObservationPublishableTests { @Test func storedPropertyPublisher() { @@ -98,7 +98,7 @@ internal struct ObservableTests { } } -extension ObservableTests { +extension ObservationPublishableTests { @Test func willChangePublisher() { @@ -199,7 +199,7 @@ extension ObservableTests { } } -extension ObservableTests { +extension ObservationPublishableTests { @Publishable @Observable final class Person { diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift similarity index 97% rename from Tests/PublishableTests/Suites/SwiftDataTests.swift rename to Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index 663b3db..017386b 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -1,17 +1,17 @@ // -// SwiftDataTests.swift +// SwiftDataPublishableTests.swift // Publishable // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable +@testable import Relay import Foundation import SwiftData import Testing -internal struct SwiftDataTests { +internal struct SwiftDataPublishableTests { @Test func storedPropertyPublisher() { @@ -99,7 +99,7 @@ internal struct SwiftDataTests { } } -extension SwiftDataTests { +extension SwiftDataPublishableTests { @Test func willChangePublisher() { @@ -200,7 +200,7 @@ extension SwiftDataTests { } } -extension SwiftDataTests { +extension SwiftDataPublishableTests { @Publishable @Model final class Person { From 694b3c661eb2517d671f710e5fea5ec437d7eb67 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 15 Nov 2025 23:55:30 +0100 Subject: [PATCH 10/39] Added Memoized macro --- Package.resolved | 4 +- Package.swift | 10 +- Sources/Relay/Imports.swift | 2 +- Sources/Relay/Memoized/Memoized.swift | 28 +++ .../Publishable/AnyPropertyPublisher.swift | 2 +- Sources/Relay/Publishable/Publishable.swift | 2 +- .../PublishableObservationRegistrar.swift | 2 +- .../SwiftObservationRegistrar.swift | 2 +- Sources/RelayMacros/Main/RelayPlugin.swift | 5 +- .../Memoized/MemoizedDeclBuilder.swift | 95 +++++++++ .../RelayMacros/Memoized/MemoizedMacro.swift | 133 +++++++++++++ .../ObservationRegistrarDeclBuilder.swift | 5 +- .../PropertyPublisherDeclBuilder.swift | 24 ++- .../Publishable/PublishableMacro.swift | 8 +- .../Publishable/PublisherDeclBuilder.swift | 2 +- .../MainActorMemoizedMacroTests.swift | 156 +++++++++++++++ .../Memoized/MemoizedMacroTests.swift | 144 ++++++++++++++ .../MainActorPublishableMacroTests.swift | 17 +- .../Publishable/PublishableMacroTests.swift | 17 +- Tests/RelayTests/Helpers/Array+PopFirst.swift | 2 +- .../Memoized/MainActorMemoizedTests.swift | 179 +++++++++++++++++ .../Memoized/ObservationMemoizedTests.swift | 178 +++++++++++++++++ .../Memoized/PublishableMemoizedTests.swift | 74 +++++++ .../Memoized/SwiftDataMemoizedTests.swift | 183 ++++++++++++++++++ .../AnyPropertyPublisherTests.swift | 10 +- .../MainActorPublishableTests.swift | 10 +- .../ObservationPublishableTests.swift | 10 +- .../SwiftDataPublishableTests.swift | 10 +- 28 files changed, 1267 insertions(+), 47 deletions(-) create mode 100644 Sources/Relay/Memoized/Memoized.swift create mode 100644 Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift create mode 100644 Sources/RelayMacros/Memoized/MemoizedMacro.swift create mode 100644 Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift create mode 100644 Tests/RelayTests/Memoized/MainActorMemoizedTests.swift create mode 100644 Tests/RelayTests/Memoized/ObservationMemoizedTests.swift create mode 100644 Tests/RelayTests/Memoized/PublishableMemoizedTests.swift create mode 100644 Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift diff --git a/Package.resolved b/Package.resolved index fcc7122..ef0b0b0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "c7f470c434b63b5457085b32585e613e796b42f710570891a11544ef6760a02c", + "originHash" : "49e7ced1729dac73b31288ac69c288f2532e1445d91ce73d773c8446ce5cdb09", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "0fed1881daa3a2a8f6ebe606f17e5b5416369f6f" + "revision" : "210cb630fa67c59239070fac094d88bd6cd27419" } }, { diff --git a/Package.swift b/Package.swift index 4ef7d02..7f305ad 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - revision: "0fed1881daa3a2a8f6ebe606f17e5b5416369f6f" + revision: "210cb630fa67c59239070fac094d88bd6cd27419" ), .package( url: "https://github.com/swiftlang/swift-syntax", @@ -33,7 +33,13 @@ let package = Package( targets: [ .target( name: "Relay", - dependencies: ["RelayMacros"] + dependencies: [ + "RelayMacros", + .product( + name: "PrincipleMacrosClientSupport", + package: "PrincipleMacros" + ) + ] ), .testTarget( name: "RelayTests", diff --git a/Sources/Relay/Imports.swift b/Sources/Relay/Imports.swift index 145ec8d..bb19c57 100644 --- a/Sources/Relay/Imports.swift +++ b/Sources/Relay/Imports.swift @@ -1,6 +1,6 @@ // // Imports.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/Relay/Memoized/Memoized.swift b/Sources/Relay/Memoized/Memoized.swift new file mode 100644 index 0000000..c2f6aea --- /dev/null +++ b/Sources/Relay/Memoized/Memoized.swift @@ -0,0 +1,28 @@ +// +// Memoized.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import PrincipleMacrosClientSupport + +@attached(peer, names: arbitrary) +public macro Memoized( + _ accessControlLevel: AccessControlLevel? = nil, + _ propertyName: StaticString? = nil +) = #externalMacro( + module: "RelayMacros", + type: "MemoizedMacro" +) + +@attached(peer, names: arbitrary) +public macro Memoized( + _ accessControlLevel: AccessControlLevel? = nil, + _ propertyName: StaticString? = nil, + isolation: Isolation.Type? +) = #externalMacro( + module: "RelayMacros", + type: "MemoizedMacro" +) diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift index 0702658..c33fef7 100644 --- a/Sources/Relay/Publishable/AnyPropertyPublisher.swift +++ b/Sources/Relay/Publishable/AnyPropertyPublisher.swift @@ -1,6 +1,6 @@ // // AnyPropertyPublisher.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 17/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/Relay/Publishable/Publishable.swift b/Sources/Relay/Publishable/Publishable.swift index 785e49d..e6ec82d 100644 --- a/Sources/Relay/Publishable/Publishable.swift +++ b/Sources/Relay/Publishable/Publishable.swift @@ -1,6 +1,6 @@ // // Publishable.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift b/Sources/Relay/Publishable/PublishableObservationRegistrar.swift index 4ac7f2a..10fe6fb 100644 --- a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift +++ b/Sources/Relay/Publishable/PublishableObservationRegistrar.swift @@ -1,6 +1,6 @@ // // PublishableObservationRegistrar.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/Relay/Publishable/SwiftObservationRegistrar.swift b/Sources/Relay/Publishable/SwiftObservationRegistrar.swift index 4afa00a..4286d11 100644 --- a/Sources/Relay/Publishable/SwiftObservationRegistrar.swift +++ b/Sources/Relay/Publishable/SwiftObservationRegistrar.swift @@ -1,6 +1,6 @@ // // SwiftObservationRegistrar.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/RelayMacros/Main/RelayPlugin.swift b/Sources/RelayMacros/Main/RelayPlugin.swift index dfb4174..1d7219f 100644 --- a/Sources/RelayMacros/Main/RelayPlugin.swift +++ b/Sources/RelayMacros/Main/RelayPlugin.swift @@ -1,6 +1,6 @@ // // RelayPlugin.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 11/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -13,6 +13,7 @@ import SwiftCompilerPlugin internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ - PublishableMacro.self + PublishableMacro.self, + MemoizedMacro.self ] } diff --git a/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift new file mode 100644 index 0000000..2c74e4c --- /dev/null +++ b/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -0,0 +1,95 @@ +// +// MemoizedDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import PrincipleMacros + +internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + + let lexicalContext: [Syntax] + let preferredAccessControlLevel: AccessControlLevel? + let preferredGlobalActorIsolation: GlobalActorIsolation? + + func build() -> [DeclSyntax] { + [ + """ + \(inheritedGlobalActorIsolation)private var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil + """, + """ + \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)var \(raw: propertyName): \(trimmedReturnType) { + if let cached = _\(raw: propertyName) { + access(keyPath: \\._\(raw: propertyName)) + return cached + } + + nonisolated(unsafe) weak var instance = self + + \(assumeIsolatedIfNeededFunction()) + + \(invalidateCacheFunction()) + + \(observationTrackingBlock()) + } + """ + ] + } + + private func observationTrackingBlock() -> CodeBlockItemSyntax { + """ + return withObservationTracking { + let result = \(declaration.name.trimmed)() + _\(raw: propertyName) = result + return result + } onChange: { + invalidateCache() + } + """ + } + + private func invalidateCacheFunction() -> CodeBlockItemSyntax { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \\._\(raw: propertyName)) { + instance?._\(raw: propertyName) = nil + } + } + } + """ + } + + private func assumeIsolatedIfNeededFunction() -> CodeBlockItemSyntax { + if let globalActor = inheritedGlobalActorIsolation?.standardizedIsolationType { + // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift + """ + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @\(globalActor) () throws -> Void + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + try \(globalActor).shared.assumeIsolated { _ in + try rawOperation() + } + } + } + """ + } else { + """ + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + """ + } + } +} diff --git a/Sources/RelayMacros/Memoized/MemoizedMacro.swift b/Sources/RelayMacros/Memoized/MemoizedMacro.swift new file mode 100644 index 0000000..1324af1 --- /dev/null +++ b/Sources/RelayMacros/Memoized/MemoizedMacro.swift @@ -0,0 +1,133 @@ +// +// MemoizedMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import PrincipleMacros + +public enum MemoizedMacro { + + private typealias Input = ( + declaration: FunctionDeclSyntax, + trimmedReturnType: TypeSyntax, + propertyName: String + ) + + private static func validate( + _ declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext, + with parameters: Parameters + ) -> Input? { + guard let declaration = declaration.as(FunctionDeclSyntax.self), + let trimmedReturnType = trimmedReturnType(of: declaration), + declaration.signature.parameterClause.parameters.isEmpty, + declaration.signature.effectSpecifiers == nil, + declaration.typeScopeSpecifier == nil + else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro can only be applied to non-void, non-async, non-throwing \ + methods that don't take any arguments + """ + ) + return nil + } + + guard let scope = context.lexicalContext.first?.as(ClassDeclSyntax.self), + scope.attributes.contains(likeOneOf: "@Observable", "@Model") + else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro can only be applied to methods declared in body (not extension) \ + of @Observable or @Model classes + """ + ) + return nil + } + + if let propertyName = parameters.preferredPropertyName { + guard !propertyName.isEmpty else { + context.diagnose( + node: declaration, + errorMessage: "Memoized macro requires a non-empty property name" + ) + return nil + } + return (declaration, trimmedReturnType, propertyName) + } + + let propertyName = defaultPropertyName(for: declaration) + guard !propertyName.isEmpty else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro requires a function name with at least two words \ + or explicit property name + """ + ) + return nil + } + + return (declaration, trimmedReturnType, propertyName) + } + + static func defaultPropertyName(for declaration: FunctionDeclSyntax) -> String { + let functionName = declaration.name.trimmedDescription + var notation = CamelCaseNotation(string: functionName) + notation.removeFirst() + return notation.joined(as: .lowerCamelCase) + } + + static func trimmedReturnType(of declaration: FunctionDeclSyntax) -> TypeSyntax? { + declaration.signature.returnClause?.type.trimmed + } +} + +extension MemoizedMacro: PeerMacro { + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let parameters = try Parameters(from: node) + let input = validate(declaration, in: context, with: parameters) + + guard let input else { + return [] + } + + let builder = MemoizedDeclBuilder( + declaration: input.declaration, + trimmedReturnType: input.trimmedReturnType, + propertyName: input.propertyName, + lexicalContext: context.lexicalContext, + preferredAccessControlLevel: parameters.preferredAccessControlLevel, + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + ) + + return builder.build() + } +} + +extension MemoizedMacro { + + struct Parameters { + + let preferredAccessControlLevel: AccessControlLevel? + let preferredPropertyName: String? + let preferredGlobalActorIsolation: GlobalActorIsolation? + + init(from node: AttributeSyntax) throws { + let extractor = ParameterExtractor(from: node) + self.preferredAccessControlLevel = try extractor.accessControlLevel(withLabel: nil) + self.preferredPropertyName = try extractor.rawString(withLabel: nil) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") + } + } +} diff --git a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index aca59e8..e32e635 100644 --- a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -1,6 +1,6 @@ // // ObservationRegistrarDeclBuilder.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 14/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -62,9 +62,6 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin } """ } - """ - assertionFailure("Unknown keyPath: \\(keyPath)") - """ } @MemberBlockItemListBuilder diff --git a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index 5ca134e..8bb8505 100644 --- a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -1,6 +1,6 @@ // // PropertyPublisherDeclBuilder.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -24,6 +24,8 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { \(storedPropertiesPublishers().formatted()) \(computedPropertiesPublishers().formatted()) + + \(memoizedPropertiesPublishers().formatted()) } """ ] @@ -74,4 +76,24 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { """ } } + + @MemberBlockItemListBuilder + private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { + for member in declaration.memberBlock.members { + if let functionDecl = member.decl.as(FunctionDeclSyntax.self), + let attribute = functionDecl.attributes.first(like: "@Memoized"), + let parameters = try? MemoizedMacro.Parameters(from: attribute), + let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { + let globalActor = parameters.preferredGlobalActorIsolation ?? inheritedGlobalActorIsolation + let accessControlLevel = parameters.preferredAccessControlLevel + let name = parameters.preferredPropertyName ?? MemoizedMacro.defaultPropertyName(for: functionDecl) + let type = trimmedReturnType + """ + \(globalActor)\(accessControlLevel)var \(raw: name): AnyPublisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(raw: name)) + } + """ + } + } + } } diff --git a/Sources/RelayMacros/Publishable/PublishableMacro.swift b/Sources/RelayMacros/Publishable/PublishableMacro.swift index da008e6..12a5246 100644 --- a/Sources/RelayMacros/Publishable/PublishableMacro.swift +++ b/Sources/RelayMacros/Publishable/PublishableMacro.swift @@ -1,6 +1,6 @@ // // PublishableMacro.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -14,7 +14,7 @@ public enum PublishableMacro { _ declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) -> ClassDeclSyntax? { - guard let declaration = declaration as? ClassDeclSyntax, + guard let declaration = declaration.as(ClassDeclSyntax.self), declaration.attributes.contains(likeOneOf: "@Observable", "@Model"), declaration.isFinal else { @@ -115,9 +115,7 @@ extension PublishableMacro { init(from node: AttributeSyntax) throws { let extractor = ParameterExtractor(from: node) - self.preferredGlobalActorIsolation = try extractor.globalActorIsolation( - withLabel: "isolation" - ) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") } } } diff --git a/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift b/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift index 2aea0ca..4c1be44 100644 --- a/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift @@ -1,6 +1,6 @@ // // PublisherDeclBuilder.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift new file mode 100644 index 0000000..5295a02 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -0,0 +1,156 @@ +// +// MainActorMemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import PrincipleMacrosTestSupport + import RelayMacros + import XCTest + + internal final class MainActorMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + + @Memoized + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private var _area: Optional = nil + + @MainActor var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + try MainActor.shared.assumeIsolated { _ in + try rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @Memoized(.public, "customName") + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private var _customName: Optional = nil + + @MainActor public var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + try MainActor.shared.assumeIsolated { _ in + try rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift new file mode 100644 index 0000000..d503d50 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -0,0 +1,144 @@ +// +// MemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import PrincipleMacrosTestSupport + import RelayMacros + import XCTest + + internal final class MemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Observable + public class Square { + + var side = 12.3 + + @Memoized + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + private var _area: Optional = nil + + var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Observable + public final class Square { + + var side = 12.3 + + @Memoized(.public, "customName") + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public final class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + private var _customName: Optional = nil + + public var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 2b303df..bff62d2 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -1,6 +1,6 @@ // // MainActorPublishableMacroTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 24/08/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -43,6 +43,11 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } } """#, expandedSource: @@ -70,6 +75,11 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. @@ -107,6 +117,10 @@ @MainActor fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } + + @MainActor public var label: AnyPublisher { + _computedPropertyPublisher(for: \.label) + } } private enum Observation { @@ -129,7 +143,6 @@ subject.send(object[keyPath: keyPath]) return } - assertionFailure("Unknown keyPath: \(keyPath)") } @MainActor private func subject( diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index df46e13..bf384f1 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -1,6 +1,6 @@ // // PublishableMacroTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -43,6 +43,11 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } } """#, expandedSource: @@ -70,6 +75,11 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. @@ -107,6 +117,10 @@ fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } + + public var label: AnyPublisher { + _computedPropertyPublisher(for: \.label) + } } private enum Observation { @@ -129,7 +143,6 @@ subject.send(object[keyPath: keyPath]) return } - assertionFailure("Unknown keyPath: \(keyPath)") } private func subject( diff --git a/Tests/RelayTests/Helpers/Array+PopFirst.swift b/Tests/RelayTests/Helpers/Array+PopFirst.swift index 29f8f33..f39d4ea 100644 --- a/Tests/RelayTests/Helpers/Array+PopFirst.swift +++ b/Tests/RelayTests/Helpers/Array+PopFirst.swift @@ -1,6 +1,6 @@ // // Array+PopFirst.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift new file mode 100644 index 0000000..ca8d543 --- /dev/null +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -0,0 +1,179 @@ +// +// MainActorMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +@MainActor +internal struct MainActorMemoizedTests { + + @Test + func access() { + let square = Square() + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + square.side = 2.0 + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + let access1 = square.area + #expect(access1 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + let access3 = square.area + #expect(access3 == 9) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.offset = 100.0 + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + } + + @Test + func trackWhenCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + let access1 = square.area + #expect(access1 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + observe() // access2 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @Test + func trackWhenNotCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + observe() // access1 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let square = Square() + var observationsQueue = [Double]() + + let task = Task.immediate { + let areaObservations = Observations { + square.area + } + for await area in areaObservations { + observationsQueue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(observationsQueue.popFirst() == 1.0) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(observationsQueue.popFirst() == 4.0) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 2) + #expect(!square.isAreaCached) + + square.side = 4.0 + let access3 = square.area + #expect(access3 == 16.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(observationsQueue.popFirst() == 16.0) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + task.cancel() + await task.value + #expect(observationsQueue.isEmpty) + } +} + +extension MainActorMemoizedTests { + + @MainActor @Observable + final class Square { + + var offset = 0.0 + var side = 1.0 + + private(set) var calculateAreaCallsCount = 0 + var isAreaCached: Bool { _area != nil } + + @Memoized + func calculateArea() -> Double { + calculateAreaCallsCount += 1 + return side * side + } + } +} diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift new file mode 100644 index 0000000..cfe1daf --- /dev/null +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -0,0 +1,178 @@ +// +// ObservationMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +internal struct ObservationMemoizedTests { + + @Test + func access() { + let square = Square() + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + square.side = 2.0 + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + let access1 = square.area + #expect(access1 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + let access3 = square.area + #expect(access3 == 9) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.offset = 100.0 + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + } + + @Test + func trackWhenCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + let access1 = square.area + #expect(access1 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + observe() // access2 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @Test + func trackWhenNotCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + observe() // access1 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let square = Square() + var observationsQueue = [Double]() + + let task = Task.immediate { + let areaObservations = Observations { + square.area + } + for await area in areaObservations { + observationsQueue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(observationsQueue.popFirst() == 1.0) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(observationsQueue.popFirst() == 4.0) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 2) + #expect(!square.isAreaCached) + + square.side = 4.0 + let access3 = square.area + #expect(access3 == 16.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(observationsQueue.popFirst() == 16.0) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + task.cancel() + await task.value + #expect(observationsQueue.isEmpty) + } +} + +extension ObservationMemoizedTests { + + @Observable + final class Square { + + var offset = 0.0 + var side = 1.0 + + private(set) var calculateAreaCallsCount = 0 + var isAreaCached: Bool { _area != nil } + + @Memoized + func calculateArea() -> Double { + calculateAreaCallsCount += 1 + return side * side + } + } +} diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift new file mode 100644 index 0000000..3511260 --- /dev/null +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -0,0 +1,74 @@ +// +// PublishableMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +internal struct PublishableMemoizedTests { + + @Test + func publisher() { + var square: Square? = .init() + var publishableQueue = [Double]() + + let access1 = square?.area + #expect(access1 == 1.0) + #expect(publishableQueue.popFirst() == nil) + #expect(square?.calculateAreaCallsCount == 1) + #expect(square?.isAreaCached == true) + + var completion: Subscribers.Completion? + let cancellable = square?.publisher.area.sink( // access2 + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + #expect(publishableQueue.popFirst() == 1.0) + #expect(square?.calculateAreaCallsCount == 1) + #expect(square?.isAreaCached == true) + + square?.side = 2.0 // access3 + #expect(publishableQueue.popFirst() == 4.0) + #expect(square?.calculateAreaCallsCount == 2) + #expect(square?.isAreaCached == true) + + let access4 = square?.area + #expect(access4 == 4.0) + #expect(publishableQueue.popFirst() == nil) + #expect(square?.calculateAreaCallsCount == 2) + #expect(square?.isAreaCached == true) + + square = nil + #expect(publishableQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension PublishableMemoizedTests { + + @Publishable @Observable + final class Square { + + var offset = 0.0 + var side = 1.0 + + @ObservationIgnored + private(set) var calculateAreaCallsCount = 0 + + var isAreaCached: Bool { + _area != nil + } + + @Memoized + func calculateArea() -> Double { + calculateAreaCallsCount += 1 + return side * side + } + } +} diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift new file mode 100644 index 0000000..ffd96f6 --- /dev/null +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -0,0 +1,183 @@ +// +// SwiftDataMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import SwiftData +import Testing + +internal struct SwiftDataMemoizedTests { + + @Test + func access() { + let square = Square() + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + square.side = 2.0 + #expect(square.calculateAreaCallsCount == 0) + #expect(!square.isAreaCached) + + let access1 = square.area + #expect(access1 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 4) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + let access3 = square.area + #expect(access3 == 9) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.offset = 100.0 + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + } + + @Test + func trackWhenCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + let access1 = square.area + #expect(access1 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + observe() // access2 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @Test + func trackWhenNotCached() { + let square = Square() + nonisolated(unsafe) var observationsQueue = [Bool]() + + func observe() { + withObservationTracking { + _ = square.area + } onChange: { + observationsQueue.append(true) + } + } + + observe() // access1 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + let access2 = square.area + #expect(access2 == 1.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == true) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let square = Square() + var observationsQueue = [Double]() + + let task = Task.immediate { + let areaObservations = Observations { + square.area + } + for await area in areaObservations { + observationsQueue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(observationsQueue.popFirst() == 1.0) + #expect(square.calculateAreaCallsCount == 1) + #expect(square.isAreaCached) + + square.side = 2.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 1) + #expect(!square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(observationsQueue.popFirst() == 4.0) + #expect(square.calculateAreaCallsCount == 2) + #expect(square.isAreaCached) + + square.side = 3.0 + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 2) + #expect(!square.isAreaCached) + + square.side = 4.0 + let access3 = square.area + #expect(access3 == 16.0) + #expect(observationsQueue.popFirst() == nil) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(observationsQueue.popFirst() == 16.0) + #expect(square.calculateAreaCallsCount == 3) + #expect(square.isAreaCached) + + task.cancel() + await task.value + #expect(observationsQueue.isEmpty) + } +} + +extension SwiftDataMemoizedTests { + + @Model + final class Square { + + var offset = 0.0 + var side: Double + + private(set) var calculateAreaCallsCount = 0 + var isAreaCached: Bool { _area != nil } + + init() { + self.side = 1.0 + } + + @Memoized + func calculateArea() -> Double { + calculateAreaCallsCount += 1 + return side * side + } + } +} diff --git a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index 224b2f1..46b73b1 100644 --- a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -1,6 +1,6 @@ // // AnyPropertyPublisherTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -25,7 +25,7 @@ internal struct AnyPropertyPublisherTests { } @Test - func nonEquatableStoredPropertyPublisher() { + func nonEquatableStoredProperty() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -65,7 +65,7 @@ internal struct AnyPropertyPublisherTests { } @Test - func nonEquatableComputedPropertyPublisher() { + func nonEquatableComputedProperty() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -119,7 +119,7 @@ extension AnyPropertyPublisherTests { } @Test - func equatableStoredPropertyPublisher() { + func equatableStoredProperty() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -163,7 +163,7 @@ extension AnyPropertyPublisherTests { } @Test - func equatableComputedPropertyPublisher() { + func equatableComputedProperty() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() nonisolated(unsafe) var observationsQueue = [Bool]() diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 4f607a7..834266e 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -1,6 +1,6 @@ // // MainActorPublishableTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -14,7 +14,7 @@ import Testing internal struct MainActorPublishableTests { @Test - func storedPropertyPublisher() { + func storedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -54,7 +54,7 @@ internal struct MainActorPublishableTests { } @Test - func computedPropertyPublisher() { + func computedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -102,7 +102,7 @@ internal struct MainActorPublishableTests { extension MainActorPublishableTests { @Test - func willChangePublisher() { + func willChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -151,7 +151,7 @@ extension MainActorPublishableTests { } @Test - func didChangePublisher() { + func didChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index 71bf639..a66b4ed 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -1,6 +1,6 @@ // // ObservationPublishableTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -13,7 +13,7 @@ import Testing internal struct ObservationPublishableTests { @Test - func storedPropertyPublisher() { + func storedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -53,7 +53,7 @@ internal struct ObservationPublishableTests { } @Test - func computedPropertyPublisher() { + func computedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -101,7 +101,7 @@ internal struct ObservationPublishableTests { extension ObservationPublishableTests { @Test - func willChangePublisher() { + func willChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -150,7 +150,7 @@ extension ObservationPublishableTests { } @Test - func didChangePublisher() { + func didChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index 017386b..d7b3d3d 100644 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -1,6 +1,6 @@ // // SwiftDataPublishableTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -14,7 +14,7 @@ import Testing internal struct SwiftDataPublishableTests { @Test - func storedPropertyPublisher() { + func storedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -54,7 +54,7 @@ internal struct SwiftDataPublishableTests { } @Test - func computedPropertyPublisher() { + func computedProperty() { var person: Person? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -102,7 +102,7 @@ internal struct SwiftDataPublishableTests { extension SwiftDataPublishableTests { @Test - func willChangePublisher() { + func willChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -151,7 +151,7 @@ extension SwiftDataPublishableTests { } @Test - func didChangePublisher() { + func didChange() { var person: Person? = .init() var publishableQueue = [Person]() nonisolated(unsafe) var observationsQueue = [Bool]() From 5e42749e3657a95b9d4aa99615049a1405c96080 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 15 Nov 2025 23:55:30 +0100 Subject: [PATCH 11/39] [SwiftFormat] Applied formatting --- .../PropertyPublisherDeclBuilder.swift | 2 +- .../MainActorMemoizedMacroTests.swift | 36 +++++++++---------- .../Memoized/MemoizedMacroTests.swift | 36 +++++++++---------- .../MainActorPublishableMacroTests.swift | 6 ++-- .../Publishable/PublishableMacroTests.swift | 6 ++-- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index 8bb8505..87010f0 100644 --- a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -24,7 +24,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { \(storedPropertiesPublishers().formatted()) \(computedPropertiesPublishers().formatted()) - + \(memoizedPropertiesPublishers().formatted()) } """ diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift index 5295a02..f31d94d 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -22,9 +22,9 @@ #""" @MainActor @Observable public class Square { - + var side = 12.3 - + @Memoized private func calculateArea() -> Double { side * side @@ -35,22 +35,22 @@ #""" @MainActor @Observable public class Square { - + var side = 12.3 private func calculateArea() -> Double { side * side } - + @MainActor private var _area: Optional = nil - + @MainActor var area: Double { if let cached = _area { access(keyPath: \._area) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: @MainActor () throws -> Void ) rethrows { @@ -62,7 +62,7 @@ } } } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { instance?.withMutation(keyPath: \._area) { @@ -70,7 +70,7 @@ } } } - + return withObservationTracking { let result = calculateArea() _area = result @@ -90,9 +90,9 @@ #""" @MainActor @Observable public final class Square { - + var side = 12.3 - + @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -103,22 +103,22 @@ #""" @MainActor @Observable public final class Square { - + var side = 12.3 private func calculateArea() -> Double { side * side } - + @MainActor private var _customName: Optional = nil - + @MainActor public var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: @MainActor () throws -> Void ) rethrows { @@ -130,7 +130,7 @@ } } } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { instance?.withMutation(keyPath: \._customName) { @@ -138,7 +138,7 @@ } } } - + return withObservationTracking { let result = calculateArea() _customName = result diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index d503d50..b328218 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -22,9 +22,9 @@ #""" @Observable public class Square { - + var side = 12.3 - + @Memoized private func calculateArea() -> Double { side * side @@ -35,28 +35,28 @@ #""" @Observable public class Square { - + var side = 12.3 private func calculateArea() -> Double { side * side } - + private var _area: Optional = nil - + var area: Double { if let cached = _area { access(keyPath: \._area) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () throws -> Void ) rethrows { try operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { instance?.withMutation(keyPath: \._area) { @@ -64,7 +64,7 @@ } } } - + return withObservationTracking { let result = calculateArea() _area = result @@ -84,9 +84,9 @@ #""" @Observable public final class Square { - + var side = 12.3 - + @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -97,28 +97,28 @@ #""" @Observable public final class Square { - + var side = 12.3 private func calculateArea() -> Double { side * side } - + private var _customName: Optional = nil - + public var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () throws -> Void ) rethrows { try operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { instance?.withMutation(keyPath: \._customName) { @@ -126,7 +126,7 @@ } } } - + return withObservationTracking { let result = calculateArea() _customName = result diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index bff62d2..4e3868b 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -43,7 +43,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" @@ -75,7 +75,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" @@ -117,7 +117,7 @@ @MainActor fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } - + @MainActor public var label: AnyPublisher { _computedPropertyPublisher(for: \.label) } diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index bf384f1..19a248a 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -43,7 +43,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" @@ -75,7 +75,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" @@ -117,7 +117,7 @@ fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } - + public var label: AnyPublisher { _computedPropertyPublisher(for: \.label) } From d7e3322d3bd4de45494ba0d91b1b762954a413ba Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 12:20:49 +0100 Subject: [PATCH 12/39] - --- .../Publishable/AnyPropertyPublisher.swift | 8 +- Sources/Relay/Publishable/Publishable.swift | 8 +- .../Memoized/MainActorMemoizedTests.swift | 476 +++++++++++++----- .../Memoized/ObservationMemoizedTests.swift | 473 ++++++++++++----- .../Memoized/PublishableMemoizedTests.swift | 203 ++++++-- .../Memoized/SwiftDataMemoizedTests.swift | 475 +++++++++++------ 6 files changed, 1176 insertions(+), 467 deletions(-) diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift index c33fef7..fe37afd 100644 --- a/Sources/Relay/Publishable/AnyPropertyPublisher.swift +++ b/Sources/Relay/Publishable/AnyPropertyPublisher.swift @@ -47,17 +47,17 @@ open class AnyPropertyPublisher { extension AnyPropertyPublisher { func beginModifications() { - if pendingModifications == 0 { + pendingModifications += 1 + if pendingModifications == 1 { _willChange.send(object) } - pendingModifications += 1 } func endModifications() { - pendingModifications -= 1 - if pendingModifications == 0 { + if pendingModifications == 1 { _didChange.send(object) } + pendingModifications -= 1 } } diff --git a/Sources/Relay/Publishable/Publishable.swift b/Sources/Relay/Publishable/Publishable.swift index e6ec82d..967e591 100644 --- a/Sources/Relay/Publishable/Publishable.swift +++ b/Sources/Relay/Publishable/Publishable.swift @@ -13,9 +13,9 @@ import Observation /// - 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. +/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied. /// -/// The `Publishable` macro adds a new `publisher` property to your type, +/// 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. @@ -46,9 +46,9 @@ public macro Publishable() = #externalMacro( /// - 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. +/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. /// -/// The `Publishable` macro adds a new `publisher` property to your type, +/// 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. diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index ca8d543..5cb4ab0 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -9,171 +9,369 @@ @testable import Relay import Testing -@MainActor -internal struct MainActorMemoizedTests { - - @Test - func access() { - let square = Square() - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - square.side = 2.0 - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - let access1 = square.area - #expect(access1 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - let access3 = square.area - #expect(access3 == 9) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.offset = 100.0 - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - } - - @Test - func trackWhenCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() +internal enum MainActorMemoizedTests { + + @MainActor + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) } - let access1 = square.area - #expect(access1 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - observe() // access2 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - } + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } - @Test - func trackWhenNotCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } } - } - observe() // access1 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } +} + +extension MainActorMemoizedTests { - @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let square = Square() - var observationsQueue = [Double]() + @MainActor + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } - let task = Task.immediate { - let areaObservations = Observations { - square.area + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } - for await area in areaObservations { - observationsQueue.append(area) + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) } - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(observationsQueue.popFirst() == 1.0) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(observationsQueue.popFirst() == 4.0) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 2) - #expect(!square.isAreaCached) - - square.side = 4.0 - let access3 = square.area - #expect(access3 == 16.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(observationsQueue.popFirst() == 16.0) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - task.cancel() - await task.value - #expect(observationsQueue.isEmpty) + @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } } extension MainActorMemoizedTests { @MainActor @Observable - final class Square { + final class Cube { var offset = 0.0 - var side = 1.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } - private(set) var calculateAreaCallsCount = 0 - var isAreaCached: Bool { _area != nil } + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } @Memoized - func calculateArea() -> Double { - calculateAreaCallsCount += 1 - return side * side + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z } } } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index cfe1daf..8a10a1c 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -9,170 +9,367 @@ @testable import Relay import Testing -internal struct ObservationMemoizedTests { - - @Test - func access() { - let square = Square() - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - square.side = 2.0 - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - let access1 = square.area - #expect(access1 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - let access3 = square.area - #expect(access3 == 9) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.offset = 100.0 - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - } - - @Test - func trackWhenCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() +internal enum ObservationMemoizedTests { + + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) } - let access1 = square.area - #expect(access1 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - observe() // access2 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - } + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } - @Test - func trackWhenNotCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } } - } - observe() // access1 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } +} + +extension ObservationMemoizedTests { - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let square = Square() - var observationsQueue = [Double]() + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } - let task = Task.immediate { - let areaObservations = Observations { - square.area + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } - for await area in areaObservations { - observationsQueue.append(area) + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) } - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(observationsQueue.popFirst() == 1.0) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(observationsQueue.popFirst() == 4.0) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 2) - #expect(!square.isAreaCached) - - square.side = 4.0 - let access3 = square.area - #expect(access3 == 16.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(observationsQueue.popFirst() == 16.0) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - task.cancel() - await task.value - #expect(observationsQueue.isEmpty) + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } } extension ObservationMemoizedTests { @Observable - final class Square { + final class Cube { var offset = 0.0 - var side = 1.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } - private(set) var calculateAreaCallsCount = 0 - var isAreaCached: Bool { _area != nil } + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } @Memoized - func calculateArea() -> Double { - calculateAreaCallsCount += 1 - return side * side + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z } } } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 3511260..662a08a 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -12,63 +12,182 @@ import Testing internal struct PublishableMemoizedTests { @Test - func publisher() { - var square: Square? = .init() - var publishableQueue = [Double]() + func independent() { + let cube = Cube() + var queue = [Double]() - let access1 = square?.area + let access1 = cube.baseArea #expect(access1 == 1.0) - #expect(publishableQueue.popFirst() == nil) - #expect(square?.calculateAreaCallsCount == 1) - #expect(square?.isAreaCached == true) - - var completion: Subscribers.Completion? - let cancellable = square?.publisher.area.sink( // access2 - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - #expect(publishableQueue.popFirst() == 1.0) - #expect(square?.calculateAreaCallsCount == 1) - #expect(square?.isAreaCached == true) - - square?.side = 2.0 // access3 - #expect(publishableQueue.popFirst() == 4.0) - #expect(square?.calculateAreaCallsCount == 2) - #expect(square?.isAreaCached == true) - - let access4 = square?.area - #expect(access4 == 4.0) - #expect(publishableQueue.popFirst() == nil) - #expect(square?.calculateAreaCallsCount == 2) - #expect(square?.isAreaCached == true) - - square = nil - #expect(publishableQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + // access2 + let cancellable = cube.publisher.baseArea.sink { baseArea in + queue.append(baseArea) + } + + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3 + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let access4 = cube.baseArea + #expect(access4 == 2.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable.cancel() + #expect(queue.isEmpty) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + } + + @Test + func dependent() { + let cube = Cube() + var volumeQueue = [Double]() + var baseAreaQueue = [Double]() + + let accessBaseArea1 = cube.baseArea + #expect(accessBaseArea1 == 1.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 0) + #expect(!cube.isVolumeCached) + + // accessVolume1, accessBaseArea2, accessBaseArea3 + let volumeCancellable = cube.publisher.volume.sink { volume in + volumeQueue.append(volume) + } + let baseAreaCancellable = cube.publisher.baseArea.sink { baseArea in + baseAreaQueue.append(baseArea) + } + + #expect(baseAreaQueue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + cube.x = 2.0 // accessVolume2, accessBaseArea4, accessBaseArea5 + #expect(baseAreaQueue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + let accessVolume3 = cube.volume + #expect(accessVolume3 == 2.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 // accessVolume4, accessBaseArea6 + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + + volumeCancellable.cancel() + #expect(volumeQueue.isEmpty) + + cube.y = 4.0 // accessBaseArea7 + #expect(baseAreaQueue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(!cube.isVolumeCached) + + baseAreaCancellable.cancel() + #expect(baseAreaQueue.isEmpty) + + cube.y = 5.0 + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(!cube.isBaseAreaCached) + } + + @Test + func share() { + let cube = Cube() + var queue1 = [Double]() + var queue2 = [Double]() + + // access1, access2 + let cancellable1 = cube.publisher.baseArea.sink { baseArea in + queue1.append(baseArea) + } + let cancellable2 = cube.publisher.baseArea.sink { baseArea in + queue2.append(baseArea) + } + + #expect(queue1.popFirst() == 1.0) + #expect(queue2.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3, access4 + #expect(queue1.popFirst() == 2.0) + #expect(queue2.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable1.cancel() + #expect(queue1.isEmpty) + + cube.y = 3.0 // access5 + #expect(queue2.popFirst() == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + cancellable2.cancel() + #expect(queue2.isEmpty) } + } extension PublishableMemoizedTests { @Publishable @Observable - final class Square { + final class Cube { var offset = 0.0 - var side = 1.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 - @ObservationIgnored - private(set) var calculateAreaCallsCount = 0 + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } - var isAreaCached: Bool { - _area != nil + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y } @Memoized - func calculateArea() -> Double { - calculateAreaCallsCount += 1 - return side * side + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z } } } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index ffd96f6..715547a 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -10,174 +10,369 @@ import SwiftData import Testing -internal struct SwiftDataMemoizedTests { - - @Test - func access() { - let square = Square() - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - square.side = 2.0 - #expect(square.calculateAreaCallsCount == 0) - #expect(!square.isAreaCached) - - let access1 = square.area - #expect(access1 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 4) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - let access3 = square.area - #expect(access3 == 9) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.offset = 100.0 - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - } - - @Test - func trackWhenCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() +internal enum SwiftDataMemoizedTests { + + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) } - let access1 = square.area - #expect(access1 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - observe() // access2 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - } + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } - @Test - func trackWhenNotCached() { - let square = Square() - nonisolated(unsafe) var observationsQueue = [Bool]() + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } - func observe() { - withObservationTracking { - _ = square.area - } onChange: { - observationsQueue.append(true) + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } } - } - observe() // access1 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - let access2 = square.area - #expect(access2 == 1.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == true) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } +} - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let square = Square() - var observationsQueue = [Double]() +extension SwiftDataMemoizedTests { - let task = Task.immediate { - let areaObservations = Observations { - square.area + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } - for await area in areaObservations { - observationsQueue.append(area) + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) } - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(observationsQueue.popFirst() == 1.0) - #expect(square.calculateAreaCallsCount == 1) - #expect(square.isAreaCached) - - square.side = 2.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 1) - #expect(!square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(observationsQueue.popFirst() == 4.0) - #expect(square.calculateAreaCallsCount == 2) - #expect(square.isAreaCached) - - square.side = 3.0 - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 2) - #expect(!square.isAreaCached) - - square.side = 4.0 - let access3 = square.area - #expect(access3 == 16.0) - #expect(observationsQueue.popFirst() == nil) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(observationsQueue.popFirst() == 16.0) - #expect(square.calculateAreaCallsCount == 3) - #expect(square.isAreaCached) - - task.cancel() - await task.value - #expect(observationsQueue.isEmpty) + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } } } extension SwiftDataMemoizedTests { @Model - final class Square { + final class Cube { var offset = 0.0 - var side: Double + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } - private(set) var calculateAreaCallsCount = 0 - var isAreaCached: Bool { _area != nil } + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } - init() { - self.side = 1.0 + init() {} + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y } @Memoized - func calculateArea() -> Double { - calculateAreaCallsCount += 1 - return side * side + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z } } } From 9d3fe44a9272d8b4862fcd369bb56c06d00f4b46 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 15:08:58 +0100 Subject: [PATCH 13/39] Added docs --- .spi.yml | 2 +- README.md | 105 +++++++++++++++--- .../Documentation.docc/MemoizedMacros.md | 89 +++++++++++++++ .../Relay/Documentation.docc/Publishable.md | 15 --- .../Documentation.docc/PublishableMacros.md | 62 +++++++++++ Sources/Relay/Documentation.docc/Relay.md | 8 ++ Sources/Relay/Memoized/Memoized.swift | 44 ++++++++ Sources/Relay/Publishable/Publishable.swift | 3 +- 8 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 Sources/Relay/Documentation.docc/MemoizedMacros.md delete mode 100644 Sources/Relay/Documentation.docc/Publishable.md create mode 100644 Sources/Relay/Documentation.docc/PublishableMacros.md create mode 100644 Sources/Relay/Documentation.docc/Relay.md diff --git a/.spi.yml b/.spi.yml index f780de7..71282c6 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,4 +2,4 @@ version: 1 builder: configs: - documentation_targets: - - Publishable + - Relay diff --git a/README.md b/README.md index d45d503..e52eddb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,28 @@ -# Publishable +# Relay [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/NSFatalError/Publishable) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Publishable) [![Codecov](https://codecov.io/github/NSFatalError/Publishable/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/github/NSFatalError/Publishable) -Synchronous observation of `Observable` changes through `Combine` +Extend functionality of `Observable` types. #### Contents -- [What Problem Publishable Solves?](#what-problem-publishable-solves) -- [How Publishable Works?](#how-publishable-works) +- [Publishable](#publishable) +- [Memoized](#memoized) - [Documentation](#documentation) - [Installation](#installation) -## What Problem Publishable Solves? +## Publishable -With the introduction of [SE-0475: Transactional Observation of Values](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md), -Swift gains built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, as it -publishes the updates via an `AsyncSequence`. +With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), +Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, +as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where `Publishable` comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you take advantage of the latest -`Observable` features, while processing changes synchronously when needed. It integrates just as smoothly with the `SwiftData.Model` macro -and can be extended to support other macros built on `Observation`. +This is where `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates +with both the `@Observable` and `@Model` macros and could be extended to support other macros built on top of `Observation`: ```swift import Relay @@ -59,7 +59,7 @@ person.surname = "Strzelecki" // Full name - Kamil Strzelecki ``` -## How Publishable Works? +### How Publishable Works? The `@Publishable` macro relies on two key properties of Swift Macros and `Observation` module: - Macro expansions are compiled in the context of the module where they’re used. This allows references in the macro to be overloaded by locally available symbols. @@ -72,7 +72,86 @@ The `@Publishable` macro relies on two key properties of Swift Macros and `Obser While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. It relies solely on well-understood behaviors of Swift and its public APIs. -This approach has been carefully tested and verified to work with both `Observable` and `SwiftData.Model` macros. +This approach has been carefully tested and verified to work with both `@Observable` and `@Model` macros. + +## Memoized + +Computed properties in Swift are a great way of getting an always-up-to date values derived from other properties of a type. +However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial +and therefore call them repeatedly. + +With the conveniences afforded by `SwiftUI` and `Observation`, it’s easy to fall into this trap by performing expensive computations, +like mapping or filtering a collection, every time `View.body` is accessed: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + var filteredData: [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute +``` + +In the example above, it’s clear that we could save computing resources on repeated access to `filteredData` +when both `query` and `data` remain unchanged. The `@Memoized` macro allows you to do exactly that +by automatically caching and updating values derived from their underlying `Observable` inputs. + +To use it, refactor your computed property into a method and apply the `@Memoized` macro to it. +The call site remains the same: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + @Memoized("filteredData") + private func filterData() -> [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData +``` ## Documentation diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md new file mode 100644 index 0000000..232538d --- /dev/null +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -0,0 +1,89 @@ +# Memoized + +Perform expensive computations lazily and cache their outputs until `Observable` inputs change. + +## Overview + +Computed properties in Swift are a great way of getting an always-up-to date values derived from other properties of a type. +However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial +and therefore call them repeatedly. + +With the conveniences afforded by `SwiftUI` and `Observation`, it’s easy to fall into this trap by performing expensive computations, +like mapping or filtering a collection, every time `View.body` is accessed: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + var filteredData: [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute +``` + +In the example above, it’s clear that we could save computing resources on repeated access to `filteredData` +when both `query` and `data` remain unchanged. The ``Memoized(_:_:)`` macro allows you to do exactly that +by automatically caching and updating values derived from their underlying `Observable` inputs. + +To use it, refactor your computed property into a method and apply the ``Memoized(_:_:)`` macro to it. +The call site remains the same: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + @Memoized("filteredData") + private func filterData() -> [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData +``` + +## Topics + +### Memoizing Function Outputs + +- ``Memoized(_:_:)`` +- ``Memoized(_:_:isolation:)`` diff --git a/Sources/Relay/Documentation.docc/Publishable.md b/Sources/Relay/Documentation.docc/Publishable.md deleted file mode 100644 index 4087be9..0000000 --- a/Sources/Relay/Documentation.docc/Publishable.md +++ /dev/null @@ -1,15 +0,0 @@ -# ``Publishable-module`` - -Observe changes to `Observable` types synchronously with `Combine`. - -## Topics - -### Making Types Publishable - -- ``Publishable()`` -- ``Publishable(isolation:)`` -- ``Publishable-protocol`` - -### Getting Property Publishers - -- ``AnyPropertyPublisher`` diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md new file mode 100644 index 0000000..b9b2dd6 --- /dev/null +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -0,0 +1,62 @@ +# Publishable + +Observe changes to `Observable` types synchronously with `Combine`. + +## Overview + +With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), +Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, +as it publishes the updates via an `AsyncSequence`. + +In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. + +This is where ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates +with both the `@Observable` and `@Model` macros and could be extended to support other macros built on top of `Observation`: + +```swift +import Relay + +@Publishable @Observable +final class Person { + var name = "John" + var surname = "Doe" + + var fullName: String { + "\(name) \(surname)" + } +} + +let person = Person() +let nameCancellable = person.publisher.name.sink { name in + print("Name -", name) +} +let fullNameCancellable = person.publisher.fullName.sink { fullName in + print("Full name -", fullName) +} + +// Initially prints (same as `Published` property wrapper): +// Name - John +// Full name - John Doe + +person.name = "Kamil" +// Prints: +// Name - Kamil +// Full name - Kamil Doe + +person.surname = "Strzelecki" +// Prints: +// Full name - Kamil Strzelecki +``` + +## Topics + +### Making Types Publishable + +- ``Publishable()`` +- ``Publishable(isolation:)`` +- ``Publishable-protocol`` + +### Getting Property Publishers + +- ``AnyPropertyPublisher`` diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md new file mode 100644 index 0000000..eee003e --- /dev/null +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -0,0 +1,8 @@ +# ``Relay-module`` + +Extend functionality of `Observable` types. + +## Topics + +- +- diff --git a/Sources/Relay/Memoized/Memoized.swift b/Sources/Relay/Memoized/Memoized.swift index c2f6aea..ff90d14 100644 --- a/Sources/Relay/Memoized/Memoized.swift +++ b/Sources/Relay/Memoized/Memoized.swift @@ -8,6 +8,28 @@ import PrincipleMacrosClientSupport +/// A macro allowing a method to be used as a computed property, whose value will be automatically cached +/// and updated when its underlying `Observable` inputs change. +/// +/// - Parameters: +/// - accessControlLevel: Access control level of the generated computed property. +/// Defaults to `nil`, meaning that no explicit access control level will be applied. +/// - propertyName: Name of the generated computed property. +/// Defaults to `nil`, meaning that the name will be derived from the method by trimming its first word. +/// +/// - Note: This macro infers the global actor isolation of the method and applies it to the generated declarations. +/// If this causes compilation errors, use ``Memoized(_:_:isolation:)`` instead. +/// +/// - Note: This macro works only with pure methods of classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Memoized` macro adds a new computed property to your type that returns the same value as a direct call to the original method. +/// Unlike a direct method call, this computed property automatically caches its output and returns the cached value on subsequent accesses, +/// until any of its underlying `Observable` inputs change. After an input changes, the value will be recomputed on the next access. +/// If the computed property is never accessed again, the original method will not be invoked. +/// +/// Like any other property on an `Observable` type, the generated computed property can be tracked with the `Observation` APIs, +/// as well as `Combine` if the ``Publishable()`` macro has been applied to the enclosing class. +/// @attached(peer, names: arbitrary) public macro Memoized( _ accessControlLevel: AccessControlLevel? = nil, @@ -17,6 +39,28 @@ public macro Memoized( type: "MemoizedMacro" ) +/// A macro allowing a method to be used as a computed property, whose value will be automatically cached +/// and updated when its underlying `Observable` inputs change. +/// +/// - Parameters: +/// - accessControlLevel: Access control level of the generated computed property. +/// Defaults to `nil`, meaning that no explicit access control level will be applied. +/// - propertyName: Name of the generated computed property. +/// Defaults to `nil`, meaning that the name will be derived from the method by trimming its first word. +/// - isolation: The global actor to which the generated computed property is isolated. +/// If set to `nil`, the property will be `nonisolated`. +/// To infer isolation automatically, use the ``Memoized(_:_:)`` macro instead. +/// +/// - Note: This macro works only with pure methods of classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Memoized` macro adds a new computed property to your type that returns the same value as a direct call to the original method. +/// Unlike a direct method call, this computed property automatically caches its output and returns the cached value on subsequent accesses, +/// until any of its underlying `Observable` inputs change. After an input changes, the value will be recomputed on the next access. +/// If the computed property is never accessed again, the original method will not be invoked. +/// +/// Like any other property on an `Observable` type, the generated computed property can be tracked with the `Observation` APIs, +/// as well as `Combine` if the ``Publishable()`` macro has been applied to the enclosing class. +/// @attached(peer, names: arbitrary) public macro Memoized( _ accessControlLevel: AccessControlLevel? = nil, diff --git a/Sources/Relay/Publishable/Publishable.swift b/Sources/Relay/Publishable/Publishable.swift index 967e591..2c081db 100644 --- a/Sources/Relay/Publishable/Publishable.swift +++ b/Sources/Relay/Publishable/Publishable.swift @@ -43,7 +43,8 @@ public macro Publishable() = #externalMacro( /// 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`. +/// - 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 `@Model` macro has been applied directly. From 50d83c179a81e7375a4be6c8e2ab8a57b795d0a3 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 15:40:13 +0100 Subject: [PATCH 14/39] - --- .github/workflows/pull-request.yml | 16 ++++++++-------- .github/workflows/release.yml | 4 ++-- Package.resolved | 5 +++-- Package.swift | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5a877bb..da19e5e 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,11 +12,11 @@ concurrency: cancel-in-progress: true env: - XCODE_VERSION: "16.3" + XCODE_VERSION: "26.1" jobs: prepare: - runs-on: macos-15 + runs-on: macos-26 outputs: platforms: ${{ steps.platforms.outputs.platforms }} scheme: ${{ steps.scheme.outputs.scheme }} @@ -62,7 +62,7 @@ jobs: build-and-test: needs: prepare - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -81,16 +81,16 @@ jobs: destination="platform=macOS,variant=Mac Catalyst" ;; ios) - destination="platform=iOS Simulator,name=iPhone 16 Pro Max,OS=latest" + destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.1" ;; tvos) - destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=latest" + destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.1" ;; watchos) - destination="platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=latest" + destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.1" ;; visionos) - destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=latest" + destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1" ;; *) echo "Unknown platform: ${{ matrix.platform }}" @@ -136,4 +136,4 @@ jobs: if: ${{ matrix.platform == 'macos' }} uses: codecov/codecov-action@v5 with: - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d109093..5431cec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,11 @@ on: - '*' env: - XCODE_VERSION: "16.3" + XCODE_VERSION: "26.1" jobs: release: - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 with: diff --git a/Package.resolved b/Package.resolved index ef0b0b0..7092be9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "49e7ced1729dac73b31288ac69c288f2532e1445d91ce73d773c8446ce5cdb09", + "originHash" : "b17a09e1d940fb0329fbce871bb40b6a014d67a3582dc4b5ced1b2973ff91a37", "pins" : [ { "identity" : "principlemacros", "kind" : "remoteSourceControl", "location" : "https://github.com/NSFatalError/PrincipleMacros", "state" : { - "revision" : "210cb630fa67c59239070fac094d88bd6cd27419" + "revision" : "38996a779f7702248aaf6dd3360c9be86faa368c", + "version" : "3.0.0" } }, { diff --git a/Package.swift b/Package.swift index 7f305ad..cbf2057 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/NSFatalError/PrincipleMacros", - revision: "210cb630fa67c59239070fac094d88bd6cd27419" + "3.0.0" ..< "4.0.0" ), .package( url: "https://github.com/swiftlang/swift-syntax", From 360409f06725f9c47d531a0d6775fa9e943ba897 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 15:52:44 +0100 Subject: [PATCH 15/39] - --- README.md | 2 +- Sources/Relay/Documentation.docc/Relay.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e52eddb..fb19c5e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Publishable) [![Codecov](https://codecov.io/github/NSFatalError/Publishable/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/github/NSFatalError/Publishable) -Extend functionality of `Observable` types. +Essential tools that extend the capabilities of `Observation`. #### Contents - [Publishable](#publishable) diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index eee003e..57d66ce 100644 --- a/Sources/Relay/Documentation.docc/Relay.md +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -1,6 +1,6 @@ # ``Relay-module`` -Extend functionality of `Observable` types. +Essential tools that extend the capabilities of `Observation`. ## Topics From 82dd94a09d1fdae42c143d1ec95dc3052183b60e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:05:51 +0100 Subject: [PATCH 16/39] - --- .swiftlint.yml | 2 +- .../Memoized/MemoizedDeclBuilder.swift | 6 ++- .../RelayMacros/Memoized/MemoizedMacro.swift | 45 ++++++++++++++----- .../MainActorMemoizedMacroTests.swift | 4 ++ .../Memoized/SwiftDataMemoizedTests.swift | 6 ++- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 1b923f0..7d4520a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -157,7 +157,7 @@ file_header: required_pattern: | // // SWIFTLINT_CURRENT_FILENAME - // Publishable + // Relay // // Created by .+ on \d{2}/\d{2}/\d{4}\. // Copyright © \d{4} .+\. All rights reserved\. diff --git a/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift index 2c74e4c..9ffaf3b 100644 --- a/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -21,10 +21,12 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { func build() -> [DeclSyntax] { [ """ - \(inheritedGlobalActorIsolation)private var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil + \(inheritedGlobalActorIsolation)private \ + var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil """, """ - \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)var \(raw: propertyName): \(trimmedReturnType) { + \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)\ + var \(raw: propertyName): \(trimmedReturnType) { if let cached = _\(raw: propertyName) { access(keyPath: \\._\(raw: propertyName)) return cached diff --git a/Sources/RelayMacros/Memoized/MemoizedMacro.swift b/Sources/RelayMacros/Memoized/MemoizedMacro.swift index 1324af1..9a18c11 100644 --- a/Sources/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Sources/RelayMacros/Memoized/MemoizedMacro.swift @@ -10,11 +10,12 @@ import PrincipleMacros public enum MemoizedMacro { - private typealias Input = ( - declaration: FunctionDeclSyntax, - trimmedReturnType: TypeSyntax, - propertyName: String - ) + private struct Input { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + } private static func validate( _ declaration: some DeclSyntaxProtocol, @@ -50,19 +51,41 @@ public enum MemoizedMacro { return nil } - if let propertyName = parameters.preferredPropertyName { - guard !propertyName.isEmpty else { + let propertyName = validatePropertyName( + for: declaration, + in: context, + preferred: parameters.preferredPropertyName + ) + + guard let propertyName else { + return nil + } + + return Input( + declaration: declaration, + trimmedReturnType: trimmedReturnType, + propertyName: propertyName + ) + } + + private static func validatePropertyName( + for declaration: FunctionDeclSyntax, + in context: some MacroExpansionContext, + preferred: String? + ) -> String? { + if let preferred { + guard !preferred.isEmpty else { context.diagnose( node: declaration, errorMessage: "Memoized macro requires a non-empty property name" ) return nil } - return (declaration, trimmedReturnType, propertyName) + return preferred } - let propertyName = defaultPropertyName(for: declaration) - guard !propertyName.isEmpty else { + let inferred = defaultPropertyName(for: declaration) + guard !inferred.isEmpty else { context.diagnose( node: declaration, errorMessage: """ @@ -73,7 +96,7 @@ public enum MemoizedMacro { return nil } - return (declaration, trimmedReturnType, propertyName) + return inferred } static func defaultPropertyName(for declaration: FunctionDeclSyntax) -> String { diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift index f31d94d..6153b08 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -17,6 +17,8 @@ "Memoized": MemoizedMacro.self ] + // swiftlint:disable global_actor_attribute_order + func testExpansion() { assertMacroExpansion( #""" @@ -152,5 +154,7 @@ macros: macros ) } + + // swiftlint:enable global_actor_attribute_order } #endif diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index 715547a..6891b38 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -350,7 +350,7 @@ extension SwiftDataMemoizedTests { @Model final class Cube { - var offset = 0.0 + var offset: Double var x = 1.0 var y = 1.0 var z = 1.0 @@ -361,7 +361,9 @@ extension SwiftDataMemoizedTests { private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } - init() {} + init() { + self.offset = 0.0 + } @Memoized func calculateBaseArea() -> Double { From bf1555fbacf74a5c7d9f65135062281a0c09fe4a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:11:51 +0100 Subject: [PATCH 17/39] - --- README.md | 8 +++++++- Sources/Relay/Documentation.docc/MemoizedMacros.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fb19c5e..b9aa2e8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Essential tools that extend the capabilities of `Observation`. ## Publishable +
+ Observe changes to `Observable` types synchronously with `Combine`. + With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, as it publishes the updates via an `AsyncSequence`. @@ -76,7 +79,10 @@ This approach has been carefully tested and verified to work with both `@Observa ## Memoized -Computed properties in Swift are a great way of getting an always-up-to date values derived from other properties of a type. +
+ Perform expensive computations lazily and cache their outputs until `Observable` inputs change. + +Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial and therefore call them repeatedly. diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index 232538d..be6bfb2 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -4,7 +4,7 @@ Perform expensive computations lazily and cache their outputs until `Observable` ## Overview -Computed properties in Swift are a great way of getting an always-up-to date values derived from other properties of a type. +Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial and therefore call them repeatedly. From 09670b1fb09fe0b389be780d10fd6b4aa41c603a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:14:11 +0100 Subject: [PATCH 18/39] - --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b9aa2e8..b8bd107 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ It relies solely on well-understood behaviors of Swift and its public APIs. This approach has been carefully tested and verified to work with both `@Observable` and `@Model` macros. +
+ ## Memoized
@@ -159,6 +161,8 @@ model.filteredData // Prints: recompute model.filteredData ``` +
+ ## Documentation [Full documentation is available on the Swift Package Index.](https://swiftpackageindex.com/NSFatalError/Publishable/documentation/publishable) From 467140a8c0b630a6fe15472aa835ab9b1fe374a0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:19:47 +0100 Subject: [PATCH 19/39] - --- README.md | 7 +++---- Sources/Relay/Documentation.docc/MemoizedMacros.md | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b8bd107..82186b9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Essential tools that extend the capabilities of `Observation`. ## Publishable
- Observe changes to `Observable` types synchronously with `Combine`. + Observe changes to Observable types synchronously with Combine. With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, @@ -82,7 +82,7 @@ This approach has been carefully tested and verified to work with both `@Observa ## Memoized
- Perform expensive computations lazily and cache their outputs until `Observable` inputs change. + Perform expensive computations lazily and cache their outputs until Observable inputs change. Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial @@ -126,8 +126,7 @@ In the example above, it’s clear that we could save computing resources on rep when both `query` and `data` remain unchanged. The `@Memoized` macro allows you to do exactly that by automatically caching and updating values derived from their underlying `Observable` inputs. -To use it, refactor your computed property into a method and apply the `@Memoized` macro to it. -The call site remains the same: +To use it, refactor your computed property into a method and apply the `@Memoized` macro to it: ```swift @MainActor @Observable diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index be6bfb2..1318ba1 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -46,8 +46,7 @@ In the example above, it’s clear that we could save computing resources on rep when both `query` and `data` remain unchanged. The ``Memoized(_:_:)`` macro allows you to do exactly that by automatically caching and updating values derived from their underlying `Observable` inputs. -To use it, refactor your computed property into a method and apply the ``Memoized(_:_:)`` macro to it. -The call site remains the same: +To use it, refactor your computed property into a method and apply the ``Memoized(_:_:)`` macro to it: ```swift @MainActor @Observable From cf3c16696d472e0c9a428cbcfeec42724a5d14b4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:34:20 +0100 Subject: [PATCH 20/39] - --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82186b9..5210064 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Relay -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/NSFatalError/Publishable) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Publishable) -[![Codecov](https://codecov.io/github/NSFatalError/Publishable/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/github/NSFatalError/Publishable) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FRelay%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/NSFatalError/Relay) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FRelay%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Relay) +[![Codecov](https://codecov.io/gh/NSFatalError/Relay/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/gh/NSFatalError/Relay) Essential tools that extend the capabilities of `Observation`. @@ -68,7 +68,7 @@ The `@Publishable` macro relies on two key properties of Swift Macros and `Obser - Macro expansions are compiled in the context of the module where they’re used. This allows references in the macro to be overloaded by locally available symbols. - Swift exposes `ObservationRegistrar` as a documented, public API, making it possible to use it safely and directly. -`Publishable` leverages these facts to overload the default `ObservationRegistrar` with a custom one that: +By leveraging these facts, the `@Publishable` macro can overload the default `ObservationRegistrar` with a custom one that: - Forwards changes to Swift’s native `ObservationRegistrar` - Simultaneously emits values through generated `Combine` publishers @@ -164,13 +164,13 @@ model.filteredData ## Documentation -[Full documentation is available on the Swift Package Index.](https://swiftpackageindex.com/NSFatalError/Publishable/documentation/publishable) +[Full documentation is available on the Swift Package Index.](https://swiftpackageindex.com/NSFatalError/Relay/documentation/relay) ## Installation ```swift .package( - url: "https://github.com/NSFatalError/Publishable", + url: "https://github.com/NSFatalError/Relay", from: "1.0.0" ) ``` From 2f05ba1df8cd13216381d8656b01f2f844b43a5e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:37:06 +0100 Subject: [PATCH 21/39] - --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5210064..6a51e49 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Essential tools that extend the capabilities of `Observation`.
Observe changes to Observable types synchronously with Combine. + With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, as it publishes the updates via an `AsyncSequence`. @@ -84,6 +85,7 @@ This approach has been carefully tested and verified to work with both `@Observa
Perform expensive computations lazily and cache their outputs until Observable inputs change. + Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial and therefore call them repeatedly. From db0a7243f8f56e1db0611b8a85741e19a17be630 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:38:13 +0100 Subject: [PATCH 22/39] - --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a51e49..1a6139f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Essential tools that extend the capabilities of `Observation`.
Observe changes to Observable types synchronously with Combine. - +
With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, From 1ca38ac83c472992eeb166a6d49f447da7ff0a5c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:39:59 +0100 Subject: [PATCH 23/39] - --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a6139f..17b28dc 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ This approach has been carefully tested and verified to work with both `@Observa
Perform expensive computations lazily and cache their outputs until Observable inputs change. - +
Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial From 2b6fde72ea4155ab88077e68759b4822309d837c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 16:55:18 +0100 Subject: [PATCH 24/39] - --- Package.swift | 6 ++++-- Sources/RelayMacros/Main/RelayPlugin.swift | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Package.swift b/Package.swift index cbf2057..0fd6990 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,8 @@ let package = Package( ), .product( name: "SwiftCompilerPlugin", - package: "swift-syntax" + package: "swift-syntax", + condition: .when(platforms: [.macOS]) ) ] ), @@ -68,7 +69,8 @@ let package = Package( ), .product( name: "SwiftCompilerPlugin", - package: "swift-syntax" + package: "swift-syntax", + condition: .when(platforms: [.macOS]) ) ] ) diff --git a/Sources/RelayMacros/Main/RelayPlugin.swift b/Sources/RelayMacros/Main/RelayPlugin.swift index 1d7219f..c60e7b0 100644 --- a/Sources/RelayMacros/Main/RelayPlugin.swift +++ b/Sources/RelayMacros/Main/RelayPlugin.swift @@ -6,14 +6,16 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros -import SwiftCompilerPlugin +#if canImport(SwiftCompilerPlugin) + import PrincipleMacros + import SwiftCompilerPlugin -@main -internal struct RelayPlugin: CompilerPlugin { + @main + internal struct RelayPlugin: CompilerPlugin { - let providingMacros: [any Macro.Type] = [ - PublishableMacro.self, - MemoizedMacro.self - ] -} + let providingMacros: [any Macro.Type] = [ + PublishableMacro.self, + MemoizedMacro.self + ] + } +#endif From 532b9d6b59bb151bc625eb3d880c17458f836cb0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 17:24:17 +0100 Subject: [PATCH 25/39] - --- Sources/Relay/Publishable/AnyPropertyPublisher.swift | 8 ++++---- .../Publishable/ObservationRegistrarDeclBuilder.swift | 8 ++++---- .../Publishable/MainActorPublishableMacroTests.swift | 8 ++++---- .../Publishable/PublishableMacroTests.swift | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift index fe37afd..9cdd966 100644 --- a/Sources/Relay/Publishable/AnyPropertyPublisher.swift +++ b/Sources/Relay/Publishable/AnyPropertyPublisher.swift @@ -44,16 +44,18 @@ open class AnyPropertyPublisher { } } +// swiftlint:disable identifier_name + extension AnyPropertyPublisher { - func beginModifications() { + public func _beginModifications() { pendingModifications += 1 if pendingModifications == 1 { _willChange.send(object) } } - func endModifications() { + public func _endModifications() { if pendingModifications == 1 { _didChange.send(object) } @@ -61,8 +63,6 @@ extension AnyPropertyPublisher { } } -// swiftlint:disable identifier_name - extension AnyPropertyPublisher { public func _storedPropertyPublisher( diff --git a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index e32e635..1d07573 100644 --- a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -101,7 +101,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin ) { nonisolated(unsafe) let keyPath = keyPath assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() underlying.willSet(object, keyPath: keyPath) } } @@ -114,7 +114,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin assumeIsolatedIfNeeded { underlying.didSet(object, keyPath: keyPath) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } } @@ -135,10 +135,10 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin nonisolated(unsafe) var result: T! try assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } return result diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 4e3868b..c3a413c 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -173,7 +173,7 @@ ) { nonisolated(unsafe) let keyPath = keyPath assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() underlying.willSet(object, keyPath: keyPath) } } @@ -186,7 +186,7 @@ assumeIsolatedIfNeeded { underlying.didSet(object, keyPath: keyPath) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } } @@ -207,10 +207,10 @@ nonisolated(unsafe) var result: T! try assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } return result diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 19a248a..ea026ed 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -173,7 +173,7 @@ ) { nonisolated(unsafe) let keyPath = keyPath assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() underlying.willSet(object, keyPath: keyPath) } } @@ -186,7 +186,7 @@ assumeIsolatedIfNeeded { underlying.didSet(object, keyPath: keyPath) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } } @@ -207,10 +207,10 @@ nonisolated(unsafe) var result: T! try assumeIsolatedIfNeeded { - object.publisher.beginModifications() + object.publisher._beginModifications() result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) publish(object, keyPath: keyPath) - object.publisher.endModifications() + object.publisher._endModifications() } return result From 1b22e1bbd58891ac9103264465f5a761f0c75a8c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 17:25:19 +0100 Subject: [PATCH 26/39] - --- Tests/RelayTests/Memoized/MainActorMemoizedTests.swift | 2 +- Tests/RelayTests/Memoized/ObservationMemoizedTests.swift | 2 +- Tests/RelayTests/Memoized/PublishableMemoizedTests.swift | 2 +- Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift | 2 +- Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift | 2 +- Tests/RelayTests/Publishable/MainActorPublishableTests.swift | 2 +- Tests/RelayTests/Publishable/ObservationPublishableTests.swift | 2 +- Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 5cb4ab0..0331aef 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing internal enum MainActorMemoizedTests { diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index 8a10a1c..3e930b7 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing internal enum ObservationMemoizedTests { diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 662a08a..2fa45ac 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing internal struct PublishableMemoizedTests { diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index 6891b38..53eb56b 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import SwiftData import Testing diff --git a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index 46b73b1..dafca42 100644 --- a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing internal struct AnyPropertyPublisherTests { diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 834266e..2453190 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Foundation import Testing diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index a66b4ed..b389971 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Foundation import Testing diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index d7b3d3d..83093e5 100644 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Foundation import SwiftData import Testing From b14451da26523d157b65afa36a3f908c27a9874a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 17:25:19 +0100 Subject: [PATCH 27/39] [SwiftFormat] Applied formatting --- Tests/RelayTests/Publishable/MainActorPublishableTests.swift | 2 +- Tests/RelayTests/Publishable/ObservationPublishableTests.swift | 2 +- Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 2453190..8c86215 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import Relay import Foundation +import Relay import Testing @MainActor diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index b389971..38f7cc2 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import Relay import Foundation +import Relay import Testing internal struct ObservationPublishableTests { diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index 83093e5..8404c4b 100644 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import Relay import Foundation +import Relay import SwiftData import Testing From 51ac958be8cac8c7af2d30e351a1f58eaa92afb0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 18:54:48 +0100 Subject: [PATCH 28/39] - --- .gitmodules | 3 +++ Macros/Dependencies/PrincipleMacros | 1 + Package.resolved | 11 +--------- Package.swift | 33 ++++++++++------------------- 4 files changed, 16 insertions(+), 32 deletions(-) create mode 100644 .gitmodules create mode 160000 Macros/Dependencies/PrincipleMacros diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a3bcbd3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Macros/Dependencies/PrincipleMacros"] + path = Macros/Dependencies/PrincipleMacros + url = https://github.com/NSFatalError/PrincipleMacros diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros new file mode 160000 index 0000000..de18b35 --- /dev/null +++ b/Macros/Dependencies/PrincipleMacros @@ -0,0 +1 @@ +Subproject commit de18b35227f6373de478bccdc87b90839dc8bf1c diff --git a/Package.resolved b/Package.resolved index 7092be9..022f6eb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "b17a09e1d940fb0329fbce871bb40b6a014d67a3582dc4b5ced1b2973ff91a37", + "originHash" : "517cc7d6013773868fbf68195ad5536cab25006a94a1feafa5e9044137ac23d1", "pins" : [ - { - "identity" : "principlemacros", - "kind" : "remoteSourceControl", - "location" : "https://github.com/NSFatalError/PrincipleMacros", - "state" : { - "revision" : "38996a779f7702248aaf6dd3360c9be86faa368c", - "version" : "3.0.0" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0fd6990..f0dd9f2 100644 --- a/Package.swift +++ b/Package.swift @@ -21,10 +21,6 @@ let package = Package( ) ], dependencies: [ - .package( - url: "https://github.com/NSFatalError/PrincipleMacros", - "3.0.0" ..< "4.0.0" - ), .package( url: "https://github.com/swiftlang/swift-syntax", "602.0.0" ..< "603.0.0" @@ -33,13 +29,7 @@ let package = Package( targets: [ .target( name: "Relay", - dependencies: [ - "RelayMacros", - .product( - name: "PrincipleMacrosClientSupport", - package: "PrincipleMacros" - ) - ] + dependencies: ["RelayMacros"] ), .testTarget( name: "RelayTests", @@ -49,14 +39,18 @@ let package = Package( name: "RelayMacros", dependencies: [ .product( - name: "PrincipleMacros", - package: "PrincipleMacros" + name: "SwiftSyntaxMacros", + package: "swift-syntax" ), .product( name: "SwiftCompilerPlugin", - package: "swift-syntax", - condition: .when(platforms: [.macOS]) + package: "swift-syntax" ) + ], + path: "Macros", + sources: [ + "RelayMacros/", + "Dependencies/PrincipleMacros/Sources/PrincipleMacros/" ] ), .testTarget( @@ -64,13 +58,8 @@ let package = Package( dependencies: [ "RelayMacros", .product( - name: "PrincipleMacrosTestSupport", - package: "PrincipleMacros" - ), - .product( - name: "SwiftCompilerPlugin", - package: "swift-syntax", - condition: .when(platforms: [.macOS]) + name: "SwiftSyntaxMacrosTestSupport", + package: "swift-syntax" ) ] ) From 6ac2184d590b6d2406e77ebc16bf33a091a3be4f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 18:56:16 +0100 Subject: [PATCH 29/39] - --- .../RelayMacros/Main/RelayPlugin.swift | 2 +- .../Memoized/MemoizedDeclBuilder.swift | 2 +- .../RelayMacros/Memoized/MemoizedMacro.swift | 2 +- .../ObservationRegistrarDeclBuilder.swift | 5 +++-- .../PropertyPublisherDeclBuilder.swift | 9 +++++---- .../Publishable/PublishableMacro.swift | 2 +- .../Publishable/PublisherDeclBuilder.swift | 2 +- Sources/Relay/Memoized/AccessControlLevel.swift | 17 +++++++++++++++++ Sources/Relay/Memoized/Memoized.swift | 2 -- .../Memoized/MainActorMemoizedMacroTests.swift | 2 +- .../Memoized/MemoizedMacroTests.swift | 2 +- .../MainActorPublishableMacroTests.swift | 2 +- .../Publishable/PublishableMacroTests.swift | 2 +- 13 files changed, 34 insertions(+), 17 deletions(-) rename {Sources => Macros}/RelayMacros/Main/RelayPlugin.swift (93%) rename {Sources => Macros}/RelayMacros/Memoized/MemoizedDeclBuilder.swift (99%) rename {Sources => Macros}/RelayMacros/Memoized/MemoizedMacro.swift (99%) rename {Sources => Macros}/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift (98%) rename {Sources => Macros}/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift (93%) rename {Sources => Macros}/RelayMacros/Publishable/PublishableMacro.swift (99%) rename {Sources => Macros}/RelayMacros/Publishable/PublisherDeclBuilder.swift (97%) create mode 100644 Sources/Relay/Memoized/AccessControlLevel.swift diff --git a/Sources/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift similarity index 93% rename from Sources/RelayMacros/Main/RelayPlugin.swift rename to Macros/RelayMacros/Main/RelayPlugin.swift index c60e7b0..52b1e47 100644 --- a/Sources/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -7,7 +7,7 @@ // #if canImport(SwiftCompilerPlugin) - import PrincipleMacros + import SwiftSyntaxMacros import SwiftCompilerPlugin @main diff --git a/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift similarity index 99% rename from Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift rename to Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index 9ffaf3b..53067cf 100644 --- a/Sources/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { diff --git a/Sources/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift similarity index 99% rename from Sources/RelayMacros/Memoized/MemoizedMacro.swift rename to Macros/RelayMacros/Memoized/MemoizedMacro.swift index 9a18c11..d004bb0 100644 --- a/Sources/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros public enum MemoizedMacro { diff --git a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift similarity index 98% rename from Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift rename to Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index 1d07573..1ecf4ae 100644 --- a/Sources/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -6,7 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros +import SwiftSyntaxBuilder internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { @@ -80,7 +81,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in registeredProperties.withInferredType(like: inferredType) { + for property in registeredProperties.withInferredType(like: inferredType).all { let name = property.trimmedName """ if keyPath == \\.\(name) { diff --git a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift similarity index 93% rename from Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift rename to Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index 87010f0..f60337d 100644 --- a/Sources/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -6,7 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros +import SwiftSyntaxBuilder internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @@ -41,14 +42,14 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @CodeBlockItemListBuilder private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { - for property in properties.stored.mutable.instance { + for property in properties.stored.mutable.instance.all { "_\(property.trimmedName).send(completion: .finished)" } } @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.stored.mutable.instance { + for property in properties.stored.mutable.instance.all { let globalActor = inheritedGlobalActorIsolation let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName @@ -64,7 +65,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.computed.instance { + for property in properties.computed.instance.all { let globalActor = inheritedGlobalActorIsolation let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName diff --git a/Sources/RelayMacros/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Publishable/PublishableMacro.swift similarity index 99% rename from Sources/RelayMacros/Publishable/PublishableMacro.swift rename to Macros/RelayMacros/Publishable/PublishableMacro.swift index 12a5246..a8368d2 100644 --- a/Sources/RelayMacros/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Publishable/PublishableMacro.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros public enum PublishableMacro { diff --git a/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift similarity index 97% rename from Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift rename to Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift index 4c1be44..c5c70c4 100644 --- a/Sources/RelayMacros/Publishable/PublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { diff --git a/Sources/Relay/Memoized/AccessControlLevel.swift b/Sources/Relay/Memoized/AccessControlLevel.swift new file mode 100644 index 0000000..603b57b --- /dev/null +++ b/Sources/Relay/Memoized/AccessControlLevel.swift @@ -0,0 +1,17 @@ +// +// AccessControlLevel.swift +// PrincipleMacros +// +// Created by Kamil Strzelecki on 13/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +public enum AccessControlLevel: Hashable { + + case `private` + case `fileprivate` + case `internal` + case package + case `public` + case open +} diff --git a/Sources/Relay/Memoized/Memoized.swift b/Sources/Relay/Memoized/Memoized.swift index ff90d14..7820aff 100644 --- a/Sources/Relay/Memoized/Memoized.swift +++ b/Sources/Relay/Memoized/Memoized.swift @@ -6,8 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacrosClientSupport - /// A macro allowing a method to be used as a computed property, whose value will be automatically cached /// and updated when its underlying `Observable` inputs change. /// diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift index 6153b08..39eb245 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -7,7 +7,7 @@ // #if canImport(RelayMacros) - import PrincipleMacrosTestSupport + import SwiftSyntaxMacrosTestSupport import RelayMacros import XCTest diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index b328218..5c4ca92 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -7,7 +7,7 @@ // #if canImport(RelayMacros) - import PrincipleMacrosTestSupport + import SwiftSyntaxMacrosTestSupport import RelayMacros import XCTest diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index c3a413c..486501d 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -7,7 +7,7 @@ // #if canImport(RelayMacros) - import PrincipleMacrosTestSupport + import SwiftSyntaxMacrosTestSupport import RelayMacros import XCTest diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index ea026ed..d86e6da 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -7,7 +7,7 @@ // #if canImport(RelayMacros) - import PrincipleMacrosTestSupport + import SwiftSyntaxMacrosTestSupport import RelayMacros import XCTest From e565fb91a22698d6710121ad12fe0e6166c70f5c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 18:56:16 +0100 Subject: [PATCH 30/39] [SwiftFormat] Applied formatting --- Macros/RelayMacros/Main/RelayPlugin.swift | 2 +- .../Publishable/ObservationRegistrarDeclBuilder.swift | 2 +- .../RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift | 2 +- .../RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift | 2 +- Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift | 2 +- .../Publishable/MainActorPublishableMacroTests.swift | 2 +- Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index 52b1e47..df888ba 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -7,8 +7,8 @@ // #if canImport(SwiftCompilerPlugin) - import SwiftSyntaxMacros import SwiftCompilerPlugin + import SwiftSyntaxMacros @main internal struct RelayPlugin: CompilerPlugin { diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index 1ecf4ae..c3ee12d 100644 --- a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import SwiftSyntaxMacros import SwiftSyntaxBuilder +import SwiftSyntaxMacros internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { diff --git a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index f60337d..77ab29f 100644 --- a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import SwiftSyntaxMacros import SwiftSyntaxBuilder +import SwiftSyntaxMacros internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift index 39eb245..7046355 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -7,8 +7,8 @@ // #if canImport(RelayMacros) - import SwiftSyntaxMacrosTestSupport import RelayMacros + import SwiftSyntaxMacrosTestSupport import XCTest internal final class MainActorMemoizedMacroTests: XCTestCase { diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index 5c4ca92..aa7df17 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -7,8 +7,8 @@ // #if canImport(RelayMacros) - import SwiftSyntaxMacrosTestSupport import RelayMacros + import SwiftSyntaxMacrosTestSupport import XCTest internal final class MemoizedMacroTests: XCTestCase { diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 486501d..6e230ed 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -7,8 +7,8 @@ // #if canImport(RelayMacros) - import SwiftSyntaxMacrosTestSupport import RelayMacros + import SwiftSyntaxMacrosTestSupport import XCTest internal final class MainActorPublishableMacroTests: XCTestCase { diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index d86e6da..2ba430a 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -7,8 +7,8 @@ // #if canImport(RelayMacros) - import SwiftSyntaxMacrosTestSupport import RelayMacros + import SwiftSyntaxMacrosTestSupport import XCTest internal final class PublishableMacroTests: XCTestCase { From 8538785c622bbc794693eb0d032ff53d86193e6d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 19:10:48 +0100 Subject: [PATCH 31/39] - --- Sources/Relay/Documentation.docc/MemoizedMacros.md | 1 + Sources/Relay/Memoized/AccessControlLevel.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index 1318ba1..cde807f 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -86,3 +86,4 @@ model.filteredData - ``Memoized(_:_:)`` - ``Memoized(_:_:isolation:)`` +- ``AccessControlLevel`` diff --git a/Sources/Relay/Memoized/AccessControlLevel.swift b/Sources/Relay/Memoized/AccessControlLevel.swift index 603b57b..2b97f1b 100644 --- a/Sources/Relay/Memoized/AccessControlLevel.swift +++ b/Sources/Relay/Memoized/AccessControlLevel.swift @@ -1,6 +1,6 @@ // // AccessControlLevel.swift -// PrincipleMacros +// Relay // // Created by Kamil Strzelecki on 13/11/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. From 7d7bc8847508d78aa1a6c8374f4fdda08a92dccc Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 19:54:05 +0100 Subject: [PATCH 32/39] - --- .github/workflows/pull-request.yml | 11 +++++++++-- .github/workflows/release.yml | 4 +++- Sources/Relay/Documentation.docc/MemoizedMacros.md | 5 ++++- Sources/Relay/Documentation.docc/PublishableMacros.md | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index da19e5e..5895643 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -21,7 +21,10 @@ jobs: platforms: ${{ steps.platforms.outputs.platforms }} scheme: ${{ steps.scheme.outputs.scheme }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + submodules: true + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app @@ -31,6 +34,7 @@ jobs: run: | curl https://mise.run | sh mise install + - name: Run linters run: mise lint @@ -68,7 +72,10 @@ jobs: matrix: platform: ${{ fromJSON(needs.prepare.outputs.platforms) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + submodules: true + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5431cec..b076f96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,11 @@ jobs: release: runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + submodules: true fetch-depth: 0 + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index cde807f..237e060 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -82,8 +82,11 @@ model.filteredData ## Topics -### Memoizing Function Outputs +### Memoizing Method Outputs - ``Memoized(_:_:)`` - ``Memoized(_:_:isolation:)`` + +### Customizing Generated Declarations + - ``AccessControlLevel`` diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md index b9b2dd6..5cd59cb 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -57,6 +57,6 @@ person.surname = "Strzelecki" - ``Publishable(isolation:)`` - ``Publishable-protocol`` -### Getting Property Publishers +### Observing Changes with Combine - ``AnyPropertyPublisher`` From 88030ba1c88152d5fdad3e22c5f2b9bf7a0113bf Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 20:04:20 +0100 Subject: [PATCH 33/39] - --- .github/workflows/pull-request.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5895643..b6e8af2 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -116,7 +116,8 @@ jobs: set -o pipefail xcodebuild build \ -scheme ${{ needs.prepare.outputs.scheme }} \ - -destination "${{ steps.destination.outputs.destination }}" | \ + -destination "${{ steps.destination.outputs.destination }}" \ + -IDEPackageEnablePrebuilts=NO | \ xcbeautify --renderer github-actions - name: Test (SPM) @@ -136,7 +137,8 @@ jobs: set -o pipefail xcodebuild test \ -scheme ${{ needs.prepare.outputs.scheme }} \ - -destination "${{ steps.destination.outputs.destination }}" | \ + -destination "${{ steps.destination.outputs.destination }}" \ + -IDEPackageEnablePrebuilts=NO | \ xcbeautify --renderer github-actions - name: Check coverage (SPM) From d00a01613eb1eeb191c4da9e117ec72a6293cde2 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 20:30:23 +0100 Subject: [PATCH 34/39] - --- .mise.toml | 6 ++---- .swiftlint.yml | 3 +++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.mise.toml b/.mise.toml index 7811bca..62cb361 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,4 @@ [vars] -sources = "Sources" -tests = "Tests" swiftlint = '~/.local/bin/mise x -- swiftlint' swiftformat = '~/.local/bin/mise x -- swiftformat' @@ -19,7 +17,7 @@ run = """ {{ vars.swiftlint }} lint \ --config .swiftlint.yml \ --strict \ -{{ vars.sources }} +Sources Macros """ [tasks."swiftlint:tests"] @@ -30,7 +28,7 @@ run = """ --config .swiftlint.yml \ --config .swiftlint.tests.yml \ --strict \ -{{ vars.tests }} +Tests """ [tasks.swiftformat] diff --git a/.swiftlint.yml b/.swiftlint.yml index 7d4520a..5bc3ecb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -163,6 +163,9 @@ file_header: // Copyright © \d{4} .+\. All rights reserved\. // +excluded: + - Macros/Dependencies + file_length: warning: 500 From 88e207afaa28ed9af8bba7674e49e751f4c07641 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 20:45:55 +0100 Subject: [PATCH 35/39] - --- Macros/Dependencies/PrincipleMacros | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index de18b35..85aab93 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit de18b35227f6373de478bccdc87b90839dc8bf1c +Subproject commit 85aab93496550f03b8888a96598fe06ad7685a53 From 1cb17e75fa94398cf2f095c4afd8a7b0dc36f0d5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 21:08:10 +0100 Subject: [PATCH 36/39] Code review --- Macros/RelayMacros/Main/RelayPlugin.swift | 20 +++++++--------- .../Memoized/MemoizedDeclBuilder.swift | 18 +++++++------- .../RelayMacros/Memoized/MemoizedMacro.swift | 2 +- .../ObservationRegistrarDeclBuilder.swift | 12 +++++++--- README.md | 2 +- .../MainActorMemoizedMacroTests.swift | 24 +++++++++---------- .../Memoized/MemoizedMacroTests.swift | 12 +++++----- .../MainActorPublishableMacroTests.swift | 12 +++++++--- .../Publishable/PublishableMacroTests.swift | 12 +++++++--- 9 files changed, 65 insertions(+), 49 deletions(-) diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index df888ba..9da9a12 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -6,16 +6,14 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -#if canImport(SwiftCompilerPlugin) - import SwiftCompilerPlugin - import SwiftSyntaxMacros +import SwiftCompilerPlugin +import SwiftSyntaxMacros - @main - internal struct RelayPlugin: CompilerPlugin { +@main +internal struct RelayPlugin: CompilerPlugin { - let providingMacros: [any Macro.Type] = [ - PublishableMacro.self, - MemoizedMacro.self - ] - } -#endif + let providingMacros: [any Macro.Type] = [ + PublishableMacro.self, + MemoizedMacro.self + ] +} diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index 53067cf..e889b5e 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -73,13 +73,13 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift """ @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: @\(globalActor) () throws -> Void - ) rethrows { - try withoutActuallyEscaping(operation) { operation in - typealias Nonisolated = () throws -> Void + _ operation: @\(globalActor) () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - try \(globalActor).shared.assumeIsolated { _ in - try rawOperation() + \(globalActor).shared.assumeIsolated { _ in + rawOperation() } } } @@ -87,9 +87,9 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { } else { """ @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() + _ operation: () -> Void + ) { + operation() } """ } diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index d004bb0..c8d66fa 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -89,7 +89,7 @@ public enum MemoizedMacro { context.diagnose( node: declaration, errorMessage: """ - Memoized macro requires a function name with at least two words \ + Memoized macro requires a method name with at least two words \ or explicit property name """ ) diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index c3ee12d..2ffb05d 100644 --- a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -137,9 +137,15 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin try assumeIsolatedIfNeeded { object.publisher._beginModifications() - result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher._endModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) } return result diff --git a/README.md b/README.md index 17b28dc..1e053c7 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,6 @@ model.filteredData ```swift .package( url: "https://github.com/NSFatalError/Relay", - from: "1.0.0" + from: "2.0.0" ) ``` diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift index 7046355..d1597c7 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -54,13 +54,13 @@ nonisolated(unsafe) weak var instance = self @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: @MainActor () throws -> Void - ) rethrows { - try withoutActuallyEscaping(operation) { operation in - typealias Nonisolated = () throws -> Void + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - try MainActor.shared.assumeIsolated { _ in - try rawOperation() + MainActor.shared.assumeIsolated { _ in + rawOperation() } } } @@ -122,13 +122,13 @@ nonisolated(unsafe) weak var instance = self @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: @MainActor () throws -> Void - ) rethrows { - try withoutActuallyEscaping(operation) { operation in - typealias Nonisolated = () throws -> Void + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - try MainActor.shared.assumeIsolated { _ in - try rawOperation() + MainActor.shared.assumeIsolated { _ in + rawOperation() } } } diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index aa7df17..afd9c82 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -52,9 +52,9 @@ nonisolated(unsafe) weak var instance = self @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() + _ operation: () -> Void + ) { + operation() } @Sendable nonisolated func invalidateCache() { @@ -114,9 +114,9 @@ nonisolated(unsafe) weak var instance = self @Sendable nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() + _ operation: () -> Void + ) { + operation() } @Sendable nonisolated func invalidateCache() { diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 6e230ed..9c9537e 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -208,9 +208,15 @@ try assumeIsolatedIfNeeded { object.publisher._beginModifications() - result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher._endModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) } return result diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 2ba430a..a225b80 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -208,9 +208,15 @@ try assumeIsolatedIfNeeded { object.publisher._beginModifications() - result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher._endModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) } return result From 8170717216a6cba7b2a243403f364ed6be12cbd5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 21:18:11 +0100 Subject: [PATCH 37/39] - --- .../Publishable/ObservationRegistrarDeclBuilder.swift | 11 ++++++++--- .../Publishable/PropertyPublisherDeclBuilder.swift | 1 - 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift index 2ffb05d..f0551e0 100644 --- a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -6,7 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import SwiftSyntaxBuilder import SwiftSyntaxMacros internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { @@ -32,7 +31,9 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin \(subjectFunctions().formatted()) - \(publishableObservationRegistrarFunctions()) + \(observationRegistrarWillSetDidSetAccessFunctions()) + + \(observationRegistrarWithMutationFunction()) \(assumeIsolatedIfNeededFunction()) } @@ -94,7 +95,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin """ } - private func publishableObservationRegistrarFunctions() -> MemberBlockItemListSyntax { + private func observationRegistrarWillSetDidSetAccessFunctions() -> MemberBlockItemListSyntax { """ nonisolated func willSet( _ object: \(trimmedType), @@ -125,7 +126,11 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin ) { underlying.access(object, keyPath: keyPath) } + """ + } + private func observationRegistrarWithMutationFunction() -> MemberBlockItemListSyntax { + """ nonisolated func withMutation( of object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any>, diff --git a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift index 77ab29f..de0564d 100644 --- a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -6,7 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import SwiftSyntaxBuilder import SwiftSyntaxMacros internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { From b7e8bf840304d673bd904926fa507400cf0c5105 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 21:23:54 +0100 Subject: [PATCH 38/39] - --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e053c7..ded2b40 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ In some scenarios, however, developers need to perform actions synchronously - i This is where `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other macros built on top of `Observation`: +with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: ```swift import Relay From dad67080a83c9d90d8ef4d10725bd889168b81c5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 16 Nov 2025 21:24:22 +0100 Subject: [PATCH 39/39] - --- Sources/Relay/Documentation.docc/PublishableMacros.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md index 5cd59cb..b50492f 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -12,7 +12,7 @@ In some scenarios, however, developers need to perform actions synchronously - i This is where ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other macros built on top of `Observation`: +with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: ```swift import Relay