Add a new BuildExtensions package that contains a UserPreference macro.

The build extensions package can be used for both macros and plugins.
This commit is contained in:
Doug
2026-06-26 13:45:09 +01:00
committed by Doug
parent f3da459d16
commit c53e0c1466
13 changed files with 561 additions and 59 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "d28d5f304a8ea0ef1a0f8227c3988fe3f25592efb0ff1dc7ce76c92ad6a503a2",
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
}
],
"version" : 3
}
+43
View File
@@ -0,0 +1,43 @@
// swift-tools-version: 6.2
import CompilerPluginSupport
import PackageDescription
let package = Package(
name: "BuildExtensions",
platforms: [.macOS(.v15)],
products: [
.library(name: "Macros", targets: ["Macros"])
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", from: "602.0.0")
],
targets: [
// The public macro declarations imported by the app.
.target(name: "Macros",
dependencies: ["MacrosImplementation"],
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]),
// The compiler plugin holding the SwiftSyntax expansions.
.macro(name: "MacrosImplementation",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
],
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]),
.testTarget(name: "MacrosTests",
dependencies: [
"MacrosImplementation",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
],
swiftSettings: [
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
])
]
)
@@ -0,0 +1,29 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
/// Exposes a value backed by keyed storage as a computed property, alongside a `<name>Publisher`
/// peer that emits the current value and every subsequent change.
///
/// The generated accessors read and write through the enclosing type's `store` (a
/// `UserDefaultsProtocol`); a `volatile` preference is instead held in memory and resets on launch.
///
/// When `key` is omitted the property's own name is used as the storage key. Provide an explicit
/// `key` whenever the storage key must differ from the property name (e.g. to keep matching a value
/// persisted under a legacy key).
///
/// A `reset<Name>()` method is also generated, which clears the stored value so it reverts to the
/// default.
@attached(accessor)
@attached(peer, names: arbitrary)
public macro UserPreference<Value>(key: String? = nil, defaultValue: Value, volatile: Bool = false) =
#externalMacro(module: "MacrosImplementation", type: "UserPreferenceMacro")
/// A variant for optional preferences that default to `nil`.
@attached(accessor)
@attached(peer, names: arbitrary)
public macro UserPreference(key: String? = nil, volatile: Bool = false) =
#externalMacro(module: "MacrosImplementation", type: "UserPreferenceMacro")
@@ -0,0 +1,14 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct BuildExtensionsPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [UserPreferenceMacro.self]
}
@@ -0,0 +1,178 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftSyntax
import SwiftSyntaxMacros
/// Implements `@UserPreference`. The generated property reads and writes through the enclosing
/// type's `store` (a `UserDefaultsProtocol`), wrapped in `UserDefaultsStorage` for the
/// encode/decode handling, and publishes changes via a generated per-preference subject.
///
/// A `volatile` preference instead reads and writes through a generated in-memory store, so its
/// value is never persisted and resets on each launch.
public enum UserPreferenceMacro {
enum DiagnosticError: Error, CustomStringConvertible {
case notAStoredProperty
case missingTypeAnnotation
var description: String {
switch self {
case .notAStoredProperty:
"@UserPreference can only be applied to a stored property."
case .missingTypeAnnotation:
"@UserPreference requires an explicit type annotation."
}
}
}
private struct Preference {
let name: String
let type: TypeSyntax
let key: ExprSyntax
let defaultValue: ExprSyntax
let isVolatile: Bool
var subjectName: TokenSyntax {
"_\(raw: name)Subject"
}
/// The in-memory backing for a volatile preference.
var volatileValueName: TokenSyntax {
"_\(raw: name)VolatileValue"
}
var resetMethodName: TokenSyntax {
"reset\(raw: name.prefix(1).uppercased() + name.dropFirst())"
}
}
private static func preference(for declaration: some DeclSyntaxProtocol, node: AttributeSyntax) throws -> Preference {
guard let variable = declaration.as(VariableDeclSyntax.self),
let binding = variable.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
throw DiagnosticError.notAStoredProperty
}
guard let type = binding.typeAnnotation?.type.trimmed else {
throw DiagnosticError.missingTypeAnnotation
}
var key: ExprSyntax?
var defaultValue: ExprSyntax?
var isVolatile = false
if let list = node.arguments?.as(LabeledExprListSyntax.self) {
for argument in list {
switch argument.label?.text {
case "key": key = argument.expression
case "defaultValue": defaultValue = argument.expression
case "volatile": isVolatile = argument.expression.as(BooleanLiteralExprSyntax.self)?.literal.text == "true"
default: break
}
}
}
let name = identifier.text
return Preference(name: name,
type: type,
key: key ?? "\(literal: name)",
defaultValue: defaultValue ?? "nil",
isVolatile: isVolatile)
}
}
extension UserPreferenceMacro: AccessorMacro {
public static func expansion(of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
let preference = try preference(for: declaration, node: node)
if preference.isVolatile {
return [
"""
get {
\(preference.volatileValueName)
}
""",
"""
set {
\(preference.volatileValueName) = newValue
\(preference.subjectName).send(newValue)
}
"""
]
}
let type = preference.type
let key = preference.key
// Parenthesise a default that's a binary expression: `??` binds tighter than e.g. `==`,
// so `storage[key] ?? a == b` would otherwise parse as `(storage[key] ?? a) == b`.
let isBinaryExpression = preference.defaultValue.is(SequenceExprSyntax.self) || preference.defaultValue.is(InfixOperatorExprSyntax.self)
let defaultValue: ExprSyntax = isBinaryExpression ? "(\(preference.defaultValue))" : preference.defaultValue
return [
"""
get {
let storage = UserDefaultsStorage<\(type)>(userDefaults: store)
return storage[\(key)] ?? \(defaultValue)
}
""",
"""
set {
let storage = UserDefaultsStorage<\(type)>(userDefaults: store)
storage[\(key)] = newValue
\(preference.subjectName).send(newValue)
}
"""
]
}
}
extension UserPreferenceMacro: PeerMacro {
public static func expansion(of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext) throws -> [DeclSyntax] {
let preference = try preference(for: declaration, node: node)
let type = preference.type
let name = preference.name
var peers: [DeclSyntax] = []
if preference.isVolatile {
peers.append("private var \(preference.volatileValueName): \(type) = \(preference.defaultValue)")
}
peers.append("private let \(preference.subjectName) = PassthroughSubject<\(type), Never>()")
peers.append("""
var \(raw: name)Publisher: AnyPublisher<\(type), Never> {
\(preference.subjectName).prepend(\(raw: name)).eraseToAnyPublisher()
}
""")
peers.append(resetMethod(for: preference))
return peers
}
/// A method that clears the stored value so it reverts to the default.
private static func resetMethod(for preference: Preference) -> DeclSyntax {
if preference.isVolatile {
"""
func \(preference.resetMethodName)() {
\(preference.volatileValueName) = \(preference.defaultValue)
}
"""
} else {
"""
func \(preference.resetMethodName)() {
let storage = UserDefaultsStorage<\(preference.type)>(userDefaults: store)
storage[\(preference.key)] = nil
}
"""
}
}
}
@@ -0,0 +1,178 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import MacrosImplementation
import SwiftSyntaxMacrosTestSupport
import XCTest
final class UserPreferenceMacroTests: XCTestCase {
private let macros = ["UserPreference": UserPreferenceMacro.self]
func testKeyDefaultsToPropertyName() {
assertMacroExpansion("""
@UserPreference(defaultValue: true)
var hasSeenNewSoundBanner: Bool
""",
expandedSource:
"""
var hasSeenNewSoundBanner: Bool {
get {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
return storage["hasSeenNewSoundBanner"] ?? true
}
set {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
storage["hasSeenNewSoundBanner"] = newValue
_hasSeenNewSoundBannerSubject.send(newValue)
}
}
private let _hasSeenNewSoundBannerSubject = PassthroughSubject<Bool, Never>()
var hasSeenNewSoundBannerPublisher: AnyPublisher<Bool, Never> {
_hasSeenNewSoundBannerSubject.prepend(hasSeenNewSoundBanner).eraseToAnyPublisher()
}
func resetHasSeenNewSoundBanner() {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
storage["hasSeenNewSoundBanner"] = nil
}
""",
macros: macros)
}
func testExplicitKey() {
assertMacroExpansion("""
@UserPreference(key: "liveLocationSharingTimeoutDatesByRoomID", defaultValue: [String: LiveLocationSession]())
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession]
""",
expandedSource:
"""
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] {
get {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store)
return storage["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]()
}
set {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store)
storage["liveLocationSharingTimeoutDatesByRoomID"] = newValue
_liveLocationSharingSessionsByRoomIDSubject.send(newValue)
}
}
private let _liveLocationSharingSessionsByRoomIDSubject = PassthroughSubject<[String: LiveLocationSession], Never>()
var liveLocationSharingSessionsByRoomIDPublisher: AnyPublisher<[String: LiveLocationSession], Never> {
_liveLocationSharingSessionsByRoomIDSubject.prepend(liveLocationSharingSessionsByRoomID).eraseToAnyPublisher()
}
func resetLiveLocationSharingSessionsByRoomID() {
let storage = UserDefaultsStorage<[String: LiveLocationSession]>(userDefaults: store)
storage["liveLocationSharingTimeoutDatesByRoomID"] = nil
}
""",
macros: macros)
}
func testOptionalWithoutDefault() {
assertMacroExpansion("""
@UserPreference
var pusherProfileTag: String?
""",
expandedSource:
"""
var pusherProfileTag: String? {
get {
let storage = UserDefaultsStorage<String?>(userDefaults: store)
return storage["pusherProfileTag"] ?? nil
}
set {
let storage = UserDefaultsStorage<String?>(userDefaults: store)
storage["pusherProfileTag"] = newValue
_pusherProfileTagSubject.send(newValue)
}
}
private let _pusherProfileTagSubject = PassthroughSubject<String?, Never>()
var pusherProfileTagPublisher: AnyPublisher<String?, Never> {
_pusherProfileTagSubject.prepend(pusherProfileTag).eraseToAnyPublisher()
}
func resetPusherProfileTag() {
let storage = UserDefaultsStorage<String?>(userDefaults: store)
storage["pusherProfileTag"] = nil
}
""",
macros: macros)
}
func testBinaryExpressionDefaultIsParenthesised() {
assertMacroExpansion("""
@UserPreference(defaultValue: Self.appBuildType == .debug)
var viewSourceEnabled: Bool
""",
expandedSource:
"""
var viewSourceEnabled: Bool {
get {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
return storage["viewSourceEnabled"] ?? (Self.appBuildType == .debug)
}
set {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
storage["viewSourceEnabled"] = newValue
_viewSourceEnabledSubject.send(newValue)
}
}
private let _viewSourceEnabledSubject = PassthroughSubject<Bool, Never>()
var viewSourceEnabledPublisher: AnyPublisher<Bool, Never> {
_viewSourceEnabledSubject.prepend(viewSourceEnabled).eraseToAnyPublisher()
}
func resetViewSourceEnabled() {
let storage = UserDefaultsStorage<Bool>(userDefaults: store)
storage["viewSourceEnabled"] = nil
}
""",
macros: macros)
}
func testVolatileStorage() {
assertMacroExpansion("""
@UserPreference(defaultValue: false, volatile: true)
var clientPausingAndResumingEnabled: Bool
""",
expandedSource:
"""
var clientPausingAndResumingEnabled: Bool {
get {
_clientPausingAndResumingEnabledVolatileValue
}
set {
_clientPausingAndResumingEnabledVolatileValue = newValue
_clientPausingAndResumingEnabledSubject.send(newValue)
}
}
private var _clientPausingAndResumingEnabledVolatileValue: Bool = false
private let _clientPausingAndResumingEnabledSubject = PassthroughSubject<Bool, Never>()
var clientPausingAndResumingEnabledPublisher: AnyPublisher<Bool, Never> {
_clientPausingAndResumingEnabledSubject.prepend(clientPausingAndResumingEnabled).eraseToAnyPublisher()
}
func resetClientPausingAndResumingEnabled() {
_clientPausingAndResumingEnabledVolatileValue = false
}
""",
macros: macros)
}
}