mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Move BuildExtensions into the Components folder
Complete grouping the standalone modules under Components. Updates the local Swift package path in project.yml.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "d28d5f304a8ea0ef1a0f8227c3988fe3f25592efb0ff1dc7ce76c92ad6a503a2",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||
"state" : {
|
||||
"revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1",
|
||||
"version" : "603.0.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -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: "603.0.2")
|
||||
],
|
||||
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,16 @@
|
||||
//
|
||||
// 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 `Mutex`-backed hook as a read-only computed property, alongside a `register<Name>`
|
||||
/// peer that swaps in a replacement hook.
|
||||
///
|
||||
/// The `default` argument provides the hook used until (and unless) a replacement is registered.
|
||||
/// The enclosing type must have `Synchronization` in scope for the generated `Mutex` storage.
|
||||
@attached(accessor)
|
||||
@attached(peer, names: arbitrary)
|
||||
public macro AppHook<Value>(default: Value) =
|
||||
#externalMacro(module: "MacrosImplementation", type: "AppHookMacro")
|
||||
@@ -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,98 @@
|
||||
//
|
||||
// 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 `@AppHook`. The generated property reads through a `Mutex`-backed store, and a
|
||||
/// generated `register<Name>` method swaps in a replacement hook.
|
||||
enum AppHookMacro {
|
||||
enum DiagnosticError: Error, CustomStringConvertible {
|
||||
case notAStoredProperty
|
||||
case missingTypeAnnotation
|
||||
case missingDefault
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .notAStoredProperty:
|
||||
"@AppHook can only be applied to a stored property."
|
||||
case .missingTypeAnnotation:
|
||||
"@AppHook requires an explicit type annotation."
|
||||
case .missingDefault:
|
||||
"@AppHook requires a default value."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppHook {
|
||||
let name: String
|
||||
let type: TypeSyntax
|
||||
let defaultValue: ExprSyntax
|
||||
|
||||
var storageName: TokenSyntax {
|
||||
"_\(raw: name)"
|
||||
}
|
||||
|
||||
var registerMethodName: TokenSyntax {
|
||||
"register\(raw: name.prefix(1).uppercased() + name.dropFirst())"
|
||||
}
|
||||
}
|
||||
|
||||
private static func appHook(for declaration: some DeclSyntaxProtocol, node: AttributeSyntax) throws -> AppHook {
|
||||
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 defaultValue: ExprSyntax?
|
||||
if let list = node.arguments?.as(LabeledExprListSyntax.self) {
|
||||
for argument in list where argument.label?.text == "default" {
|
||||
defaultValue = argument.expression
|
||||
}
|
||||
}
|
||||
guard let defaultValue else {
|
||||
throw DiagnosticError.missingDefault
|
||||
}
|
||||
|
||||
return AppHook(name: identifier.text, type: type, defaultValue: defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppHookMacro: AccessorMacro {
|
||||
static func expansion(of node: AttributeSyntax,
|
||||
providingAccessorsOf declaration: some DeclSyntaxProtocol,
|
||||
in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
|
||||
let appHook = try appHook(for: declaration, node: node)
|
||||
return [
|
||||
"""
|
||||
get {
|
||||
\(appHook.storageName).withLock { $0 }
|
||||
}
|
||||
"""
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
extension AppHookMacro: PeerMacro {
|
||||
static func expansion(of node: AttributeSyntax,
|
||||
providingPeersOf declaration: some DeclSyntaxProtocol,
|
||||
in context: some MacroExpansionContext) throws -> [DeclSyntax] {
|
||||
let appHook = try appHook(for: declaration, node: node)
|
||||
return [
|
||||
"private let \(appHook.storageName) = Mutex<\(appHook.type)>(\(appHook.defaultValue))",
|
||||
"""
|
||||
func \(appHook.registerMethodName)(_ hook: \(appHook.type)) {
|
||||
\(appHook.storageName).withLock { $0 = hook }
|
||||
}
|
||||
"""
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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, AppHookMacro.self]
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// 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`), 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.
|
||||
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,
|
||||
isVolatile: isVolatile)
|
||||
}
|
||||
}
|
||||
|
||||
extension UserPreferenceMacro: AccessorMacro {
|
||||
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 key = preference.key
|
||||
|
||||
return [
|
||||
"""
|
||||
get {
|
||||
\(readExpression(for: preference))
|
||||
}
|
||||
""",
|
||||
"""
|
||||
set {
|
||||
store[\(key)] = newValue
|
||||
\(preference.subjectName).send(newValue)
|
||||
}
|
||||
"""
|
||||
]
|
||||
}
|
||||
|
||||
/// The getter expression: `store[key]`, with the default appended when there is one. A default
|
||||
/// that's a binary expression is parenthesised, as `??` binds tighter than e.g. `==`.
|
||||
private static func readExpression(for preference: Preference) -> ExprSyntax {
|
||||
guard let defaultValue = preference.defaultValue else {
|
||||
return "store[\(preference.key)]"
|
||||
}
|
||||
|
||||
let isBinaryExpression = defaultValue.is(SequenceExprSyntax.self) || defaultValue.is(InfixOperatorExprSyntax.self)
|
||||
let wrappedDefault: ExprSyntax = isBinaryExpression ? "(\(defaultValue))" : defaultValue
|
||||
return "store[\(preference.key)] ?? \(wrappedDefault)"
|
||||
}
|
||||
}
|
||||
|
||||
extension UserPreferenceMacro: PeerMacro {
|
||||
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 ?? "nil")")
|
||||
}
|
||||
|
||||
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 ?? "nil")
|
||||
}
|
||||
"""
|
||||
} else {
|
||||
"""
|
||||
func \(preference.resetMethodName)() {
|
||||
store.removeObject(forKey: \(preference.key))
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// 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 {
|
||||
store["hasSeenNewSoundBanner"] ?? true
|
||||
}
|
||||
set {
|
||||
store["hasSeenNewSoundBanner"] = newValue
|
||||
_hasSeenNewSoundBannerSubject.send(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private let _hasSeenNewSoundBannerSubject = PassthroughSubject<Bool, Never>()
|
||||
|
||||
var hasSeenNewSoundBannerPublisher: AnyPublisher<Bool, Never> {
|
||||
_hasSeenNewSoundBannerSubject.prepend(hasSeenNewSoundBanner).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func resetHasSeenNewSoundBanner() {
|
||||
store.removeObject(forKey: "hasSeenNewSoundBanner")
|
||||
}
|
||||
""",
|
||||
macros: macros)
|
||||
}
|
||||
|
||||
func testExplicitKey() {
|
||||
assertMacroExpansion("""
|
||||
@UserPreference(key: "liveLocationSharingTimeoutDatesByRoomID", defaultValue: [String: LiveLocationSession]())
|
||||
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession]
|
||||
""",
|
||||
expandedSource:
|
||||
"""
|
||||
var liveLocationSharingSessionsByRoomID: [String: LiveLocationSession] {
|
||||
get {
|
||||
store["liveLocationSharingTimeoutDatesByRoomID"] ?? [String: LiveLocationSession]()
|
||||
}
|
||||
set {
|
||||
store["liveLocationSharingTimeoutDatesByRoomID"] = newValue
|
||||
_liveLocationSharingSessionsByRoomIDSubject.send(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private let _liveLocationSharingSessionsByRoomIDSubject = PassthroughSubject<[String: LiveLocationSession], Never>()
|
||||
|
||||
var liveLocationSharingSessionsByRoomIDPublisher: AnyPublisher<[String: LiveLocationSession], Never> {
|
||||
_liveLocationSharingSessionsByRoomIDSubject.prepend(liveLocationSharingSessionsByRoomID).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func resetLiveLocationSharingSessionsByRoomID() {
|
||||
store.removeObject(forKey: "liveLocationSharingTimeoutDatesByRoomID")
|
||||
}
|
||||
""",
|
||||
macros: macros)
|
||||
}
|
||||
|
||||
func testOptionalWithoutDefault() {
|
||||
assertMacroExpansion("""
|
||||
@UserPreference
|
||||
var pusherProfileTag: String?
|
||||
""",
|
||||
expandedSource:
|
||||
"""
|
||||
var pusherProfileTag: String? {
|
||||
get {
|
||||
store["pusherProfileTag"]
|
||||
}
|
||||
set {
|
||||
store["pusherProfileTag"] = newValue
|
||||
_pusherProfileTagSubject.send(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private let _pusherProfileTagSubject = PassthroughSubject<String?, Never>()
|
||||
|
||||
var pusherProfileTagPublisher: AnyPublisher<String?, Never> {
|
||||
_pusherProfileTagSubject.prepend(pusherProfileTag).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func resetPusherProfileTag() {
|
||||
store.removeObject(forKey: "pusherProfileTag")
|
||||
}
|
||||
""",
|
||||
macros: macros)
|
||||
}
|
||||
|
||||
func testBinaryExpressionDefaultIsParenthesised() {
|
||||
assertMacroExpansion("""
|
||||
@UserPreference(defaultValue: Self.appBuildType == .debug)
|
||||
var viewSourceEnabled: Bool
|
||||
""",
|
||||
expandedSource:
|
||||
"""
|
||||
var viewSourceEnabled: Bool {
|
||||
get {
|
||||
store["viewSourceEnabled"] ?? (Self.appBuildType == .debug)
|
||||
}
|
||||
set {
|
||||
store["viewSourceEnabled"] = newValue
|
||||
_viewSourceEnabledSubject.send(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private let _viewSourceEnabledSubject = PassthroughSubject<Bool, Never>()
|
||||
|
||||
var viewSourceEnabledPublisher: AnyPublisher<Bool, Never> {
|
||||
_viewSourceEnabledSubject.prepend(viewSourceEnabled).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func resetViewSourceEnabled() {
|
||||
store.removeObject(forKey: "viewSourceEnabled")
|
||||
}
|
||||
""",
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user