From e91fa3eace1d0d433059284460c36eefb2734c7c Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Wed, 10 Dec 2025 21:52:02 +0100 Subject: [PATCH 1/6] WIP --- .../MacroSettings/MacroSettings.swift | 9 +- Sources/SettingsClient/main.swift | 5 + Sources/SettingsMacros/SettingMacro.swift | 137 +- Sources/SettingsMacros/SettingsMacro.swift | 226 +- ...SettingMacroExpansionDiagnosticTests.swift | 393 ++-- .../SettingMacroExpansionTests.swift | 1849 +++++++++++------ ...SettingsContainerMacroExpansionTests.swift | 507 ++++- .../UserDefaultsWithObservationTests.swift | 36 +- 8 files changed, 2138 insertions(+), 1024 deletions(-) diff --git a/Sources/Settings/MacroSettings/MacroSettings.swift b/Sources/Settings/MacroSettings/MacroSettings.swift index 1a4e637..0e5c25a 100644 --- a/Sources/Settings/MacroSettings/MacroSettings.swift +++ b/Sources/Settings/MacroSettings/MacroSettings.swift @@ -58,12 +58,13 @@ /// /// // SwiftUI views automatically update when settings.hasSeenOnboarding changes /// ``` -@attached(member) +@attached( + member, + names: named(Config), named(_config), named(store), named(prefix) +) @attached( extension, - conformances: __Settings_Container, - names: named(prefix), - named(suiteName) + conformances: __Settings_Container ) public macro Settings( prefix: String? = nil, diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index e74ddf4..7c85d7f 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -3,6 +3,7 @@ import SettingsMock import Foundation import Observation import Combine +import os // protocol ConstString { // static var value: String { get } @@ -46,6 +47,10 @@ import Combine // print("Hello UserDefaults!") // try test() +@Settings struct Settings1 { + @Setting var setting: String = "default" +} + @Settings(prefix: "app_") struct Settings { @Setting var setting: String = "default" } diff --git a/Sources/SettingsMacros/SettingMacro.swift b/Sources/SettingsMacros/SettingMacro.swift index 07004e8..1ecd075 100644 --- a/Sources/SettingsMacros/SettingMacro.swift +++ b/Sources/SettingsMacros/SettingMacro.swift @@ -20,7 +20,7 @@ public struct SettingMacro: AccessorMacro, PeerMacro { // MARK: - AccessorMacro Implementation // Add getter and setter to the declaration - + public static func expansion( of node: SwiftSyntax.AttributeSyntax, providingAccessorsOf declaration: some SwiftSyntax.DeclSyntaxProtocol, @@ -139,7 +139,7 @@ public struct SettingMacro: AccessorMacro, PeerMacro { of: node, for: varDecl, propertyName: userDefaultPropertyName, - prefix: containerAttributes.prefix, + prefix: containerAttributes.prefix ?? "", in: context ) @@ -187,7 +187,9 @@ public struct SettingMacro: AccessorMacro, PeerMacro { ), typeAnnotation: TypeAnnotationSyntax( type: IdentifierTypeSyntax( - name: .identifier("__AttributeProxy<\(attributeTypeName)>") + name: .identifier( + "__AttributeProxy<\(attributeTypeName)>" + ) ) ), accessorBlock: AccessorBlockSyntax( @@ -224,15 +226,19 @@ extension SettingMacro { var members: [MemberBlockItemSyntax] = [] members.append(makeContainerTypealias(containerType: containerTypeName)) members.append(makeValueTypealias(valueType: valueTypeName)) - + // For Optional types, add explicit Wrapped typealias - if valueTypeName.isOptional, let wrappedTypealias = makeWrappedTypealias(valueType: valueTypeName) { + if valueTypeName.isOptional, + let wrappedTypealias = makeWrappedTypealias( + valueType: valueTypeName + ) + { members.append(wrappedTypealias) } - + // Add key pr members.append(makeNameProperty(attributeQualifiedName: qualifiedName)) - + if !valueTypeName.isOptional { if let defaultProp = makeDefaultValueProperty( valueType: valueTypeName, @@ -243,15 +249,20 @@ extension SettingMacro { } } - members.append(contentsOf: makeEncoderProperties(providerDirective: encodingProviderDirective)) - + members.append( + contentsOf: makeEncoderProperties( + providerDirective: encodingProviderDirective + ) + ) + return EnumDeclSyntax( modifiers: [DeclModifierSyntax(name: .keyword(.public))], name: .identifier(attributeTypeName), inheritanceClause: InheritanceClauseSyntax { InheritedTypeSyntax( type: IdentifierTypeSyntax( - name: valueTypeName.isOptional ? "__AttributeOptional" : "__AttributeNonOptional" + name: valueTypeName.isOptional + ? "__AttributeOptional" : "__AttributeNonOptional" ) ) }, @@ -260,9 +271,11 @@ extension SettingMacro { ) ) } - + /// Creates the `typealias Container = ` member for the attribute enum. - private static func makeContainerTypealias(containerType: String) -> MemberBlockItemSyntax { + private static func makeContainerTypealias(containerType: String) + -> MemberBlockItemSyntax + { MemberBlockItemSyntax( decl: TypeAliasDeclSyntax( modifiers: [DeclModifierSyntax(name: .keyword(.public))], @@ -275,9 +288,11 @@ extension SettingMacro { ) ) } - + /// Creates the `typealias Value = ` member for the attribute enum. - private static func makeValueTypealias(valueType: ParsedValueType) -> MemberBlockItemSyntax { + private static func makeValueTypealias(valueType: ParsedValueType) + -> MemberBlockItemSyntax + { MemberBlockItemSyntax( decl: TypeAliasDeclSyntax( modifiers: [DeclModifierSyntax(name: .keyword(.public))], @@ -290,12 +305,14 @@ extension SettingMacro { ) ) } - + /// Creates the `typealias Wrapped = ` member for Optional value types. /// This is required because extensions have explicit `Wrapped == T` constraints for `Value == Optional`. - private static func makeWrappedTypealias(valueType: ParsedValueType) -> MemberBlockItemSyntax? { + private static func makeWrappedTypealias(valueType: ParsedValueType) + -> MemberBlockItemSyntax? + { guard valueType.isOptional else { return nil } - + // Extract the unwrapped type from the Optional let unwrappedTypeName: String if let syntax = valueType.syntax { @@ -304,18 +321,22 @@ extension SettingMacro { // Handle "T?" syntax unwrappedTypeName = optionalType.wrappedType.trimmedDescription } else if let identifierType = syntax.as(IdentifierTypeSyntax.self), - let genericArgs = identifierType.genericArgumentClause?.arguments.first { + let genericArgs = identifierType.genericArgumentClause? + .arguments.first + { // Handle "Optional" or "Swift.Optional" syntax unwrappedTypeName = genericArgs.argument.trimmedDescription } else { // Fallback to string parsing - unwrappedTypeName = extractUnwrappedTypeFromString(valueType.name) + unwrappedTypeName = extractUnwrappedTypeFromString( + valueType.name + ) } } else { // No AST available, use string parsing unwrappedTypeName = extractUnwrappedTypeFromString(valueType.name) } - + return MemberBlockItemSyntax( decl: TypeAliasDeclSyntax( modifiers: [DeclModifierSyntax(name: .keyword(.public))], @@ -328,36 +349,43 @@ extension SettingMacro { ) ) } - + /// Extracts the unwrapped type name from an Optional type string. /// "String?" -> "String", "Optional" -> "String", "Swift.Optional" -> "Int" - private static func extractUnwrappedTypeFromString(_ typeName: String) -> String { + private static func extractUnwrappedTypeFromString(_ typeName: String) + -> String + { if typeName.hasSuffix("?") { // Handle "T?" syntax return String(typeName.dropLast()) } else if typeName.contains("<") && typeName.hasSuffix(">") { // Handle "Optional" or "Swift.Optional" syntax if let startIndex = typeName.firstIndex(of: "<"), - let endIndex = typeName.lastIndex(of: ">") { - let innerType = typeName[typeName.index(after: startIndex)..") + { + let innerType = typeName[ + typeName.index(after: startIndex).. MemberBlockItemSyntax { + private static func makeNameProperty(attributeQualifiedName: String) + -> MemberBlockItemSyntax + { MemberBlockItemSyntax( decl: VariableDeclSyntax( modifiers: [ DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)) + DeclModifierSyntax(name: .keyword(.static)), ], bindingSpecifier: .keyword(.let), bindings: PatternBindingListSyntax([ @@ -375,7 +403,7 @@ extension SettingMacro { ) ) } - + /// Creates the optional `static var defaultValue: Value { ... }` property if a default is specified. /// Returns nil if no default value is provided. private static func makeDefaultValueProperty( @@ -389,7 +417,7 @@ extension SettingMacro { decl: VariableDeclSyntax( modifiers: [ DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)) + DeclModifierSyntax(name: .keyword(.static)), ], bindingSpecifier: .keyword(.let), bindings: PatternBindingListSyntax([ @@ -410,14 +438,15 @@ extension SettingMacro { ) ) } - + /// Creates the `static let defaultRegistrar = __DefaultRegistrar()` member used to register defaults at load time. - private static func makeDefaultRegistrarProperty() -> MemberBlockItemSyntax { + private static func makeDefaultRegistrarProperty() -> MemberBlockItemSyntax + { MemberBlockItemSyntax( decl: VariableDeclSyntax( modifiers: [ DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)) + DeclModifierSyntax(name: .keyword(.static)), ], bindingSpecifier: .keyword(.let), bindings: PatternBindingListSyntax([ @@ -426,14 +455,16 @@ extension SettingMacro { identifier: .identifier("defaultRegistrar") ), initializer: InitializerClauseSyntax( - value: ExprSyntax(stringLiteral: "__DefaultRegistrar()") + value: ExprSyntax( + stringLiteral: "__DefaultRegistrar()" + ) ) ) ]) ) ) } - + /// Creates encoder/decoder properties based on the encoding directive (strategy or custom). /// Returns empty array for property-list types that don't need custom encoding. private static func makeEncoderProperties( @@ -451,7 +482,7 @@ extension SettingMacro { name: "decoder", typeDescription: "some AttributeDecoding", expression: decoderExpr - ) + ), ] case .strategy(let strategy): return [ @@ -464,13 +495,13 @@ extension SettingMacro { name: "decoder", typeDescription: "some AttributeDecoding", expression: strategy.decoderExpression - ) + ), ] case .none: return [] } } - + /// Creates a single encoder or decoder property member with the specified name and value expression. private static func makeCoderProperty( name: String, @@ -481,7 +512,7 @@ extension SettingMacro { decl: VariableDeclSyntax( modifiers: [ DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)) + DeclModifierSyntax(name: .keyword(.static)), ], bindingSpecifier: .keyword(.var), bindings: PatternBindingListSyntax([ @@ -978,7 +1009,7 @@ extension SettingMacro { } return nil } - + let keyName = extractCustomKeyName(from: node) if let keyName { return keyName @@ -1091,7 +1122,7 @@ extension SettingMacro { ) throws -> String { // For nested types, include the namespace hierarchy in the name // e.g., AppSettings.UserSettings -> "UserSettings::setting" - + // Helper function to check if attributes contain @Settings macro func isAnnotatedWithUserDefaultsMacro( _ attributes: SwiftSyntax.AttributeListSyntax @@ -1109,12 +1140,12 @@ extension SettingMacro { } return false } - + // Collect all nested type names and the extension info // Lexical context is traversed from innermost to outermost var nestedTypeNames: [String] = [] var extensionTypeName: String? = nil - + for contextNode in context.lexicalContext { // Check for nested type declarations (enum, struct, class) if let enumDecl = contextNode.as(EnumDeclSyntax.self) { @@ -1122,43 +1153,46 @@ extension SettingMacro { if !isAnnotatedWithUserDefaultsMacro(enumDecl.attributes) { nestedTypeNames.append(enumDecl.name.text) } - } - else if let structDecl = contextNode.as(StructDeclSyntax.self) { + } else if let structDecl = contextNode.as(StructDeclSyntax.self) { // Only add if not annotated with @UserDefaults (not the container itself) if !isAnnotatedWithUserDefaultsMacro(structDecl.attributes) { nestedTypeNames.append(structDecl.name.text) } - } - else if let classDecl = contextNode.as(ClassDeclSyntax.self) { + } else if let classDecl = contextNode.as(ClassDeclSyntax.self) { // Only add if not annotated with @UserDefaults (not the container itself) if !isAnnotatedWithUserDefaultsMacro(classDecl.attributes) { nestedTypeNames.append(classDecl.name.text) } } // Check for extension - else if let extensionDecl = contextNode.as(ExtensionDeclSyntax.self) { + else if let extensionDecl = contextNode.as(ExtensionDeclSyntax.self) + { extensionTypeName = extensionDecl.extendedType.description .trimmingCharacters(in: .whitespacesAndNewlines) // Once we find the extension, we can stop break } } - + // Determine the namespace based on what we found if let extendedType = extensionTypeName { // If extending a nested type (AppSettings.Profile), use the nested part if let dotIndex = extendedType.firstIndex(of: ".") { - let namespace = String(extendedType[extendedType.index(after: dotIndex)...]) + let namespace = String( + extendedType[extendedType.index(after: dotIndex)...] + ) return "\(namespace)::\(propertyName)" } // If we have nested types inside a simple extension, use those if !nestedTypeNames.isEmpty { // Reverse because we collected from innermost to outermost - let namespace = nestedTypeNames.reversed().joined(separator: "::") + let namespace = nestedTypeNames.reversed().joined( + separator: "::" + ) return "\(namespace)::\(propertyName)" } } - + // Default: just use the property name return propertyName } @@ -1491,4 +1525,3 @@ extension StructDeclSyntax: _Conformable { } var _typeName: String { name.text } } - diff --git a/Sources/SettingsMacros/SettingsMacro.swift b/Sources/SettingsMacros/SettingsMacro.swift index 13d4d64..eb2e64f 100644 --- a/Sources/SettingsMacros/SettingsMacro.swift +++ b/Sources/SettingsMacros/SettingsMacro.swift @@ -4,38 +4,77 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros -/// Implementation of the `@Settings` container macro -/// -/// This macro generates the prefix property for a container type. -/// The container must manually conform to `__Settings_Container`. -public struct SettingsMacro: MemberMacro, ExtensionMacro { +public struct SettingsMacro {} - struct Attributes { - var prefix: String - var suiteName: String? - } - - // MARK: - MemberMacro Implementation +extension SettingsMacro: MemberMacro { + // Implementation of the `@Settings` container macro + // + // This macro generates the configuration infrastructure for a container type. + // The container must manually conform to `__Settings_Container`. public static func expansion( - of node: SwiftSyntax.AttributeSyntax, - providingMembersOf declaration: some SwiftSyntax.DeclGroupSyntax, - conformingTo protocols: [SwiftSyntax.TypeSyntax], - in context: some SwiftSyntaxMacros.MacroExpansionContext - ) throws -> [SwiftSyntax.DeclSyntax] { + of node: AttributeSyntax, + providingMembersOf decl: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + // Extract the prefix from the macro arguments + let initialPrefix: String? = try extractPrefix(from: node) + if let prefix = initialPrefix { + try validatePrefix(prefix) + } + let literalPrefix = initialPrefix == nil ? "" : initialPrefix! + + let declSyntax = DeclSyntax( + """ + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "\(raw: literalPrefix)" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } - // We only need this of we implement ObservableStore / ObservableAttribute - // // Generate property `tokens`: - // let tokensDecl: DeclSyntax = """ - // private let tokens = TokenStore() - // """ - // - // return [tokensDecl] - return [] + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } + """ + ) + return [declSyntax] } +} + +extension SettingsMacro: ExtensionMacro { // MARK: Extension Macro Implementation + struct Attributes { + var prefix: String? + var suiteName: String? + } + static func validatePrefix(_ prefix: String) throws { // The prefix string needs to be KVC compliant. guard !prefix.contains(".") else { @@ -53,101 +92,17 @@ public struct SettingsMacro: MemberMacro, ExtensionMacro { in context: some SwiftSyntaxMacros.MacroExpansionContext ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { - // Extract the prefix and suiteName from the macro arguments - let prefix = try extractPrefix(from: node) - try validatePrefix(prefix) - let suiteName = try extractSuiteName(from: node) - - // Generate the static prefix property - let prefixProperty = MemberBlockItemSyntax( - decl: VariableDeclSyntax( - modifiers: [ - DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)), - ], - bindingSpecifier: .keyword(.let), - bindings: PatternBindingListSyntax([ - PatternBindingSyntax( - pattern: IdentifierPatternSyntax( - identifier: .identifier("prefix") - ), - typeAnnotation: TypeAnnotationSyntax( - type: IdentifierTypeSyntax( - name: .identifier("String") - ) - ), - initializer: InitializerClauseSyntax( - value: StringLiteralExprSyntax(content: prefix) - ) - ) - ]) - ) - ) - - // Generate the static suiteName property - let suiteNameProperty = MemberBlockItemSyntax( - decl: VariableDeclSyntax( - modifiers: [ - DeclModifierSyntax(name: .keyword(.public)), - DeclModifierSyntax(name: .keyword(.static)), - ], - bindingSpecifier: .keyword(.let), - bindings: PatternBindingListSyntax([ - PatternBindingSyntax( - pattern: IdentifierPatternSyntax( - identifier: .identifier("suiteName") - ), - typeAnnotation: TypeAnnotationSyntax( - type: OptionalTypeSyntax( - wrappedType: IdentifierTypeSyntax( - name: .identifier("String") - ) - ) - ), - initializer: InitializerClauseSyntax( - value: suiteName != nil - ? ExprSyntax( - StringLiteralExprSyntax(content: suiteName!) - ) - : ExprSyntax(NilLiteralExprSyntax()) - ) - ) - ]) - ) - ) - - // Check if the type already has prefix or suiteName properties defined - let hasExistingPrefix = hasExistingProperty( - named: "prefix", - in: declaration - ) - let hasExistingSuiteName = hasExistingProperty( - named: "suiteName", - in: declaration - ) - - // Only add properties that don't already exist - var members: [MemberBlockItemSyntax] = [] - if !hasExistingPrefix { - members.append(prefixProperty) - } - if !hasExistingSuiteName { - members.append(suiteNameProperty) - } - // Generate an extension that adds conformance to __Settings_Container - // and includes only the missing properties let extensionDecl = ExtensionDeclSyntax( - extensionKeyword: .keyword(.extension), extendedType: type, inheritanceClause: InheritanceClauseSyntax { InheritedTypeSyntax( - type: IdentifierTypeSyntax(name: .identifier("__Settings_Container")) + type: IdentifierTypeSyntax( + name: .identifier("__Settings_Container") + ) ) }, - memberBlock: MemberBlockSyntax( - members: MemberBlockItemListSyntax(members) - ) + memberBlock: MemberBlockSyntax(members: []) ) return [extensionDecl] @@ -167,35 +122,14 @@ extension SettingsMacro { ) } - /// Check if a type declaration already has a property with the given name - private static func hasExistingProperty( - named propertyName: String, - in declaration: some DeclGroupSyntax - ) -> Bool { - // Check the member list for existing properties - for member in declaration.memberBlock.members { - if let varDecl = member.decl.as(VariableDeclSyntax.self) { - for binding in varDecl.bindings { - if let identifier = binding.pattern.as( - IdentifierPatternSyntax.self - ), - identifier.identifier.text == propertyName - { - return true - } - } - } - } - return false - } - - /// Extracts the prefix parameter from the macro arguments + /// Extracts the prefix parameter from the macro arguments. + /// Returns `nil` if no prefix is specified. private static func extractPrefix(from node: AttributeSyntax) throws - -> String + -> String? { guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { - return "" // Default to empty prefix if no arguments + return nil // Default to nil if no arguments } for argument in arguments { @@ -221,7 +155,7 @@ extension SettingsMacro { .content.text ?? "" } - return "" // Default to empty prefix + return nil // Default to nil prefix } /// Extracts the suiteName parameter from the macro arguments @@ -248,20 +182,4 @@ extension SettingsMacro { return nil // Default to nil if suiteName not found } - /// Gets the container type name from the declaration - private static func getContainerTypeName( - from declaration: some DeclGroupSyntax - ) throws -> String { - if let structDecl = declaration.as(StructDeclSyntax.self) { - return structDecl.name.text - } else if let classDecl = declaration.as(ClassDeclSyntax.self) { - return classDecl.name.text - } else if let enumDecl = declaration.as(EnumDeclSyntax.self) { - return enumDecl.name.text - } else if let actorDecl = declaration.as(ActorDeclSyntax.self) { - return actorDecl.name.text - } else { - throw MacroError.invalidContainerType - } - } } diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionDiagnosticTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionDiagnosticTests.swift index 154d0ed..ac6a4b1 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionDiagnosticTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionDiagnosticTests.swift @@ -1,7 +1,7 @@ +import Foundation import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest -import Foundation @testable import SettingsMacros @@ -17,11 +17,27 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { .init(description: "Int", type: "Int", initialValue: "1"), .init(description: "Double", type: "Double", initialValue: "2.5"), .init(description: "Float", type: "Float", initialValue: "1.5"), - .init(description: "String", type: "String", initialValue: "\"hello\""), - .init(description: "Date", type: "Date", initialValue: "Date(timeIntervalSince1970: 0)"), + .init( + description: "String", + type: "String", + initialValue: "\"hello\"" + ), + .init( + description: "Date", + type: "Date", + initialValue: "Date(timeIntervalSince1970: 0)" + ), .init(description: "Data", type: "Data", initialValue: "Data()"), - .init(description: "URL", type: "URL", initialValue: "URL(string: \"https://example.com/resource\")!"), - .init(description: "Any", type: "Any", initialValue: "\"value\" as Any"), + .init( + description: "URL", + type: "URL", + initialValue: "URL(string: \"https://example.com/resource\")!" + ), + .init( + description: "Any", + type: "Any", + initialValue: "\"value\" as Any" + ), ] let cases = tests.map { test in var test = test @@ -33,21 +49,39 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { try assertEncodingRejected(for: cases) } - func testEncoderDecoderStrategyRejectedForScalarPropertyListTypes1() throws { + func testEncoderDecoderStrategyRejectedForScalarPropertyListTypes1() throws + { let tests: [EncodingDiagnosticCase] = [ .init(description: "Bool", type: "Bool", initialValue: "true"), .init(description: "Int", type: "Int", initialValue: "1"), .init(description: "Double", type: "Double", initialValue: "2.5"), .init(description: "Float", type: "Float", initialValue: "1.5"), - .init(description: "String", type: "String", initialValue: "\"hello\""), - .init(description: "Date", type: "Date", initialValue: "Date(timeIntervalSince1970: 0)"), + .init( + description: "String", + type: "String", + initialValue: "\"hello\"" + ), + .init( + description: "Date", + type: "Date", + initialValue: "Date(timeIntervalSince1970: 0)" + ), .init(description: "Data", type: "Data", initialValue: "Data()"), - .init(description: "URL", type: "URL", initialValue: "URL(string: \"https://example.com/resource\")!"), - .init(description: "Any", type: "Any", initialValue: "\"value\" as Any") + .init( + description: "URL", + type: "URL", + initialValue: "URL(string: \"https://example.com/resource\")!" + ), + .init( + description: "Any", + type: "Any", + initialValue: "\"value\" as Any" + ), ] let cases = tests.map { test in var test = test - test.parameterList = "(encoder: JSONEncoder(), decoder: JSONDecoder())" + test.parameterList = + "(encoder: JSONEncoder(), decoder: JSONDecoder())" test.fixedParameterList = "" test.fixIts = [EncodingDiagnosticCase.encoderDecoderFixItMessage] return test @@ -55,21 +89,39 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { try assertEncodingRejected(for: cases) } - func testEncoderDecoderStrategyRejectedForScalarPropertyListTypes2() throws { + func testEncoderDecoderStrategyRejectedForScalarPropertyListTypes2() throws + { let tests: [EncodingDiagnosticCase] = [ .init(description: "Bool", type: "Bool", initialValue: "true"), .init(description: "Int", type: "Int", initialValue: "1"), .init(description: "Double", type: "Double", initialValue: "2.5"), .init(description: "Float", type: "Float", initialValue: "1.5"), - .init(description: "String", type: "String", initialValue: "\"hello\""), - .init(description: "Date", type: "Date", initialValue: "Date(timeIntervalSince1970: 0)"), + .init( + description: "String", + type: "String", + initialValue: "\"hello\"" + ), + .init( + description: "Date", + type: "Date", + initialValue: "Date(timeIntervalSince1970: 0)" + ), .init(description: "Data", type: "Data", initialValue: "Data()"), - .init(description: "URL", type: "URL", initialValue: "URL(string: \"https://example.com/resource\")!"), - .init(description: "Any", type: "Any", initialValue: "\"value\" as Any") + .init( + description: "URL", + type: "URL", + initialValue: "URL(string: \"https://example.com/resource\")!" + ), + .init( + description: "Any", + type: "Any", + initialValue: "\"value\" as Any" + ), ] let cases = tests.map { test in var test = test - test.parameterList = "(name: \"value\", encoder: JSONEncoder(), decoder: JSONDecoder())" + test.parameterList = + "(name: \"value\", encoder: JSONEncoder(), decoder: JSONDecoder())" test.fixedParameterList = "(name: \"value\")" test.fixIts = [EncodingDiagnosticCase.encoderDecoderFixItMessage] return test @@ -102,16 +154,40 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { func testEncodingStrategyRejectedForCollectionPropertyListTypes() throws { let cases: [EncodingDiagnosticCase] = [ - .init(description: "String Array", type: "[String]", initialValue: "[\"alpha\", \"beta\"]"), - .init(description: "Integer Array", type: "Array", initialValue: "[1, 2, 3]"), - .init(description: "Dictionary String Int", type: "[String: Int]", initialValue: "[\"count\": 1]"), - .init(description: "Dictionary With Arrays", type: "Dictionary", initialValue: "[\"numbers\": [1, 2, 3]]"), - .init(description: "Array Of Dictionaries", type: "Array>", initialValue: "[[\"score\": 42]]"), - .init(description: "Dictionary With Any Values", type: "[String: Any]", initialValue: "[\"flag\": true] as [String: Any]"), + .init( + description: "String Array", + type: "[String]", + initialValue: "[\"alpha\", \"beta\"]" + ), + .init( + description: "Integer Array", + type: "Array", + initialValue: "[1, 2, 3]" + ), + .init( + description: "Dictionary String Int", + type: "[String: Int]", + initialValue: "[\"count\": 1]" + ), + .init( + description: "Dictionary With Arrays", + type: "Dictionary", + initialValue: "[\"numbers\": [1, 2, 3]]" + ), + .init( + description: "Array Of Dictionaries", + type: "Array>", + initialValue: "[[\"score\": 42]]" + ), + .init( + description: "Dictionary With Any Values", + type: "[String: Any]", + initialValue: "[\"flag\": true] as [String: Any]" + ), ] try assertEncodingRejected(for: cases) } - + func testEncodingStrategyRejectedForPathologicalPropertyListTypes() throws { let cases: [EncodingDiagnosticCase] = [ .init( @@ -138,14 +214,14 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { description: "Nested Collections", type: "Dictionary>", initialValue: "[\"metrics\": [\"scores\": [1, 2]]]" - ) + ), ] try assertEncodingRejected(for: cases) } - + func testExplicitCoderProvidersRejectedForPropertyListTypes() throws { -#if canImport(SettingsMacros) - assertMacroExpansion( + #if canImport(SettingsMacros) + assertMacroExpansion( """ extension UserDefaultsStandard { @Setting( @@ -165,142 +241,172 @@ final class SettingMacroExpansionDiagnosticTests: XCTestCase { "`encoding`/`encoder` arguments are only supported for Codable types. `String` already stores natively in UserDefaults.", line: 2, column: 5, - fixIts: [FixItSpec(message: EncodingDiagnosticCase.encoderDecoderFixItMessage)] + fixIts: [ + FixItSpec( + message: EncodingDiagnosticCase + .encoderDecoderFixItMessage + ) + ] ) ], macros: testMacros, - applyFixIts: [EncodingDiagnosticCase.encoderDecoderFixItMessage], + applyFixIts: [ + EncodingDiagnosticCase.encoderDecoderFixItMessage + ], fixedSource: """ extension UserDefaultsStandard { @Setting var value: String = "hello" } """ - ) -#else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) -#endif + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif } - + func testFixItRemovesEncodingButKeepsName() throws { - #if canImport(SettingsMacros) - assertMacroExpansion( - """ - extension UserDefaultsStandard { - @Setting(name: "custom.key", encoding: .json) var value: String = "hello" - } - """, - expandedSource: """ + #if canImport(SettingsMacros) + assertMacroExpansion( + """ extension UserDefaultsStandard { - var value: String = "hello" + @Setting(name: "custom.key", encoding: .json) var value: String = "hello" } """, - diagnostics: [ - DiagnosticSpec( - message: - "`encoding`/`encoder` arguments are only supported for Codable types. `String` already stores natively in UserDefaults.", - line: 2, - column: 5, - fixIts: [FixItSpec(message: EncodingDiagnosticCase.encodingArgumentFixItMessage)] - ) - ], - macros: testMacros, - applyFixIts: [EncodingDiagnosticCase.encodingArgumentFixItMessage], - fixedSource: """ - extension UserDefaultsStandard { - @Setting(name: "custom.key") var value: String = "hello" - } - """ - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif + expandedSource: """ + extension UserDefaultsStandard { + var value: String = "hello" + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "`encoding`/`encoder` arguments are only supported for Codable types. `String` already stores natively in UserDefaults.", + line: 2, + column: 5, + fixIts: [ + FixItSpec( + message: EncodingDiagnosticCase + .encodingArgumentFixItMessage + ) + ] + ) + ], + macros: testMacros, + applyFixIts: [ + EncodingDiagnosticCase.encodingArgumentFixItMessage + ], + fixedSource: """ + extension UserDefaultsStandard { + @Setting(name: "custom.key") var value: String = "hello" + } + """ + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif } func testFixItRemovesEncodingAndParenthesesWhenAlone() throws { - #if canImport(SettingsMacros) - assertMacroExpansion( - """ - extension UserDefaultsStandard { - @Setting(encoding: .plist) var value: Int = 1 - } - """, - expandedSource: """ + #if canImport(SettingsMacros) + assertMacroExpansion( + """ extension UserDefaultsStandard { - var value: Int = 1 + @Setting(encoding: .plist) var value: Int = 1 } """, - diagnostics: [ - DiagnosticSpec( - message: - "`encoding`/`encoder` arguments are only supported for Codable types. `Int` already stores natively in UserDefaults.", - line: 2, - column: 5, - fixIts: [FixItSpec(message: EncodingDiagnosticCase.encodingArgumentFixItMessage)] - ) - ], - macros: testMacros, - applyFixIts: [EncodingDiagnosticCase.encodingArgumentFixItMessage], - fixedSource: """ - extension UserDefaultsStandard { - @Setting var value: Int = 1 - } - """ - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif + expandedSource: """ + extension UserDefaultsStandard { + var value: Int = 1 + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "`encoding`/`encoder` arguments are only supported for Codable types. `Int` already stores natively in UserDefaults.", + line: 2, + column: 5, + fixIts: [ + FixItSpec( + message: EncodingDiagnosticCase + .encodingArgumentFixItMessage + ) + ] + ) + ], + macros: testMacros, + applyFixIts: [ + EncodingDiagnosticCase.encodingArgumentFixItMessage + ], + fixedSource: """ + extension UserDefaultsStandard { + @Setting var value: Int = 1 + } + """ + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif } func testFixItRemovesEncoderDecoderButKeepsName() throws { - #if canImport(SettingsMacros) - assertMacroExpansion( - """ - extension UserDefaultsStandard { - @Setting(name: "another.key", encoder: JSONEncoder(), decoder: JSONDecoder()) var value: String = "world" - } - """, - expandedSource: """ + #if canImport(SettingsMacros) + assertMacroExpansion( + """ extension UserDefaultsStandard { - var value: String = "world" + @Setting(name: "another.key", encoder: JSONEncoder(), decoder: JSONDecoder()) var value: String = "world" } """, - diagnostics: [ - DiagnosticSpec( - message: - "`encoding`/`encoder` arguments are only supported for Codable types. `String` already stores natively in UserDefaults.", - line: 2, - column: 5, - fixIts: [FixItSpec(message: EncodingDiagnosticCase.encoderDecoderFixItMessage)] - ) - ], - macros: testMacros, - applyFixIts: [EncodingDiagnosticCase.encoderDecoderFixItMessage], - fixedSource: """ - extension UserDefaultsStandard { - @Setting(name: "another.key") var value: String = "world" - } - """ - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif + expandedSource: """ + extension UserDefaultsStandard { + var value: String = "world" + } + """, + diagnostics: [ + DiagnosticSpec( + message: + "`encoding`/`encoder` arguments are only supported for Codable types. `String` already stores natively in UserDefaults.", + line: 2, + column: 5, + fixIts: [ + FixItSpec( + message: EncodingDiagnosticCase + .encoderDecoderFixItMessage + ) + ] + ) + ], + macros: testMacros, + applyFixIts: [ + EncodingDiagnosticCase.encoderDecoderFixItMessage + ], + fixedSource: """ + extension UserDefaultsStandard { + @Setting(name: "another.key") var value: String = "world" + } + """ + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif } } extension SettingMacroExpansionDiagnosticTests { - + struct EncodingDiagnosticCase { - static let encodingArgumentFixItMessage = "Remove the `encoding` argument for property-list values." - static let encoderDecoderFixItMessage = "Remove the `encoder` and `decoder` arguments for property-list values." - + static let encodingArgumentFixItMessage = + "Remove the `encoding` argument for property-list values." + static let encoderDecoderFixItMessage = + "Remove the `encoder` and `decoder` arguments for property-list values." + init( description: String, type: String, @@ -310,7 +416,7 @@ extension SettingMacroExpansionDiagnosticTests { self.type = type self.initialValue = initialValue } - + var parameterList: String = "(encoding: .json)" var fixedParameterList: String = "" var description: String @@ -319,11 +425,17 @@ extension SettingMacroExpansionDiagnosticTests { var fixIts: [String] = [encodingArgumentFixItMessage] } - private func assertEncodingRejected(for cases: [EncodingDiagnosticCase]) throws { + private func assertEncodingRejected(for cases: [EncodingDiagnosticCase]) + throws + { #if canImport(SettingsMacros) for testCase in cases { - let normalizedType = testCase.type.trimmingCharacters(in: .whitespacesAndNewlines) - let initialiser = testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" + let normalizedType = testCase.type.trimmingCharacters( + in: .whitespacesAndNewlines + ) + let initialiser = + testCase.initialValue.isEmpty + ? "" : " = \(testCase.initialValue)" assertMacroExpansion( """ extension UserDefaultsStandard { @@ -341,7 +453,9 @@ extension SettingMacroExpansionDiagnosticTests { "`encoding`/`encoder` arguments are only supported for Codable types. `\(normalizedType)` already stores natively in UserDefaults.", line: 2, column: 5, - fixIts: testCase.fixIts.map { FixItSpec(message: $0) } + fixIts: testCase.fixIts.map { + FixItSpec(message: $0) + } ) ], macros: testMacros, @@ -361,4 +475,3 @@ extension SettingMacroExpansionDiagnosticTests { } } - diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift index df6ca47..7f7b978 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift @@ -11,7 +11,7 @@ final class SettingMacroExpansionTests: XCTestCase { self.initialValue = initializer self.description = description } - + init(optionalType: String, description: String) { self.type = "\(optionalType)?" self.initialValue = "" @@ -27,690 +27,1197 @@ final class SettingMacroExpansionTests: XCTestCase { "Setting": SettingMacro.self, "Settings": SettingsMacro.self, ] - - func testBool00() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings { - @Setting var setting: Bool = true - } - """ - let expected = """ - struct AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() + func testBool00() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - } - - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testBool10() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - } - - extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil - } - """ - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } - func testBool01() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings {} - - extension AppSettings { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings {} + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} - extension AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() + extension AppSettings { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - } + extension AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + } - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ + extension AppSettings: __Settings_Container { + } + """ - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testBool11() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings {} - - extension AppSettings { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings {} + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} - extension AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() + extension AppSettings { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - } + extension AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + } - extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil - } - """ + extension AppSettings: __Settings_Container { + } + """ - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } - + func testNestedBool00() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings {} - - extension AppSettings { enum Profile {} } - - extension AppSettings.Profile { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings {} + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} - extension AppSettings { enum Profile {} - } - - extension AppSettings.Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() + extension AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings { enum Profile {} + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedBool10() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings {} - - extension AppSettings { enum Profile {} } - - extension AppSettings.Profile { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings {} + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} - extension AppSettings { enum Profile {} - } - - extension AppSettings.Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() + extension AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings { enum Profile {} + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } - extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedBool01() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings { enum Profile {} } - - extension AppSettings.Profile { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings { enum Profile {} - } + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings { enum Profile {} } - extension AppSettings.Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() + extension AppSettings.Profile { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { enum Profile {} + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } + extension AppSettings: __Settings_Container { + } + """ - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedBool11() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings { enum Profile {} } - - extension AppSettings.Profile { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings { enum Profile {} - } + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings { enum Profile {} } - extension AppSettings.Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() + extension AppSettings.Profile { + @Setting var setting: Bool = true } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { enum Profile {} + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } + extension AppSettings: __Settings_Container { + } + """ - extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } - + func testNestedBool02() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings {} - - extension AppSettings { enum Profile { - @Setting var setting: Bool = true - } } - """ - - let expected = """ - struct AppSettings {} - - extension AppSettings { enum Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() - } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + + extension AppSettings { enum Profile { + @Setting var setting: Bool = true + } } + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings { enum Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } - } + extension AppSettings: __Settings_Container { + } + """ - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedBool12() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings {} - - extension AppSettings { enum Profile { - @Setting var setting: Bool = true - } } - """ - - let expected = """ - struct AppSettings {} - - extension AppSettings { enum Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() - } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} + + extension AppSettings { enum Profile { + @Setting var setting: Bool = true + } } + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } + extension AppSettings { enum Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } - } + extension AppSettings: __Settings_Container { + } + """ - extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } - - func testAllPropertyListTypes() throws { #if canImport(SettingsMacros) - let allCases: [Case] = [ - // Non-optional PropertyList types - Case(type: "Bool", initializer: "true", description: "Bool"), - Case(type: "Int", initializer: "42", description: "Int"), - Case(type: "Double", initializer: "3.14", description: "Double"), - Case(type: "Float", initializer: "2.71", description: "Float"), - Case(type: "String", initializer: "\"default\"", description: "String"), - Case(type: "Date", initializer: "Date()", description: "Date"), - Case(type: "Data", initializer: "Data()", description: "Data"), - Case(type: "Array", initializer: "[]", description: "Array of Any"), - Case(type: "Dictionary", initializer: "[:]", description: "Dictionary of Any"), - - // Optional PropertyList types - Case(optionalType: "Bool", description: "Optional Bool"), - Case(optionalType: "Int", description: "Optional Int"), - Case(optionalType: "Double", description: "Optional Double"), - Case(optionalType: "Float", description: "Optional Float"), - Case(optionalType: "String", description: "Optional String"), - Case(optionalType: "Date", description: "Optional Date"), - Case(optionalType: "Data", description: "Optional Data"), - Case(optionalType: "Array", description: "Optional Array of Any"), - Case(optionalType: "Dictionary", description: "Optional Dictionary of Any"), - - // Collections - Case(type: "[String]", initializer: "[]", description: "[String]"), - Case(type: "Array", initializer: "[]", description: "Array"), - Case(type: "[String: Int]", initializer: "[:]", description: "[String: Int]"), - ] - - for value in allCases { - let testCase = TestCase( - description: value.description, - containerQualifiedName: "AppSettings", - valueType: value.type, - initialValue: value.initialValue - ) - let input = makeSource(for: testCase) - let expected = makeExpectedExpandedSource(for: testCase) - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) - } + let allCases: [Case] = [ + // Non-optional PropertyList types + Case(type: "Bool", initializer: "true", description: "Bool"), + Case(type: "Int", initializer: "42", description: "Int"), + Case( + type: "Double", + initializer: "3.14", + description: "Double" + ), + Case(type: "Float", initializer: "2.71", description: "Float"), + Case( + type: "String", + initializer: "\"default\"", + description: "String" + ), + Case(type: "Date", initializer: "Date()", description: "Date"), + Case(type: "Data", initializer: "Data()", description: "Data"), + Case( + type: "Array", + initializer: "[]", + description: "Array of Any" + ), + Case( + type: "Dictionary", + initializer: "[:]", + description: "Dictionary of Any" + ), + + // Optional PropertyList types + Case(optionalType: "Bool", description: "Optional Bool"), + Case(optionalType: "Int", description: "Optional Int"), + Case(optionalType: "Double", description: "Optional Double"), + Case(optionalType: "Float", description: "Optional Float"), + Case(optionalType: "String", description: "Optional String"), + Case(optionalType: "Date", description: "Optional Date"), + Case(optionalType: "Data", description: "Optional Data"), + Case( + optionalType: "Array", + description: "Optional Array of Any" + ), + Case( + optionalType: "Dictionary", + description: "Optional Dictionary of Any" + ), + + // Collections + Case( + type: "[String]", + initializer: "[]", + description: "[String]" + ), + Case( + type: "Array", + initializer: "[]", + description: "Array" + ), + Case( + type: "[String: Int]", + initializer: "[:]", + description: "[String: Int]" + ), + ] + + for value in allCases { + let testCase = TestCase( + description: value.description, + containerQualifiedName: "AppSettings", + valueType: value.type, + initialValue: value.initialValue + ) + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + } #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedContainer() throws { #if canImport(SettingsMacros) - let testCase = TestCase( - description: "nested property", - containerQualifiedName: "AppSettings.UserSettings", - valueType: "String", - initialValue: "\"default\"" - ) - - let input = makeSource(for: testCase) - let expected = makeExpectedExpandedSource(for: testCase) - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + let testCase = TestCase( + description: "nested property", + containerQualifiedName: "AppSettings.UserSettings", + valueType: "String", + initialValue: "\"default\"" + ) + + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedContainer0() throws { #if canImport(SettingsMacros) - - let input = """ - @Settings struct AppSettings {} - extension AppSettings { enum UserSettings {} } - - extension AppSettings.UserSettings { - @Setting var setting: String = "default" - } - """ - - let expected = """ - struct AppSettings {} - extension AppSettings { enum UserSettings {} - } - - extension AppSettings.UserSettings { - var setting: String { - get { - return __Attribute_AppSettings_UserSettings_setting.read() + + let input = """ + @Settings struct AppSettings {} + extension AppSettings { enum UserSettings {} } + + extension AppSettings.UserSettings { + @Setting var setting: String = "default" } - set { - __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - - public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = String - public static let name = "UserSettings::setting" - public static let defaultValue: String = "default" - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) - } - } - - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings { enum UserSettings {} + } + + extension AppSettings.UserSettings { + var setting: String { + get { + return __Attribute_AppSettings_UserSettings_setting.read() + } + set { + __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = String + public static let name = "UserSettings::setting" + public static let defaultValue: String = "default" + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testNestedContainer2() throws { -#if canImport(SettingsMacros) - let input = """ - @Settings struct AppSettings {} - extension AppSettings { enum UserSettings { - @Setting var setting: String = "default" - } } - """ - - let expected = """ - struct AppSettings {} - extension AppSettings { enum UserSettings { - var setting: String { - get { - return __Attribute_AppSettings_UserSettings_setting.read() - } - set { - __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + extension AppSettings { enum UserSettings { + @Setting var setting: String = "default" + } } + """ + + let expected = """ + struct AppSettings { + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } - } - - public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = String - public static let name = "UserSettings::setting" - public static let defaultValue: String = "default" - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) - } - } - } - - extension AppSettings: __Settings_Container { - public static let prefix: String = "" - public static let suiteName: String? = nil - } - """ - - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + extension AppSettings { enum UserSettings { + var setting: String { + get { + return __Attribute_AppSettings_UserSettings_setting.read() + } + set { + __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = String + public static let name = "UserSettings::setting" + public static let defaultValue: String = "default" + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testStaticProperty() throws { #if canImport(SettingsMacros) - let testCase = TestCase( - description: "static property", - containerQualifiedName: "AppSettings", - valueType: "String", - initialValue: "\"default\"", - isStatic: true - ) - - let input = makeSource(for: testCase) - let expected = makeExpectedExpandedSource(for: testCase) - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + let testCase = TestCase( + description: "static property", + containerQualifiedName: "AppSettings", + valueType: "String", + initialValue: "\"default\"", + isStatic: true + ) + + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } func testCodableWithEncodingStrategy() throws { #if canImport(SettingsMacros) - let testCase = TestCase( - description: "Codable with encoding", - containerQualifiedName: "AppSettingsd", - valueType: "CustomValue", - initialValue: ".init()" - ) + let testCase = TestCase( + description: "Codable with encoding", + containerQualifiedName: "AppSettingsd", + valueType: "CustomValue", + initialValue: ".init()" + ) - let input = makeSource(for: testCase) - let expected = makeExpectedExpandedSource(for: testCase) - assertMacroExpansion(input, expandedSource: expected, macros: testMacros) + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) #else - throw XCTSkip("macros are only supported when running tests for the host platform") + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) #endif } } @@ -724,15 +1231,15 @@ extension SettingMacroExpansionTests { var initialValue: String = "" var macroAttributes: String = "" var isStatic: Bool = false - + var isOptional: Bool { valueType.hasSuffix("?") } - + var protocolName: String { isOptional ? "__AttributeOptional" : "__AttributeNonOptional" } - + var wrappedDecl: String { guard isOptional else { return "" @@ -746,31 +1253,44 @@ extension SettingMacroExpansionTests { private func makeSource( for testCase: TestCase, ) -> String { - let initializer = testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" + let initializer = + testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" let staticModifier = testCase.isStatic ? "static " : "" - - let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl(from: testCase) + + let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl( + from: testCase + ) let template: String = - """ - @Settings struct \(containerBaseName)\(testCase.macroAttributes) {}\ - \(nestedContainerDecl, indentation: 0) - extension \(testCase.containerQualifiedName) { - @Setting\(testCase.macroAttributes) \(staticModifier)var setting: \(testCase.valueType)\(initializer) - } - """ + """ + @Settings struct \(containerBaseName)\(testCase.macroAttributes) {}\ + \(nestedContainerDecl, indentation: 0) + extension \(testCase.containerQualifiedName) { + @Setting\(testCase.macroAttributes) \(staticModifier)var setting: \(testCase.valueType)\(initializer) + } + """ return template } - private func attributeTypeName(containerQualifiedName: String, property: String) -> String { - let sanitizedContainer = containerQualifiedName.replacingOccurrences(of: ".", with: "_") + private func attributeTypeName( + containerQualifiedName: String, + property: String + ) -> String { + let sanitizedContainer = containerQualifiedName.replacingOccurrences( + of: ".", + with: "_" + ) return "__Attribute_\(sanitizedContainer)_\(property)" } - - private func makeNestedContainerDecl(from testCase: TestCase, forSource: Bool = true) -> (decl: String, container: String) { + + private func makeNestedContainerDecl( + from testCase: TestCase, + forSource: Bool = true + ) -> (decl: String, container: String) { // Make nested container declaration: var nestedContainerDecl: String = "" - let components = testCase.containerQualifiedName.split(separator: ".").map(String.init) + let components = testCase.containerQualifiedName.split(separator: ".") + .map(String.init) assert(components.count > 0) assert(components.count <= 2) let containerBaseName = components.first! @@ -782,7 +1302,8 @@ extension SettingMacroExpansionTests { if forSource { nestedContainerDecl = "extension \(root) { enum \(nested) {} }" } else { - nestedContainerDecl = "extension \(root) { enum \(nested) {} \n}\n" // Note: the swift syntax will add a new line character after the enum decl. + nestedContainerDecl = + "extension \(root) { enum \(nested) {} \n}\n" // Note: the swift syntax will add a new line character after the enum decl. } } return (nestedContainerDecl, containerBaseName) @@ -793,81 +1314,163 @@ extension SettingMacroExpansionTests { attributeArguments: String? = nil, nameOverride: String? = nil ) -> String { - let initializer = testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" + let initializer = + testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" let staticModifier = testCase.isStatic ? "static " : "" - - let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl(from: testCase, forSource: false) + + let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl( + from: testCase, + forSource: false + ) // make name decl - let nestedNameComponents = testCase.containerQualifiedName.split(separator: ".").dropFirst(1).map(String.init) + ["setting"] + let nestedNameComponents = + testCase.containerQualifiedName.split(separator: ".").dropFirst(1) + .map(String.init) + ["setting"] let name = nameOverride ?? nestedNameComponents.joined(separator: "::") - + // default decls (only for non-optional) - let defaultValueDecl = initializer.isEmpty ? "" : "public static let defaultValue: \(testCase.valueType)\(initializer)" - let defaultRegistrarDecl = initializer.isEmpty ? "" : "public static let defaultRegistrar = __DefaultRegistrar()" + let defaultValueDecl = + initializer.isEmpty + ? "" + : "public static let defaultValue: \(testCase.valueType)\(initializer)" + let defaultRegistrarDecl = + initializer.isEmpty + ? "" : "public static let defaultRegistrar = __DefaultRegistrar()" // make enum name: - let enumName = "__Attribute_\(testCase.containerQualifiedName.split(separator: ".").joined(separator: "_"))_setting" - + let enumName = + "__Attribute_\(testCase.containerQualifiedName.split(separator: ".").joined(separator: "_"))_setting" + let encodingBlock: String if let attrArgs = attributeArguments, - attrArgs.contains("(encoding: .json)") { + attrArgs.contains("(encoding: .json)") + { encodingBlock = - """ - public static var encoder: some AttributeEncoding { - return JSONEncoder() - } - public static var decoder: some AttributeDecoding { - return JSONDecoder() - } - """ + """ + public static var encoder: some AttributeEncoding { + return JSONEncoder() + } + public static var decoder: some AttributeDecoding { + return JSONDecoder() + } + """ } else { encodingBlock = "" } - + let proxyAccessor = """ - public \(staticModifier)var $setting: __AttributeProxy<\(enumName)> { - return __AttributeProxy(attributeType: \(enumName).self) + public \(staticModifier)var $setting: __AttributeProxy<\(enumName)> { + return __AttributeProxy(attributeType: \(enumName).self) + } + """ + + // Extract prefix from test case macro attributes + let prefixValue: String + if testCase.macroAttributes.contains("prefix:") { + // Extract the prefix value from macro attributes + if let match = testCase.macroAttributes.range( + of: #"prefix: "([^"]*)"#, + options: .regularExpression + ) { + let prefixMatch = testCase.macroAttributes[match] + if let valueStart = prefixMatch.range(of: "\"") { + let afterQuote = prefixMatch[valueStart.upperBound...] + if let endQuote = afterQuote.firstIndex(of: "\"") { + prefixValue = String(afterQuote[.. { return __AttributeProxy(attributeType: __Attribute_AppSettings_username.self) } + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "app_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } extension AppSettings: __Settings_Container { - public static let prefix: String = "app_" - public static let suiteName: String? = nil } """, macros: testMacros @@ -377,7 +717,7 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { public static let defaultValue: String = "guest" public static let defaultRegistrar = __DefaultRegistrar() } - + public var $username: __AttributeProxy<__Attribute_UserPreferences_username> { return __AttributeProxy(attributeType: __Attribute_UserPreferences_username.self) } @@ -397,15 +737,46 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { public static let defaultValue: String = "light" public static let defaultRegistrar = __DefaultRegistrar() } - + public var $theme: __AttributeProxy<__Attribute_UserPreferences_theme> { return __AttributeProxy(attributeType: __Attribute_UserPreferences_theme.self) } + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "settings_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } extension UserPreferences: __Settings_Container { - public static let prefix: String = "settings_" - public static let suiteName: String? = nil } """, macros: testMacros @@ -445,7 +816,7 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { public static let defaultValue: String = "Player1" public static let defaultRegistrar = __DefaultRegistrar() } - + public var $playerName: __AttributeProxy<__Attribute_GameSettings_playerName> { return __AttributeProxy(attributeType: __Attribute_GameSettings_playerName.self) } @@ -465,15 +836,46 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { public static let defaultValue: Int = 0 public static let defaultRegistrar = __DefaultRegistrar() } - + public var $highScore: __AttributeProxy<__Attribute_GameSettings_highScore> { return __AttributeProxy(attributeType: __Attribute_GameSettings_highScore.self) } + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "game_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } extension GameSettings: __Settings_Container { - public static let prefix: String = "game_" - public static let suiteName: String? = "com.example.gamedata" } """, macros: testMacros @@ -499,11 +901,42 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { expandedSource: """ struct ExistingContainer: __Settings_Container { static let someProperty = "value" + + struct Config { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String = "existing_" + } + + private static let _config = OSAllocatedUnfairLock(initialState: Config()) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } + } + set { + _config.withLock { config in + config.store = newValue + } + } + } + + public static var prefix: String { + get { + _config.withLock { config in + config.prefix + } + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") + } + } + } } extension ExistingContainer: __Settings_Container { - public static let prefix: String = "existing_" - public static let suiteName: String? = nil } """, macros: testMacros diff --git a/Tests/SettingsTests/UserDefaultsWithObservationTests.swift b/Tests/SettingsTests/UserDefaultsWithObservationTests.swift index 45d0099..e4d3733 100644 --- a/Tests/SettingsTests/UserDefaultsWithObservationTests.swift +++ b/Tests/SettingsTests/UserDefaultsWithObservationTests.swift @@ -1,18 +1,24 @@ -import Testing -import Observation import Dispatch +import Foundation +import Observation import Settings +import Testing import Utilities +import os @MainActor -final class Observer: Sendable where Observable: Sendable { +final class Observer: Sendable +where Observable: Sendable { let observable: Observable - + init(observable: Observable) { self.observable = observable } - - func observe(_ keyPath: KeyPath, action: @escaping @Sendable (Subject) -> Void ) { + + func observe( + _ keyPath: KeyPath, + action: @escaping @Sendable (Subject) -> Void + ) { nonisolated(unsafe) let capturedKeyPath = keyPath withObservationTracking { action(observable[keyPath: capturedKeyPath]) @@ -25,12 +31,12 @@ final class Observer: Sendable where Observa } } - struct UserDefaultsWithObservationTests { - - @Settings(prefix: "UserDefaultsWithObservationTests_Settings_") struct AppSettings { + + @Settings(prefix: "UserDefaultsWithObservationTests_Settings_") + struct AppSettings { @Setting var hasSeenOnboarding = false - + static func clear() { store.dictionaryRepresentation().keys .filter { $0.hasPrefix(self.prefix) } @@ -51,9 +57,9 @@ struct UserDefaultsWithObservationTests { let viewModel = ViewModel() let observer = Observer(observable: viewModel) - + #expect(viewModel.settings.hasSeenOnboarding == false) - + let expectationInitial = Utilities.Expectation() let expectationSet = Utilities.Expectation() @@ -66,14 +72,14 @@ struct UserDefaultsWithObservationTests { expectationSet.fulfill() } } - + viewModel.settings.hasSeenOnboarding = true - + try await expectationInitial.await() try await expectationSet.await() #expect(viewModel.settings.hasSeenOnboarding == true) viewModel.settings.$hasSeenOnboarding.reset() - + AppSettings.clear() } From 247901ec8082e285157b6fba418c054a4d2120b6 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Wed, 10 Dec 2025 21:57:09 +0100 Subject: [PATCH 2/6] Apply swift-format --- .../Combine/AsyncStreamPublisher.swift | 26 +- Sources/Settings/MacroSetting/Attribute.swift | 20 +- .../MacroSettings/MacroSettings.swift | 16 +- .../MacroSettings/UserDefaultsObserver.swift | 5 +- Sources/Settings/SendableMetatype.swift | 2 +- Sources/Settings/SwiftUI/AppSetting.swift | 512 ++++++++--------- .../UserDefaultsStoreEnvironment.swift | 116 ++-- Sources/SettingsClient/main.swift | 83 +-- Sources/SettingsMacros/MacroError.swift | 18 +- Sources/SettingsMock/UserDefaultsMock.swift | 139 +++-- .../SettingMacroErrorTests.swift | 2 +- .../UserDefaultsStoreMockTests.swift | 28 +- .../AttributeCodingTests.swift | 269 +++++---- .../AttributeCustomCodableTypesTests.swift | 2 +- .../AttributeTests/AttributeTests.swift | 197 ++++--- .../DefaultRegistrarTests.swift | 49 +- .../UserDefaultProxyTest1.swift | 12 +- .../UserDefaultProxyTest2.swift | 12 +- .../UserDefaultProxyTest3.swift | 34 +- .../UserDefaultProxyTest4.swift | 32 +- .../UserDefaultProxyTest5.swift | 36 +- .../UserDefaultProxyTest6.swift | 38 +- .../UserDefaultProxyTest7.swift | 34 +- .../UserDefaultProxyTest8.swift | 38 +- .../UserDefaultProxyTest9.swift | 32 +- Tests/SwiftUITests/AppSettingTests.swift | 514 +++++++++--------- Tests/Utilities/Expectation.swift | 22 +- 27 files changed, 1240 insertions(+), 1048 deletions(-) diff --git a/Sources/Settings/Combine/AsyncStreamPublisher.swift b/Sources/Settings/Combine/AsyncStreamPublisher.swift index b3eba59..bfc74b3 100644 --- a/Sources/Settings/Combine/AsyncStreamPublisher.swift +++ b/Sources/Settings/Combine/AsyncStreamPublisher.swift @@ -12,7 +12,8 @@ import Combine /// (convenient for UI consumers). If you prefer the loop off-main and only /// hop to the main actor for delivery, consider modifying the Task to call /// `await MainActor.run { ... }` for each delivery instead. -struct AsyncStreamPublisher: Publisher where S: AsyncSequence & Sendable, S.Element: Sendable { +struct AsyncStreamPublisher: Publisher +where S: AsyncSequence & Sendable, S.Element: Sendable { typealias Output = S.Element typealias Failure = Swift.Error @@ -22,14 +23,15 @@ struct AsyncStreamPublisher: Publisher where S: AsyncSequence & Sendable, S.E self.sequence = sequence } - func receive(subscriber: Sink) where Sink.Input == Output, Sink.Failure == Failure { + func receive(subscriber: Sink) + where Sink.Input == Output, Sink.Failure == Failure { let erased = AnySubscriber(subscriber) let subscription = Subscription(sequence: sequence, downstream: erased) subscriber.receive(subscription: subscription) } } -extension AsyncStreamPublisher { +extension AsyncStreamPublisher { // Subscription assumptions / environment // - This publisher observes infrequently-changing sources (UserDefaults), so @@ -73,7 +75,9 @@ extension AsyncStreamPublisher { // @Sendable closure does not capture a generic subscriber metatype. private struct DownstreamBox: @unchecked Sendable { let downstream: AnySubscriber - init(_ downstream: AnySubscriber) { self.downstream = downstream } + init(_ downstream: AnySubscriber) { + self.downstream = downstream + } } private var task: Task? @@ -86,22 +90,22 @@ extension AsyncStreamPublisher { do { for try await value in sequence { try Task.checkCancellation() - _ = downstreamBox.downstream.receive(value) // ignore returned demand for now + _ = downstreamBox.downstream.receive(value) // ignore returned demand for now } - + // natural completion -> send finished downstreamBox.downstream.receive(completion: .finished) - } - catch is CancellationError { + } catch is CancellationError { // we should reach here only, when the cancel function // has been called – which cancels the task. /* nothing */ - } - catch { + } catch { // We reach here, when the stream has been forcibly terminated // or the `do` above threw another error. So, `downstream` // should be intact. - downstreamBox.downstream.receive(completion: .failure(error)) + downstreamBox.downstream.receive( + completion: .failure(error) + ) } } } diff --git a/Sources/Settings/MacroSetting/Attribute.swift b/Sources/Settings/MacroSetting/Attribute.swift index 1ffdb71..7685b6f 100644 --- a/Sources/Settings/MacroSetting/Attribute.swift +++ b/Sources/Settings/MacroSetting/Attribute.swift @@ -4,23 +4,24 @@ import Foundation public protocol __Attribute: SendableMetatype { associatedtype Container: __Settings_Container associatedtype Value - + static var name: String { get } - + static func read() -> Value static func write(value: Value) - + static func reset() - + static var defaultValue: Value { get } static func registerDefault() - + static var stream: AsyncThrowingStream { get } - + static func stream( for keyPath: KeyPath - ) -> AsyncThrowingStream where Subject: Equatable, Subject: Sendable + ) -> AsyncThrowingStream + where Subject: Equatable, Subject: Sendable } extension __Attribute { @@ -31,12 +32,11 @@ extension __Attribute { public static func reset() { Container.removeObject(forKey: key) } - + } public protocol PropertyListValue {} - // MARK: - Internal extension Bool: PropertyListValue {} @@ -47,7 +47,6 @@ extension String: PropertyListValue {} extension Date: PropertyListValue {} extension Data: PropertyListValue {} - // Arrays are PropertyListValue only when their elements are PropertyListValue // This prevents Array from being treated as PropertyListValue extension Array: PropertyListValue where Element: PropertyListValue {} @@ -58,4 +57,3 @@ where Key == String, Value: PropertyListValue {} typealias PropertyListArray = [Any] typealias PropertyListDictionary = [String: Any] - diff --git a/Sources/Settings/MacroSettings/MacroSettings.swift b/Sources/Settings/MacroSettings/MacroSettings.swift index 0e5c25a..2429c54 100644 --- a/Sources/Settings/MacroSettings/MacroSettings.swift +++ b/Sources/Settings/MacroSettings/MacroSettings.swift @@ -52,7 +52,7 @@ /// @Settings struct Settings { /// @Setting var hasSeenOnboarding = false /// } -/// +/// /// var settings = Settings() /// } /// @@ -60,7 +60,10 @@ /// ``` @attached( member, - names: named(Config), named(_config), named(store), named(prefix) + names: named(Config), + named(_config), + named(store), + named(prefix) ) @attached( extension, @@ -69,7 +72,8 @@ public macro Settings( prefix: String? = nil, suiteName: String? = nil -) = #externalMacro( - module: "SettingsMacros", - type: "SettingsMacro" -) +) = + #externalMacro( + module: "SettingsMacros", + type: "SettingsMacro" + ) diff --git a/Sources/Settings/MacroSettings/UserDefaultsObserver.swift b/Sources/Settings/MacroSettings/UserDefaultsObserver.swift index 7173770..61878f9 100644 --- a/Sources/Settings/MacroSettings/UserDefaultsObserver.swift +++ b/Sources/Settings/MacroSettings/UserDefaultsObserver.swift @@ -44,7 +44,10 @@ final class UserDefaultsObserver: NSObject, Cancellable, @unchecked Sendable { change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { - assert(keyPath == key, "KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)") + assert( + keyPath == key, + "KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)" + ) callback(keyPath, change?[.oldKey], change?[.newKey]) } diff --git a/Sources/Settings/SendableMetatype.swift b/Sources/Settings/SendableMetatype.swift index 447ce95..58b742e 100644 --- a/Sources/Settings/SendableMetatype.swift +++ b/Sources/Settings/SendableMetatype.swift @@ -1,3 +1,3 @@ #if compiler(<6.2) -public typealias SendableMetatype = Any + public typealias SendableMetatype = Any #endif diff --git a/Sources/Settings/SwiftUI/AppSetting.swift b/Sources/Settings/SwiftUI/AppSetting.swift index b65d696..1c04451 100644 --- a/Sources/Settings/SwiftUI/AppSetting.swift +++ b/Sources/Settings/SwiftUI/AppSetting.swift @@ -1,296 +1,298 @@ #if canImport(SwiftUI) -import SwiftUI -import Combine -import os + import SwiftUI + import Combine + import os -/// A default container for UserDefaults that uses a configurable store. -/// -/// This container provides a convenient way to define app-wide settings without -/// needing to create a custom container. Extend this type with `@Setting` -/// properties to make them available throughout your app: -/// -/// ```swift -/// extension AppSettingValues { -/// @Setting var username: String = "guest" -/// @Setting var theme: String = "light" -/// @Setting var notificationsEnabled: Bool = true -/// } -/// ``` -/// -/// These settings can then be used in SwiftUI views with keypath syntax: -/// -/// ```swift -/// struct SettingsView: View { -/// @AppSetting(\.username) var username -/// @AppSetting(\.theme) var theme -/// -/// var body: some View { -/// Form { -/// TextField("Username", text: $username) -/// Picker("Theme", selection: $theme) { -/// Text("Light").tag("light") -/// Text("Dark").tag("dark") -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Store Configuration -/// -/// By default, `AppSettingValues` uses `UserDefaults.standard`. You can configure -/// a different store at runtime: -/// -/// ```swift -/// // Use a mock store for testing -/// AppSettingValues.configureStore(UserDefaultsStoreMock()) -/// -/// // Reset to standard -/// AppSettingValues.resetStore() -/// ``` -public struct AppSettingValues: __Settings_Container { - struct Config { - var store: any UserDefaultsStore = UserDefaults.standard - var prefix: String? = nil - } + /// A default container for UserDefaults that uses a configurable store. + /// + /// This container provides a convenient way to define app-wide settings without + /// needing to create a custom container. Extend this type with `@Setting` + /// properties to make them available throughout your app: + /// + /// ```swift + /// extension AppSettingValues { + /// @Setting var username: String = "guest" + /// @Setting var theme: String = "light" + /// @Setting var notificationsEnabled: Bool = true + /// } + /// ``` + /// + /// These settings can then be used in SwiftUI views with keypath syntax: + /// + /// ```swift + /// struct SettingsView: View { + /// @AppSetting(\.username) var username + /// @AppSetting(\.theme) var theme + /// + /// var body: some View { + /// Form { + /// TextField("Username", text: $username) + /// Picker("Theme", selection: $theme) { + /// Text("Light").tag("light") + /// Text("Dark").tag("dark") + /// } + /// } + /// } + /// } + /// ``` + /// + /// ## Store Configuration + /// + /// By default, `AppSettingValues` uses `UserDefaults.standard`. You can configure + /// a different store at runtime: + /// + /// ```swift + /// // Use a mock store for testing + /// AppSettingValues.configureStore(UserDefaultsStoreMock()) + /// + /// // Reset to standard + /// AppSettingValues.resetStore() + /// ``` + public struct AppSettingValues: __Settings_Container { + struct Config { + var store: any UserDefaultsStore = UserDefaults.standard + var prefix: String? = nil + } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store + private static let _config = OSAllocatedUnfairLock( + initialState: Config() + ) + + public internal(set) static var store: any UserDefaultsStore { + get { + _config.withLock { config in + config.store + } } - } - set { - _config.withLock { config in - config.store = newValue + set { + _config.withLock { config in + config.store = newValue + } } } - } - public static var prefix: String { - get { - _config.withLock { config in - if let prefix = config.prefix { - return prefix + public static var prefix: String { + get { + _config.withLock { config in + if let prefix = config.prefix { + return prefix + } + guard let identifier = Bundle.main.bundleIdentifier else { + return "app_" + } + return identifier.replacing(".", with: "_") + "_" } - guard let identifier = Bundle.main.bundleIdentifier else { - return "app_" + } + set { + _config.withLock { config in + config.prefix = newValue.replacing(".", with: "_") } - return identifier.replacing(".", with: "_") + "_" - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") } } } -} - -/// A SwiftUI property wrapper that provides a binding to a single UserDefault value. -/// -/// Use this property wrapper in SwiftUI views to create bindings to individual -/// UserDefault properties, with automatic view updates when the value changes: -/// -/// ```swift -/// @Settings(prefix: "com_example_app_") -/// struct AppSettings { -/// @Setting static var username: String = "guest" -/// @Setting static var isDarkMode: Bool = false -/// } -/// -/// struct SettingsView: View { -/// @AppSetting(AppSettings.$username) var username -/// @AppSetting(AppSettings.$isDarkMode) var darkMode -/// -/// var body: some View { -/// Form { -/// TextField("Username", text: $username) -/// Toggle("Dark Mode", isOn: $darkMode) -/// } -/// } -/// } -/// ``` -/// -/// Additionally, the Settings library provides `AppSettingValues`, a default container that simplifies -/// setting up UserDefaults-backed settings. You can declare settings in an extension and use them via -/// key paths: -/// -/// ```swift -/// extension AppSettingValues { -/// @Setting var username: String = "guest" -/// @Setting var isDarkMode: Bool = false -/// } -/// -/// struct SettingsView: View { -/// @AppSetting(\.$username) var username -/// @AppSetting(\.$isDarkMode) var isDarkMode -/// -/// var body: some View { -/// Form { -/// TextField("Username", text: $username) -/// Toggle("Dark Mode", isOn: $isDarkMode) -/// } -/// } -/// } -/// ``` -/// -/// ## Store Configuration -/// -/// By default, `AppSetting` uses `UserDefaults.standard`. You can configure -/// a different store at runtime using SwiftUI environment value `userDefaultsStore`: -/// -/// ```swift -/// @main -/// struct ViewAppApp: App { -/// var body: some Scene { -/// WindowGroup { -/// MainView() -/// .environment( -/// \.userDefaultsStore, -/// UserDefaultsStoreMock() -/// ) -/// } -/// } -/// } -/// ``` -/// -/// See the documentation on `AppSettingValues` for additional details. -/// -/// The property wrapper observes the specific UserDefaults key and triggers -/// view updates when the value changes, whether from the current view or elsewhere -/// in the app. -@MainActor -@propertyWrapper -public struct AppSetting: @MainActor DynamicProperty -where Attribute.Value: Sendable { - - @State private var value: Attribute.Value - @State private var observer: Observer = Observer() - @Environment(\.userDefaultsStore) private var environmentStore - /// The current UserDefaults value. - public var wrappedValue: Attribute.Value { - get { - value - } - nonmutating set { - Attribute.write(value: newValue) - } - } - - /// Provides a binding to the UserDefaults value. - public var projectedValue: Binding { - Binding( - get: { - wrappedValue - }, - set: { value in - wrappedValue = value - } - ) - } - - /// Creates a new AppSetting property wrapper for the specified attribute. - public init(_ attribute: Attribute.Type) { - self._value = .init(initialValue: Attribute.defaultValue) - } - - /// Creates a new AppSetting property wrapper from a UserDefaults projected value. + /// A SwiftUI property wrapper that provides a binding to a single UserDefault value. + /// + /// Use this property wrapper in SwiftUI views to create bindings to individual + /// UserDefault properties, with automatic view updates when the value changes: /// - /// This allows cleaner syntax for custom containers: /// ```swift - /// @MyAppSetting(MyAppSettingValues.$username) var username + /// @Settings(prefix: "com_example_app_") + /// struct AppSettings { + /// @Setting static var username: String = "guest" + /// @Setting static var isDarkMode: Bool = false + /// } + /// + /// struct SettingsView: View { + /// @AppSetting(AppSettings.$username) var username + /// @AppSetting(AppSettings.$isDarkMode) var darkMode + /// + /// var body: some View { + /// Form { + /// TextField("Username", text: $username) + /// Toggle("Dark Mode", isOn: $darkMode) + /// } + /// } + /// } /// ``` - public init(_ proxy: __AttributeProxy) { - self._value = .init(initialValue: Attribute.defaultValue) - } - - /// Creates a new AppSetting property wrapper using a key path to an - /// `AppSettingValues` property. /// - /// This provides the cleanest syntax for the default container: + /// Additionally, the Settings library provides `AppSettingValues`, a default container that simplifies + /// setting up UserDefaults-backed settings. You can declare settings in an extension and use them via + /// key paths: + /// /// ```swift /// extension AppSettingValues { /// @Setting var username: String = "guest" + /// @Setting var isDarkMode: Bool = false /// } /// - /// struct MyView: View { + /// struct SettingsView: View { /// @AppSetting(\.$username) var username + /// @AppSetting(\.$isDarkMode) var isDarkMode + /// + /// var body: some View { + /// Form { + /// TextField("Username", text: $username) + /// Toggle("Dark Mode", isOn: $isDarkMode) + /// } + /// } /// } /// ``` /// - /// - Parameter keyPath: A key path to a property on the default `AppSettingValues` container. - public init( - _ keyPath: KeyPath> - ) where Attribute.Container == AppSettingValues { - self._value = .init(initialValue: Attribute.defaultValue) - } - - /// Creates a new AppSetting property wrapper using a key path to a property - /// on a custom settings container. + /// ## Store Configuration /// - /// Use this to reference settings defined on a non-default container: - /// ```swift - /// extension MyAppSettingValues { - /// @Setting var username: String = "guest" - /// } + /// By default, `AppSetting` uses `UserDefaults.standard`. You can configure + /// a different store at runtime using SwiftUI environment value `userDefaultsStore`: /// - /// struct MyView: View { - /// @AppSetting(\AppSettingValues.$username) var username + /// ```swift + /// @main + /// struct ViewAppApp: App { + /// var body: some Scene { + /// WindowGroup { + /// MainView() + /// .environment( + /// \.userDefaultsStore, + /// UserDefaultsStoreMock() + /// ) + /// } + /// } /// } /// ``` /// - /// - Parameter keyPath: A key path to a property on the custom `Attribute.Container`. - public init( - _ keyPath: KeyPath> - ) { - self._value = .init(initialValue: Attribute.defaultValue) - } + /// See the documentation on `AppSettingValues` for additional details. + /// + /// The property wrapper observes the specific UserDefaults key and triggers + /// view updates when the value changes, whether from the current view or elsewhere + /// in the app. + @MainActor + @propertyWrapper + public struct AppSetting: @MainActor DynamicProperty + where Attribute.Value: Sendable { - /// Called by SwiftUI to set up the publisher subscription. - public mutating func update() { - if !(AppSettingValues.store === environmentStore) { - AppSettingValues.store = environmentStore + @State private var value: Attribute.Value + @State private var observer: Observer = Observer() + @Environment(\.userDefaultsStore) private var environmentStore + + /// The current UserDefaults value. + public var wrappedValue: Attribute.Value { + get { + value + } + nonmutating set { + Attribute.write(value: newValue) + } } - // print("*** SwiftUI update: \(AppSettingValues.store.dictionaryRepresentation())") - observer.observe(binding: $value) - } -} -extension AppSetting { - - @MainActor - final class Observer { - var cancellable: AnyCancellable? = nil - weak var usersDefaultsStore: (any UserDefaultsStore)? + /// Provides a binding to the UserDefaults value. + public var projectedValue: Binding { + Binding( + get: { + wrappedValue + }, + set: { value in + wrappedValue = value + } + ) + } + + /// Creates a new AppSetting property wrapper for the specified attribute. + public init(_ attribute: Attribute.Type) { + self._value = .init(initialValue: Attribute.defaultValue) + } + + /// Creates a new AppSetting property wrapper from a UserDefaults projected value. + /// + /// This allows cleaner syntax for custom containers: + /// ```swift + /// @MyAppSetting(MyAppSettingValues.$username) var username + /// ``` + public init(_ proxy: __AttributeProxy) { + self._value = .init(initialValue: Attribute.defaultValue) + } - init() {} - - func observe(binding: Binding) { - // When the store of the attribute has changed, we need - // to cancel the subscription and create a new one: - if !(usersDefaultsStore === Attribute.Container.store) { - cancel() + /// Creates a new AppSetting property wrapper using a key path to an + /// `AppSettingValues` property. + /// + /// This provides the cleanest syntax for the default container: + /// ```swift + /// extension AppSettingValues { + /// @Setting var username: String = "guest" + /// } + /// + /// struct MyView: View { + /// @AppSetting(\.$username) var username + /// } + /// ``` + /// + /// - Parameter keyPath: A key path to a property on the default `AppSettingValues` container. + public init( + _ keyPath: KeyPath> + ) where Attribute.Container == AppSettingValues { + self._value = .init(initialValue: Attribute.defaultValue) + } + + /// Creates a new AppSetting property wrapper using a key path to a property + /// on a custom settings container. + /// + /// Use this to reference settings defined on a non-default container: + /// ```swift + /// extension MyAppSettingValues { + /// @Setting var username: String = "guest" + /// } + /// + /// struct MyView: View { + /// @AppSetting(\AppSettingValues.$username) var username + /// } + /// ``` + /// + /// - Parameter keyPath: A key path to a property on the custom `Attribute.Container`. + public init( + _ keyPath: KeyPath> + ) { + self._value = .init(initialValue: Attribute.defaultValue) + } + + /// Called by SwiftUI to set up the publisher subscription. + public mutating func update() { + if !(AppSettingValues.store === environmentStore) { + AppSettingValues.store = environmentStore } - if cancellable == nil { - usersDefaultsStore = Attribute.Container.store - cancellable = Attribute.publisher - .catch { _ in Just(Attribute.read()) } - .receive(on: DispatchQueue.main) - .sink { newValue in - print("Observer: \(Attribute.self) = \(newValue)") - binding.wrappedValue = newValue + // print("*** SwiftUI update: \(AppSettingValues.store.dictionaryRepresentation())") + observer.observe(binding: $value) + } + } + + extension AppSetting { + + @MainActor + final class Observer { + var cancellable: AnyCancellable? = nil + weak var usersDefaultsStore: (any UserDefaultsStore)? + + init() {} + + func observe(binding: Binding) { + // When the store of the attribute has changed, we need + // to cancel the subscription and create a new one: + if !(usersDefaultsStore === Attribute.Container.store) { + cancel() + } + if cancellable == nil { + usersDefaultsStore = Attribute.Container.store + cancellable = Attribute.publisher + .catch { _ in Just(Attribute.read()) } + .receive(on: DispatchQueue.main) + .sink { newValue in + print("Observer: \(Attribute.self) = \(newValue)") + binding.wrappedValue = newValue + } } } + + func cancel() { + cancellable = nil + } } - - func cancel() { - cancellable = nil - } + } - -} #endif diff --git a/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift b/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift index d6d381b..8e8cfee 100644 --- a/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift +++ b/Sources/Settings/SwiftUI/UserDefaultsStoreEnvironment.swift @@ -1,71 +1,71 @@ #if canImport(SwiftUI) -import SwiftUI -import Foundation + import SwiftUI + import Foundation -/// Environment key for providing a `UserDefaultsStore` to SwiftUI views. -/// -/// This allows you to inject a custom store (like `UserDefaultsStoreMock`) for testing -/// and previews, while using the real `UserDefaults.standard` in production. -struct UserDefaultsStoreKey: EnvironmentKey { - static var defaultValue: any UserDefaultsStore { UserDefaults.standard } -} - -extension EnvironmentValues { - /// The `UserDefaultsStore` used by `@AppSetting` property wrappers in the current view hierarchy. - /// - /// By default, this is `UserDefaults.standard`. You can override it for testing or previews: + /// Environment key for providing a `UserDefaultsStore` to SwiftUI views. /// - /// ```swift - /// struct ContentView_Previews: PreviewProvider { - /// static var previews: some View { - /// ContentView() - /// .userDefaultsStore(UserDefaultsStoreMock()) - /// } - /// } - /// ``` - public var userDefaultsStore: any UserDefaultsStore { - get { - self[__Key_userDefaultsStore.self] - } - set { - self[__Key_userDefaultsStore.self] = newValue - } + /// This allows you to inject a custom store (like `UserDefaultsStoreMock`) for testing + /// and previews, while using the real `UserDefaults.standard` in production. + struct UserDefaultsStoreKey: EnvironmentKey { + static var defaultValue: any UserDefaultsStore { UserDefaults.standard } } - - private struct __Key_userDefaultsStore: SwiftUICore.EnvironmentKey { - - static var defaultValue: any UserDefaultsStore { + + extension EnvironmentValues { + /// The `UserDefaultsStore` used by `@AppSetting` property wrappers in the current view hierarchy. + /// + /// By default, this is `UserDefaults.standard`. You can override it for testing or previews: + /// + /// ```swift + /// struct ContentView_Previews: PreviewProvider { + /// static var previews: some View { + /// ContentView() + /// .userDefaultsStore(UserDefaultsStoreMock()) + /// } + /// } + /// ``` + public var userDefaultsStore: any UserDefaultsStore { get { + self[__Key_userDefaultsStore.self] + } + set { + self[__Key_userDefaultsStore.self] = newValue + } + } + + private struct __Key_userDefaultsStore: SwiftUICore.EnvironmentKey { + + static var defaultValue: any UserDefaultsStore { UserDefaults.standard } } + } - -} -extension View { - /// Sets the `UserDefaultsStore` for this view and its children. - /// - /// Use this modifier to provide a mock store for testing or previews: - /// - /// ```swift - /// struct SettingsView_Previews: PreviewProvider { - /// static var previews: some View { - /// let mockStore = UserDefaultsStoreMock() - /// mockStore.set("TestUser", forKey: "username") - /// mockStore.set(true, forKey: "darkMode") - /// - /// return SettingsView() - /// .userDefaultsStore(mockStore) - /// } - /// } - /// ``` - /// - /// - Parameter store: The `UserDefaultsStore` to use for `@AppSetting` properties. - /// - Returns: A view that uses the specified store. - public func userDefaultsStore(_ store: some UserDefaultsStore) -> some View { - environment(\.userDefaultsStore, store) + extension View { + /// Sets the `UserDefaultsStore` for this view and its children. + /// + /// Use this modifier to provide a mock store for testing or previews: + /// + /// ```swift + /// struct SettingsView_Previews: PreviewProvider { + /// static var previews: some View { + /// let mockStore = UserDefaultsStoreMock() + /// mockStore.set("TestUser", forKey: "username") + /// mockStore.set(true, forKey: "darkMode") + /// + /// return SettingsView() + /// .userDefaultsStore(mockStore) + /// } + /// } + /// ``` + /// + /// - Parameter store: The `UserDefaultsStore` to use for `@AppSetting` properties. + /// - Returns: A view that uses the specified store. + public func userDefaultsStore(_ store: some UserDefaultsStore) + -> some View + { + environment(\.userDefaultsStore, store) + } } -} #endif diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index 7c85d7f..7457302 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -1,8 +1,10 @@ -import Settings -import SettingsMock +import Combine import Foundation import Observation -import Combine +import Settings +import SettingsMock +// MARK: - App +import SwiftUI import os // protocol ConstString { @@ -55,49 +57,54 @@ import os @Setting var setting: String = "default" } - @Settings struct AppSettings {} -extension AppSettings { enum UserSettings { - @Setting var setting: String = "default" - @Setting var theme: String? -} } +extension AppSettings { + enum UserSettings { + @Setting var setting: String = "default" + @Setting var theme: String? + } +} @Settings(prefix: "app_") struct AppSettings1 {} -extension AppSettings1 { enum UserSettings { - @Setting var setting: String = "default" - @Setting var theme: String? -} } - +extension AppSettings1 { + enum UserSettings { + @Setting var setting: String = "default" + @Setting var theme: String? + } +} @Settings(prefix: "com_example_MyApp_") struct AppSettings2 { @Setting static var username: String = "Guest" // Default value "Guest" - @Setting(name: "colorScheme") static var theme: String = "light" // key = "colorScheme" + @Setting(name: "colorScheme") static var theme: String = "light" // key = "colorScheme" @Setting static var apiKey: String? // Optionals don't have default values @Setting(name: "value") var primitive: Int = 42 } -extension AppSettings2 { enum Profiles { - struct UserProfile: Equatable, Codable, Identifiable { - var id: UUID - var user: String - var image: URL? - } +extension AppSettings2 { + enum Profiles { + struct UserProfile: Equatable, Codable, Identifiable { + var id: UUID + var user: String + var image: URL? + } - @Setting(encoding: .json) - static var userProfile: UserProfile? // - - @Setting var setting = "abc" -}} + @Setting(encoding: .json) + static var userProfile: UserProfile? // + + @Setting var setting = "abc" + } +} final class UserProfileObserver { - var subscriptions: Array = [] - + var subscriptions: [AnyCancellable] = [] + init() { AppSettings2.Profiles.$userProfile.publisher.sink( receiveCompletion: { error in print(error) - }, receiveValue: { value in + }, + receiveValue: { value in switch value { case .none: print("No user profile") @@ -109,35 +116,31 @@ final class UserProfileObserver { } } - func main1() async throws { // let userProfileObserver = UserProfileObserver() - - AppSettings2.Profiles.userProfile = .init(id: UUID(), user: "John Appleseed", image: nil) - print(AppSettings2.theme) // "light" + + AppSettings2.Profiles.userProfile = .init( + id: UUID(), + user: "John Appleseed", + image: nil + ) + print(AppSettings2.theme) // "light" print(AppSettings2.Profiles.$userProfile.key) try await Task.sleep(for: .milliseconds(1000)) } - -import Observation - @Observable final class ViewModel { @Settings struct Settings { @Setting var hasSeenOnboarding = false } - + var settings = Settings() } - try await main1() -// MARK: - App -import SwiftUI - extension AppSettingValues { @Setting var user: String? @Setting var theme: String = "default" diff --git a/Sources/SettingsMacros/MacroError.swift b/Sources/SettingsMacros/MacroError.swift index ba6dfe3..f00e13c 100644 --- a/Sources/SettingsMacros/MacroError.swift +++ b/Sources/SettingsMacros/MacroError.swift @@ -19,25 +19,31 @@ enum MacroError: Error, CustomStringConvertible { case .invalidDeclaration: return "@Setting can only be applied to variable declarations" case .noContainerFound: - return "@Setting must be declared within a type conforming to __Settings_Container" + return + "@Setting must be declared within a type conforming to __Settings_Container" case .cannotDetermineType: - return "Cannot determine the type of the property. Please provide a type annotation." + return + "Cannot determine the type of the property. Please provide a type annotation." case .missingInitialValue: return "Non-optional @Setting properties require an initial value" case .invalidInitialValue: - return "Optional @Setting properties should not have an initial value" + return + "Optional @Setting properties should not have an initial value" case .duplicateKeyName(let keyName): - return "Duplicate name name '\(keyName)' detected. Each name name should be unique within a store." + return + "Duplicate name name '\(keyName)' detected. Each name name should be unique within a store." case .invalidPrefix(let message): return message case .invalidEnclosingContainerDecl(let decl): - return "A @Setting declaration cannot reside inside a \(decl) declaration." + return + "A @Setting declaration cannot reside inside a \(decl) declaration." case .invalidEnclosingContainer(let what): return "A @Setting declaration cannot reside inside a \(what)." case .invalidContainerType: - return "@Settings can only be applied to struct, class, enum, or actor declarations" + return + "@Settings can only be applied to struct, class, enum, or actor declarations" } } } diff --git a/Sources/SettingsMock/UserDefaultsMock.swift b/Sources/SettingsMock/UserDefaultsMock.swift index 2a82776..c80c6fa 100644 --- a/Sources/SettingsMock/UserDefaultsMock.swift +++ b/Sources/SettingsMock/UserDefaultsMock.swift @@ -1,11 +1,11 @@ import Foundation -import os import Settings +import os -public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable { - +public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable +{ public static let standard: UserDefaultsStoreMock = .init(store: [:]) - + struct State: @unchecked Sendable { init(store: sending [String: Any] = [:]) { self.values = store @@ -14,58 +14,77 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable var values: [String: Any] var defaults: [String: Any] } - + public init(store: [String: Any]) { var sanitized: [String: Any] = [:] for (k, v) in store { if PropertyListSerialization.propertyList(v, isValidFor: .binary), - let data = try? PropertyListSerialization.data(fromPropertyList: v, format: .binary, options: 0), - let copy = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { + let data = try? PropertyListSerialization.data( + fromPropertyList: v, + format: .binary, + options: 0 + ), + let copy = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) + { sanitized[k] = copy } } state = OSAllocatedUnfairLock(initialState: State(store: sanitized)) super.init() } - + override public init() { state = OSAllocatedUnfairLock(initialState: State()) super.init() } - + let state: OSAllocatedUnfairLock - - + public func reset() { state.withLock { state in state.values.removeAll() } } - + public func clear() { state.withLock { state in state.values.removeAll() state.defaults.removeAll() } } - + public func unregisterDefaults() { state.withLock { state in state.defaults.removeAll() } } - + private static func plistDeepCopy(_ value: Any) -> Any? { - guard PropertyListSerialization.propertyList(value, isValidFor: .binary) else { return nil } - guard let data = try? PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0) else { return nil } - return try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard PropertyListSerialization.propertyList(value, isValidFor: .binary) + else { return nil } + guard + let data = try? PropertyListSerialization.data( + fromPropertyList: value, + format: .binary, + options: 0 + ) + else { return nil } + return try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) } - + private static func isEqualPlist(_ lhs: Any?, _ rhs: Any?) -> Bool { switch (lhs, rhs) { case (nil, nil): return true - case let (l?, r?): + case (let l?, let r?): if let lo = l as? NSObject, let ro = r as? NSObject { return lo.isEqual(ro) } @@ -74,13 +93,13 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable return false } } - + private func readEffectiveValue(forKey key: String) -> Any? { state.withLockUnchecked { state in state.values[key] ?? state.defaults[key] } } - + private func number(from any: Any) -> NSNumber? { if let n = any as? NSNumber { return n } if let i = any as? Int { return NSNumber(value: i) } @@ -89,11 +108,11 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable if let b = any as? Bool { return NSNumber(value: b) } return nil } - + public func object(forKey key: String) -> Any? { readEffectiveValue(forKey: key) } - + public func set(_ value: Any?, forKey key: String) { if let value { // Only accept property-list-serializable values; mimic UserDefaults by ignoring unsupported values. @@ -126,70 +145,80 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable if shouldNotify { didChangeValue(forKey: key) } } } - + public func removeObject(forKey key: String) { set(nil, forKey: key) } - + public func string(forKey key: String) -> String? { readEffectiveValue(forKey: key) as? String } - + public func array(forKey key: String) -> [Any]? { readEffectiveValue(forKey: key) as? [Any] } - - public func dictionary(forKey key: String) -> [String : Any]? { - readEffectiveValue(forKey: key) as? [String : Any] + + public func dictionary(forKey key: String) -> [String: Any]? { + readEffectiveValue(forKey: key) as? [String: Any] } - + public func data(forKey key: String) -> Data? { readEffectiveValue(forKey: key) as? Data } - + public func stringArray(forKey key: String) -> [String]? { readEffectiveValue(forKey: key) as? [String] } - + public func integer(forKey key: String) -> Int { guard let v = readEffectiveValue(forKey: key) else { return 0 } if let i = v as? Int { return i } if let n = number(from: v) { return n.intValue } return 0 } - + public func float(forKey key: String) -> Float { guard let v = readEffectiveValue(forKey: key) else { return 0 } if let f = v as? Float { return f } if let n = number(from: v) { return n.floatValue } return 0 } - + public func double(forKey key: String) -> Double { guard let v = readEffectiveValue(forKey: key) else { return 0 } if let d = v as? Double { return d } if let n = number(from: v) { return n.doubleValue } return 0 } - + public func bool(forKey key: String) -> Bool { guard let v = readEffectiveValue(forKey: key) else { return false } if let b = v as? Bool { return b } if let n = number(from: v) { return n.boolValue } return false } - + public func url(forKey key: String) -> URL? { readEffectiveValue(forKey: key) as? URL } - - public func set(_ value: Int, forKey key: String) { set(value as Any, forKey: key) } - public func set(_ value: Float, forKey key: String) { set(value as Any, forKey: key) } - public func set(_ value: Double, forKey key: String) { set(value as Any, forKey: key) } - public func set(_ value: Bool, forKey key: String) { set(value as Any, forKey: key) } - public func set(_ url: URL?, forKey key: String) { set(url as Any?, forKey: key) } - - public func register(defaults newDefaults: [String : Any]) { + + public func set(_ value: Int, forKey key: String) { + set(value as Any, forKey: key) + } + public func set(_ value: Float, forKey key: String) { + set(value as Any, forKey: key) + } + public func set(_ value: Double, forKey key: String) { + set(value as Any, forKey: key) + } + public func set(_ value: Bool, forKey key: String) { + set(value as Any, forKey: key) + } + public func set(_ url: URL?, forKey key: String) { + set(url as Any?, forKey: key) + } + + public func register(defaults newDefaults: [String: Any]) { print(Self.self, "register(defaults(\(newDefaults)") for (key, newDefault) in newDefaults { // Only accept property-list-serializable defaults @@ -204,7 +233,8 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable newEffective = hasUserValue ? state.values[key] : copy } - let shouldNotify = !hasUserValue && !Self.isEqualPlist(oldEffective, newEffective) + let shouldNotify = + !hasUserValue && !Self.isEqualPlist(oldEffective, newEffective) if shouldNotify { willChangeValue(forKey: key) } state.withLockUnchecked { state in state.defaults[key] = copy @@ -212,16 +242,16 @@ public final class UserDefaultsStoreMock: NSObject, UserDefaultsStore, Sendable if shouldNotify { didChangeValue(forKey: key) } } } - + public override func value(forKey key: String) -> Any? { object(forKey: key) } - + public override func setValue(_ value: Any?, forKey key: String) { set(value, forKey: key) } - - public func dictionaryRepresentation() -> [String : Any] { + + public func dictionaryRepresentation() -> [String: Any] { state.withLockUnchecked { state in var values = state.values values.merge(state.defaults, uniquingKeysWith: { lhs, rhs in lhs }) @@ -248,8 +278,9 @@ extension UserDefaultsStoreMock { } - -final class UserDefaultsStoreMockObserver: NSObject, Cancellable, @unchecked Sendable { +final class UserDefaultsStoreMockObserver: NSObject, Cancellable, + @unchecked Sendable +{ typealias StoreChangeCallback = @Sendable (_ keyPath: String?, _ oldValue: Any?, _ newValue: Any?) -> @@ -292,7 +323,10 @@ final class UserDefaultsStoreMockObserver: NSObject, Cancellable, @unchecked Sen change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { - assert(keyPath == key, "KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)") + assert( + keyPath == key, + "KVO notification received for unexpected keyPath: \(keyPath ?? "nil"), expected: \(key)" + ) callback(keyPath, change?[.oldKey], change?[.newKey]) } @@ -303,4 +337,3 @@ final class UserDefaultsStoreMockObserver: NSObject, Cancellable, @unchecked Sen } } } - diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroErrorTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroErrorTests.swift index c1d5fd7..552522c 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroErrorTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroErrorTests.swift @@ -1,7 +1,7 @@ +import SettingsMacros import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import Testing -import SettingsMacros @Suite("UserDefault Macro Error Tests") struct SettingMacroErrorTests { diff --git a/Tests/SettingsMockTests/UserDefaultsStoreMockTests.swift b/Tests/SettingsMockTests/UserDefaultsStoreMockTests.swift index 1827cd1..9e9a83a 100644 --- a/Tests/SettingsMockTests/UserDefaultsStoreMockTests.swift +++ b/Tests/SettingsMockTests/UserDefaultsStoreMockTests.swift @@ -1,16 +1,18 @@ import Foundation -import Testing import Settings import SettingsMock +import Testing struct UserDefaultsStoreMockTests { - + protocol ConstString { static var value: String { get } } struct TestContainer: __Settings_Container { - static var store: any UserDefaultsStore { UserDefaultsStoreMock.standard } + static var store: any UserDefaultsStore { + UserDefaultsStoreMock.standard + } static var prefix: String { Prefix.value } static func clear() { @@ -26,14 +28,13 @@ struct UserDefaultsStoreMockTests { } } - @Test("Defaults vs values precedence and removal restores default") func defaultsAndValuesPrecedence() async throws { let store = UserDefaultsStoreMock(store: [:]) let key = "name" // Register a default and read it - store.register(defaults: [key: "def"]) + store.register(defaults: [key: "def"]) #expect(store.string(forKey: key) == "def") #expect(store.object(forKey: key) as? String == "def") @@ -77,7 +78,7 @@ struct UserDefaultsStoreMockTests { let token = store.observer(forKey: key) { old, new in nonisolated(unsafe) let _old: Any? = old nonisolated(unsafe) let _new: Any? = new - + MainActor.assumeIsolated { events.append(Event(old: _old, new: _new)) } @@ -87,7 +88,7 @@ struct UserDefaultsStoreMockTests { await Task.yield() // Register a default; expect a change from nil -> "d" - store.register(defaults: [key: "d"]) + store.register(defaults: [key: "d"]) #expect(events.count >= 2) // Last event should reflect the change to default if let last = events.last { @@ -116,15 +117,16 @@ struct UserDefaultsStoreMockTests { #expect((last.old as? String) == "v1") #expect((last.new as? String) == "d") } - _ = token // keep alive + _ = token // keep alive } - + @Test func testBool() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testBool_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testBool_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Bool @@ -132,10 +134,10 @@ struct UserDefaultsStoreMockTests { static let defaultValue = false static let defaultRegistrar = __DefaultRegistrar() } - + #expect(Attr.Encoder.self == Never.self) #expect(Attr.Decoder.self == Never.self) - + let obj = try Attr.encode(true) #expect((obj as? Attr.Value) == true) let value = try Attr.decode(from: obj) diff --git a/Tests/SettingsTests/AttributeCodingTests/AttributeCodingTests.swift b/Tests/SettingsTests/AttributeCodingTests/AttributeCodingTests.swift index c4ec913..1619a87 100644 --- a/Tests/SettingsTests/AttributeCodingTests/AttributeCodingTests.swift +++ b/Tests/SettingsTests/AttributeCodingTests/AttributeCodingTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing import Settings +import Testing struct AttributeCodingTests { @@ -30,10 +30,11 @@ struct AttributeCodingTests { extension AttributeCodingTests { @Test func testBool() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testBool_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testBool_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Bool @@ -41,45 +42,47 @@ extension AttributeCodingTests { static let defaultValue = false static let defaultRegistrar = __DefaultRegistrar() } - + #expect(Attr.Encoder.self == Never.self) #expect(Attr.Decoder.self == Never.self) - + let obj = try Attr.encode(true) #expect((obj as? Attr.Value) == true) let value = try Attr.decode(from: obj) #expect(value == true) } - + @Test func testOptionalBool() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalBool_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalBool_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Bool? typealias Wrapped = Bool static let name = "boolOpt" } - + #expect(Attr.Wrapped.self == Bool.self) #expect(Attr.Encoder.self == Never.self) #expect(Attr.Decoder.self == Never.self) - + let obj = try Attr.encode(true) #expect((obj as? Attr.Value) == true) let value = try Attr.decode(from: obj) #expect(value == true) } - + @Test func testInt() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testInt_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testInt_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Int @@ -87,40 +90,42 @@ extension AttributeCodingTests { static let defaultValue = -1 static let defaultRegistrar = __DefaultRegistrar() } - + let obj = try Attr.encode(7) #expect((obj as? Attr.Value) == 7) let value = try Attr.decode(from: obj) #expect(value == 7) } - + @Test func testOptionalInt() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalInt_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalInt_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Int? typealias Wrapped = Int static let name = "intOpt" } - + #expect(Attr.Wrapped.self == Int.self) - + let obj = try Attr.encode(7) #expect((obj as? Attr.Value) == 7) let value = try Attr.decode(from: obj) #expect(value == 7) } - + @Test func testFloat() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testFloat_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testFloat_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Float @@ -128,40 +133,42 @@ extension AttributeCodingTests { static let defaultValue: Float = 0 static let defaultRegistrar = __DefaultRegistrar() } - + let obj = try Attr.encode(3.5) #expect((obj as? Attr.Value) == 3.5) let value = try Attr.decode(from: obj) #expect(value == 3.5) } - + @Test func testOptionalFloat() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalFloat_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalFloat_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Float? typealias Wrapped = Float static let name = "floatOpt" } - + #expect(Attr.Wrapped.self == Float.self) - + let obj = try Attr.encode(3.5) #expect((obj as? Attr.Value) == 3.5) let value = try Attr.decode(from: obj) #expect(value == 3.5) } - + @Test func testDouble() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDouble_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDouble_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Double @@ -169,40 +176,42 @@ extension AttributeCodingTests { static let defaultValue: Double = 0 static let defaultRegistrar = __DefaultRegistrar() } - + let obj = try Attr.encode(7.25) #expect((obj as? Attr.Value) == 7.25) let value = try Attr.decode(from: obj) #expect(value == 7.25) } - + @Test func testOptionalDouble() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDouble_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDouble_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Double? typealias Wrapped = Double static let name = "doubleOpt" } - + #expect(Attr.Wrapped.self == Double.self) - + let obj = try Attr.encode(7.25) #expect((obj as? Attr.Value) == 7.25) let value = try Attr.decode(from: obj) #expect(value == 7.25) } - + @Test func testString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testString_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = String @@ -210,40 +219,42 @@ extension AttributeCodingTests { static let defaultValue: String = "" static let defaultRegistrar = __DefaultRegistrar() } - + let obj = try Attr.encode("hello") #expect((obj as? Attr.Value) == "hello") let value = try Attr.decode(from: obj) #expect(value == "hello") } - + @Test func testOptionalString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalString_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = String? typealias Wrapped = String static let name = "stringOpt" } - + #expect(Attr.Wrapped.self == String.self) - + let obj = try Attr.encode("hello") #expect((obj as? Attr.Value) == "hello") let value = try Attr.decode(from: obj) #expect(value == "hello") } - + @Test func testDate() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDate_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDate_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Date @@ -251,40 +262,42 @@ extension AttributeCodingTests { static let defaultValue = Date(timeIntervalSince1970: 0) static let defaultRegistrar = __DefaultRegistrar() } - + let sample = Date(timeIntervalSince1970: 1_234_567) let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) let value = try Attr.decode(from: obj) #expect(value == sample) } - + @Test func testOptionalDate() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDate_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDate_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Date? typealias Wrapped = Date static let name = "dateOpt" } - + let sample = Date(timeIntervalSince1970: 1_234_567) let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) let value = try Attr.decode(from: obj) #expect(value == sample) } - + @Test func testData() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testData_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testData_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Data @@ -292,44 +305,46 @@ extension AttributeCodingTests { static let defaultValue = Data() static let defaultRegistrar = __DefaultRegistrar() } - + let sample = Data([0x01, 0x02, 0x03]) let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) let value = try Attr.decode(from: obj) #expect(value == sample) } - + @Test func testOptionalData() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalData_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalData_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Data? typealias Wrapped = Data static let name = "dataOpt" } - + let sample = Data([0x01, 0x02, 0x03]) let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) let value = try Attr.decode(from: obj) #expect(value == sample) } - + } // MARK: - Primitive URL extension AttributeCodingTests { @Test func testURL() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testURL_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testURL_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = URL @@ -337,31 +352,32 @@ extension AttributeCodingTests { static let defaultValue = URL(string: "https://example.com")! static let defaultRegistrar = __DefaultRegistrar() } - + let url = URL(string: "https://swift.org")! let encoded = try Attr.encode(url) let decoded = try Attr.decode(from: encoded) - + #expect(decoded == url) } - + @Test func testOptionalURL() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalURL_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalURL_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = URL? typealias Wrapped = URL static let name = "urlOpt" } - + let url = URL(string: "https://swift.org")! let encoded = try Attr.encode(url) let decoded = try Attr.decode(from: encoded) - + #expect(decoded == url) } } @@ -369,21 +385,22 @@ extension AttributeCodingTests { // MARK: - Primitive Array extension AttributeCodingTests { - + @Test func testArrayAny() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testArrayAny_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testArrayAny_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C - typealias Value = Array + typealias Value = [Any] static let name = "arrayAny" static var defaultValue: [Any] { [] } static let defaultRegistrar = __DefaultRegistrar() } - + let sample: [Any] = ["a", 1, true] let obj = try Attr.encode(sample) let encoded = obj as? Attr.Value @@ -399,20 +416,21 @@ extension AttributeCodingTests { #expect((decoded[1] as? Int) == 1) #expect((decoded[2] as? Bool) == true) } - + @Test func testOptionalArrayAny() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalArrayAny_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalArrayAny_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C - typealias Value = Array? - typealias Wrapped = Array + typealias Value = [Any]? + typealias Wrapped = [Any] static let name = "arrayAnyOpt" } - + let sample: [Any] = ["a", 1, true] let obj = try Attr.encode(sample) let encoded = obj as? Attr.Value @@ -431,41 +449,43 @@ extension AttributeCodingTests { #expect((decodedArray[1] as? Int) == 1) #expect((decodedArray[2] as? Bool) == true) } - + @Test func testArrayString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testArrayString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testArrayString_" } typealias C = TestContainer - + enum Attr: __AttributeNonOptional { typealias Container = C - typealias Value = Array + typealias Value = [String] static let name = "arrayString" static let defaultValue: [String] = [] static let defaultRegistrar = __DefaultRegistrar() } - + let sample = ["a", "b", "c"] let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) let value = try Attr.decode(from: obj) #expect(value == sample) } - + @Test func testOptionalArrayString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalArrayString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalArrayString_" } typealias C = TestContainer - + enum Attr: __AttributeOptional { typealias Container = C - typealias Value = Array? - typealias Wrapped = Array + typealias Value = [String]? + typealias Wrapped = [String] static let name = "arrayStringOpt" } - + let sample = ["a", "b", "c"] let obj = try Attr.encode(sample) #expect((obj as? Attr.Value) == sample) @@ -480,13 +500,14 @@ extension AttributeCodingTests { @Test func testDictionaryAny() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDictionaryAny_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDictionaryAny_" } typealias C = TestContainer enum Attr: __AttributeNonOptional { typealias Container = C - typealias Value = Dictionary + typealias Value = [String: Any] static let name = "dictAny" static var defaultValue: [String: Any] { [:] } static let defaultRegistrar = __DefaultRegistrar() @@ -495,7 +516,7 @@ extension AttributeCodingTests { let sample: [String: Any] = [ "s": "str", "i": 1, - "b": true + "b": true, ] let obj = try Attr.encode(sample) let encoded = obj as? Attr.Value @@ -512,21 +533,22 @@ extension AttributeCodingTests { @Test func testOptionalDictionaryAny() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDictionaryAny_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDictionaryAny_" } typealias C = TestContainer enum Attr: __AttributeOptional { typealias Container = C - typealias Value = Dictionary? - typealias Wrapped = Dictionary + typealias Value = [String: Any]? + typealias Wrapped = [String: Any] static let name = "dictAnyOpt" } let sample: [String: Any] = [ "s": "str", "i": 1, - "b": true + "b": true, ] let obj = try Attr.encode(sample) let encoded = obj as? Attr.Value @@ -546,13 +568,14 @@ extension AttributeCodingTests { @Test func testDictionaryString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDictionaryString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testDictionaryString_" } typealias C = TestContainer enum Attr: __AttributeNonOptional { typealias Container = C - typealias Value = Dictionary + typealias Value = [String: String] static let name = "dictString" static let defaultValue: [String: String] = [:] static let defaultRegistrar = __DefaultRegistrar() @@ -567,14 +590,15 @@ extension AttributeCodingTests { @Test func testOptionalDictionaryString() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDictionaryString_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalDictionaryString_" } typealias C = TestContainer enum Attr: __AttributeOptional { typealias Container = C - typealias Value = Dictionary? - typealias Wrapped = Dictionary + typealias Value = [String: String]? + typealias Wrapped = [String: String] static let name = "dictStringOpt" } @@ -588,18 +612,19 @@ extension AttributeCodingTests { // MARK: Custom Codable Types extension AttributeCodingTests { - + private struct CustomCodable: Codable, Equatable { let id: String let count: Int let flag: Bool } - + // MARK: Coding: JSON - + @Test func testCustomCodableJSON() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testCustomCodableJSON_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testCustomCodableJSON_" } typealias C = TestContainer @@ -607,7 +632,11 @@ extension AttributeCodingTests { typealias Container = C typealias Value = CustomCodable static let name = "customJSON" - static let defaultValue = CustomCodable(id: "default", count: 0, flag: false) + static let defaultValue = CustomCodable( + id: "default", + count: 0, + flag: false + ) static let defaultRegistrar = __DefaultRegistrar() static var encoder: JSONEncoder { JSONEncoder() } static var decoder: JSONDecoder { JSONDecoder() } @@ -622,7 +651,8 @@ extension AttributeCodingTests { @Test func testOptionalCustomCodableJSON() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalCustomCodableJSON_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalCustomCodableJSON_" } typealias C = TestContainer @@ -643,10 +673,11 @@ extension AttributeCodingTests { } // MARK: Coding: PropertyList - + @Test func testCustomCodablePropertyList() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testCustomCodablePropertyList_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testCustomCodablePropertyList_" } typealias C = TestContainer @@ -654,7 +685,11 @@ extension AttributeCodingTests { typealias Container = C typealias Value = CustomCodable static let name = "customPlist" - static let defaultValue = CustomCodable(id: "default", count: 0, flag: false) + static let defaultValue = CustomCodable( + id: "default", + count: 0, + flag: false + ) static let defaultRegistrar = __DefaultRegistrar() static var encoder: PropertyListEncoder { PropertyListEncoder() } static var decoder: PropertyListDecoder { PropertyListDecoder() } @@ -669,7 +704,8 @@ extension AttributeCodingTests { @Test func testOptionalCustomCodablePropertyList() async throws { enum Prefix: ConstString { - static let value = "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalCustomCodablePropertyList_" + static let value = + "com_UserDefaultsTests_UserDefaultAttributeCodingTests_testOptionalCustomCodablePropertyList_" } typealias C = TestContainer @@ -690,4 +726,3 @@ extension AttributeCodingTests { } } - diff --git a/Tests/SettingsTests/AttributeCodingTests/AttributeCustomCodableTypesTests.swift b/Tests/SettingsTests/AttributeCodingTests/AttributeCustomCodableTypesTests.swift index 7a3f7d6..57ff0df 100644 --- a/Tests/SettingsTests/AttributeCodingTests/AttributeCustomCodableTypesTests.swift +++ b/Tests/SettingsTests/AttributeCodingTests/AttributeCustomCodableTypesTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing import Settings +import Testing /// Tests for encoding/decoding of UserDefault values /// Verifies JSON and PropertyList encoding/decoding work correctly diff --git a/Tests/SettingsTests/AttributeTests/AttributeTests.swift b/Tests/SettingsTests/AttributeTests/AttributeTests.swift index 60b54b3..ff15e56 100644 --- a/Tests/SettingsTests/AttributeTests/AttributeTests.swift +++ b/Tests/SettingsTests/AttributeTests/AttributeTests.swift @@ -1,10 +1,10 @@ -import Testing +import Combine import Foundation import Settings -import Combine +import Testing struct AttributeTests { - + protocol ConstString { static var value: String { get } } @@ -12,7 +12,7 @@ struct AttributeTests { struct TestContainer: __Settings_Container { static var store: any UserDefaultsStore { UserDefaults.standard } static var prefix: String { Prefix.value } - + static func clear() { for key in keys { store.removeObject(forKey: key) @@ -21,19 +21,23 @@ struct AttributeTests { static var allKeys: [String] { Array(store.dictionaryRepresentation().keys) } - + static var keys: [String] { - Array(store.dictionaryRepresentation().keys).filter { $0.hasPrefix(prefix) } + Array(store.dictionaryRepresentation().keys).filter { + $0.hasPrefix(prefix) + } } } - @Test("Key composition - prefix + name") func testKeyComposition() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_key_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_key_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = String @@ -41,27 +45,30 @@ struct AttributeTests { static let defaultValue = "default" static let defaultRegistrar = __DefaultRegistrar() } - + // Write a value via the typed API C[Attr.self] = "value" - + // Expected full key is container prefix + attribute name let expectedFullKey = C.prefix + Attr.name - + // Assert the underlying store has exactly that full key, and not the bare name #expect(C.allKeys.contains(expectedFullKey)) #expect(!C.allKeys.contains(Attr.name)) - + // Read directly from the store to verify composition #expect(C.store.string(forKey: expectedFullKey) == "value") - + // Sanity: typed read still returns the written value #expect(C[Attr.self] == "value") } - + @Test("Bool - Non-Optional") func testBoolNonOptional() async throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_bool_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_bool_" + } typealias C = TestContainer defer { C.clear() } @@ -81,10 +88,13 @@ struct AttributeTests { @Test("Int - Non-Optional") func testIntNonOptional() async throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_int_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_int_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Int @@ -101,10 +111,13 @@ struct AttributeTests { @Test("Float - Non-Optional") func testFloatNonOptional() async throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_float_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_float_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Float @@ -121,10 +134,13 @@ struct AttributeTests { @Test("Double - Non-Optional") func testDoubleNonOptional() async throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_double_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_double_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Double @@ -141,11 +157,15 @@ struct AttributeTests { @Test("String - Non-Optional") func testStringNonOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_string_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_string_" + } typealias C = TestContainer defer { C.clear() } - - enum Attr: __AttributeNonOptional { typealias Container = C + + enum Attr: __AttributeNonOptional { + typealias Container = C typealias Value = String static let name = "nonOptional" static let defaultValue = "default" @@ -162,17 +182,20 @@ struct AttributeTests { @Test("URL - Non-Optional") func testURLNonOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_url_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_url_" + } typealias C = TestContainer defer { C.clear() } - - enum Attr: __AttributeNonOptional { - typealias Container = C - typealias Value = URL - static let name = "nonOptional" - static let defaultValue = URL(string: "https://example.com")! - static let defaultRegistrar = __DefaultRegistrar() - } + + enum Attr: __AttributeNonOptional { + typealias Container = C + typealias Value = URL + static let name = "nonOptional" + static let defaultValue = URL(string: "https://example.com")! + static let defaultRegistrar = __DefaultRegistrar() + } #expect(C[Attr.self].absoluteString == "https://example.com") let newURL = URL(string: "https://apple.com")! C[Attr.self] = newURL @@ -183,10 +206,13 @@ struct AttributeTests { @Test("Date - Non-Optional") func testDateNonOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_date_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_date_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Date @@ -197,17 +223,23 @@ struct AttributeTests { #expect(C[Attr.self].timeIntervalSince1970 == 123456) let now = Date() C[Attr.self] = now - #expect(abs(C[Attr.self].timeIntervalSince1970 - now.timeIntervalSince1970) < 0.05) + #expect( + abs(C[Attr.self].timeIntervalSince1970 - now.timeIntervalSince1970) + < 0.05 + ) Attr.reset() #expect(C[Attr.self].timeIntervalSince1970 == 123456) } @Test("Data - Non-Optional") func testDataNonOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_data_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_data_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = Data @@ -227,17 +259,20 @@ struct AttributeTests { @Test("Bool? - Optional including coercion") func testBoolOptionalAndCoercion() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_boolOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_boolOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Bool? typealias Wrapped = Bool static let name = "optional" } - + #expect(C[Attr.self] == nil) C[Attr.self] = true #expect(C[Attr.self] == true) @@ -261,10 +296,13 @@ struct AttributeTests { @Test("Int? - Optional") func testIntOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_intOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_intOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Int? @@ -280,10 +318,13 @@ struct AttributeTests { @Test("Float? - Optional") func testFloatOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_floatOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_floatOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Float? @@ -299,10 +340,13 @@ struct AttributeTests { @Test("Double? - Optional") func testDoubleOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_doubleOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_doubleOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Double? @@ -318,10 +362,13 @@ struct AttributeTests { @Test("String? - Optional") func testStringOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_stringOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_stringOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum StringOpt: __AttributeOptional { typealias Container = C typealias Value = String? @@ -339,10 +386,13 @@ struct AttributeTests { @Test("URL? - Optional") func testURLOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_urlOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_urlOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = URL? @@ -359,10 +409,13 @@ struct AttributeTests { @Test("Date? - Optional") func testDateOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_dateOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_dateOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Date? @@ -372,17 +425,25 @@ struct AttributeTests { #expect(C[Attr.self] == nil) let d = Date() C[Attr.self] = d - #expect(abs((C[Attr.self] ?? .distantPast).timeIntervalSince1970 - d.timeIntervalSince1970) < 0.05) + #expect( + abs( + (C[Attr.self] ?? .distantPast).timeIntervalSince1970 + - d.timeIntervalSince1970 + ) < 0.05 + ) C[Attr.self] = nil #expect(C[Attr.self] == nil) } @Test("Data? - Optional") func testDataOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_dataOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_dataOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = Data? @@ -401,10 +462,13 @@ struct AttributeTests { @Test("Array Codable - Non-Optional") func testArrayCodableNonOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_arrayCodable_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_arrayCodable_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeNonOptional { typealias Container = C typealias Value = [String] @@ -421,10 +485,13 @@ struct AttributeTests { @Test("Dictionary Codable - Optional") func testDictionaryCodableOptional() throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_dictCodableOpt_" } + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_dictCodableOpt_" + } typealias C = TestContainer defer { C.clear() } - + enum Attr: __AttributeOptional { typealias Container = C typealias Value = [String: Int]? @@ -441,8 +508,12 @@ struct AttributeTests { } @Test("CustomCodable default registration encodes into store") - func testCustomCodableDefaultRegistrationStoresEncodedDefault() async throws { - enum Prefix: ConstString { static let value = "com_UserDefaultsTests_UserDefaultAttributeTests_customCodableDefault_" } + func testCustomCodableDefaultRegistrationStoresEncodedDefault() async throws + { + enum Prefix: ConstString { + static let value = + "com_UserDefaultsTests_UserDefaultAttributeTests_customCodableDefault_" + } typealias C = TestContainer defer { C.clear() } diff --git a/Tests/SettingsTests/AttributeTests/DefaultRegistrarTests.swift b/Tests/SettingsTests/AttributeTests/DefaultRegistrarTests.swift index f61405b..77b2a5c 100644 --- a/Tests/SettingsTests/AttributeTests/DefaultRegistrarTests.swift +++ b/Tests/SettingsTests/AttributeTests/DefaultRegistrarTests.swift @@ -1,49 +1,51 @@ import Foundation -import Testing import Settings import Synchronization +import Testing /// Tests that verify DefaultRegistrar only registers defaults once per attribute type struct DefaultRegistrarTests { - + @Test("DefaultRegistrar calls register only once despite multiple reads") func testDefaultRegistrarCallsRegisterOnlyOnce() async throws { // Create a custom container that tracks registration calls final class RegistrationTracker: @unchecked Sendable { private let count = Mutex(0) - + func recordRegistration() { count.withLock { $0 += 1 } } - + func getCallCount() -> Int { count.withLock { $0 } } } - + struct TrackedContainer: __Settings_Container { static let tracker = RegistrationTracker() - static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } + static var store: any UserDefaultsStore { + Foundation.UserDefaults.standard + } static var prefix: String { "DefaultRegistrarTest_" } static var suiteName: String? { nil } - + static func registerDefault(key: String, defaultValue: Any) { // Track the call tracker.recordRegistration() // Still register with actual UserDefaults store.register(defaults: [key: defaultValue]) } - + static func clear() { store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } .forEach { store.removeObject(forKey: $0) } } } - + TrackedContainer.clear() defer { TrackedContainer.clear() } - + // Define an attribute enum TestAttr: __AttributeNonOptional { typealias Container = TrackedContainer @@ -52,19 +54,22 @@ struct DefaultRegistrarTests { static let defaultValue = "default" static let defaultRegistrar = __DefaultRegistrar() } - + // Perform multiple reads - each triggers registerDefaultIfNeeded() _ = TestAttr.read() _ = TestAttr.read() _ = TestAttr.read() _ = TestAttr.read() _ = TestAttr.read() - + // Verify registration was called exactly once let callCount = TrackedContainer.tracker.getCallCount() - #expect(callCount == 1, "DefaultRegistrar should register only once, but was called \(callCount) times") + #expect( + callCount == 1, + "DefaultRegistrar should register only once, but was called \(callCount) times" + ) } - + @Test("DefaultRegistrar is a stored property per attribute type") func testDefaultRegistrarIsStoredProperty() { // Define two different attributes @@ -75,7 +80,7 @@ struct DefaultRegistrarTests { static let defaultValue = "default1" static let defaultRegistrar = __DefaultRegistrar() } - + enum Attr2: __AttributeNonOptional { typealias Container = TestContainer typealias Value = String @@ -83,18 +88,21 @@ struct DefaultRegistrarTests { static let defaultValue = "default2" static let defaultRegistrar = __DefaultRegistrar() } - + // Get the registrar instances multiple times _ = Attr1.defaultRegistrar _ = Attr1.defaultRegistrar _ = Attr2.defaultRegistrar _ = Attr2.defaultRegistrar - + // Note: This test primarily documents the requirement that defaultRegistrar - // must be a stored property. The actual verification happens in the + // must be a stored property. The actual verification happens in the // testDefaultRegistrarCallsRegisterOnlyOnce test above, which proves // that multiple reads don't cause multiple registrations. - #expect(Bool(true), "DefaultRegistrar instances exist per attribute type") + #expect( + Bool(true), + "DefaultRegistrar instances exist per attribute type" + ) } } @@ -110,11 +118,10 @@ struct TestContainer: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } static var prefix: String { Prefix.value } static var suiteName: String? { nil } - + static func clear() { store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } .forEach { store.removeObject(forKey: $0) } } } - diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest1.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest1.swift index d0d7856..fb26917 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest1.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest1.swift @@ -1,16 +1,16 @@ import Foundation -import Testing import Settings +import Testing struct ProxyTestContainer1: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() { store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -19,8 +19,8 @@ struct ProxyTestContainer1: __Settings_Container { } struct UserDefaultProxyTest1 { - - @Test + + @Test func testProxyNonOptional() async throws { ProxyTestContainer1.setPrefix("testProxyNonOptional_") ProxyTestContainer1.clear() @@ -38,7 +38,7 @@ struct UserDefaultProxyTest1 { #expect(proxy.wrappedValue == "default") #expect(proxy.key.contains("proxyString")) - + proxy.wrappedValue = "updated" #expect(Attr.read() == "updated") #expect(proxy.wrappedValue == "updated") diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest2.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest2.swift index 209deec..18d1dec 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest2.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest2.swift @@ -1,16 +1,16 @@ import Foundation -import Testing import Settings +import Testing struct ProxyTestContainer2: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() { store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -19,8 +19,8 @@ struct ProxyTestContainer2: __Settings_Container { } struct UserDefaultProxyTest2 { - - @Test + + @Test func testProxyOptional() async throws { ProxyTestContainer2.setPrefix("testProxyOptional_") ProxyTestContainer2.clear() @@ -36,7 +36,7 @@ struct UserDefaultProxyTest2 { let proxy = __AttributeProxy(attributeType: AttrOpt.self) #expect(proxy.wrappedValue == nil) - + proxy.wrappedValue = "hello" #expect(AttrOpt.read() == "hello") #expect(proxy.wrappedValue == "hello") diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest3.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest3.swift index cdfcdfa..6e6f682 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest3.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest3.swift @@ -1,18 +1,18 @@ +import Combine import Foundation -import Testing import Settings -import Combine +import Testing import Utilities struct ProxyTestContainer3: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -23,12 +23,12 @@ struct ProxyTestContainer3: __Settings_Container { } struct UserDefaultProxyTest3 { - - @Test + + @Test func testProxyPublisherNonOptional() async throws { ProxyTestContainer3.setPrefix("testProxyPublisherNonOptional_") await ProxyTestContainer3.clear() - + enum Attr: __AttributeNonOptional { typealias Container = ProxyTestContainer3 typealias Value = String @@ -38,27 +38,27 @@ struct UserDefaultProxyTest3 { } let proxy = __AttributeProxy(attributeType: Attr.self) - + actor ValueCollector { var values: [String] = [] var callCount = 0 - + func append(_ value: String) -> Int { values.append(value) callCount += 1 return callCount } - + func getValues() -> [String] { values } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) let expectation3 = Expectation(minFulfillCount: 1) - + let cancellable = proxy.publisher .sink( receiveCompletion: { _ in }, @@ -75,21 +75,21 @@ struct UserDefaultProxyTest3 { } } ) - + try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + Attr.write(value: "first") try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + Attr.write(value: "second") try await expectation3.await(timeout: .seconds(2), clock: .continuous) - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 3) #expect(receivedValues[0] == "initial") #expect(receivedValues[1] == "first") #expect(receivedValues[2] == "second") - + cancellable.cancel() await ProxyTestContainer3.clear() } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest4.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest4.swift index f117999..be50351 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest4.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest4.swift @@ -1,18 +1,18 @@ +import Combine import Foundation -import Testing import Settings -import Combine +import Testing import Utilities struct ProxyTestContainer4: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -22,8 +22,8 @@ struct ProxyTestContainer4: __Settings_Container { } struct UserDefaultProxyTest4 { - - @Test + + @Test func testProxyPublisherOptional() async throws { ProxyTestContainer4.setPrefix("testProxyPublisherOptional_") await ProxyTestContainer4.clear() @@ -36,27 +36,27 @@ struct UserDefaultProxyTest4 { } let proxy = __AttributeProxy(attributeType: AttrOpt.self) - + actor ValueCollector { var values: [String?] = [] var callCount = 0 - + func append(_ value: String?) -> Int { values.append(value) callCount += 1 return callCount } - + func getValues() -> [String?] { values } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) let expectation3 = Expectation(minFulfillCount: 1) - + let cancellable = proxy.publisher .sink( receiveCompletion: { _ in }, @@ -73,21 +73,21 @@ struct UserDefaultProxyTest4 { } } ) - + try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + AttrOpt.write(value: "exists") try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + AttrOpt.write(value: nil) try await expectation3.await(timeout: .seconds(2), clock: .continuous) - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 3) #expect(receivedValues[0] == nil) #expect(receivedValues[1] == "exists") #expect(receivedValues[2] == nil) - + cancellable.cancel() await ProxyTestContainer4.clear() } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest5.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest5.swift index d6eb590..de6aaf3 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest5.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest5.swift @@ -1,18 +1,18 @@ +import Combine import Foundation -import Testing import Settings -import Combine +import Testing import Utilities struct ProxyTestContainer5: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -22,8 +22,8 @@ struct ProxyTestContainer5: __Settings_Container { } struct UserDefaultProxyTest5 { - - @Test + + @Test func testProxyPublisherArray() async throws { ProxyTestContainer5.setPrefix("testProxyPublisherArray_") await ProxyTestContainer5.clear() @@ -37,16 +37,16 @@ struct UserDefaultProxyTest5 { } let proxy = __AttributeProxy(attributeType: AttrArray.self) - + actor ValueCollector { var values: [[String]] = [] var started = false var callCount = 0 - + func start() { started = true } - + func append(_ value: [String]) -> Int { if started { values.append(value) @@ -54,18 +54,18 @@ struct UserDefaultProxyTest5 { } return callCount } - + func getValues() -> [[String]] { values } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) - + let cancellable = proxy.publisher - .dropFirst() // Skip initial value + .dropFirst() // Skip initial value .sink( receiveCompletion: { _ in }, receiveValue: { value in @@ -79,22 +79,22 @@ struct UserDefaultProxyTest5 { } } ) - + // Give publisher time to start listening try await Task.sleep(for: .milliseconds(100)) await collector.start() - + AttrArray.write(value: ["one", "two"]) try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + AttrArray.write(value: ["one", "two", "three"]) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 2) #expect(receivedValues[0] == ["one", "two"]) #expect(receivedValues[1] == ["one", "two", "three"]) - + cancellable.cancel() await ProxyTestContainer5.clear() } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift index e2df112..a411e37 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest6.swift @@ -1,17 +1,17 @@ import Foundation -import Testing import Settings +import Testing import Utilities struct ProxyTestContainer6: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -21,8 +21,8 @@ struct ProxyTestContainer6: __Settings_Container { } struct UserDefaultProxyTest6 { - - @Test + + @Test func testProxyStreamNonOptional() async throws { ProxyTestContainer6.setPrefix("testProxyStreamNonOptional_") await ProxyTestContainer6.clear() @@ -36,28 +36,28 @@ struct UserDefaultProxyTest6 { } let proxy = __AttributeProxy(attributeType: Attr.self) - + let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) let expectation3 = Expectation(minFulfillCount: 1) - + actor ValueCollector { var values: [Int] = [] var callCount = 0 - + func append(_ value: Int) -> Int { values.append(value) callCount += 1 return callCount } - + func getValues() -> [Int] { values } } - + let collector = ValueCollector() - + let streamTask = Task { do { for try await value in proxy.stream { @@ -75,29 +75,29 @@ struct UserDefaultProxyTest6 { Issue.record("Stream threw error: \(error)") } } - + // Wait for initial default value try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + // Small delay to ensure stream is fully ready try await Task.sleep(for: .milliseconds(10)) - + Attr.write(value: 42) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + try await Task.sleep(for: .milliseconds(100)) - + Attr.write(value: 99) try await expectation3.await(timeout: .seconds(2), clock: .continuous) - + streamTask.cancel() - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 3) #expect(receivedValues[0] == 0) #expect(receivedValues[1] == 42) #expect(receivedValues[2] == 99) - + await ProxyTestContainer6.clear() } } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest7.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest7.swift index ae29c9b..0d0dc47 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest7.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest7.swift @@ -1,17 +1,17 @@ import Foundation -import Testing import Settings +import Testing import Utilities struct ProxyTestContainer7: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -21,12 +21,12 @@ struct ProxyTestContainer7: __Settings_Container { } struct UserDefaultProxyTest7 { - - @Test + + @Test func testProxyStreamOptional() async throws { ProxyTestContainer7.setPrefix("testProxyStreamOptional_") await ProxyTestContainer7.clear() - + enum AttrOpt: __AttributeOptional { typealias Container = ProxyTestContainer7 typealias Value = Bool? @@ -35,27 +35,27 @@ struct UserDefaultProxyTest7 { } let proxy = __AttributeProxy(attributeType: AttrOpt.self) - + actor ValueCollector { var values: [Bool?] = [] var callCount = 0 - + func append(_ value: Bool?) -> Int { values.append(value) callCount += 1 return callCount } - + func getValues() -> [Bool?] { values } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) let expectation3 = Expectation(minFulfillCount: 1) - + let streamTask = Task { do { for try await value in proxy.stream { @@ -73,23 +73,23 @@ struct UserDefaultProxyTest7 { Issue.record("Stream threw error: \(error)") } } - + try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + AttrOpt.write(value: true) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + AttrOpt.write(value: nil) try await expectation3.await(timeout: .seconds(2), clock: .continuous) - + streamTask.cancel() - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 3) #expect(receivedValues[0] == nil) #expect(receivedValues[1] == true) #expect(receivedValues[2] == nil) - + await ProxyTestContainer7.clear() } } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest8.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest8.swift index 3878caf..fc58de1 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest8.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest8.swift @@ -1,18 +1,18 @@ +import Combine import Foundation -import Testing import Settings -import Combine +import Testing import Utilities struct ProxyTestContainer8: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -22,12 +22,12 @@ struct ProxyTestContainer8: __Settings_Container { } struct UserDefaultProxyTest8 { - - @Test + + @Test func testProxyPublisherCustomType() async throws { ProxyTestContainer8.setPrefix("testProxyPublisherCustomType_") await ProxyTestContainer8.clear() - + struct User: Codable, Equatable, Sendable { var name: String var age: Int @@ -44,16 +44,16 @@ struct UserDefaultProxyTest8 { } let proxy = __AttributeProxy(attributeType: AttrUser.self) - + actor ValueCollector { var values: [User] = [] var started = false var callCount = 0 - + func start() { started = true } - + func append(_ value: User) -> Int { if started { values.append(value) @@ -61,18 +61,18 @@ struct UserDefaultProxyTest8 { } return callCount } - + func getValues() -> [User] { values } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) - + let cancellable = proxy.publisher - .dropFirst() // Skip initial value + .dropFirst() // Skip initial value .sink( receiveCompletion: { _ in }, receiveValue: { value in @@ -86,22 +86,22 @@ struct UserDefaultProxyTest8 { } } ) - + // Give publisher time to start listening try await Task.sleep(for: .milliseconds(100)) await collector.start() - + AttrUser.write(value: User(name: "Alice", age: 30)) try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + AttrUser.write(value: User(name: "Bob", age: 25)) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + let receivedValues = await collector.getValues() #expect(receivedValues.count == 2) #expect(receivedValues[0] == User(name: "Alice", age: 30)) #expect(receivedValues[1] == User(name: "Bob", age: 25)) - + cancellable.cancel() await ProxyTestContainer8.clear() } diff --git a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest9.swift b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest9.swift index dd50f39..10c8fc6 100644 --- a/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest9.swift +++ b/Tests/SettingsTests/UserDefaultProxyTests/UserDefaultProxyTest9.swift @@ -1,18 +1,18 @@ +import Combine import Foundation -import Testing import Settings -import Combine +import Testing import Utilities struct ProxyTestContainer9: __Settings_Container { static var store: any UserDefaultsStore { Foundation.UserDefaults.standard } nonisolated(unsafe) static var _prefix: String = "" static var prefix: String { _prefix } - + static func setPrefix(_ newPrefix: String) { _prefix = newPrefix } - + static func clear() async { let keysToRemove = store.dictionaryRepresentation().keys .filter { $0.hasPrefix(prefix) } @@ -22,7 +22,7 @@ struct ProxyTestContainer9: __Settings_Container { } struct UserDefaultProxyTest9 { - + @Test func testProxyPublisherCustomTypeKeyPath() async throws { ProxyTestContainer9.setPrefix("testProxyPublisherCustomTypeKeyPath_") @@ -44,27 +44,27 @@ struct UserDefaultProxyTest9 { } let proxy = __AttributeProxy(attributeType: AttrUser.self) - + actor ValueCollector { var names: [String] = [] var callCount = 0 - + func append(_ name: String) -> Int { names.append(name) callCount += 1 return callCount } - + func getNames() -> [String] { names } } - + let collector = ValueCollector() let expectation1 = Expectation(minFulfillCount: 1) let expectation2 = Expectation(minFulfillCount: 1) let expectation3 = Expectation(minFulfillCount: 1) - + let cancellable = proxy.publisher(for: \.name) .sink( receiveCompletion: { _ in }, @@ -81,25 +81,25 @@ struct UserDefaultProxyTest9 { } } ) - + try await expectation1.await(timeout: .seconds(2), clock: .continuous) - + AttrUser.write(value: User(name: "Charlie", age: 35)) try await expectation2.await(timeout: .seconds(2), clock: .continuous) - + // Age change should not emit since we're only observing name AttrUser.write(value: User(name: "Charlie", age: 40)) try await Task.sleep(for: .milliseconds(50)) - + AttrUser.write(value: User(name: "Diana", age: 28)) try await expectation3.await(timeout: .seconds(2), clock: .continuous) - + let receivedNames = await collector.getNames() #expect(receivedNames.count == 3) #expect(receivedNames[0] == "Default") #expect(receivedNames[1] == "Charlie") #expect(receivedNames[2] == "Diana") - + cancellable.cancel() await ProxyTestContainer9.clear() } diff --git a/Tests/SwiftUITests/AppSettingTests.swift b/Tests/SwiftUITests/AppSettingTests.swift index 20f31b4..5f0fce0 100644 --- a/Tests/SwiftUITests/AppSettingTests.swift +++ b/Tests/SwiftUITests/AppSettingTests.swift @@ -1,268 +1,280 @@ #if false -import Testing -import Foundation -import SwiftUI -import Combine -import Settings -import Utilities - -// Test container using standard UserDefaults (each test cleans up its own keys) -struct TestSettingValues: __Settings_Container { - static var store: any UserDefaultsStore { - UserDefaults.standard - } - - static var prefix: String { - "com_settings_test_appsetting_" - } - - static func clear() { - let keysToRemove = store.dictionaryRepresentation().keys - .filter { $0.hasPrefix(prefix) } - for key in keysToRemove { - store.removeObject(forKey: key) + import Testing + import Foundation + import SwiftUI + import Combine + import Settings + import Utilities + + // Test container using standard UserDefaults (each test cleans up its own keys) + struct TestSettingValues: __Settings_Container { + static var store: any UserDefaultsStore { + UserDefaults.standard + } + + static var prefix: String { + "com_settings_test_appsetting_" + } + + static func clear() { + let keysToRemove = store.dictionaryRepresentation().keys + .filter { $0.hasPrefix(prefix) } + for key in keysToRemove { + store.removeObject(forKey: key) + } } } -} - -// Define test settings at file scope -extension TestSettingValues { - @Setting static var testString: String = "default" - @Setting static var testInt: Int = 0 - @Setting static var testBool: Bool = false - @Setting static var sharedValue: String = "initial" - @Setting static var observedValue: Int = 0 - @Setting static var optionalString: String? -} - -@MainActor -@Suite("AppSetting Property Wrapper Tests", .serialized) -struct AppSettingTests { - - init() { - TestSettingValues.clear() - } - - @Test("AppSetting initialization reads current UserDefaults value") - func testInitialization() throws { - defer { TestSettingValues.clear() } - - // Set a value directly in UserDefaults - TestSettingValues.testString = "initial" - - // Create AppSetting wrapper - let wrapper = AppSetting(TestSettingValues.$testString) - - // Should read the current value - #expect(wrapper.wrappedValue == "initial") - } - - @Test("AppSetting wrappedValue writes to UserDefaults") - func testWriting() throws { - defer { TestSettingValues.clear() } - - let wrapper = AppSetting(TestSettingValues.$testInt) - - // Write via wrapper - wrapper.wrappedValue = 42 - - // Verify it's in UserDefaults - #expect(TestSettingValues.testInt == 42) - } - - @Test("AppSetting projectedValue provides working binding") - func testBinding() throws { - defer { TestSettingValues.clear() } - - let wrapper = AppSetting(TestSettingValues.$testBool) - let binding = wrapper.projectedValue - - // Note: Bindings work through the underlying @State which requires - // SwiftUI's view lifecycle. In unit tests, we verify the binding - // structure is correct. - #expect(binding.wrappedValue == false) - - // Writing through the wrapper directly should work - TestSettingValues.testBool = true - #expect(TestSettingValues.testBool == true) - } - - @Test("Multiple AppSettings can reference same UserDefaults key") - func testMultipleReferences() throws { - defer { TestSettingValues.clear() } - - // Create two wrapper instances for the same setting - let wrapper1 = AppSetting(TestSettingValues.$sharedValue) - let wrapper2 = AppSetting(TestSettingValues.$sharedValue) - - // Both should read the same initial value - #expect(wrapper1.wrappedValue == "initial") - #expect(wrapper2.wrappedValue == "initial") - - // Change value through UserDefaults directly - TestSettingValues.sharedValue = "updated" - - // New wrappers will read the updated value - let wrapper3 = AppSetting(TestSettingValues.$sharedValue) - #expect(wrapper3.wrappedValue == "updated") - } - - @Test("AppSetting update method can be called") - func testUpdateMethod() throws { - defer { TestSettingValues.clear() } - - var wrapper = AppSetting(TestSettingValues.$observedValue) - - // Initially reads current value - #expect(wrapper.wrappedValue == 0) - - // Call update to establish subscription - // Note: The actual subscription behavior requires SwiftUI's lifecycle - // to properly update @State. Here we verify update() doesn't crash. - wrapper.update() - - // Can call multiple times (should be idempotent due to nil check) - wrapper.update() - wrapper.update() + + // Define test settings at file scope + extension TestSettingValues { + @Setting static var testString: String = "default" + @Setting static var testInt: Int = 0 + @Setting static var testBool: Bool = false + @Setting static var sharedValue: String = "initial" + @Setting static var observedValue: Int = 0 + @Setting static var optionalString: String? } - - @Test("AppSetting works with optional values") - func testOptionalValues() throws { - defer { TestSettingValues.clear() } - let wrapper = AppSetting(TestSettingValues.$optionalString) + @MainActor + @Suite("AppSetting Property Wrapper Tests", .serialized) + struct AppSettingTests { - // Initially nil - #expect(wrapper.wrappedValue == nil) + init() { + TestSettingValues.clear() + } - // Set a value - wrapper.wrappedValue = "present" - #expect(TestSettingValues.optionalString == "present") + @Test("AppSetting initialization reads current UserDefaults value") + func testInitialization() throws { + defer { TestSettingValues.clear() } - // Clear it - wrapper.wrappedValue = nil - #expect(TestSettingValues.optionalString == nil) - } - - @Test("Attribute publisher emits values on UserDefaults changes") - func testAttributePublisher() async throws { - defer { TestSettingValues.clear() } - - // Set initial value - TestSettingValues.sharedValue = "initial" - - // Actor to collect values safely - actor ValueCollector { - private var values: [String] = [] - - func append(_ value: String) -> Int { - values.append(value) - return values.count - } - - func getValues() -> [String] { - values - } + // Set a value directly in UserDefaults + TestSettingValues.testString = "initial" + + // Create AppSetting wrapper + let wrapper = AppSetting(TestSettingValues.$testString) + + // Should read the current value + #expect(wrapper.wrappedValue == "initial") + } + + @Test("AppSetting wrappedValue writes to UserDefaults") + func testWriting() throws { + defer { TestSettingValues.clear() } + + let wrapper = AppSetting(TestSettingValues.$testInt) + + // Write via wrapper + wrapper.wrappedValue = 42 + + // Verify it's in UserDefaults + #expect(TestSettingValues.testInt == 42) + } + + @Test("AppSetting projectedValue provides working binding") + func testBinding() throws { + defer { TestSettingValues.clear() } + + let wrapper = AppSetting(TestSettingValues.$testBool) + let binding = wrapper.projectedValue + + // Note: Bindings work through the underlying @State which requires + // SwiftUI's view lifecycle. In unit tests, we verify the binding + // structure is correct. + #expect(binding.wrappedValue == false) + + // Writing through the wrapper directly should work + TestSettingValues.testBool = true + #expect(TestSettingValues.testBool == true) + } + + @Test("Multiple AppSettings can reference same UserDefaults key") + func testMultipleReferences() throws { + defer { TestSettingValues.clear() } + + // Create two wrapper instances for the same setting + let wrapper1 = AppSetting(TestSettingValues.$sharedValue) + let wrapper2 = AppSetting(TestSettingValues.$sharedValue) + + // Both should read the same initial value + #expect(wrapper1.wrappedValue == "initial") + #expect(wrapper2.wrappedValue == "initial") + + // Change value through UserDefaults directly + TestSettingValues.sharedValue = "updated" + + // New wrappers will read the updated value + let wrapper3 = AppSetting(TestSettingValues.$sharedValue) + #expect(wrapper3.wrappedValue == "updated") + } + + @Test("AppSetting update method can be called") + func testUpdateMethod() throws { + defer { TestSettingValues.clear() } + + var wrapper = AppSetting(TestSettingValues.$observedValue) + + // Initially reads current value + #expect(wrapper.wrappedValue == 0) + + // Call update to establish subscription + // Note: The actual subscription behavior requires SwiftUI's lifecycle + // to properly update @State. Here we verify update() doesn't crash. + wrapper.update() + + // Can call multiple times (should be idempotent due to nil check) + wrapper.update() + wrapper.update() + } + + @Test("AppSetting works with optional values") + func testOptionalValues() throws { + defer { TestSettingValues.clear() } + + let wrapper = AppSetting(TestSettingValues.$optionalString) + + // Initially nil + #expect(wrapper.wrappedValue == nil) + + // Set a value + wrapper.wrappedValue = "present" + #expect(TestSettingValues.optionalString == "present") + + // Clear it + wrapper.wrappedValue = nil + #expect(TestSettingValues.optionalString == nil) } - - let collector = ValueCollector() - let expectation1 = Expectation(minFulfillCount: 1) - let expectation2 = Expectation(minFulfillCount: 1) - - // Subscribe to the attribute's publisher via proxy - let cancellable = TestSettingValues.$sharedValue.publisher - .sink( - receiveCompletion: { _ in }, - receiveValue: { value in - Task { - let count = await collector.append(value) - if count == 1 { - expectation1.fulfill() - } else if count == 2 { - expectation2.fulfill() + + @Test("Attribute publisher emits values on UserDefaults changes") + func testAttributePublisher() async throws { + defer { TestSettingValues.clear() } + + // Set initial value + TestSettingValues.sharedValue = "initial" + + // Actor to collect values safely + actor ValueCollector { + private var values: [String] = [] + + func append(_ value: String) -> Int { + values.append(value) + return values.count + } + + func getValues() -> [String] { + values + } + } + + let collector = ValueCollector() + let expectation1 = Expectation(minFulfillCount: 1) + let expectation2 = Expectation(minFulfillCount: 1) + + // Subscribe to the attribute's publisher via proxy + let cancellable = TestSettingValues.$sharedValue.publisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + Task { + let count = await collector.append(value) + if count == 1 { + expectation1.fulfill() + } else if count == 2 { + expectation2.fulfill() + } } } - } + ) + + // Wait for initial value + try await expectation1.await( + timeout: Duration.seconds(2), + clock: ContinuousClock() ) - - // Wait for initial value - try await expectation1.await(timeout: Duration.seconds(2), clock: ContinuousClock()) - - // Give time for subscription to stabilize - try await Task.sleep(for: .milliseconds(50)) - - // Change the value - TestSettingValues.sharedValue = "updated" - - // Wait for updated value - try await expectation2.await(timeout: Duration.seconds(2), clock: ContinuousClock()) - - let receivedValues = await collector.getValues() - // Verify we received at least 2 values and both expected values are present - #expect(receivedValues.count >= 2) - #expect(receivedValues.contains("initial")) - #expect(receivedValues.contains("updated")) - - cancellable.cancel() - } - - @Test("Attribute publisher observes UserDefaults changes") - func testAttributePublisherObservation() async throws { - defer { TestSettingValues.clear() } - - TestSettingValues.testBool = false - - actor BoolCollector { - private var values: [Bool] = [] - - func append(_ value: Bool) -> Int { - values.append(value) - return values.count - } - - func contains(_ value: Bool) -> Bool { - values.contains(value) - } + + // Give time for subscription to stabilize + try await Task.sleep(for: .milliseconds(50)) + + // Change the value + TestSettingValues.sharedValue = "updated" + + // Wait for updated value + try await expectation2.await( + timeout: Duration.seconds(2), + clock: ContinuousClock() + ) + + let receivedValues = await collector.getValues() + // Verify we received at least 2 values and both expected values are present + #expect(receivedValues.count >= 2) + #expect(receivedValues.contains("initial")) + #expect(receivedValues.contains("updated")) + + cancellable.cancel() } - - let collector = BoolCollector() - let expectation1 = Expectation(minFulfillCount: 1) - let expectation2 = Expectation(minFulfillCount: 1) - - let cancellable = TestSettingValues.$testBool.publisher - .sink( - receiveCompletion: { _ in }, - receiveValue: { value in - Task { - let count = await collector.append(value) - if count == 1 { - expectation1.fulfill() - } else if count == 2 { - expectation2.fulfill() + + @Test("Attribute publisher observes UserDefaults changes") + func testAttributePublisherObservation() async throws { + defer { TestSettingValues.clear() } + + TestSettingValues.testBool = false + + actor BoolCollector { + private var values: [Bool] = [] + + func append(_ value: Bool) -> Int { + values.append(value) + return values.count + } + + func contains(_ value: Bool) -> Bool { + values.contains(value) + } + } + + let collector = BoolCollector() + let expectation1 = Expectation(minFulfillCount: 1) + let expectation2 = Expectation(minFulfillCount: 1) + + let cancellable = TestSettingValues.$testBool.publisher + .sink( + receiveCompletion: { _ in }, + receiveValue: { value in + Task { + let count = await collector.append(value) + if count == 1 { + expectation1.fulfill() + } else if count == 2 { + expectation2.fulfill() + } } } - } + ) + + // Wait for initial value + try await expectation1.await( + timeout: Duration.seconds(2), + clock: ContinuousClock() ) - - // Wait for initial value - try await expectation1.await(timeout: Duration.seconds(2), clock: ContinuousClock()) - - // Give time for subscription to stabilize - try await Task.sleep(for: .milliseconds(100)) - - // Change the value - TestSettingValues.testBool = true - - // Wait for change to be observed - try await expectation2.await(timeout: Duration.seconds(2), clock: ContinuousClock()) - - // Verify both values were observed - let hasFalse = await collector.contains(false) - let hasTrue = await collector.contains(true) - #expect(hasFalse) - #expect(hasTrue) - - cancellable.cancel() + + // Give time for subscription to stabilize + try await Task.sleep(for: .milliseconds(100)) + + // Change the value + TestSettingValues.testBool = true + + // Wait for change to be observed + try await expectation2.await( + timeout: Duration.seconds(2), + clock: ContinuousClock() + ) + + // Verify both values were observed + let hasFalse = await collector.contains(false) + let hasTrue = await collector.contains(true) + #expect(hasFalse) + #expect(hasTrue) + + cancellable.cancel() + } } -} -#endif \ No newline at end of file +#endif diff --git a/Tests/Utilities/Expectation.swift b/Tests/Utilities/Expectation.swift index 5a66310..405d29a 100644 --- a/Tests/Utilities/Expectation.swift +++ b/Tests/Utilities/Expectation.swift @@ -45,7 +45,8 @@ public final class Expectation: Sendable { public func await( nanoseconds: UInt64 ) async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in + try await withCheckedThrowingContinuation { + (continuation: Continuation) in self.lock.withLock { state in switch state { case .start(let minFulfillCount): @@ -105,7 +106,8 @@ public final class Expectation: Sendable { tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock(), ) async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in + try await withCheckedThrowingContinuation { + (continuation: Continuation) in self.lock.withLock { state in switch state { case .start(let minFulfillCount): @@ -168,7 +170,8 @@ public final class Expectation: Sendable { } public func await() async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in + try await withCheckedThrowingContinuation { + (continuation: Continuation) in self.lock.withLock { state in switch state { case .start(let minFulfillCount): @@ -246,7 +249,12 @@ public final class Expectation: Sendable { ) } - case .pending(let continuation, let minFulfillCount, let fulfillCount, let timeoutTask): + case .pending( + let continuation, + let minFulfillCount, + let fulfillCount, + let timeoutTask + ): var newFulfillCount = fulfillCount + 1 if newFulfillCount >= minFulfillCount { // fullfilled, we are going to resume the continuation @@ -323,7 +331,11 @@ extension Expectation: CustomStringConvertible { public var description: String { enum State { case unitialized(fulfillCount: Int) - case pending(Continuation, fulfillCount: Int, timeoutTask: Task?) + case pending( + Continuation, + fulfillCount: Int, + timeoutTask: Task? + ) case fulfilled case rejected(any Swift.Error) } From 08fed9fcf29e774af0408bd0da7ea62a9afdd2be Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Thu, 11 Dec 2025 11:40:51 +0100 Subject: [PATCH 3/6] simplified macro expansion --- .../MacroSettings/MacroSettings.swift | 2 +- .../MacroSettings/Settings_Container.swift | 38 + Sources/SettingsClient/main.swift | 13 +- Sources/SettingsMacros/SettingsMacro.swift | 52 +- .../SettingMacroExpansionTests.swift | 327 ++----- ...SettingsContainerMacroExpansionTests.swift | 908 ++++++++---------- 6 files changed, 535 insertions(+), 805 deletions(-) diff --git a/Sources/Settings/MacroSettings/MacroSettings.swift b/Sources/Settings/MacroSettings/MacroSettings.swift index 2429c54..2e41b5d 100644 --- a/Sources/Settings/MacroSettings/MacroSettings.swift +++ b/Sources/Settings/MacroSettings/MacroSettings.swift @@ -61,7 +61,7 @@ @attached( member, names: named(Config), - named(_config), + named(state), named(store), named(prefix) ) diff --git a/Sources/Settings/MacroSettings/Settings_Container.swift b/Sources/Settings/MacroSettings/Settings_Container.swift index 6765617..3619b57 100644 --- a/Sources/Settings/MacroSettings/Settings_Container.swift +++ b/Sources/Settings/MacroSettings/Settings_Container.swift @@ -161,3 +161,41 @@ extension __Settings_Container { self.store.observer(forKey: key, update: update) } } + +public struct __Settings_Container_Config: Sendable { + struct State { + var store: any UserDefaultsStore = Foundation.UserDefaults.standard + var prefix: String + } + private let _state: OSAllocatedUnfairLock + + public init(prefix: String) { + _state = .init(initialState: .init(prefix: prefix)) + } + + public var store: any UserDefaultsStore { + get { + _state.withLock { state in + state.store + } + } + nonmutating set { + _state.withLock { state in + state.store = newValue + } + } + } + + public var prefix: String { + get { + _state.withLock { state in + state.prefix + } + } + nonmutating set { + _state.withLock { state in + state.prefix = newValue.replacing(".", with: "_") + } + } + } +} diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index 7457302..c1c31c4 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -3,10 +3,10 @@ import Foundation import Observation import Settings import SettingsMock -// MARK: - App -import SwiftUI import os +// MARK: - App + // protocol ConstString { // static var value: String { get } // } @@ -49,7 +49,7 @@ import os // print("Hello UserDefaults!") // try test() -@Settings struct Settings1 { +@Settings() struct Settings1 { @Setting var setting: String = "default" } @@ -57,7 +57,7 @@ import os @Setting var setting: String = "default" } -@Settings struct AppSettings {} +@Settings() struct AppSettings {} extension AppSettings { enum UserSettings { @Setting var setting: String = "default" @@ -146,6 +146,10 @@ extension AppSettingValues { @Setting var theme: String = "default" } +#if true + +import SwiftUI + // @main struct SettingsView: View { @Environment(\.userDefaultsStore) var settings @@ -169,3 +173,4 @@ struct ProductionApp: App { // SettingsView() // .environment(\.userDefaultsStore, UserDefaultsStoreMock(store: ["user": "John"])) // } +#endif diff --git a/Sources/SettingsMacros/SettingsMacro.swift b/Sources/SettingsMacros/SettingsMacro.swift index eb2e64f..f813a03 100644 --- a/Sources/SettingsMacros/SettingsMacro.swift +++ b/Sources/SettingsMacros/SettingsMacro.swift @@ -26,43 +26,25 @@ extension SettingsMacro: MemberMacro { } let literalPrefix = initialPrefix == nil ? "" : initialPrefix! - let declSyntax = DeclSyntax( - """ - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "\(raw: literalPrefix)" - } - - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + let stateDecl: DeclSyntax = """ + private static var state: __Settings_Container_Config { __Settings_Container_Config(prefix: "\(raw: literalPrefix)") } + """ + + let storeDecl: DeclSyntax = """ + public internal(set) static var store: any UserDefaultsStore { + get { state.store } + set { state.store = newValue } + } + """ - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } - } + let prefixDecl: DeclSyntax = """ + public internal(set) static var prefix: String { + get { state.prefix } + set { state.prefix = newValue } + } + """ - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } - } - """ - ) - return [declSyntax] + return [stateDecl, storeDecl, prefixDecl] } } diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift index 7f7b978..50d7c87 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift @@ -1,6 +1,7 @@ import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest +import SwiftParser @testable import SettingsMacros @@ -59,36 +60,25 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) } - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -140,36 +130,25 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) } - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -203,36 +182,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -289,36 +257,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -377,36 +334,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -468,36 +414,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -557,36 +492,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { enum Profile {} - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -643,36 +567,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { enum Profile {} - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -729,36 +642,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -816,36 +718,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -889,6 +780,7 @@ final class SettingMacroExpansionTests: XCTestCase { ) #endif } + func testAllPropertyListTypes() throws { #if canImport(SettingsMacros) let allCases: [Case] = [ @@ -1014,36 +906,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -1101,36 +982,25 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } } @@ -1398,36 +1268,25 @@ extension SettingMacroExpansionTests { configInfrastructure = """ - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "\(prefixValue)" + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "\(prefixValue)") } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) - public internal(set) static var store: any UserDefaultsStore { get { - _config.withLock { config in - config.store - } + state.store } set { - _config.withLock { config in - config.store = newValue - } + state.store = newValue } } - public static var prefix: String { + public internal(set) static var prefix: String { get { - _config.withLock { config in - config.prefix - } + state.prefix } set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } + state.prefix = newValue } } """ diff --git a/Tests/SettingsMacroExpansionTests/SettingsContainerMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingsContainerMacroExpansionTests.swift index fe62251..7f70023 100644 --- a/Tests/SettingsMacroExpansionTests/SettingsContainerMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingsContainerMacroExpansionTests.swift @@ -16,45 +16,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct DefaultSettings { + struct DefaultSettings { - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" - } - - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension DefaultSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension DefaultSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -73,45 +62,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" - } + struct AppSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -130,45 +108,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct MinimalSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" - } + struct MinimalSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension MinimalSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension MinimalSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -187,45 +154,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" - } + struct AppSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -244,45 +200,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "com_myapp_" - } + struct AppSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "com_myapp_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -301,45 +246,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "" - } + struct AppSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -358,45 +292,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" - } + struct AppSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -417,45 +340,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - class AppConfiguration { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" - } + class AppConfiguration { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppConfiguration: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppConfiguration: __Settings_Container { + } + """, macros: testMacros ) #else @@ -474,45 +386,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - enum ConfigSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "config_" - } + enum ConfigSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "config_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension ConfigSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension ConfigSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -531,45 +432,34 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - actor ActorSettings { - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "actor_" - } + actor ActorSettings { - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "actor_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension ActorSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension ActorSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -621,65 +511,54 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct AppSettings { - var username: String { - get { - return __Attribute_AppSettings_username.read() - } - set { - __Attribute_AppSettings_username.write(value: newValue) - } + struct AppSettings { + var username: String { + get { + return __Attribute_AppSettings_username.read() } - - public enum __Attribute_AppSettings_username: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = String - public static let name = "username" - public static let defaultValue: String = "anonymous" - public static let defaultRegistrar = __DefaultRegistrar() + set { + __Attribute_AppSettings_username.write(value: newValue) } + } - public var $username: __AttributeProxy<__Attribute_AppSettings_username> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_username.self) - } + public enum __Attribute_AppSettings_username: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = String + public static let name = "username" + public static let defaultValue: String = "anonymous" + public static let defaultRegistrar = __DefaultRegistrar() + } - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "app_" - } + public var $username: __AttributeProxy<__Attribute_AppSettings_username> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_username.self) + } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension AppSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension AppSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -700,85 +579,74 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct UserPreferences { - var username: String { - get { - return __Attribute_UserPreferences_username.read() - } - set { - __Attribute_UserPreferences_username.write(value: newValue) - } + struct UserPreferences { + var username: String { + get { + return __Attribute_UserPreferences_username.read() } - - public enum __Attribute_UserPreferences_username: __AttributeNonOptional { - public typealias Container = UserPreferences - public typealias Value = String - public static let name = "user_name" - public static let defaultValue: String = "guest" - public static let defaultRegistrar = __DefaultRegistrar() + set { + __Attribute_UserPreferences_username.write(value: newValue) } + } - public var $username: __AttributeProxy<__Attribute_UserPreferences_username> { - return __AttributeProxy(attributeType: __Attribute_UserPreferences_username.self) - } - var theme: String { - get { - return __Attribute_UserPreferences_theme.read() - } - set { - __Attribute_UserPreferences_theme.write(value: newValue) - } - } + public enum __Attribute_UserPreferences_username: __AttributeNonOptional { + public typealias Container = UserPreferences + public typealias Value = String + public static let name = "user_name" + public static let defaultValue: String = "guest" + public static let defaultRegistrar = __DefaultRegistrar() + } - public enum __Attribute_UserPreferences_theme: __AttributeNonOptional { - public typealias Container = UserPreferences - public typealias Value = String - public static let name = "theme" - public static let defaultValue: String = "light" - public static let defaultRegistrar = __DefaultRegistrar() + public var $username: __AttributeProxy<__Attribute_UserPreferences_username> { + return __AttributeProxy(attributeType: __Attribute_UserPreferences_username.self) + } + var theme: String { + get { + return __Attribute_UserPreferences_theme.read() } - - public var $theme: __AttributeProxy<__Attribute_UserPreferences_theme> { - return __AttributeProxy(attributeType: __Attribute_UserPreferences_theme.self) + set { + __Attribute_UserPreferences_theme.write(value: newValue) } + } - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "settings_" - } + public enum __Attribute_UserPreferences_theme: __AttributeNonOptional { + public typealias Container = UserPreferences + public typealias Value = String + public static let name = "theme" + public static let defaultValue: String = "light" + public static let defaultRegistrar = __DefaultRegistrar() + } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + public var $theme: __AttributeProxy<__Attribute_UserPreferences_theme> { + return __AttributeProxy(attributeType: __Attribute_UserPreferences_theme.self) + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } - } + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "settings_") + } - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue } } - extension UserPreferences: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension UserPreferences: __Settings_Container { + } + """, macros: testMacros ) #else @@ -799,85 +667,74 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct GameSettings { - var playerName: String { - get { - return __Attribute_GameSettings_playerName.read() - } - set { - __Attribute_GameSettings_playerName.write(value: newValue) - } + struct GameSettings { + var playerName: String { + get { + return __Attribute_GameSettings_playerName.read() } - - public enum __Attribute_GameSettings_playerName: __AttributeNonOptional { - public typealias Container = GameSettings - public typealias Value = String - public static let name = "playerName" - public static let defaultValue: String = "Player1" - public static let defaultRegistrar = __DefaultRegistrar() + set { + __Attribute_GameSettings_playerName.write(value: newValue) } + } - public var $playerName: __AttributeProxy<__Attribute_GameSettings_playerName> { - return __AttributeProxy(attributeType: __Attribute_GameSettings_playerName.self) - } - var highScore: Int { - get { - return __Attribute_GameSettings_highScore.read() - } - set { - __Attribute_GameSettings_highScore.write(value: newValue) - } - } + public enum __Attribute_GameSettings_playerName: __AttributeNonOptional { + public typealias Container = GameSettings + public typealias Value = String + public static let name = "playerName" + public static let defaultValue: String = "Player1" + public static let defaultRegistrar = __DefaultRegistrar() + } - public enum __Attribute_GameSettings_highScore: __AttributeNonOptional { - public typealias Container = GameSettings - public typealias Value = Int - public static let name = "highScore" - public static let defaultValue: Int = 0 - public static let defaultRegistrar = __DefaultRegistrar() + public var $playerName: __AttributeProxy<__Attribute_GameSettings_playerName> { + return __AttributeProxy(attributeType: __Attribute_GameSettings_playerName.self) + } + var highScore: Int { + get { + return __Attribute_GameSettings_highScore.read() } - - public var $highScore: __AttributeProxy<__Attribute_GameSettings_highScore> { - return __AttributeProxy(attributeType: __Attribute_GameSettings_highScore.self) + set { + __Attribute_GameSettings_highScore.write(value: newValue) } + } - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "game_" - } + public enum __Attribute_GameSettings_highScore: __AttributeNonOptional { + public typealias Container = GameSettings + public typealias Value = Int + public static let name = "highScore" + public static let defaultValue: Int = 0 + public static let defaultRegistrar = __DefaultRegistrar() + } - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + public var $highScore: __AttributeProxy<__Attribute_GameSettings_highScore> { + return __AttributeProxy(attributeType: __Attribute_GameSettings_highScore.self) + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } - } + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "game_") + } - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue } } - extension GameSettings: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension GameSettings: __Settings_Container { + } + """, macros: testMacros ) #else @@ -899,46 +756,35 @@ final class UserDefaultsContainerMacroExpansionTests: XCTestCase { } """, expandedSource: """ - struct ExistingContainer: __Settings_Container { - static let someProperty = "value" - - struct Config { - var store: any UserDefaultsStore = Foundation.UserDefaults.standard - var prefix: String = "existing_" - } + struct ExistingContainer: __Settings_Container { + static let someProperty = "value" - private static let _config = OSAllocatedUnfairLock(initialState: Config()) + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "existing_") + } - public internal(set) static var store: any UserDefaultsStore { - get { - _config.withLock { config in - config.store - } - } - set { - _config.withLock { config in - config.store = newValue - } - } + public internal(set) static var store: any UserDefaultsStore { + get { + state.store } - - public static var prefix: String { - get { - _config.withLock { config in - config.prefix - } - } - set { - _config.withLock { config in - config.prefix = newValue.replacing(".", with: "_") - } - } + set { + state.store = newValue } } - extension ExistingContainer: __Settings_Container { + public internal(set) static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } } - """, + } + + extension ExistingContainer: __Settings_Container { + } + """, macros: testMacros ) #else From e60660155921839b41347022e547c8c8ec55ff5e Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Thu, 11 Dec 2025 13:18:26 +0100 Subject: [PATCH 4/6] WIP --- Sources/Settings/MacroSetting/Attribute.swift | 4 +- .../MacroSettings/MacroSettings.swift | 23 +- .../MacroSettings/Settings_Container.swift | 6 +- Sources/Settings/SwiftUI/AppSetting.swift | 2 +- Sources/SettingsClient/main.swift | 8 +- Sources/SettingsMacros/SettingMacro.swift | 1 - Sources/SettingsMacros/SettingsMacro.swift | 12 +- .../SettingMacroExpansionTests.swift | 665 +------- ...SettingsContainerMacroExpansionTests.swift | 56 +- .../SettingsSettingMacroExpansionTests.swift | 1346 +++++++++++++++++ 10 files changed, 1466 insertions(+), 657 deletions(-) create mode 100644 Tests/SettingsMacroExpansionTests/SettingsSettingMacroExpansionTests.swift diff --git a/Sources/Settings/MacroSetting/Attribute.swift b/Sources/Settings/MacroSetting/Attribute.swift index 7685b6f..04b9911 100644 --- a/Sources/Settings/MacroSetting/Attribute.swift +++ b/Sources/Settings/MacroSetting/Attribute.swift @@ -2,7 +2,7 @@ import Combine import Foundation public protocol __Attribute: SendableMetatype { - associatedtype Container: __Settings_Container + associatedtype Container // : __Settings_Container associatedtype Value static var name: String { get } @@ -24,7 +24,7 @@ public protocol __Attribute: SendableMetatype { where Subject: Equatable, Subject: Sendable } -extension __Attribute { +extension __Attribute where Container: __Settings_Container{ public static var key: String { "\(Container.prefix)\(name)" } diff --git a/Sources/Settings/MacroSettings/MacroSettings.swift b/Sources/Settings/MacroSettings/MacroSettings.swift index 2e41b5d..e2351df 100644 --- a/Sources/Settings/MacroSettings/MacroSettings.swift +++ b/Sources/Settings/MacroSettings/MacroSettings.swift @@ -10,18 +10,21 @@ /// ## Attributes /// - `prefix: String?` — Optional key prefix that is applied to all generated keys. /// - `suiteName: String?` — Optional suite name used to create a `UserDefaults` instance via `UserDefaults(suiteName:)`. -/// If `nil`, the standard defaults are used. +/// If `nil`, `UserDefaults.standard` is used. /// /// ## Members /// - Within the container, declare properties using the `@Setting` member macro to bind keys and default values. /// - You can also add `@Setting` members from extensions of the same container. /// /// ## Example -/// A simple container with one `@Setting` property. This example also demonstrates -/// the `prefix` and `suiteName` parameters on `@Settings`. +/// A UserDefaults container with one `@Setting` property. The UserDefaults container defines +/// a `prefix`parameter - which will be prepended to all keys, and a `suiteName` parameter +/// which defines the suite name for the custom UserDefaults instance. +/// on `@Settings`. /// /// ```swift /// import Foundation +/// import Settings /// /// @Settings( /// prefix: "app_", @@ -32,8 +35,13 @@ /// @Setting(key: "hasSeenOnboarding", default: false) /// static var hasSeenOnboarding: Bool /// } +/// ``` +/// >Note: A UserDefaults container using the macro `@Settings` can be declared at top level of any +/// file. For better ergonomics, declare setting values as *static* members. Make the container public so +/// that it is visible in other modules. /// -/// // Usage +/// ## Usage +/// ```swift /// // Read /// let seen = AppSettings.hasSeenOnboarding /// @@ -55,9 +63,12 @@ /// /// var settings = Settings() /// } -/// -/// // SwiftUI views automatically update when settings.hasSeenOnboarding changes /// ``` +/// SwiftUI views automatically update when settings.hasSeenOnboarding changes +/// +/// >Note: A UserDefaults container using the macro `@Settings` can be declared within classes or +/// structs. + @attached( member, names: named(Config), diff --git a/Sources/Settings/MacroSettings/Settings_Container.swift b/Sources/Settings/MacroSettings/Settings_Container.swift index 3619b57..f1a3cd2 100644 --- a/Sources/Settings/MacroSettings/Settings_Container.swift +++ b/Sources/Settings/MacroSettings/Settings_Container.swift @@ -58,7 +58,6 @@ extension __Settings_Container { attribute.write(value: newValue) } } - } extension __Settings_Container { @@ -199,3 +198,8 @@ public struct __Settings_Container_Config: Sendable { } } } + +public struct __UserDefaultsStandard: __Settings_Container { + public static var store: any UserDefaultsStore { UserDefaults.standard } + public static var prefix: String { "" } +} diff --git a/Sources/Settings/SwiftUI/AppSetting.swift b/Sources/Settings/SwiftUI/AppSetting.swift index 1c04451..6e11289 100644 --- a/Sources/Settings/SwiftUI/AppSetting.swift +++ b/Sources/Settings/SwiftUI/AppSetting.swift @@ -58,7 +58,7 @@ initialState: Config() ) - public internal(set) static var store: any UserDefaultsStore { + public static var store: any UserDefaultsStore { get { _config.withLock { config in config.store diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index c1c31c4..7bb8e29 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -3,7 +3,6 @@ import Foundation import Observation import Settings import SettingsMock -import os // MARK: - App @@ -141,6 +140,13 @@ final class ViewModel { try await main1() +// Standalone Setting +enum MyStandardUserDefaults { + enum MyModule { + @Setting static var count: Int = 0 + } +} + extension AppSettingValues { @Setting var user: String? @Setting var theme: String = "default" diff --git a/Sources/SettingsMacros/SettingMacro.swift b/Sources/SettingsMacros/SettingMacro.swift index 1ecd075..cfa19be 100644 --- a/Sources/SettingsMacros/SettingMacro.swift +++ b/Sources/SettingsMacros/SettingMacro.swift @@ -1,4 +1,3 @@ -import Foundation import SwiftCompilerPlugin import SwiftDiagnostics import SwiftSyntax diff --git a/Sources/SettingsMacros/SettingsMacro.swift b/Sources/SettingsMacros/SettingsMacro.swift index f813a03..e7fc80e 100644 --- a/Sources/SettingsMacros/SettingsMacro.swift +++ b/Sources/SettingsMacros/SettingsMacro.swift @@ -1,4 +1,3 @@ -import Foundation import SwiftCompilerPlugin import SwiftSyntax import SwiftSyntaxBuilder @@ -9,9 +8,6 @@ public struct SettingsMacro {} extension SettingsMacro: MemberMacro { // Implementation of the `@Settings` container macro - // - // This macro generates the configuration infrastructure for a container type. - // The container must manually conform to `__Settings_Container`. public static func expansion( of node: AttributeSyntax, providingMembersOf decl: some DeclGroupSyntax, @@ -27,18 +23,20 @@ extension SettingsMacro: MemberMacro { let literalPrefix = initialPrefix == nil ? "" : initialPrefix! let stateDecl: DeclSyntax = """ - private static var state: __Settings_Container_Config { __Settings_Container_Config(prefix: "\(raw: literalPrefix)") } + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "\(raw: literalPrefix)") + } """ let storeDecl: DeclSyntax = """ - public internal(set) static var store: any UserDefaultsStore { + public static var store: any UserDefaultsStore { get { state.store } set { state.store = newValue } } """ let prefixDecl: DeclSyntax = """ - public internal(set) static var prefix: String { + public static var prefix: String { get { state.prefix } set { state.prefix = newValue } } diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift index 50d7c87..ea6ef4a 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift @@ -1,9 +1,8 @@ import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest -import SwiftParser -@testable import SettingsMacros +import SettingsMacros final class SettingMacroExpansionTests: XCTestCase { struct Case { @@ -32,77 +31,7 @@ final class SettingMacroExpansionTests: XCTestCase { func testBool00() throws { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings { - @Setting var setting: Bool = true - } - """ - - let expected = """ struct AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() - } - set { - __Attribute_AppSettings_setting.write(value: newValue) - } - } - - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } - - extension AppSettings: __Settings_Container { - } - """ - - assertMacroExpansion( - input, - expandedSource: expected, - macros: testMacros - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif - } - - func testBool10() throws { - #if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings { @Setting var setting: Bool = true } """ @@ -119,7 +48,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -129,31 +58,6 @@ final class SettingMacroExpansionTests: XCTestCase { public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) } - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "app_") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } - - extension AppSettings: __Settings_Container { } """ @@ -172,7 +76,7 @@ final class SettingMacroExpansionTests: XCTestCase { func testBool01() throws { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings {} + struct AppSettings {} extension AppSettings { @Setting var setting: Bool = true @@ -180,30 +84,7 @@ final class SettingMacroExpansionTests: XCTestCase { """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { var setting: Bool { @@ -227,84 +108,6 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) } } - - extension AppSettings: __Settings_Container { - } - """ - - assertMacroExpansion( - input, - expandedSource: expected, - macros: testMacros - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif - } - - func testBool11() throws { - #if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings {} - - extension AppSettings { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "app_") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } - - extension AppSettings { - var setting: Bool { - get { - return __Attribute_AppSettings_setting.read() - } - set { - __Attribute_AppSettings_setting.write(value: newValue) - } - } - - public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) - } - } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -320,42 +123,26 @@ final class SettingMacroExpansionTests: XCTestCase { } func testNestedBool00() throws { + + // enum StandardUserDefaults { + // enum MyModule { + // @Setting static var count: Int = 0 + // } + // } + #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings {} + enum AppSettings {} extension AppSettings { enum Profile {} } extension AppSettings.Profile { - @Setting var setting: Bool = true + @Setting static var setting: Bool = true } """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { enum Profile {} } @@ -371,7 +158,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -382,9 +169,6 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -402,7 +186,7 @@ final class SettingMacroExpansionTests: XCTestCase { func testNestedBool10() throws { #if canImport(SettingsMacros) let input = """ - @Settings(prefix: "app_") struct AppSettings {} + struct AppSettings {} extension AppSettings { enum Profile {} } @@ -412,30 +196,7 @@ final class SettingMacroExpansionTests: XCTestCase { """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "app_") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { enum Profile {} } @@ -451,7 +212,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -462,9 +223,6 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -482,82 +240,7 @@ final class SettingMacroExpansionTests: XCTestCase { func testNestedBool01() throws { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings { enum Profile {} } - - extension AppSettings.Profile { - @Setting var setting: Bool = true - } - """ - - let expected = """ - struct AppSettings { enum Profile {} - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } - - extension AppSettings.Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() - } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) - } - } - - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } - - extension AppSettings: __Settings_Container { - } - """ - - assertMacroExpansion( - input, - expandedSource: expected, - macros: testMacros - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif - } - - func testNestedBool11() throws { - #if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings { enum Profile {} } + struct AppSettings { enum Profile {} } extension AppSettings.Profile { @Setting var setting: Bool = true @@ -566,28 +249,6 @@ final class SettingMacroExpansionTests: XCTestCase { let expected = """ struct AppSettings { enum Profile {} - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "app_") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } } extension AppSettings.Profile { @@ -601,7 +262,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -612,9 +273,6 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -632,7 +290,7 @@ final class SettingMacroExpansionTests: XCTestCase { func testNestedBool02() throws { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings {} + struct AppSettings {} extension AppSettings { enum Profile { @Setting var setting: Bool = true @@ -640,30 +298,7 @@ final class SettingMacroExpansionTests: XCTestCase { """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { enum Profile { var setting: Bool { @@ -676,7 +311,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -688,9 +323,6 @@ final class SettingMacroExpansionTests: XCTestCase { } } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -705,82 +337,6 @@ final class SettingMacroExpansionTests: XCTestCase { #endif } - func testNestedBool12() throws { - #if canImport(SettingsMacros) - let input = """ - @Settings(prefix: "app_") struct AppSettings {} - - extension AppSettings { enum Profile { - @Setting var setting: Bool = true - } } - """ - - let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "app_") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } - - extension AppSettings { enum Profile { - var setting: Bool { - get { - return __Attribute_AppSettings_Profile_setting.read() - } - set { - __Attribute_AppSettings_Profile_setting.write(value: newValue) - } - } - - public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings - public typealias Value = Bool - public static let name = "Profile::setting" - public static let defaultValue: Bool = true - public static let defaultRegistrar = __DefaultRegistrar() - } - - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { - return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) - } - } - } - - extension AppSettings: __Settings_Container { - } - """ - - assertMacroExpansion( - input, - expandedSource: expected, - macros: testMacros - ) - #else - throw XCTSkip( - "macros are only supported when running tests for the host platform" - ) - #endif - } - func testAllPropertyListTypes() throws { #if canImport(SettingsMacros) let allCases: [Case] = [ @@ -895,7 +451,7 @@ final class SettingMacroExpansionTests: XCTestCase { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings {} + struct AppSettings {} extension AppSettings { enum UserSettings {} } extension AppSettings.UserSettings { @@ -904,30 +460,7 @@ final class SettingMacroExpansionTests: XCTestCase { """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { enum UserSettings {} } @@ -942,7 +475,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" @@ -953,9 +486,6 @@ final class SettingMacroExpansionTests: XCTestCase { return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -973,37 +503,14 @@ final class SettingMacroExpansionTests: XCTestCase { func testNestedContainer2() throws { #if canImport(SettingsMacros) let input = """ - @Settings struct AppSettings {} + struct AppSettings {} extension AppSettings { enum UserSettings { @Setting var setting: String = "default" } } """ let expected = """ - struct AppSettings { - - private static var state: __Settings_Container_Config { - __Settings_Container_Config(prefix: "") - } - - public internal(set) static var store: any UserDefaultsStore { - get { - state.store - } - set { - state.store = newValue - } - } - - public internal(set) static var prefix: String { - get { - state.prefix - } - set { - state.prefix = newValue - } - } - } + struct AppSettings {} extension AppSettings { enum UserSettings { var setting: String { get { @@ -1015,7 +522,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __UserDefaultsStandard public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" @@ -1027,9 +534,6 @@ final class SettingMacroExpansionTests: XCTestCase { } } } - - extension AppSettings: __Settings_Container { - } """ assertMacroExpansion( @@ -1099,7 +603,7 @@ extension SettingMacroExpansionTests { var containerQualifiedName: String let valueType: String var initialValue: String = "" - var macroAttributes: String = "" + var settingMacroAttributes: String = "" var isStatic: Bool = false var isOptional: Bool { @@ -1133,10 +637,10 @@ extension SettingMacroExpansionTests { let template: String = """ - @Settings struct \(containerBaseName)\(testCase.macroAttributes) {}\ + struct \(containerBaseName) {}\ \(nestedContainerDecl, indentation: 0) extension \(testCase.containerQualifiedName) { - @Setting\(testCase.macroAttributes) \(staticModifier)var setting: \(testCase.valueType)\(initializer) + @Setting\(testCase.settingMacroAttributes) \(staticModifier)var setting: \(testCase.valueType)\(initializer) } """ return template @@ -1236,64 +740,8 @@ extension SettingMacroExpansionTests { } """ - // Extract prefix from test case macro attributes - let prefixValue: String - if testCase.macroAttributes.contains("prefix:") { - // Extract the prefix value from macro attributes - if let match = testCase.macroAttributes.range( - of: #"prefix: "([^"]*)"#, - options: .regularExpression - ) { - let prefixMatch = testCase.macroAttributes[match] - if let valueStart = prefixMatch.range(of: "\"") { - let afterQuote = prefixMatch[valueStart.upperBound...] - if let endQuote = afterQuote.firstIndex(of: "\"") { - prefixValue = String(afterQuote[.. { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testBool10() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testBool01() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + + extension AppSettings { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testBool11() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} + + extension AppSettings { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { + var setting: Bool { + get { + return __Attribute_AppSettings_setting.read() + } + set { + __Attribute_AppSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool00() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + + extension AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { enum Profile {} + } + + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool10() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} + + extension AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { enum Profile {} + } + + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool01() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { enum Profile {} + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool11() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings { enum Profile {} } + + extension AppSettings.Profile { + @Setting var setting: Bool = true + } + """ + + let expected = """ + struct AppSettings { enum Profile {} + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings.Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool02() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + + extension AppSettings { enum Profile { + @Setting var setting: Bool = true + } } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { enum Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedBool12() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings(prefix: "app_") struct AppSettings {} + + extension AppSettings { enum Profile { + @Setting var setting: Bool = true + } } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "app_") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + + extension AppSettings { enum Profile { + var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testAllPropertyListTypes() throws { + #if canImport(SettingsMacros) + let allCases: [Case] = [ + // Non-optional PropertyList types + Case(type: "Bool", initializer: "true", description: "Bool"), + Case(type: "Int", initializer: "42", description: "Int"), + Case( + type: "Double", + initializer: "3.14", + description: "Double" + ), + Case(type: "Float", initializer: "2.71", description: "Float"), + Case( + type: "String", + initializer: "\"default\"", + description: "String" + ), + Case(type: "Date", initializer: "Date()", description: "Date"), + Case(type: "Data", initializer: "Data()", description: "Data"), + Case( + type: "Array", + initializer: "[]", + description: "Array of Any" + ), + Case( + type: "Dictionary", + initializer: "[:]", + description: "Dictionary of Any" + ), + + // Optional PropertyList types + Case(optionalType: "Bool", description: "Optional Bool"), + Case(optionalType: "Int", description: "Optional Int"), + Case(optionalType: "Double", description: "Optional Double"), + Case(optionalType: "Float", description: "Optional Float"), + Case(optionalType: "String", description: "Optional String"), + Case(optionalType: "Date", description: "Optional Date"), + Case(optionalType: "Data", description: "Optional Data"), + Case( + optionalType: "Array", + description: "Optional Array of Any" + ), + Case( + optionalType: "Dictionary", + description: "Optional Dictionary of Any" + ), + + // Collections + Case( + type: "[String]", + initializer: "[]", + description: "[String]" + ), + Case( + type: "Array", + initializer: "[]", + description: "Array" + ), + Case( + type: "[String: Int]", + initializer: "[:]", + description: "[String: Int]" + ), + ] + + for value in allCases { + let testCase = TestCase( + description: value.description, + containerQualifiedName: "AppSettings", + valueType: value.type, + initialValue: value.initialValue + ) + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + } + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedContainer() throws { + #if canImport(SettingsMacros) + let testCase = TestCase( + description: "nested property", + containerQualifiedName: "AppSettings.UserSettings", + valueType: "String", + initialValue: "\"default\"" + ) + + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedContainer0() throws { + #if canImport(SettingsMacros) + + let input = """ + @Settings struct AppSettings {} + extension AppSettings { enum UserSettings {} } + + extension AppSettings.UserSettings { + @Setting var setting: String = "default" + } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + extension AppSettings { enum UserSettings {} + } + + extension AppSettings.UserSettings { + var setting: String { + get { + return __Attribute_AppSettings_UserSettings_setting.read() + } + set { + __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = String + public static let name = "UserSettings::setting" + public static let defaultValue: String = "default" + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testNestedContainer2() throws { + #if canImport(SettingsMacros) + let input = """ + @Settings struct AppSettings {} + extension AppSettings { enum UserSettings { + @Setting var setting: String = "default" + } } + """ + + let expected = """ + struct AppSettings { + + private static var state: __Settings_Container_Config { + __Settings_Container_Config(prefix: "") + } + + public static var store: any UserDefaultsStore { + get { + state.store + } + set { + state.store = newValue + } + } + + public static var prefix: String { + get { + state.prefix + } + set { + state.prefix = newValue + } + } + } + extension AppSettings { enum UserSettings { + var setting: String { + get { + return __Attribute_AppSettings_UserSettings_setting.read() + } + set { + __Attribute_AppSettings_UserSettings_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { + public typealias Container = AppSettings + public typealias Value = String + public static let name = "UserSettings::setting" + public static let defaultValue: String = "default" + public static let defaultRegistrar = __DefaultRegistrar() + } + + public var $setting: __AttributeProxy<__Attribute_AppSettings_UserSettings_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_UserSettings_setting.self) + } + } + } + + extension AppSettings: __Settings_Container { + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testStaticProperty() throws { + #if canImport(SettingsMacros) + let testCase = TestCase( + description: "static property", + containerQualifiedName: "AppSettings", + valueType: "String", + initialValue: "\"default\"", + isStatic: true + ) + + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + + func testCodableWithEncodingStrategy() throws { + #if canImport(SettingsMacros) + let testCase = TestCase( + description: "Codable with encoding", + containerQualifiedName: "AppSettingsd", + valueType: "CustomValue", + initialValue: ".init()" + ) + + let input = makeSource(for: testCase) + let expected = makeExpectedExpandedSource(for: testCase) + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } +} + +extension SettingsSettingMacroExpansionTests { + + private struct TestCase { + let description: String + var containerQualifiedName: String + let valueType: String + var initialValue: String = "" + var macroAttributes: String = "" + var isStatic: Bool = false + + var isOptional: Bool { + valueType.hasSuffix("?") + } + + var protocolName: String { + isOptional ? "__AttributeOptional" : "__AttributeNonOptional" + } + + var wrappedDecl: String { + guard isOptional else { + return "" + } + // Extract unwrapped type from Optional + let unwrappedType = String(valueType.dropLast()) + return "public typealias Wrapped = \(unwrappedType)" + } + } + + private func makeSource( + for testCase: TestCase, + ) -> String { + let initializer = + testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" + let staticModifier = testCase.isStatic ? "static " : "" + + let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl( + from: testCase + ) + + let template: String = + """ + @Settings struct \(containerBaseName)\(testCase.macroAttributes) {}\ + \(nestedContainerDecl, indentation: 0) + extension \(testCase.containerQualifiedName) { + @Setting\(testCase.macroAttributes) \(staticModifier)var setting: \(testCase.valueType)\(initializer) + } + """ + return template + } + + private func attributeTypeName( + containerQualifiedName: String, + property: String + ) -> String { + let sanitizedContainer = containerQualifiedName.replacingOccurrences( + of: ".", + with: "_" + ) + return "__Attribute_\(sanitizedContainer)_\(property)" + } + + private func makeNestedContainerDecl( + from testCase: TestCase, + forSource: Bool = true + ) -> (decl: String, container: String) { + // Make nested container declaration: + var nestedContainerDecl: String = "" + let components = testCase.containerQualifiedName.split(separator: ".") + .map(String.init) + assert(components.count > 0) + assert(components.count <= 2) + let containerBaseName = components.first! + if components.count > 1 { + // define intermediate nested container(s) + // only one level is needed for current tests, so just the first level + let root = components[0] + let nested = components[1] + if forSource { + nestedContainerDecl = "extension \(root) { enum \(nested) {} }" + } else { + nestedContainerDecl = + "extension \(root) { enum \(nested) {} \n}\n" // Note: the swift syntax will add a new line character after the enum decl. + } + } + return (nestedContainerDecl, containerBaseName) + } + + private func makeExpectedExpandedSource( + for testCase: TestCase, + attributeArguments: String? = nil, + nameOverride: String? = nil + ) -> String { + let initializer = + testCase.initialValue.isEmpty ? "" : " = \(testCase.initialValue)" + let staticModifier = testCase.isStatic ? "static " : "" + + let (nestedContainerDecl, containerBaseName) = makeNestedContainerDecl( + from: testCase, + forSource: false + ) + + // make name decl + let nestedNameComponents = + testCase.containerQualifiedName.split(separator: ".").dropFirst(1) + .map(String.init) + ["setting"] + let name = nameOverride ?? nestedNameComponents.joined(separator: "::") + + // default decls (only for non-optional) + let defaultValueDecl = + initializer.isEmpty + ? "" + : "public static let defaultValue: \(testCase.valueType)\(initializer)" + let defaultRegistrarDecl = + initializer.isEmpty + ? "" : "public static let defaultRegistrar = __DefaultRegistrar()" + + // make enum name: + let enumName = + "__Attribute_\(testCase.containerQualifiedName.split(separator: ".").joined(separator: "_"))_setting" + + let encodingBlock: String + if let attrArgs = attributeArguments, + attrArgs.contains("(encoding: .json)") + { + encodingBlock = + """ + public static var encoder: some AttributeEncoding { + return JSONEncoder() + } + public static var decoder: some AttributeDecoding { + return JSONDecoder() + } + """ + } else { + encodingBlock = "" + } + + let proxyAccessor = """ + + public \(staticModifier)var $setting: __AttributeProxy<\(enumName)> { + return __AttributeProxy(attributeType: \(enumName).self) + } + """ + + // Extract prefix from test case macro attributes + let prefixValue: String + if testCase.macroAttributes.contains("prefix:") { + // Extract the prefix value from macro attributes + if let match = testCase.macroAttributes.range( + of: #"prefix: "([^"]*)"#, + options: .regularExpression + ) { + let prefixMatch = testCase.macroAttributes[match] + if let valueStart = prefixMatch.range(of: "\"") { + let afterQuote = prefixMatch[valueStart.upperBound...] + if let endQuote = afterQuote.firstIndex(of: "\"") { + prefixValue = String(afterQuote[.. Date: Thu, 11 Dec 2025 15:48:56 +0100 Subject: [PATCH 5/6] fix macro expansion using ContainerResolver --- Sources/Settings/MacroSetting/Attribute.swift | 31 ++++++- Sources/SettingsMacros/SettingMacro.swift | 65 ++++++++++----- .../SettingMacroExpansionTests.swift | 83 ++++++++++++++----- .../SettingsSettingMacroExpansionTests.swift | 24 +++--- 4 files changed, 150 insertions(+), 53 deletions(-) diff --git a/Sources/Settings/MacroSetting/Attribute.swift b/Sources/Settings/MacroSetting/Attribute.swift index 04b9911..09c9b1f 100644 --- a/Sources/Settings/MacroSetting/Attribute.swift +++ b/Sources/Settings/MacroSetting/Attribute.swift @@ -2,7 +2,7 @@ import Combine import Foundation public protocol __Attribute: SendableMetatype { - associatedtype Container // : __Settings_Container + associatedtype Container: __Settings_Container associatedtype Value static var name: String { get } @@ -37,6 +37,35 @@ extension __Attribute where Container: __Settings_Container{ public protocol PropertyListValue {} +// MARK: - Container Resolver + +// A type-erasing wrapper that conditionally conforms to `__Settings_Container`. +// +// When `Base` conforms to `__Settings_Container`, this wrapper forwards to the base. +// Otherwise, it provides default behavior using `UserDefaults.standard` with no prefix. +public struct __ContainerResolver { + private init() {} // Never instantiated +} + +extension __ContainerResolver: __Settings_Container { + public static var store: any UserDefaultsStore { + UserDefaults.standard + } + + public static var prefix: String { "" } +} + +extension __ContainerResolver where Base: __Settings_Container { + public static var store: any UserDefaultsStore { + Base.store + } + + public static var prefix: String { + Base.prefix + } +} + + // MARK: - Internal extension Bool: PropertyListValue {} diff --git a/Sources/SettingsMacros/SettingMacro.swift b/Sources/SettingsMacros/SettingMacro.swift index cfa19be..8052a04 100644 --- a/Sources/SettingsMacros/SettingMacro.swift +++ b/Sources/SettingsMacros/SettingMacro.swift @@ -271,18 +271,21 @@ extension SettingMacro { ) } - /// Creates the `typealias Container = ` member for the attribute enum. + /// Creates the `typealias Container = __ContainerResolver` member for the attribute enum. + /// This uses conditional conformance to select either the base container (if it conforms to __Settings_Container) + /// or provides a default implementation with UserDefaults.standard. private static func makeContainerTypealias(containerType: String) -> MemberBlockItemSyntax { - MemberBlockItemSyntax( + // Generate: typealias Container = __ContainerResolver + let containerResolverType = "__ContainerResolver<\(containerType)>" + + return MemberBlockItemSyntax( decl: TypeAliasDeclSyntax( modifiers: [DeclModifierSyntax(name: .keyword(.public))], name: "Container", initializer: TypeInitializerClauseSyntax( - value: IdentifierTypeSyntax( - name: .identifier(containerType) - ) + value: TypeSyntax(stringLiteral: containerResolverType) ) ) ) @@ -857,8 +860,11 @@ extension SettingMacro { // Walk up the container type hierarchy and find the first type // which is a store Container (aka `__Settings_Container`) var topContainer: String = "" + var inExtension = false + for contextNode in context.lexicalContext { if let extensionDecl = contextNode.as(ExtensionDeclSyntax.self) { + inExtension = true let fullTypeName = extensionDecl.extendedType.description .trimmingCharacters(in: .whitespacesAndNewlines) topContainer = @@ -871,38 +877,47 @@ extension SettingMacro { } } if let structDecl = contextNode.as(StructDeclSyntax.self) { - topContainer = structDecl.name.text + if topContainer.isEmpty { + topContainer = structDecl.name.text + } if isAnnotatedWithUserDefaultsMacro(structDecl.attributes) { - return topContainer + return structDecl.name.text } } if let classDecl = contextNode.as(ClassDeclSyntax.self) { - topContainer = classDecl.name.text + if topContainer.isEmpty { + topContainer = classDecl.name.text + } if isAnnotatedWithUserDefaultsMacro(classDecl.attributes) { - return topContainer + return classDecl.name.text } } if let enumDecl = contextNode.as(EnumDeclSyntax.self) { - topContainer = enumDecl.name.text + if topContainer.isEmpty { + topContainer = enumDecl.name.text + } if isAnnotatedWithUserDefaultsMacro(enumDecl.attributes) { - return topContainer + return enumDecl.name.text } } if let actorDecl = contextNode.as(ActorDeclSyntax.self) { - topContainer = actorDecl.name.text + if topContainer.isEmpty { + topContainer = actorDecl.name.text + } if isAnnotatedWithUserDefaultsMacro(actorDecl.attributes) { - return topContainer + return actorDecl.name.text } } } - // If no Container found, use the root container, and make the - // assumption it is_a `__Settings_Container`. If it is not, - // the compiler will emit an error. - guard !topContainer.isEmpty else { - throw MacroError.noContainerFound + // If we're in an extension or found a top container name, return it + // The type system will resolve whether it's a __Settings_Container via __ContainerResolver + if !topContainer.isEmpty { + return topContainer } - return topContainer + + // No container found at all - this shouldn't happen in valid Swift code + throw MacroError.noContainerFound } /// Returns the containers attributes. @@ -1190,6 +1205,18 @@ extension SettingMacro { ) return "\(namespace)::\(propertyName)" } + } else { + // No extension, check if we have nested types (e.g., enum inside enum) + if !nestedTypeNames.isEmpty { + // Reverse because we collected from innermost to outermost + // Skip the outermost container (the one that would have @Settings) + let namespace = nestedTypeNames.dropLast().reversed().joined( + separator: "::" + ) + if !namespace.isEmpty { + return "\(namespace)::\(propertyName)" + } + } } // Default: just use the property name diff --git a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift index ea6ef4a..6960ce3 100644 --- a/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingMacroExpansionTests.swift @@ -1,9 +1,8 @@ +import SettingsMacros import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest -import SettingsMacros - final class SettingMacroExpansionTests: XCTestCase { struct Case { init(type: String, initializer: String, description: String) { @@ -48,7 +47,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -97,7 +96,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -122,14 +121,56 @@ final class SettingMacroExpansionTests: XCTestCase { #endif } - func testNestedBool00() throws { - - // enum StandardUserDefaults { - // enum MyModule { - // @Setting static var count: Int = 0 - // } - // } + func testNested01() throws { + #if canImport(SettingsMacros) + let input = """ + enum AppSettings { + enum Profile { + @Setting static var setting: Bool = true + } + } + """ + + let expected = """ + enum AppSettings { + enum Profile { + static var setting: Bool { + get { + return __Attribute_AppSettings_Profile_setting.read() + } + set { + __Attribute_AppSettings_Profile_setting.write(value: newValue) + } + } + + public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { + public typealias Container = __ContainerResolver + public typealias Value = Bool + public static let name = "Profile::setting" + public static let defaultValue: Bool = true + public static let defaultRegistrar = __DefaultRegistrar() + } + + public static var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) + } + } + } + """ + + assertMacroExpansion( + input, + expandedSource: expected, + macros: testMacros + ) + #else + throw XCTSkip( + "macros are only supported when running tests for the host platform" + ) + #endif + } + func testNestedBool00() throws { #if canImport(SettingsMacros) let input = """ enum AppSettings {} @@ -142,13 +183,13 @@ final class SettingMacroExpansionTests: XCTestCase { """ let expected = """ - struct AppSettings {} + enum AppSettings {} extension AppSettings { enum Profile {} } extension AppSettings.Profile { - var setting: Bool { + static var setting: Bool { get { return __Attribute_AppSettings_Profile_setting.read() } @@ -158,14 +199,14 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true public static let defaultRegistrar = __DefaultRegistrar() } - public var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { + public static var $setting: __AttributeProxy<__Attribute_AppSettings_Profile_setting> { return __AttributeProxy(attributeType: __Attribute_AppSettings_Profile_setting.self) } } @@ -212,7 +253,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -262,7 +303,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -311,7 +352,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -475,7 +516,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" @@ -522,7 +563,7 @@ final class SettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" @@ -754,7 +795,7 @@ extension SettingMacroExpansionTests { } public enum \(enumName): \(testCase.protocolName) { - public typealias Container = __UserDefaultsStandard + public typealias Container = __ContainerResolver public typealias Value = \(testCase.valueType)\ \(testCase.wrappedDecl, indentation: 8) public static let name = "\(name)"\ diff --git a/Tests/SettingsMacroExpansionTests/SettingsSettingMacroExpansionTests.swift b/Tests/SettingsMacroExpansionTests/SettingsSettingMacroExpansionTests.swift index a8cbeab..019d08a 100644 --- a/Tests/SettingsMacroExpansionTests/SettingsSettingMacroExpansionTests.swift +++ b/Tests/SettingsMacroExpansionTests/SettingsSettingMacroExpansionTests.swift @@ -48,7 +48,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -118,7 +118,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -215,7 +215,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -290,7 +290,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "setting" public static let defaultValue: Bool = true @@ -370,7 +370,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -450,7 +450,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -525,7 +525,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -600,7 +600,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -675,7 +675,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -751,7 +751,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_Profile_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = Bool public static let name = "Profile::setting" public static let defaultValue: Bool = true @@ -941,7 +941,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" @@ -1014,7 +1014,7 @@ final class SettingsSettingMacroExpansionTests: XCTestCase { } public enum __Attribute_AppSettings_UserSettings_setting: __AttributeNonOptional { - public typealias Container = AppSettings + public typealias Container = __ContainerResolver public typealias Value = String public static let name = "UserSettings::setting" public static let defaultValue: String = "default" From 316a9df4c9781a234efefacd8fbd5b19e2e13494 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Thu, 11 Dec 2025 16:20:51 +0100 Subject: [PATCH 6/6] Update README --- README.md | 31 +++++++++++++++++++++++-------- Sources/SettingsClient/main.swift | 5 +++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4b75824..6a3e44a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,11 @@ struct AppSettingsView: View { } } } +``` + +Use the `UserDefaultsStoreMock` class in Previews: +```swift #if DEBUG import SettingsMock @@ -77,6 +81,8 @@ import SettingsMock #endif ``` +> Note: `AppSettingValues` is a mockable UserDefaults container already declared in the Settings library. + ## Set a global key prefix: ```swift @@ -99,20 +105,25 @@ struct MyApp: App { ## Custom Settings Container -```swift -import Settings +You do not need to put the user settings into a type annotated with the `@Settings` macro. You also can declare or use any regular struct, class or enum as your UserDefaults container: -@Settings(prefix: "app_") // keys prefixed with "app_" +```swift struct AppSettings { @Setting static var username: String = "Guest" - @Setting(name: "colorScheme") static var theme: String = "light" // key = "colorScheme" - @Setting static var apiKey: String? // optional: no default } +``` +This will store and read from the setting in Foundation's `UserDefaults.standard`. -AppSettings.username = "Alice" -print(AppSettings.theme) // "light" +If you require more control, like having key prefixes, using your own UserDefaults suite, or if you want to mock Foundation UserDefaults in Previews or elsewhere, use the `@Settings` macro: + +```swift +@Settings(prefix: "app_") // keys prefixed with "app_" +struct AppSettings { + @Setting static var username: String = "Guest" +} ``` + ## Projected Value ($propertyName) Access metadata and observe changes: @@ -165,14 +176,18 @@ Settings.apiKey = nil // removes key from UserDefaults ## Nested Containers +Support keys within a name space: + ```swift +struct AppSettings {} + extension AppSettings { enum UserSettings {} } extension AppSettings.UserSettings { @Setting static var email: String? } -print(AppSettings.UserSettings.$email.key) // "app_UserSettings::email" +print(AppSettings.UserSettings.$email.key) // "UserSettings::email" AppSettings.UserSettings.email = "alice@example.com" ``` diff --git a/Sources/SettingsClient/main.swift b/Sources/SettingsClient/main.swift index 7bb8e29..95f5548 100644 --- a/Sources/SettingsClient/main.swift +++ b/Sources/SettingsClient/main.swift @@ -147,6 +147,11 @@ enum MyStandardUserDefaults { } } +enum F { + @Setting static var count: Int = 0 +} + + extension AppSettingValues { @Setting var user: String? @Setting var theme: String = "default"