Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions Sources/Publishable/Documentation.docc/Publishable.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Observe changes to `Observable` types synchronously with `Combine`.
### Making Types Publishable

- ``Publishable()``
- ``Publishable(isolation:)``
- ``Publishable-protocol``

### Getting Property Publishers
Expand Down
57 changes: 41 additions & 16 deletions Sources/Publishable/PropertyPublisher/Publishable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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: GlobalActor>(
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<Self>

/// 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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,26 @@ public protocol PublishableObservationRegistrar {

associatedtype Object: Publishable, Observable

var underlying: SwiftObservationRegistrar { get }
init()

func publish(
func willSet(
_ object: Object,
keyPath: KeyPath<Object, some Any>
)
}

extension PublishableObservationRegistrar {

public func willSet(
func didSet(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
object.publisher.beginModifications()
underlying.willSet(object, keyPath: keyPath)
}

public func didSet(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
underlying.didSet(object, keyPath: keyPath)
publish(object, keyPath: keyPath)
object.publisher.endModifications()
}
)

public func access(
func access(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
underlying.access(object, keyPath: keyPath)
}
)

public func withMutation<T>(
func withMutation<T>(
of object: Object,
keyPath: KeyPath<Object, some Any>,
_ 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,11 +32,15 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder {

struct ObservationRegistrar: PublishableObservationRegistrar {

let underlying = SwiftObservationRegistrar()
private let underlying = SwiftObservationRegistrar()

\(publishNewValueFunction())

\(subjectFunctions().formatted())

\(publishableObservationRegistrarFunctions())

\(assumeIsolatedIfNeededFunction())
}
}
"""
Expand All @@ -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())
}
Expand All @@ -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
Expand All @@ -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())
}
Expand All @@ -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<T>(
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()
}
"""
}
}
}
Loading
Loading