From 049d434bbe281ece49331698a9734d0352331c6c Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 27 Oct 2026 00:23:51 +0800 Subject: [PATCH 1/3] fix: qualify observation macro expansion --- Docs/ModuleSelectors.md | 74 ++++++++++++ README.md | 4 + .../OpenObservationMacros/Extensions.swift | 11 +- .../ObservableMacro.swift | 32 +++-- .../OpenObservationMacroTests.swift | 26 ++++- .../QualifiedObservationMacroTests.swift | 109 ++++++++++++++++++ 6 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 Docs/ModuleSelectors.md create mode 100644 Tests/OpenObservationMacroTests/QualifiedObservationMacroTests.swift diff --git a/Docs/ModuleSelectors.md b/Docs/ModuleSelectors.md new file mode 100644 index 0000000..e43f7f3 --- /dev/null +++ b/Docs/ModuleSelectors.md @@ -0,0 +1,74 @@ +# Using module selectors with OpenObservation + +You normally do not need module selectors when using OpenObservation. If only +OpenObservation's macros are visible in a source file, use their plain names: + +```swift +import OpenObservation + +@Observable +final class CounterModel { + var count = 0 + @ObservationIgnored var step = 1 + + func increment() { + count += step + } +} +``` + +## When names become ambiguous + +OpenObservation and Apple's Observation module both define `Observable`, +`ObservationTracked`, and `ObservationIgnored`. If the same source file also +imports Observation, the compiler cannot choose between these macros by name. +This can produce an `Ambiguous use of 'Observable()'` diagnostic. + +Observation can also be imported indirectly. UIKit and AppKit re-export it, +so importing either framework can introduce the same conflict without an +explicit `import Observation` statement. The same applies when another module, +such as OpenSwiftUI, re-exports UIKit or AppKit. + +## Select the OpenObservation macros explicitly + +Add the `OpenObservation::` module selector to each OpenObservation macro you +write in a file where both modules are visible: + +```swift +import AppKit +import OpenObservation + +@OpenObservation::Observable +final class CounterModel { + var count = 0 + @OpenObservation::ObservationIgnored var step = 1 + + func increment() { + count += step + } +} +``` + +For an iOS file, replace `import AppKit` with `import UIKit`. Use the same +selectors if the file imports Observation directly. The `::` syntax selects +the declaration from the named module. + +You normally do not write `ObservationTracked` yourself; `Observable` adds it +to stored properties. If you use it explicitly, write +`@OpenObservation::ObservationTracked` in files with this conflict. + +## Generated attributes must also select the module + +Qualifying only the attributes in your source is not enough. Macro-generated +code is resolved in the source file's import context, so an unqualified +generated attribute can still be ambiguous. + +OpenObservation's macro implementation therefore always emits +`@OpenObservation::ObservationTracked` and +`@OpenObservation::ObservationIgnored`. This includes the attributes on tracked +properties, the observation registrar, and backing storage. The implementation +also recognizes qualified attributes, so a property marked with +`@OpenObservation::ObservationIgnored` remains excluded from tracking. + +These internal selectors do not require you to qualify macros in files where +their names are unambiguous. diff --git a/README.md b/README.md index fe1edd1..f61d4bd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ dependencies: [ Comprehensive documentation is available on [Swift Package Index](https://swiftpackageindex.com/OpenSwiftUIProject/OpenObservation/main/documentation/openobservation). +See [Using module selectors with OpenObservation](Docs/ModuleSelectors.md) for +files that also import Observation, UIKit, AppKit, or a module that re-exports +them. + ## Usage ```swift diff --git a/Sources/OpenObservationMacros/Extensions.swift b/Sources/OpenObservationMacros/Extensions.swift index 9873ac9..d328f30 100644 --- a/Sources/OpenObservationMacros/Extensions.swift +++ b/Sources/OpenObservationMacros/Extensions.swift @@ -14,6 +14,15 @@ import SwiftSyntaxMacros import SwiftDiagnostics import SwiftSyntaxBuilder +extension AttributeSyntax { + func isMacroApplication(_ name: String) -> Bool { + let tokens = attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) + return tokens == [.identifier(name)] || + tokens == [.identifier(ObservableMacro.moduleName), .period, .identifier(name)] || + tokens == [.identifier(ObservableMacro.moduleName), .colonColon, .identifier(name)] + } +} + extension VariableDeclSyntax { var identifierPattern: IdentifierPatternSyntax? { bindings.first?.pattern.as(IdentifierPatternSyntax.self) @@ -97,7 +106,7 @@ extension VariableDeclSyntax { for attribute in attributes { switch attribute { case .attribute(let attr): - if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + if attr.isMacroApplication(name) { return true } default: diff --git a/Sources/OpenObservationMacros/ObservableMacro.swift b/Sources/OpenObservationMacros/ObservableMacro.swift index 551e17e..4ca73d2 100644 --- a/Sources/OpenObservationMacros/ObservableMacro.swift +++ b/Sources/OpenObservationMacros/ObservableMacro.swift @@ -39,7 +39,7 @@ public struct ObservableMacro { static func registrarVariable(_ observableType: TokenSyntax) -> DeclSyntax { return """ - @\(raw: ignoredMacroName) private let \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() + @\(raw: moduleName)::\(raw: ignoredMacroName) private let \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() """ } @@ -70,7 +70,10 @@ public struct ObservableMacro { AttributeSyntax( leadingTrivia: .space, atSign: .atSignToken(), - attributeName: IdentifierTypeSyntax(name: .identifier(ignoredMacroName)), + attributeName: IdentifierTypeSyntax( + moduleSelector: ModuleSelectorSyntax(moduleName: .identifier(moduleName)), + name: .identifier(ignoredMacroName) + ), trailingTrivia: .space ) } @@ -172,8 +175,15 @@ extension PatternBindingListSyntax { } extension VariableDeclSyntax { - func privatePrefixed(_ prefix: String, addingAttribute attribute: AttributeSyntax) -> VariableDeclSyntax { - let newAttributes = attributes + [.attribute(attribute)] + func privatePrefixed(_ prefix: String, addingAttribute attribute: AttributeSyntax, removingMacro name: String) -> VariableDeclSyntax { + let newAttributes = attributes.filter { attribute in + switch attribute { + case .attribute(let attr): + return !attr.isMacroApplication(name) + default: + return true + } + } + [.attribute(attribute)] return VariableDeclSyntax( leadingTrivia: leadingTrivia, attributes: newAttributes, @@ -252,7 +262,10 @@ extension ObservableMacro: MemberAttributeMacro { return [ - AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier(ObservableMacro.trackedMacroName))) + AttributeSyntax(attributeName: IdentifierTypeSyntax( + moduleSelector: ModuleSelectorSyntax(moduleName: .identifier(ObservableMacro.moduleName)), + name: .identifier(ObservableMacro.trackedMacroName) + )) ] } } @@ -356,12 +369,15 @@ extension ObservationTrackedMacro: PeerMacro { return [] } - if property.hasMacroApplication(ObservableMacro.ignoredMacroName) || - property.hasMacroApplication(ObservableMacro.trackedMacroName) { + if property.hasMacroApplication(ObservableMacro.ignoredMacroName) { return [] } - let storage = DeclSyntax(property.privatePrefixed("_", addingAttribute: ObservableMacro.ignoredAttribute)) + let storage = DeclSyntax(property.privatePrefixed( + "_", + addingAttribute: ObservableMacro.ignoredAttribute, + removingMacro: ObservableMacro.trackedMacroName + )) return [storage] } } diff --git a/Tests/OpenObservationMacroTests/OpenObservationMacroTests.swift b/Tests/OpenObservationMacroTests/OpenObservationMacroTests.swift index 260149c..bf3e880 100644 --- a/Tests/OpenObservationMacroTests/OpenObservationMacroTests.swift +++ b/Tests/OpenObservationMacroTests/OpenObservationMacroTests.swift @@ -38,7 +38,7 @@ final class OpenObservationMacroTests: XCTestCase { expandedSource: """ class Model { - @ObservationIgnored private let _$observationRegistrar = OpenObservation.ObservationRegistrar() + @OpenObservation::ObservationIgnored private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( keyPath: KeyPath @@ -132,6 +132,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_name } } + + private var _name: String = "John" var age: Int { @storageRestrictions(initializes: _age) init(initialValue) { @@ -156,6 +158,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _age: Int = 30 + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( @@ -214,6 +218,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_tracked } } + + private var _tracked: String = "tracked" var ignored: Int = 42 private let _$observationRegistrar = OpenObservation.ObservationRegistrar() @@ -276,6 +282,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_stored } } + + private var _stored: String = "stored" var computed: String { return stored + " computed" } @@ -338,6 +346,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_explicit } } + + private var _explicit: String = "explicit" var implicit: Int { @storageRestrictions(initializes: _implicit) init(initialValue) { @@ -362,6 +372,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _implicit: Int = 10 + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( @@ -422,6 +434,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _variable: Int = 10 + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( @@ -481,6 +495,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_publicProp } } + + private var _publicProp: String = "public" internal var internalProp: Int { @storageRestrictions(initializes: _internalProp) init(initialValue) { @@ -504,6 +520,8 @@ final class OpenObservationMacroTests: XCTestCase { yield &_internalProp } } + + private var _internalProp: Int = 10 private var privateProp: Bool { @storageRestrictions(initializes: _privateProp) init(initialValue) { @@ -528,6 +546,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _privateProp: Bool = true + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( @@ -588,6 +608,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _name: String = "test" + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( @@ -648,6 +670,8 @@ final class OpenObservationMacroTests: XCTestCase { } } + private var _instanceProp: Int = 10 + private let _$observationRegistrar = OpenObservation.ObservationRegistrar() internal nonisolated func access( diff --git a/Tests/OpenObservationMacroTests/QualifiedObservationMacroTests.swift b/Tests/OpenObservationMacroTests/QualifiedObservationMacroTests.swift new file mode 100644 index 0000000..6ed8089 --- /dev/null +++ b/Tests/OpenObservationMacroTests/QualifiedObservationMacroTests.swift @@ -0,0 +1,109 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion +import XCTest + +#if canImport(OpenObservationMacros) +import OpenObservationMacros + +final class QualifiedObservationMacroTests: XCTestCase { + func testQualifiedIgnoredAndTrackedPropertiesAreNotTrackedAgain() throws { + for name in ["ObservationIgnored", "ObservationTracked"] { + for prefix in ["", "OpenObservation.", "OpenObservation::"] { + let syntax: DeclSyntax = """ + class Model { + @\(raw: prefix)\(raw: name) var value = 0 + } + """ + let declaration = try XCTUnwrap(syntax.as(ClassDeclSyntax.self)) + let property = try XCTUnwrap(declaration.memberBlock.members.first?.decl.as(VariableDeclSyntax.self)) + let attributes = try ObservableMacro.expansion( + of: AttributeSyntax("@Observable"), + attachedTo: declaration, + providingAttributesFor: property, + in: BasicMacroExpansionContext() + ) + XCTAssertTrue(attributes.isEmpty, "Unexpected tracking for @\(prefix)\(name)") + } + } + } + + func testAttributesFromOtherModulesDoNotSuppressTracking() throws { + for separator in [".", "::"] { + let syntax: DeclSyntax = """ + class Model { + @Observation\(raw: separator)ObservationIgnored var value = 0 + } + """ + let declaration = try XCTUnwrap(syntax.as(ClassDeclSyntax.self)) + let property = try XCTUnwrap(declaration.memberBlock.members.first?.decl.as(VariableDeclSyntax.self)) + let attributes = try ObservableMacro.expansion( + of: AttributeSyntax("@Observable"), + attachedTo: declaration, + providingAttributesFor: property, + in: BasicMacroExpansionContext() + ) + XCTAssertEqual(attributes.count, 1) + } + } + + func testGeneratedObservationAttributesUseModuleSelectors() throws { + let syntax: DeclSyntax = """ + class Model { + var count = 0 + } + """ + let declaration = try XCTUnwrap(syntax.as(ClassDeclSyntax.self)) + let property = try XCTUnwrap(declaration.memberBlock.members.first?.decl.as(VariableDeclSyntax.self)) + let context = BasicMacroExpansionContext() + let attributes = try ObservableMacro.expansion( + of: AttributeSyntax("@Observable"), + attachedTo: declaration, + providingAttributesFor: property, + in: context + ) + XCTAssertEqual(attributes.map(\.attributeName.trimmedDescription), ["OpenObservation::ObservationTracked"]) + + let members = try ObservableMacro.expansion( + of: AttributeSyntax("@Observable"), + providingMembersOf: declaration, + conformingTo: [], + in: context + ) + let registrar = try XCTUnwrap(members.first?.as(VariableDeclSyntax.self)) + XCTAssertEqual( + registrar.attributes.first?.as(AttributeSyntax.self)?.attributeName.trimmedDescription, + "OpenObservation::ObservationIgnored" + ) + + let peers = try ObservationTrackedMacro.expansion( + of: AttributeSyntax("@ObservationTracked"), + providingPeersOf: property, + in: context + ) + let storage = try XCTUnwrap(peers.first?.as(VariableDeclSyntax.self)) + XCTAssertEqual( + storage.attributes.first?.as(AttributeSyntax.self)?.attributeName.trimmedDescription, + "OpenObservation::ObservationIgnored" + ) + } + + func testExplicitTrackedPropertiesGenerateUntrackedStorage() throws { + for prefix in ["", "OpenObservation.", "OpenObservation::"] { + let syntax: DeclSyntax = "@\(raw: prefix)ObservationTracked var count = 0" + let property = try XCTUnwrap(syntax.as(VariableDeclSyntax.self)) + let peers = try ObservationTrackedMacro.expansion( + of: AttributeSyntax("@\(raw: prefix)ObservationTracked"), + providingPeersOf: property, + in: BasicMacroExpansionContext() + ) + let storage = try XCTUnwrap(peers.first?.as(VariableDeclSyntax.self)) + XCTAssertEqual(storage.bindings.first?.pattern.trimmedDescription, "_count") + XCTAssertEqual( + storage.attributes.compactMap { $0.as(AttributeSyntax.self)?.attributeName.trimmedDescription }, + ["OpenObservation::ObservationIgnored"] + ) + } + } +} +#endif From 81164fe0aadfc29f273d40b7e349df389fe1eeba Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 27 Oct 2026 01:49:50 +0800 Subject: [PATCH 2/3] build: require Swift 6.3 --- .github/workflows/macos.yml | 8 +++++--- .github/workflows/ubuntu.yml | 6 ++++-- Docs/ModuleSelectors.md | 10 ++++++---- Package.resolved | 2 +- Package.swift | 2 +- README.md | 2 ++ 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 42a6e9c..53650e2 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -12,8 +12,8 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-15] - xcode-version: ["16.4"] # Swift 6.1.2 + os: [macos-26] + xcode-version: ["26.6"] # Swift 6.3.3 runs-on: ${{ matrix.os }} env: OPENOBSERVATION_WERROR: 1 @@ -31,4 +31,6 @@ jobs: swift build -c debug - name: Build in release mode run: | - swift build -c release \ No newline at end of file + swift build -c release + - name: Run macro tests + run: swift test --filter OpenObservationMacroTests diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 596cbf1..cad2c3c 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - swift_version: ["6.1.2"] + swift_version: ["6.3.3"] runs-on: ubuntu-22.04 env: OPENOBSERVATION_WERROR: 1 @@ -28,4 +28,6 @@ jobs: swift build -c debug - name: Build in release mode run: | - swift build -c release \ No newline at end of file + swift build -c release + - name: Run macro tests + run: swift test --filter OpenObservationMacroTests diff --git a/Docs/ModuleSelectors.md b/Docs/ModuleSelectors.md index e43f7f3..4803e41 100644 --- a/Docs/ModuleSelectors.md +++ b/Docs/ModuleSelectors.md @@ -24,13 +24,15 @@ OpenObservation and Apple's Observation module both define `Observable`, imports Observation, the compiler cannot choose between these macros by name. This can produce an `Ambiguous use of 'Observable()'` diagnostic. -Observation can also be imported indirectly. UIKit and AppKit re-export it, -so importing either framework can introduce the same conflict without an -explicit `import Observation` statement. The same applies when another module, -such as OpenSwiftUI, re-exports UIKit or AppKit. +Observation can also be imported indirectly. Foundation re-exports it, and +UIKit and AppKit import Foundation. Importing these frameworks can therefore +introduce the same conflict without an explicit `import Observation` statement. +Other modules that re-export these frameworks can introduce the conflict too. ## Select the OpenObservation macros explicitly +OpenObservation requires a Swift 6.3 or later compiler, which supports module +selectors ([SE-0491](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0491-module-selectors.md)). Add the `OpenObservation::` module selector to each OpenObservation macro you write in a file where both modules are visible: diff --git a/Package.resolved b/Package.resolved index af90978..08bc7cd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a7b6650d08f56acdbacb83d0514aed9db5e7ba0300f382e5ef0c78da32afcd3b", + "originHash" : "a71b5d8ba34eef733375f654e3da5755c78884543839d252e8a2ea811b654e9d", "pins" : [ { "identity" : "swift-syntax", diff --git a/Package.swift b/Package.swift index 96b1b63..159ab77 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport diff --git a/README.md b/README.md index f61d4bd..7e32eaa 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ The current implementation is aligned with Swift's **release/6.1 branch** implem ## Installation +Requires a Swift 6.3 or later compiler. + ```swift dependencies: [ .package(url: "https://github.com/OpenSwiftUIProject/OpenObservation", from: "1.0.0") From 3ef76f5c4b126ac18a1e7c37ba90d3aacff58bdf Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 27 Oct 2026 01:50:06 +0800 Subject: [PATCH 3/3] chore: ignore generated Xcode files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7208c72..8241da8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ .DS_Store /.build /Packages +*.xcodeproj/ xcuserdata/ +Derived/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc .vscode TODO.md -.claude \ No newline at end of file +.claude