From fab38bf1b195f1256ef82d05ebc3af34cf2c0480 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 27 Sep 2026 12:53:33 +0800 Subject: [PATCH 1/2] fix: include OpenObservation macro plugin --- Package.swift | 20 +- Package.swift.template | 20 +- README.md | 4 +- .../OpenObservationMacros/Availability.swift | 109 +++++ .../OpenObservationMacros/Extensions.swift | 273 +++++++++++++ .../ObservableMacro.swift | 380 ++++++++++++++++++ .../OpenObservationMacros.swift | 18 + 7 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 Sources/OpenObservationMacros/Availability.swift create mode 100644 Sources/OpenObservationMacros/Extensions.swift create mode 100644 Sources/OpenObservationMacros/ObservableMacro.swift create mode 100644 Sources/OpenObservationMacros/OpenObservationMacros.swift diff --git a/Package.swift b/Package.swift index 5d47600..4c0ea20 100644 --- a/Package.swift +++ b/Package.swift @@ -73,7 +73,7 @@ let openRenderBoxShims = openSwiftUIBinaryTarget( checksum: "e8e2935a56c61ee164116b4a0737963ad7e2c9df30e73a881343b6694653cb4b" ) -// MARK: - Host Macro Target +// MARK: - Host Macro Targets // Mirrored from the matching OpenSwiftUI release by its release workflow. let openSwiftUIMacros = Target.macro( @@ -87,6 +87,20 @@ let openSwiftUIMacros = Target.macro( ] ) +let openObservationMacros = Target.macro( + name: "OpenObservationMacros", + dependencies: [ + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + swiftSettings: [ + .swiftLanguageMode(.v5), + ] +) + // MARK: - Package let package = Package( @@ -109,6 +123,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ] ), .library(name: "OpenSwiftUIPlayground", targets: ["OpenSwiftUIPlayground"]), @@ -126,6 +141,7 @@ let package = Package( openQuartzCoreShims, openRenderBoxShims, openSwiftUIMacros, + openObservationMacros, .target( name: "OpenSwiftUIPlayground", dependencies: [ @@ -137,6 +153,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ], ), .target(name: "SwiftUIPlayground"), @@ -151,6 +168,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ], path: "Tests" ), diff --git a/Package.swift.template b/Package.swift.template index 8c9129c..3ec2c6d 100644 --- a/Package.swift.template +++ b/Package.swift.template @@ -73,7 +73,7 @@ let openRenderBoxShims = openSwiftUIBinaryTarget( checksum: "{{CHECKSUM_OpenRenderBoxShims}}" ) -// MARK: - Host Macro Target +// MARK: - Host Macro Targets // Mirrored from the matching OpenSwiftUI release by its release workflow. let openSwiftUIMacros = Target.macro( @@ -87,6 +87,20 @@ let openSwiftUIMacros = Target.macro( ] ) +let openObservationMacros = Target.macro( + name: "OpenObservationMacros", + dependencies: [ + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + swiftSettings: [ + .swiftLanguageMode(.v5), + ] +) + // MARK: - Package let package = Package( @@ -109,6 +123,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ] ), .library(name: "OpenSwiftUIPlayground", targets: ["OpenSwiftUIPlayground"]), @@ -126,6 +141,7 @@ let package = Package( openQuartzCoreShims, openRenderBoxShims, openSwiftUIMacros, + openObservationMacros, .target( name: "OpenSwiftUIPlayground", dependencies: [ @@ -137,6 +153,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ], ), .target(name: "SwiftUIPlayground"), @@ -151,6 +168,7 @@ let package = Package( "OpenQuartzCoreShims", "OpenRenderBoxShims", "OpenSwiftUIMacros", + "OpenObservationMacros", ], path: "Tests" ), diff --git a/README.md b/README.md index c472c44..e147352 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ Currently supports: ## Macro Distribution -Releases whose framework interfaces declare external macros also include `OpenSwiftUIMacros` as a source target. SwiftPM builds this small compiler plugin for the host because macro implementations cannot currently be distributed inside an XCFramework. +The package includes `OpenSwiftUIMacros` and `OpenObservationMacros` as source targets. SwiftPM builds these compiler plugins for the host. The XCFrameworks provide the macro declarations and runtime code; their interfaces alone do not provide the macro implementations. -The OpenSwiftUI release workflow generates `Sources/OpenSwiftUIMacros` from the same tag as the XCFrameworks before publishing this package. Do not edit the generated mirror directly. Macro-enabled releases require Xcode 26.6 or a compatible Swift 6.3 toolchain. +The OpenSwiftUI release workflow generates `Sources/OpenSwiftUIMacros` from the release source and `Sources/OpenObservationMacros` from the OpenObservation revision pinned in that release's `Package.resolved`. Do not edit these generated mirrors directly. Macro-enabled releases require Xcode 26.6 or a compatible Swift 6.3 toolchain. ## Binary Configuration History diff --git a/Sources/OpenObservationMacros/Availability.swift b/Sources/OpenObservationMacros/Availability.swift new file mode 100644 index 0000000..c232c25 --- /dev/null +++ b/Sources/OpenObservationMacros/Availability.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftDiagnostics +import SwiftSyntaxBuilder + + +extension AttributeSyntax { + var availability: AttributeSyntax? { + if attributeName.identifier == "available" { + return self + } else { + return nil + } + } +} + +extension IfConfigClauseSyntax.Elements { + var availability: IfConfigClauseSyntax.Elements? { + switch self { + case .attributes(let attributes): + if let availability = attributes.availability { + return .attributes(availability) + } else { + return nil + } + default: + return nil + } + } +} + +extension IfConfigClauseSyntax { + var availability: IfConfigClauseSyntax? { + if let availability = elements?.availability { + return with(\.elements, availability) + } else { + return nil + } + } + + var clonedAsIf: IfConfigClauseSyntax { + detached.with(\.poundKeyword, .poundIfToken()) + } +} + +extension IfConfigDeclSyntax { + var availability: IfConfigDeclSyntax? { + var elements = [IfConfigClauseListSyntax.Element]() + for clause in clauses { + if let availability = clause.availability { + if elements.isEmpty { + elements.append(availability.clonedAsIf) + } else { + elements.append(availability) + } + } + } + if elements.isEmpty { + return nil + } else { + return with(\.clauses, IfConfigClauseListSyntax(elements)) + } + + } +} + +extension AttributeListSyntax.Element { + var availability: AttributeListSyntax.Element? { + switch self { + case .attribute(let attribute): + if let availability = attribute.availability { + return .attribute(availability) + } + case .ifConfigDecl(let ifConfig): + if let availability = ifConfig.availability { + return .ifConfigDecl(availability) + } + @unknown default: + break + } + return nil + } +} + +extension AttributeListSyntax { + var availability: AttributeListSyntax? { + var elements = [AttributeListSyntax.Element]() + for element in self { + if let availability = element.availability { + elements.append(availability) + } + } + if elements.isEmpty { + return nil + } + return AttributeListSyntax(elements) + } +} diff --git a/Sources/OpenObservationMacros/Extensions.swift b/Sources/OpenObservationMacros/Extensions.swift new file mode 100644 index 0000000..9873ac9 --- /dev/null +++ b/Sources/OpenObservationMacros/Extensions.swift @@ -0,0 +1,273 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftDiagnostics +import SwiftSyntaxBuilder + +extension VariableDeclSyntax { + var identifierPattern: IdentifierPatternSyntax? { + bindings.first?.pattern.as(IdentifierPatternSyntax.self) + } + + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + var identifier: TokenSyntax? { + identifierPattern?.identifier + } + + var type: TypeSyntax? { + bindings.first?.typeAnnotation?.type + } + + func accessorsMatching(_ predicate: (TokenKind) -> Bool) -> [AccessorDeclSyntax] { + let accessors: [AccessorDeclListSyntax.Element] = bindings.compactMap { patternBinding in + switch patternBinding.accessorBlock?.accessors { + case .accessors(let accessors): + return accessors + default: + return nil + } + }.flatMap { $0 } + return accessors.compactMap { accessor in + if predicate(accessor.accessorSpecifier.tokenKind) { + return accessor + } else { + return nil + } + } + } + + var willSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.willSet) } + } + var didSetAccessors: [AccessorDeclSyntax] { + accessorsMatching { $0 == .keyword(.didSet) } + } + + var isComputed: Bool { + if accessorsMatching({ $0 == .keyword(.get) }).count > 0 { + return true + } else { + return bindings.contains { binding in + if case .getter = binding.accessorBlock?.accessors { + return true + } else { + return false + } + } + } + } + + + var isImmutable: Bool { + return bindingSpecifier.tokenKind == .keyword(.let) + } + + func isEquivalent(to other: VariableDeclSyntax) -> Bool { + if isInstance != other.isInstance { + return false + } + return identifier?.text == other.identifier?.text + } + + var initializer: InitializerClauseSyntax? { + bindings.first?.initializer + } + + func hasMacroApplication(_ name: String) -> Bool { + for attribute in attributes { + switch attribute { + case .attribute(let attr): + if attr.attributeName.tokens(viewMode: .all).map({ $0.tokenKind }) == [.identifier(name)] { + return true + } + default: + break + } + } + return false + } +} + +extension TypeSyntax { + var identifier: String? { + for token in tokens(viewMode: .all) { + switch token.tokenKind { + case .identifier(let identifier): + return identifier + default: + break + } + } + return nil + } + + func genericSubstitution(_ parameters: GenericParameterListSyntax?) -> String? { + var genericParameters = [String : TypeSyntax?]() + if let parameters { + for parameter in parameters { + genericParameters[parameter.name.text] = parameter.inheritedType + } + } + var iterator = self.asProtocol(TypeSyntaxProtocol.self).tokens(viewMode: .sourceAccurate).makeIterator() + guard let base = iterator.next() else { + return nil + } + + if let genericBase = genericParameters[base.text] { + if let text = genericBase?.identifier { + return "some " + text + } else { + return nil + } + } + var substituted = base.text + + while let token = iterator.next() { + switch token.tokenKind { + case .leftAngle: + substituted += "<" + case .rightAngle: + substituted += ">" + case .comma: + substituted += "," + case .identifier(let identifier): + let type: TypeSyntax = "\(raw: identifier)" + guard let substituedType = type.genericSubstitution(parameters) else { + return nil + } + substituted += substituedType + break + default: + // ignore? + break + } + } + + return substituted + } +} + +extension FunctionDeclSyntax { + var isInstance: Bool { + for modifier in modifiers { + for token in modifier.tokens(viewMode: .all) { + if token.tokenKind == .keyword(.static) || token.tokenKind == .keyword(.class) { + return false + } + } + } + return true + } + + struct SignatureStandin: Equatable { + var isInstance: Bool + var identifier: String + var parameters: [String] + var returnType: String + } + + var signatureStandin: SignatureStandin { + var parameters = [String]() + for parameter in signature.parameterClause.parameters { + parameters.append(parameter.firstName.text + ":" + (parameter.type.genericSubstitution(genericParameterClause?.parameters) ?? "" )) + } + let returnType = signature.returnClause?.type.genericSubstitution(genericParameterClause?.parameters) ?? "Void" + return SignatureStandin(isInstance: isInstance, identifier: name.text, parameters: parameters, returnType: returnType) + } + + func isEquivalent(to other: FunctionDeclSyntax) -> Bool { + return signatureStandin == other.signatureStandin + } +} + +extension DeclGroupSyntax { + var memberFunctionStandins: [FunctionDeclSyntax.SignatureStandin] { + var standins = [FunctionDeclSyntax.SignatureStandin]() + for member in memberBlock.members { + if let function = member.decl.as(FunctionDeclSyntax.self) { + standins.append(function.signatureStandin) + } + } + return standins + } + + func hasMemberFunction(equvalentTo other: FunctionDeclSyntax) -> Bool { + for member in memberBlock.members { + if let function = member.decl.as(FunctionDeclSyntax.self) { + if function.isEquivalent(to: other) { + return true + } + } + } + return false + } + + func hasMemberProperty(equivalentTo other: VariableDeclSyntax) -> Bool { + for member in memberBlock.members { + if let variable = member.decl.as(VariableDeclSyntax.self) { + if variable.isEquivalent(to: other) { + return true + } + } + } + return false + } + + var definedVariables: [VariableDeclSyntax] { + memberBlock.members.compactMap { member in + if let variableDecl = member.decl.as(VariableDeclSyntax.self) { + return variableDecl + } + return nil + } + } + + func addIfNeeded(_ decl: DeclSyntax?, to declarations: inout [DeclSyntax]) { + guard let decl else { return } + if let fn = decl.as(FunctionDeclSyntax.self) { + if !hasMemberFunction(equvalentTo: fn) { + declarations.append(decl) + } + } else if let property = decl.as(VariableDeclSyntax.self) { + if !hasMemberProperty(equivalentTo: property) { + declarations.append(decl) + } + } + } + + var isClass: Bool { + return self.is(ClassDeclSyntax.self) + } + + var isActor: Bool { + return self.is(ActorDeclSyntax.self) + } + + var isEnum: Bool { + return self.is(EnumDeclSyntax.self) + } + + var isStruct: Bool { + return self.is(StructDeclSyntax.self) + } +} diff --git a/Sources/OpenObservationMacros/ObservableMacro.swift b/Sources/OpenObservationMacros/ObservableMacro.swift new file mode 100644 index 0000000..551e17e --- /dev/null +++ b/Sources/OpenObservationMacros/ObservableMacro.swift @@ -0,0 +1,380 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftDiagnostics +import SwiftSyntaxBuilder + +public struct ObservableMacro { + static let moduleName = "OpenObservation" + + static let conformanceName = "Observable" + static var qualifiedConformanceName: String { + return "\(moduleName).\(conformanceName)" + } + + static var observableConformanceType: TypeSyntax { + "\(raw: qualifiedConformanceName)" + } + + static let registrarTypeName = "ObservationRegistrar" + static var qualifiedRegistrarTypeName: String { + return "\(moduleName).\(registrarTypeName)" + } + + static let trackedMacroName = "ObservationTracked" + static let ignoredMacroName = "ObservationIgnored" + + static let registrarVariableName = "_$observationRegistrar" + + static func registrarVariable(_ observableType: TokenSyntax) -> DeclSyntax { + return + """ + @\(raw: ignoredMacroName) private let \(raw: registrarVariableName) = \(raw: qualifiedRegistrarTypeName)() + """ + } + + static func accessFunction(_ observableType: TokenSyntax) -> DeclSyntax { + return + """ + internal nonisolated func access( + keyPath: KeyPath<\(observableType), Member> + ) { + \(raw: registrarVariableName).access(self, keyPath: keyPath) + } + """ + } + + static func withMutationFunction(_ observableType: TokenSyntax) -> DeclSyntax { + return + """ + internal nonisolated func withMutation( + keyPath: KeyPath<\(observableType), Member>, + _ mutation: () throws -> MutationResult + ) rethrows -> MutationResult { + try \(raw: registrarVariableName).withMutation(of: self, keyPath: keyPath, mutation) + } + """ + } + + static var ignoredAttribute: AttributeSyntax { + AttributeSyntax( + leadingTrivia: .space, + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: .identifier(ignoredMacroName)), + trailingTrivia: .space + ) + } +} + +struct ObservationDiagnostic: DiagnosticMessage { + enum ID: String { + case invalidApplication = "invalid type" + case missingInitializer = "missing initializer" + } + + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + + init(message: String, diagnosticID: SwiftDiagnostics.MessageID, severity: SwiftDiagnostics.DiagnosticSeverity = .error) { + self.message = message + self.diagnosticID = diagnosticID + self.severity = severity + } + + init(message: String, domain: String, id: ID, severity: SwiftDiagnostics.DiagnosticSeverity = .error) { + self.message = message + self.diagnosticID = MessageID(domain: domain, id: id.rawValue) + self.severity = severity + } +} + +extension DiagnosticsError { + init(syntax: S, message: String, domain: String = "OpenObservation", id: ObservationDiagnostic.ID, severity: SwiftDiagnostics.DiagnosticSeverity = .error) { + self.init(diagnostics: [ + Diagnostic(node: Syntax(syntax), message: ObservationDiagnostic(message: message, domain: domain, id: id, severity: severity)) + ]) + } +} + +extension DeclModifierListSyntax { + func privatePrefixed(_ prefix: String) -> DeclModifierListSyntax { + let modifier: DeclModifierSyntax = DeclModifierSyntax(name: "private", trailingTrivia: .space) + return [modifier] + filter { + switch $0.name.tokenKind { + case .keyword(let keyword): + switch keyword { + case .fileprivate: fallthrough + case .private: fallthrough + case .internal: fallthrough + case .package: fallthrough + case .public: + return false + default: + return true + } + default: + return true + } + } + } + + init(keyword: Keyword) { + self.init([DeclModifierSyntax(name: .keyword(keyword))]) + } +} + +extension TokenSyntax { + func privatePrefixed(_ prefix: String) -> TokenSyntax { + switch tokenKind { + case .identifier(let identifier): + return TokenSyntax(.identifier(prefix + identifier), leadingTrivia: leadingTrivia, trailingTrivia: trailingTrivia, presence: presence) + default: + return self + } + } +} + +extension PatternBindingListSyntax { + func privatePrefixed(_ prefix: String) -> PatternBindingListSyntax { + var bindings = self.map { $0 } + for index in 0.. VariableDeclSyntax { + let newAttributes = attributes + [.attribute(attribute)] + return VariableDeclSyntax( + leadingTrivia: leadingTrivia, + attributes: newAttributes, + modifiers: modifiers.privatePrefixed(prefix), + bindingSpecifier: TokenSyntax(bindingSpecifier.tokenKind, leadingTrivia: .space, trailingTrivia: .space, presence: .present), + bindings: bindings.privatePrefixed(prefix), + trailingTrivia: trailingTrivia + ) + } + + var isValidForObservation: Bool { + !isComputed && isInstance && !isImmutable && identifier != nil + } +} + +extension ObservableMacro: MemberMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + providingMembersOf declaration: Declaration, + conformingTo protocols: [TypeSyntax], + in context: Context + ) throws -> [DeclSyntax] { + guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { + return [] + } + + let observableType = identified.name.trimmed + + if declaration.isEnum { + // enumerations cannot store properties + throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to enumeration type '\(observableType.text)'", id: .invalidApplication) + } + if declaration.isStruct { + // structs are not yet supported; copying/mutation semantics tbd + throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to struct type '\(observableType.text)'", id: .invalidApplication) + } + if declaration.isActor { + // actors cannot yet be supported for their isolation + throw DiagnosticsError(syntax: node, message: "'@Observable' cannot be applied to actor type '\(observableType.text)'", id: .invalidApplication) + } + + var declarations = [DeclSyntax]() + + declaration.addIfNeeded(ObservableMacro.registrarVariable(observableType), to: &declarations) + declaration.addIfNeeded(ObservableMacro.accessFunction(observableType), to: &declarations) + declaration.addIfNeeded(ObservableMacro.withMutationFunction(observableType), to: &declarations) + + return declarations + } +} + +extension ObservableMacro: MemberAttributeMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + MemberDeclaration: DeclSyntaxProtocol, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + attachedTo declaration: Declaration, + providingAttributesFor member: MemberDeclaration, + in context: Context + ) throws -> [AttributeSyntax] { + guard let property = member.as(VariableDeclSyntax.self), property.isValidForObservation, + property.identifier != nil else { + return [] + } + + // dont apply to ignored properties or properties that are already flagged as tracked + if property.hasMacroApplication(ObservableMacro.ignoredMacroName) || + property.hasMacroApplication(ObservableMacro.trackedMacroName) { + return [] + } + + + return [ + AttributeSyntax(attributeName: IdentifierTypeSyntax(name: .identifier(ObservableMacro.trackedMacroName))) + ] + } +} + +extension ObservableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + // This method can be called twice - first with an empty `protocols` when + // no conformance is needed, and second with a `MissingTypeSyntax` instance. + if protocols.isEmpty { + return [] + } + + let decl: DeclSyntax = """ + extension \(raw: type.trimmedDescription): \(raw: qualifiedConformanceName) {} + """ + let ext = decl.cast(ExtensionDeclSyntax.self) + + if let availability = declaration.attributes.availability { + return [ext.with(\.attributes, availability)] + } else { + return [ext] + } + } +} + +public struct ObservationTrackedMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation, + let identifier = property.identifier?.trimmed else { + return [] + } + + if property.hasMacroApplication(ObservableMacro.ignoredMacroName) { + return [] + } + + let initAccessor: AccessorDeclSyntax = + """ + @storageRestrictions(initializes: _\(identifier)) + init(initialValue) { + _\(identifier) = initialValue + } + """ + + let getAccessor: AccessorDeclSyntax = + """ + get { + access(keyPath: \\.\(identifier)) + return _\(identifier) + } + """ + + let setAccessor: AccessorDeclSyntax = + """ + set { + withMutation(keyPath: \\.\(identifier)) { + _\(identifier) = newValue + } + } + """ + + let modifyAccessor: AccessorDeclSyntax = + """ + _modify { + access(keyPath: \\.\(identifier)) + \(raw: ObservableMacro.registrarVariableName).willSet(self, keyPath: \\.\(identifier)) + defer { \(raw: ObservableMacro.registrarVariableName).didSet(self, keyPath: \\.\(identifier)) } + yield &_\(identifier) + } + """ + + return [initAccessor, getAccessor, setAccessor, modifyAccessor] + } +} + +extension ObservationTrackedMacro: PeerMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: Declaration, + in context: Context + ) throws -> [DeclSyntax] { + guard let property = declaration.as(VariableDeclSyntax.self), + property.isValidForObservation else { + return [] + } + + if property.hasMacroApplication(ObservableMacro.ignoredMacroName) || + property.hasMacroApplication(ObservableMacro.trackedMacroName) { + return [] + } + + let storage = DeclSyntax(property.privatePrefixed("_", addingAttribute: ObservableMacro.ignoredAttribute)) + return [storage] + } +} + +public struct ObservationIgnoredMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + return [] + } +} diff --git a/Sources/OpenObservationMacros/OpenObservationMacros.swift b/Sources/OpenObservationMacros/OpenObservationMacros.swift new file mode 100644 index 0000000..585cbee --- /dev/null +++ b/Sources/OpenObservationMacros/OpenObservationMacros.swift @@ -0,0 +1,18 @@ +// +// OpenObservationMacros.swift +// OpenObservation +// +// Created by Kyle on 2025/8/30. +// + +import SwiftSyntaxMacros +import SwiftCompilerPlugin + +@main +struct OpenObservationMacros: CompilerPlugin { + var providingMacros: [any Macro.Type] = [ + ObservableMacro.self, + ObservationTrackedMacro.self, + ObservationIgnoredMacro.self, + ] +} From f2098c9a1c17fa588713227f5cf66437e002723d Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 27 Sep 2026 12:53:47 +0800 Subject: [PATCH 2/2] chore: update playground code --- .../OpenSwiftUIPlayground/ContentView.swift | 1 - .../OpenSwiftUIPlayground/CounterView.swift | 37 ++++++++++++++++++ Sources/SwiftUIPlayground/ContentView.swift | 1 - Sources/SwiftUIPlayground/CounterView.swift | 38 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 Sources/OpenSwiftUIPlayground/CounterView.swift create mode 100644 Sources/SwiftUIPlayground/CounterView.swift diff --git a/Sources/OpenSwiftUIPlayground/ContentView.swift b/Sources/OpenSwiftUIPlayground/ContentView.swift index bed27d8..95d60a1 100644 --- a/Sources/OpenSwiftUIPlayground/ContentView.swift +++ b/Sources/OpenSwiftUIPlayground/ContentView.swift @@ -1,7 +1,6 @@ // // ContentView.swift // OpenSwiftUIPlayground -// import OpenSwiftUI diff --git a/Sources/OpenSwiftUIPlayground/CounterView.swift b/Sources/OpenSwiftUIPlayground/CounterView.swift new file mode 100644 index 0000000..0914661 --- /dev/null +++ b/Sources/OpenSwiftUIPlayground/CounterView.swift @@ -0,0 +1,37 @@ +// +// CounterView.swift +// OpenSwiftUIPlayground + +import OpenObservation +import OpenSwiftUI + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +@Observable +private final class Model { + var count = 0 + @ObservationIgnored var step = 1 + + func increment() { + count += step + } +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +struct CounterView: View { + @State private var counter = Model() + var body: some View { + VStack { + Text("Count: \(counter.count)") + // TODO: Button + Text("Increment") + .onTapGesture { + counter.increment() + } + } + } +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +#Preview { + CounterView() +} diff --git a/Sources/SwiftUIPlayground/ContentView.swift b/Sources/SwiftUIPlayground/ContentView.swift index 9b5465e..47e83e3 100644 --- a/Sources/SwiftUIPlayground/ContentView.swift +++ b/Sources/SwiftUIPlayground/ContentView.swift @@ -1,7 +1,6 @@ // // ContentView.swift // SwiftUIPlayground -// import SwiftUI diff --git a/Sources/SwiftUIPlayground/CounterView.swift b/Sources/SwiftUIPlayground/CounterView.swift new file mode 100644 index 0000000..37509fa --- /dev/null +++ b/Sources/SwiftUIPlayground/CounterView.swift @@ -0,0 +1,38 @@ +// +// CounterView.swift +// OpenSwiftUIPlayground + +import Observation +import SwiftUI + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +@Observable +private final class Model { + var count = 0 + @ObservationIgnored var step = 1 + + func increment() { + count += step + } +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +struct CounterView: View { + @State private var counter = Model() + var body: some View { + VStack { + Text("Count: \(counter.count)") + // TODO: Button + Text("Increment") + .onTapGesture { + counter.increment() + } + } + } +} + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) +#Preview { + CounterView() + .frame(width: 500, height: 500) +}