Skip to content
Merged
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
8 changes: 5 additions & 3 deletions .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,4 +31,6 @@ jobs:
swift build -c debug
- name: Build in release mode
run: |
swift build -c release
swift build -c release
- name: Run macro tests
run: swift test --filter OpenObservationMacroTests
6 changes: 4 additions & 2 deletions .github/workflows/ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,4 +28,6 @@ jobs:
swift build -c debug
- name: Build in release mode
run: |
swift build -c release
swift build -c release
- name: Run macro tests
run: swift test --filter OpenObservationMacroTests
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -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
.claude
76 changes: 76 additions & 0 deletions Docs/ModuleSelectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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. 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:

```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.
2 changes: 1 addition & 1 deletion Package.resolved

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

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -42,6 +44,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
Expand Down
11 changes: 10 additions & 1 deletion Sources/OpenObservationMacros/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 24 additions & 8 deletions Sources/OpenObservationMacros/ObservableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)()
"""
}

Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
))
]
}
}
Expand Down Expand Up @@ -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]
}
}
Expand Down
26 changes: 25 additions & 1 deletion Tests/OpenObservationMacroTests/OpenObservationMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Member>(
keyPath: KeyPath<Model, Member>
Expand Down Expand Up @@ -132,6 +132,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_name
}
}

private var _name: String = "John"
var age: Int {
@storageRestrictions(initializes: _age)
init(initialValue) {
Expand All @@ -156,6 +158,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _age: Int = 30

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down Expand Up @@ -214,6 +218,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_tracked
}
}

private var _tracked: String = "tracked"
var ignored: Int = 42

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()
Expand Down Expand Up @@ -276,6 +282,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_stored
}
}

private var _stored: String = "stored"
var computed: String {
return stored + " computed"
}
Expand Down Expand Up @@ -338,6 +346,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_explicit
}
}

private var _explicit: String = "explicit"
var implicit: Int {
@storageRestrictions(initializes: _implicit)
init(initialValue) {
Expand All @@ -362,6 +372,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _implicit: Int = 10

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down Expand Up @@ -422,6 +434,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _variable: Int = 10

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down Expand Up @@ -481,6 +495,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_publicProp
}
}

private var _publicProp: String = "public"
internal var internalProp: Int {
@storageRestrictions(initializes: _internalProp)
init(initialValue) {
Expand All @@ -504,6 +520,8 @@ final class OpenObservationMacroTests: XCTestCase {
yield &_internalProp
}
}

private var _internalProp: Int = 10
private var privateProp: Bool {
@storageRestrictions(initializes: _privateProp)
init(initialValue) {
Expand All @@ -528,6 +546,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _privateProp: Bool = true

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down Expand Up @@ -588,6 +608,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _name: String = "test"

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down Expand Up @@ -648,6 +670,8 @@ final class OpenObservationMacroTests: XCTestCase {
}
}

private var _instanceProp: Int = 10

private let _$observationRegistrar = OpenObservation.ObservationRegistrar()

internal nonisolated func access<Member>(
Expand Down
Loading
Loading