Skip to content

Commit 5a8011d

Browse files
authored
Added Publishable subclassing support (#8)
1 parent f4a4c04 commit 5a8011d

49 files changed

Lines changed: 3553 additions & 1262 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pull-request.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ concurrency:
1212
cancel-in-progress: true
1313

1414
env:
15-
XCODE_VERSION: "26.1"
15+
XCODE_VERSION: "26.0"
1616

1717
jobs:
1818
prepare:
@@ -88,16 +88,16 @@ jobs:
8888
destination="platform=macOS,variant=Mac Catalyst"
8989
;;
9090
ios)
91-
destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.1"
91+
destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.0.1"
9292
;;
9393
tvos)
94-
destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.1"
94+
destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.0"
9595
;;
9696
watchos)
97-
destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.1"
97+
destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.0"
9898
;;
9999
visionos)
100-
destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1"
100+
destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.0"
101101
;;
102102
*)
103103
echo "Unknown platform: ${{ matrix.platform }}"

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
- '*'
77

88
env:
9-
XCODE_VERSION: "26.1"
9+
XCODE_VERSION: "26.0"
1010

1111
jobs:
1212
release:

.swiftlint.tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
disabled_rules:
22
- function_body_length
3+
- type_body_length
34
- no_magic_numbers

.swiftlint.yml

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ opt_in_rules:
88
- anonymous_argument_in_multiline_closure
99
- array_init
1010
- async_without_await
11-
# attributes
11+
# attributes (swiftformat)
1212
# balanced_xctest_lifecycle
1313
# closure_body_length
1414
- closure_end_indentation
@@ -27,7 +27,7 @@ opt_in_rules:
2727
- discouraged_assert
2828
- discouraged_none_name
2929
- discouraged_object_literal
30-
- discouraged_optional_boolean
30+
# discouraged_optional_boolean
3131
- discouraged_optional_collection
3232
- empty_collection_literal
3333
- empty_count
@@ -55,9 +55,9 @@ opt_in_rules:
5555
- ibinspectable_in_extension
5656
- identical_operands
5757
- implicit_return
58-
# implicitly_unwrapped_optional
58+
- implicitly_unwrapped_optional
5959
# incompatible_concurrency_annotation
60-
# indentation_width
60+
# indentation_width (swiftformat)
6161
- joined_default_parameter
6262
- last_where
6363
- legacy_multiple
@@ -67,7 +67,7 @@ opt_in_rules:
6767
- local_doc_comment
6868
- lower_acl_than_parent
6969
# missing_docs
70-
- modifier_order
70+
# modifier_order (swiftformat)
7171
- multiline_arguments
7272
- multiline_arguments_brackets
7373
- multiline_function_chains
@@ -144,7 +144,7 @@ opt_in_rules:
144144
# vertical_whitespace_opening_braces
145145
- weak_delegate
146146
- xct_specific_matcher
147-
# yoda_condition
147+
# yoda_condition (swiftformat)
148148

149149
analyzer_rules:
150150
- capture_variable
@@ -173,6 +173,7 @@ identifier_name:
173173
excluded: [id, ui, x, y, z, dx, dy, dz]
174174

175175
line_length:
176+
ignores_multiline_strings: true
176177
ignores_comments: true
177178

178179
nesting:
@@ -189,10 +190,6 @@ type_contents_order:
189190
order: [[case], [type_alias, associated_type], [subtype], [type_property], [instance_property], [ib_inspectable], [ib_outlet], [initializer], [deinitializer], [type_method], [view_life_cycle_method], [ib_action, ib_segue_action], [other_method], [subscript]]
190191

191192
custom_rules:
192-
global_actor_attribute_order:
193-
name: "Global actor attribute order"
194-
message: "Global actor should be the first attribute."
195-
regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor\\s)"
196193
sendable_attribute_order:
197194
name: "Sendable attribute order"
198195
message: "Sendable should be the first attribute."
@@ -204,4 +201,4 @@ custom_rules:
204201
empty_line_after_type_declaration:
205202
name: "Empty line after type declaration"
206203
message: "Type declaration should start with an empty line."
207-
regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\{]*? \\{(?!\\s*\\}) *\\n? *\\S"
204+
regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\n\\{]*? \\{(?!\\s*\\}) *\\n? *\\S"
Submodule PrincipleMacros updated 56 files
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// ObservableMacro.swift
3+
// Relay
4+
//
5+
// Created by Kamil Strzelecki on 23/11/2025.
6+
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
7+
//
8+
9+
import SwiftSyntaxMacros
10+
11+
internal enum ObservableMacro {
12+
13+
static let attribute: AttributeSyntax = "@Observable"
14+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// ObservationIgnoredMacro.swift
3+
// Relay
4+
//
5+
// Created by Kamil Strzelecki on 22/11/2025.
6+
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
7+
//
8+
9+
import SwiftSyntaxMacros
10+
11+
internal enum ObservationIgnoredMacro {
12+
13+
static let attribute: AttributeSyntax = "@ObservationIgnored"
14+
}
15+
16+
extension Property {
17+
18+
var isStoredObservationTracked: Bool {
19+
kind == .stored
20+
&& mutability == .mutable
21+
&& underlying.typeScopeSpecifier == nil
22+
&& underlying.overrideSpecifier == nil
23+
&& !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute)
24+
}
25+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
//
2+
// PropertyPublisherDeclBuilder.swift
3+
// Relay
4+
//
5+
// Created by Kamil Strzelecki on 12/01/2025.
6+
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
7+
//
8+
9+
import SwiftSyntaxMacros
10+
11+
internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding {
12+
13+
let declaration: ClassDeclSyntax
14+
let properties: PropertiesList
15+
let trimmedSuperclassType: TypeSyntax?
16+
let preferredGlobalActorIsolation: GlobalActorIsolation?
17+
18+
func build() -> [DeclSyntax] {
19+
[
20+
"""
21+
\(inheritedGlobalActorIsolation)\(inheritedAccessControlLevelAllowingOpen)\(inheritedFinalModifier)\
22+
class PropertyPublisher: \(inheritanceClause()) {
23+
24+
private final unowned let object: \(trimmedType)
25+
26+
\(objectWillChangeDidChangePublishers())
27+
28+
\(initializer())
29+
30+
\(deinitializer())
31+
32+
\(storedPropertiesPublishers().formatted())
33+
34+
\(computedPropertiesPublishers().formatted())
35+
36+
\(memoizedPropertiesPublishers().formatted())
37+
}
38+
"""
39+
]
40+
}
41+
42+
private func inheritanceClause() -> TypeSyntax {
43+
if let trimmedSuperclassType {
44+
"\(trimmedSuperclassType).PropertyPublisher"
45+
} else {
46+
"Relay.AnyPropertyPublisher"
47+
}
48+
}
49+
50+
private func objectWillChangeDidChangePublishers() -> MemberBlockItemListSyntax {
51+
let notation = CamelCaseNotation(string: trimmedType.description)
52+
let prefix = notation.joined(as: .lowerCamelCase)
53+
54+
return """
55+
\(inheritedAccessControlLevel)final var \
56+
\(raw: prefix)WillChange: some Publisher<\(trimmedType), Never> {
57+
willChange.map { [unowned object] _ in
58+
object
59+
}
60+
}
61+
62+
\(inheritedAccessControlLevel)final var \
63+
\(raw: prefix)DidChange: some Publisher<\(trimmedType), Never> {
64+
didChange.map { [unowned object] _ in
65+
object
66+
}
67+
}
68+
"""
69+
}
70+
71+
private func initializer() -> MemberBlockItemListSyntax {
72+
"""
73+
\(inheritedAccessControlLevel)init(object: \(trimmedType)) {
74+
self.object = object
75+
super.init(object: object)
76+
}
77+
"""
78+
}
79+
80+
private func deinitializer() -> MemberBlockItemListSyntax {
81+
"""
82+
\(inheritedGlobalActorIsolation)deinit {
83+
\(storedPropertiesSubjectsFinishCalls().formatted())
84+
}
85+
"""
86+
}
87+
88+
@CodeBlockItemListBuilder
89+
private func storedPropertiesSubjectsFinishCalls() -> CodeBlockItemListSyntax {
90+
for property in properties.all where property.isStoredPublisherTracked {
91+
let call = storedPropertySubjectFinishCall(for: property)
92+
if let ifConfigCall = property.underlying.applyingEnclosingIfConfig(to: call) {
93+
ifConfigCall
94+
} else {
95+
call
96+
}
97+
}
98+
}
99+
100+
private func storedPropertySubjectFinishCall(for property: Property) -> CodeBlockItemListSyntax {
101+
"_\(property.trimmedName).send(completion: .finished)"
102+
}
103+
104+
@MemberBlockItemListBuilder
105+
private func storedPropertiesPublishers() -> MemberBlockItemListSyntax {
106+
for property in properties.all where property.isStoredPublisherTracked {
107+
let publisher = storedPropertyPublisher(for: property)
108+
if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) {
109+
ifConfigPublisher
110+
} else {
111+
publisher
112+
}
113+
}
114+
}
115+
116+
private func storedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax {
117+
// Stored properties cannot be made potentially unavailable
118+
let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying)
119+
let name = property.trimmedName
120+
let type = property.inferredType
121+
122+
return """
123+
fileprivate final let _\(name) = PassthroughSubject<\(type), Never>()
124+
\(accessControlLevel)final var \(name): some Publisher<\(type), Never> {
125+
_storedPropertyPublisher(_\(name), for: \\.\(name), object: object)
126+
}
127+
"""
128+
}
129+
130+
@MemberBlockItemListBuilder
131+
private func computedPropertiesPublishers() -> MemberBlockItemListSyntax {
132+
for property in properties.all where property.isComputedPublisherTracked {
133+
let publisher = computedPropertyPublisher(for: property)
134+
if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) {
135+
ifConfigPublisher
136+
} else {
137+
publisher
138+
}
139+
}
140+
}
141+
142+
private func computedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax {
143+
let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying)
144+
let availability = property.availability?.trimmed.withTrailingNewline
145+
let name = property.trimmedName
146+
let type = property.inferredType
147+
148+
return """
149+
\(availability)\(accessControlLevel)final var \(name): some Publisher<\(type), Never> {
150+
_computedPropertyPublisher(for: \\.\(name), object: object)
151+
}
152+
"""
153+
}
154+
155+
@MemberBlockItemListBuilder
156+
private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax {
157+
for member in declaration.memberBlock.members {
158+
if let extractionResult = MemoizedMacro.extract(from: member.decl) {
159+
let declaration = extractionResult.declaration
160+
161+
if !declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) {
162+
let publisher = memoizedPropertyPublisher(for: extractionResult)
163+
if let ifConfigPublisher = declaration.applyingEnclosingIfConfig(to: publisher) {
164+
ifConfigPublisher
165+
} else {
166+
publisher
167+
}
168+
}
169+
}
170+
}
171+
}
172+
173+
private func memoizedPropertyPublisher(
174+
for extractionResult: MemoizedMacro.ExtractionResult
175+
) -> MemberBlockItemListSyntax {
176+
let accessControlLevel = extractionResult.preferredAccessControlLevel?.inheritedBySibling()
177+
let availability = extractionResult.declaration.availability?.trimmed.withTrailingNewline
178+
let name = extractionResult.propertyName
179+
let type = extractionResult.trimmedReturnType
180+
181+
return """
182+
\(availability)\(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> {
183+
_computedPropertyPublisher(for: \\.\(raw: name), object: object)
184+
}
185+
"""
186+
}
187+
}

Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift renamed to Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import SwiftSyntaxMacros
1111
internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding {
1212

1313
let declaration: ClassDeclSyntax
14-
let properties: PropertiesList
14+
let trimmedSuperclassType: TypeSyntax?
1515

1616
func build() -> [DeclSyntax] {
1717
[
18+
"""
19+
private final lazy var _publisher = PropertyPublisher(object: self)
20+
""",
1821
"""
1922
/// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable
2023
/// or computed instance properties of this object.
@@ -23,7 +26,9 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding {
2326
/// the original object has been deallocated may result in a crash. Always access it directly
2427
/// through the object that exposes it.
2528
///
26-
\(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self)
29+
\(inheritedOverrideModifier)\(inheritedAccessControlLevelAllowingOpen)var publisher: PropertyPublisher {
30+
_publisher
31+
}
2732
"""
2833
]
2934
}

0 commit comments

Comments
 (0)