diff --git a/CLAUDE.md b/CLAUDE.md index cd04bfde3..bff8a3dac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,13 @@ The project uses **swift-testing framework** (not XCTest): #expect(value.isApproximatelyEqual(to: expectedValue)) ``` +**Exception: Macro Tests** +- Macro tests must use **XCTest** framework (not swift-testing) +- Import with `import XCTest` +- Use `final class TestName: XCTestCase` with `func testName()` methods +- Use XCTest assertions like `XCTAssertEqual()`, `XCTAssertTrue()`, etc. +- For macro expansion testing, use `assertMacroExpansion` from `SwiftSyntaxMacrosTestSupport` + ### Compatibility Tests When writing tests in `OpenSwiftUICompatibilityTests`: diff --git a/Package.swift b/Package.swift index fe72e6c16..4fa94d44a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ // swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import Foundation import PackageDescription @@ -240,6 +241,26 @@ let openSwiftUISPITestTarget = Target.testTarget( swiftSettings: sharedSwiftSettings ) +// MARK: - OpenSwiftUIMacros Target + +let openSwiftUIMacrosTarget = Target.macro( + name: "OpenSwiftUIMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + swiftSettings: sharedSwiftSettings +) + +let openSwiftUIMacrosTestTarget = Target.testTarget( + name: "OpenSwiftUIMacrosTests", + dependencies: [ + "OpenSwiftUIMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ], + swiftSettings: sharedSwiftSettings +) + // MARK: - OpenSwiftUICore Target // NOTE: @@ -249,6 +270,7 @@ let openSwiftUICoreTarget = Target.target( name: "OpenSwiftUICore", dependencies: [ "OpenSwiftUI_SPI", + "OpenSwiftUIMacros", .product(name: "OpenCoreGraphicsShims", package: "OpenCoreGraphics"), .product(name: "OpenQuartzCoreShims", package: "OpenCoreGraphics"), .product(name: "OpenAttributeGraphShims", package: "OpenAttributeGraph"), @@ -435,9 +457,11 @@ let package = Package( products: products, dependencies: [ .package(url: "https://github.com/apple/swift-numerics", from: "1.0.3"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.0"), ], targets: [ openSwiftUISPITarget, + openSwiftUIMacrosTarget, openSwiftUICoreTarget, cOpenSwiftUITarget, openSwiftUITarget, @@ -454,6 +478,7 @@ if renderGTKCondition { if !compatibilityTestCondition { package.targets += [ openSwiftUISPITestTarget, + openSwiftUIMacrosTestTarget, openSwiftUICoreTestTarget, openSwiftUITestTarget, openSwiftUIBridgeTestTarget, diff --git a/Sources/OpenSwiftUICore/Macro/EntryMacro.swift b/Sources/OpenSwiftUICore/Macro/EntryMacro.swift new file mode 100644 index 000000000..1b30c57af --- /dev/null +++ b/Sources/OpenSwiftUICore/Macro/EntryMacro.swift @@ -0,0 +1,11 @@ +// +// EntryMacro.swift +// OpenSwiftUICore + +@attached(accessor) @attached(peer, names: prefixed(__Key_)) public macro Entry() = #externalMacro( + module: "OpenSwiftUIMacros", type: "EntryMacro" +) + +@attached(accessor) public macro __EntryDefaultValue() = #externalMacro( + module: "OpenSwiftUIMacros", type: "EntryDefaultValueMacro" +) diff --git a/Sources/OpenSwiftUIMacros/EntryDefaultValueMacro.swift b/Sources/OpenSwiftUIMacros/EntryDefaultValueMacro.swift new file mode 100644 index 000000000..09168ca1c --- /dev/null +++ b/Sources/OpenSwiftUIMacros/EntryDefaultValueMacro.swift @@ -0,0 +1,31 @@ +// +// EntryDefaultValueMacro.swift +// OpenSwiftUIMacros + +package import SwiftSyntax +package import SwiftSyntaxMacros + +package struct EntryDefaultValueMacro: AccessorMacro { + package static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let varDecl = declaration.as(VariableDeclSyntax.self), + let binding = varDecl.bindings.first, + let initializer = binding.initializer else { + throw MacroExpansionErrorMessage("@__EntryDefaultValue can only be applied to stored properties with initial values") + } + + let defaultValue = initializer.value + + return [ + AccessorDeclSyntax( + accessorSpecifier: .keyword(.get), + body: CodeBlockSyntax { + "\(defaultValue)" + } + ) + ] + } +} diff --git a/Sources/OpenSwiftUIMacros/EntryMacro.swift b/Sources/OpenSwiftUIMacros/EntryMacro.swift new file mode 100644 index 000000000..34f8803e9 --- /dev/null +++ b/Sources/OpenSwiftUIMacros/EntryMacro.swift @@ -0,0 +1,123 @@ +// +// EntryMacro.swift +// OpenSwiftUIMacros + +package import SwiftSyntax +package import SwiftSyntaxMacros + +package struct EntryMacro: AccessorMacro, PeerMacro { + package static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let varDecl = declaration.as(VariableDeclSyntax.self), + let binding = varDecl.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { + throw MacroExpansionErrorMessage("@Entry can only be applied to stored properties") + } + + // Check that we have either a type annotation OR an initializer (for type inference) + let hasType = binding.typeAnnotation?.type != nil + let hasInitializer = binding.initializer != nil + + guard hasType || hasInitializer else { + throw MacroExpansionErrorMessage("@Entry requires either a type annotation or an initial value") + } + + + let identifierText = identifier.text + let keyName = "__Key_\(identifierText)" + + return [ + AccessorDeclSyntax( + accessorSpecifier: .keyword(.get), + body: CodeBlockSyntax { + "self[\(raw: keyName).self]" + } + ), + AccessorDeclSyntax( + accessorSpecifier: .keyword(.set), + body: CodeBlockSyntax { + "self[\(raw: keyName).self] = newValue" + } + ) + ] + } + + package static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let varDecl = declaration.as(VariableDeclSyntax.self), + let binding = varDecl.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { + throw MacroExpansionErrorMessage("@Entry requires a property with valid identifier") + } + + let identifierText = identifier.text + let keyName = "__Key_\(identifierText)" + + // Determine default value + let defaultValue: ExprSyntax + + if let initializer = binding.initializer { + // Has initializer - use the initializer value + defaultValue = initializer.value + } else if let explicitType = binding.typeAnnotation?.type { + // Only type annotation, no initializer + // Check if it's optional type - if so, default to nil + let typeString = explicitType.description + if typeString.contains("?") { + defaultValue = ExprSyntax(NilLiteralExprSyntax()) + } else { + throw MacroExpansionErrorMessage("@Entry requires an initial value for non-optional types") + } + } else { + throw MacroExpansionErrorMessage("@Entry requires either a type annotation or an initial value") + } + + let keyStruct = StructDeclSyntax( + modifiers: [DeclModifierSyntax(name: .keyword(.private))], + name: .identifier(keyName), + inheritanceClause: InheritanceClauseSyntax { + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("OpenSwiftUICore.EnvironmentKey"))) + } + ) { + // Create the variable declaration + let patternBinding = PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier("defaultValue")), + typeAnnotation: binding.typeAnnotation, + initializer: InitializerClauseSyntax( + equal: .equalToken(), + value: defaultValue + ) + ) + + VariableDeclSyntax( + attributes: AttributeListSyntax([ + AttributeListSyntax.Element( + AttributeSyntax( + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: .identifier("__EntryDefaultValue")) + ) + ) + ]), + modifiers: [DeclModifierSyntax(name: .keyword(.static))], + bindingSpecifier: .keyword(.var), + bindings: PatternBindingListSyntax([patternBinding]) + ) + } + + return [DeclSyntax(keyStruct)] + } +} + +package struct MacroExpansionErrorMessage: Error, CustomStringConvertible { + package let description: String + + package init(_ description: String) { + self.description = description + } +} diff --git a/Sources/OpenSwiftUIMacros/OpenSwiftUIMacrosPlugin.swift b/Sources/OpenSwiftUIMacros/OpenSwiftUIMacrosPlugin.swift new file mode 100644 index 000000000..b54e2f4d9 --- /dev/null +++ b/Sources/OpenSwiftUIMacros/OpenSwiftUIMacrosPlugin.swift @@ -0,0 +1,16 @@ +// +// OpenSwiftUIMacrosPlugin.swift +// OpenSwiftUIMacros + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +@main +struct OpenSwiftUIMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + EntryMacro.self, + EntryDefaultValueMacro.self, + ] +} diff --git a/Tests/OpenSwiftUIMacrosTests/EntryMacroTests.swift b/Tests/OpenSwiftUIMacrosTests/EntryMacroTests.swift new file mode 100644 index 000000000..7fce36cd0 --- /dev/null +++ b/Tests/OpenSwiftUIMacrosTests/EntryMacroTests.swift @@ -0,0 +1,299 @@ +// +// EntryMacroTests.swift +// OpenSwiftUIMacrosTests + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +#if canImport(OpenSwiftUIMacros) +import OpenSwiftUIMacros + +private let testMacros: [String: Macro.Type] = [ + "Entry": EntryMacro.self, + "__EntryDefaultValue": EntryDefaultValueMacro.self, +] + +private let testEntryMacros: [String: Macro.Type] = [ + "Entry": EntryMacro.self, +] + +final class EntryMacroTests: XCTestCase { + func testEntryMacroExpansion() { + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var myCustomValue: String = "Default value" + } + """, + expandedSource: + """ + extension EnvironmentValues { + var myCustomValue: String { + get { + self[__Key_myCustomValue.self] + } + set { + self[__Key_myCustomValue.self] = newValue + } + } + + private struct __Key_myCustomValue: OpenSwiftUICore.EnvironmentKey { + static var defaultValue: String { + get { + "Default value" + } + } + } + } + """, + macros: testMacros + ) + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var myCustomValue: String = "Default value" + } + """, + expandedSource: + """ + extension EnvironmentValues { + var myCustomValue: String { + get { + self[__Key_myCustomValue.self] + } + set { + self[__Key_myCustomValue.self] = newValue + } + } + + private struct __Key_myCustomValue: OpenSwiftUICore.EnvironmentKey { + @__EntryDefaultValue static var defaultValue: String = "Default value" + } + } + """, + macros: testEntryMacros + ) + } + + func testEntryMacroExpansionWithIntType() { + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var intValue: Int = 42 + } + """, + expandedSource: + """ + extension EnvironmentValues { + var intValue: Int { + get { + self[__Key_intValue.self] + } + set { + self[__Key_intValue.self] = newValue + } + } + + private struct __Key_intValue: OpenSwiftUICore.EnvironmentKey { + static var defaultValue: Int { + get { + 42 + } + } + } + } + """, + macros: testMacros + ) + } + + func testEntryDefaultValueMacroExpansion() { + assertMacroExpansion( + """ + @__EntryDefaultValue + static var defaultValue: String = "Default value" + """, + expandedSource: + """ + static var defaultValue: String { + get { + "Default value" + } + } + """, + macros: testMacros + ) + } + + func testEntryMacroWithInferredType() { + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var inferType = 0 + } + """, + expandedSource: + """ + extension EnvironmentValues { + var inferType { + get { + self[__Key_inferType.self] + } + set { + self[__Key_inferType.self] = newValue + } + } + + private struct __Key_inferType: OpenSwiftUICore.EnvironmentKey { + static var defaultValue { + get { + 0 + } + } + } + } + """, + macros: testMacros + ) + } + + func testEntryMacroWithOptionalType() { + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var optionalType: Int? + } + """, + expandedSource: + """ + extension EnvironmentValues { + var optionalType: Int? { + get { + self[__Key_optionalType.self] + } + set { + self[__Key_optionalType.self] = newValue + } + } + + private struct __Key_optionalType: OpenSwiftUICore.EnvironmentKey { + static var defaultValue: Int? { + get { + nil + } + } + } + } + """, + macros: testMacros + ) + } + + func testEntryMacroWithCustomTypeInference() { + assertMacroExpansion( + """ + struct CustomType {} + + extension EnvironmentValues { + @Entry var inferCustomType = CustomType() + } + """, + expandedSource: + """ + struct CustomType {} + + extension EnvironmentValues { + var inferCustomType { + get { + self[__Key_inferCustomType.self] + } + set { + self[__Key_inferCustomType.self] = newValue + } + } + + private struct __Key_inferCustomType: OpenSwiftUICore.EnvironmentKey { + static var defaultValue { + get { + CustomType() + } + } + } + } + """, + macros: testMacros + ) + } + + func testEntryMacroWithMemberFunctionCallInference() { + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var custom = A.B() + } + enum A { + static func B() -> C { .init() } + } + """, + expandedSource: + """ + extension EnvironmentValues { + var custom { + get { + self[__Key_custom.self] + } + set { + self[__Key_custom.self] = newValue + } + } + + private struct __Key_custom: OpenSwiftUICore.EnvironmentKey { + static var defaultValue { + get { + A.B() + } + } + } + } + enum A { + static func B() -> C { .init() } + } + """, + macros: testMacros + ) + assertMacroExpansion( + """ + extension EnvironmentValues { + @Entry var custom = A.B() + } + enum A { + static func B() -> C { .init() } + } + """, + expandedSource: + """ + extension EnvironmentValues { + var custom { + get { + self[__Key_custom.self] + } + set { + self[__Key_custom.self] = newValue + } + } + + private struct __Key_custom: OpenSwiftUICore.EnvironmentKey { + @__EntryDefaultValue static var defaultValue = A.B() + } + } + enum A { + static func B() -> C { .init() } + } + """, + macros: testEntryMacros + ) + } +} + +#endif