diff --git a/.gitignore b/.gitignore index 0854237e..02576fed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.build +/.swiftlint-cache /Packages xcuserdata project.xcworkspace diff --git a/.swiftlint.yml b/.swiftlint.yml index 4602d5cc..6cc51860 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -87,7 +87,7 @@ only_rules: - number_separator - opening_brace - operator_usage_whitespace - - operator_whitespace + - function_name_whitespace - overridden_super_call - prefer_self_in_static_references - prefer_self_type_over_type_of_self @@ -104,7 +104,7 @@ only_rules: - redundant_discardable_let - redundant_nil_coalescing - redundant_objc_attribute - - redundant_optional_initialization + - implicit_optional_initialization - redundant_set_access_control - redundant_string_enum_value - redundant_type_annotation @@ -153,6 +153,8 @@ only_rules: - xct_specific_matcher - xctfail_message - yoda_condition +excluded: + - .build analyzer_rules: - capture_variable - unused_declaration diff --git a/Sources/Defaults/Defaults+Bridge.swift b/Sources/Defaults/Defaults+Bridge.swift index 7dc27344..83115f87 100644 --- a/Sources/Defaults/Defaults+Bridge.swift +++ b/Sources/Defaults/Defaults+Bridge.swift @@ -323,16 +323,15 @@ extension Defaults { if Bound.isNativelySupportedType { return [value.lowerBound, value.upperBound] - } else { - guard - let lowerBound = Bound.bridge.serialize(value.lowerBound as? Bound.Value), - let upperBound = Bound.bridge.serialize(value.upperBound as? Bound.Value) - else { - return nil - } - - return [lowerBound, upperBound] } + guard + let lowerBound = Bound.bridge.serialize(value.lowerBound as? Bound.Value), + let upperBound = Bound.bridge.serialize(value.upperBound as? Bound.Value) + else { + return nil + } + + return [lowerBound, upperBound] } public func deserialize(_ object: Serializable?) -> Value? { diff --git a/Sources/Defaults/Documentation.docc/Documentation.md b/Sources/Defaults/Documentation.docc/Documentation.md index 2951c04d..149d038a 100644 --- a/Sources/Defaults/Documentation.docc/Documentation.md +++ b/Sources/Defaults/Documentation.docc/Documentation.md @@ -60,6 +60,7 @@ struct ContentView: View { ### Customize Storage +- - - ``Defaults/Serializable`` - ``Defaults/Bridge`` diff --git a/Sources/Defaults/Documentation.docc/SerdeDefault.md b/Sources/Defaults/Documentation.docc/SerdeDefault.md new file mode 100644 index 00000000..ea88f760 --- /dev/null +++ b/Sources/Defaults/Documentation.docc/SerdeDefault.md @@ -0,0 +1,84 @@ +# Using @SerdeDefault + +Use `@SerdeDefault` to make a struct compatible with Defaults without writing a custom bridge. + +## Overview + +The `@SerdeDefault` macro generates a ``Defaults/Serializable`` conformance from a struct's stored properties. Add the `DefaultsMacros` product to your target, import both modules, and attach the macro to your type: + +```swift +import Defaults +import DefaultsMacros + +@SerdeDefault +struct User { + var name: String + var age: UInt? +} + +extension Defaults.Keys { + static let user = Key( + "user", + default: User(name: "Appleseed", age: nil) + ) +} + +Defaults[.user].name +//=> "Appleseed" +``` + +The generated bridge stores the properties in a dictionary keyed by property name. Native Defaults values are stored directly. Other property types use their own ``Defaults/Serializable`` conformance, so you can compose models from other compatible types. + +```swift +@SerdeDefault +struct Profile { + var user: User + var tags: [String] +} +``` + +### Provide Fallback Values + +A property initializer is used as a fallback when the stored value is missing or cannot be deserialized. An optional property without an initializer falls back to `nil`. + +```swift +@SerdeDefault +struct Settings { + var displayName = "Anonymous" + var launchCount = 0 + var lastOpenedAt: Date? +} +``` + +A required property without an initializer must be present and valid. If it is not, the generated bridge cannot deserialize the struct and Defaults returns the key's default value. + +### Exclude a Property + +Use `@serde(.skip)` for optional state that should not be persisted. The generated bridge omits the property when serializing and initializes it to `nil` when deserializing. + +```swift +@SerdeDefault +struct User { + var name: String + + @serde(.skip) + var cachedAvatar: Data? +} +``` + +Only optional properties can use `@serde(.skip)`. + +### Understand the Limitations + +`@SerdeDefault` supports non-generic structs. Static and computed properties are ignored. Stored properties must either use a type supported by Defaults or conform to ``Defaults/Serializable``. + +> Warning: Renaming a stored property changes its dictionary key. Migrate previously stored values before renaming properties in a released app. + +> Warning: Using macros can increase build times because the `DefaultsMacros` product depends on `swift-syntax`. + +## Topics + +### Related APIs + +- ``Defaults/Serializable`` +- ``Defaults/Bridge`` diff --git a/Sources/Defaults/Documentation.docc/SupportedTypes.md b/Sources/Defaults/Documentation.docc/SupportedTypes.md index e365c60d..d3d12491 100644 --- a/Sources/Defaults/Documentation.docc/SupportedTypes.md +++ b/Sources/Defaults/Documentation.docc/SupportedTypes.md @@ -104,12 +104,15 @@ Defaults[.user].name ## Custom Types -You can easily add support for any custom type. See for how to add support for your own types. +You can generate ``Defaults/Serializable`` conformance for a struct with the `@SerdeDefault` macro. See to get started. + +For complete control over the stored representation, see to learn how to create a custom bridge. ## Topics ### Making Types Compatible +- - ``Defaults/Serializable`` - ``Defaults/Bridge`` diff --git a/Sources/Defaults/Utilities.swift b/Sources/Defaults/Utilities.swift index a51d4b13..9ea70049 100644 --- a/Sources/Defaults/Utilities.swift +++ b/Sources/Defaults/Utilities.swift @@ -200,7 +200,7 @@ extension Defaults.Serializable { return Value.toValue(anyObject) ``` */ - static func toValue(_ anyObject: Any, type: T.Type = Self.self) -> T? { + public static func toValue(_ anyObject: Any, type: T.Type = Self.self) -> T? { if T.isNativelySupportedType, let anyObject = anyObject as? T @@ -228,8 +228,7 @@ extension Defaults.Serializable { set(Value.toSerialize(value), forKey: key) ``` */ - @usableFromInline - static func toSerializable(_ value: T) -> Any? { + public static func toSerializable(_ value: T) -> Any? { guard !T.isNativelySupportedType else { return value } diff --git a/Sources/DefaultsMacros/SerdeDefault.swift b/Sources/DefaultsMacros/SerdeDefault.swift new file mode 100644 index 00000000..f6110e31 --- /dev/null +++ b/Sources/DefaultsMacros/SerdeDefault.swift @@ -0,0 +1,65 @@ +import Defaults + +/** +Options for customizing how a property is serialized. +*/ +public enum SerdeOption { + /** + Exclude the property from serialization and deserialization. + */ + case skip +} + +/** +Make a struct conform to `Defaults.Serializable`. + +The macro uses the struct's stored properties to serialize and deserialize it. + +```swift +import Defaults +import DefaultsMacros + +@SerdeDefault +struct User { + var name: String + var age: UInt? +} + +extension Defaults.Keys { + static let user = Key( + "user", + default: User(name: "Appleseed", age: nil) + ) +} + +Defaults[.user].name +//=> "Appleseed" +``` + +- Important: This macro only supports non-generic structs. +*/ +@attached(extension, conformances: Defaults.Serializable, names: named(bridge), suffixed(Bridge)) +public macro SerdeDefault() = #externalMacro(module: "DefaultsMacrosDeclarations", type: "SerdeDefaultMacro") + +/** +Customize how a stored property in a `@SerdeDefault` struct is serialized. + +Use `.skip` to exclude an optional property from serialization and deserialization. + +```swift +@SerdeDefault +struct User { + var name: String + + @serde(.skip) + var cachedAvatar: Data? +} +``` + +- Important: Only optional properties can use `.skip`. +*/ +@attached(peer) +public macro serde(_ options: SerdeOption...) = #externalMacro( + module: "DefaultsMacrosDeclarations", + type: "SerdeMacro" +) diff --git a/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift index 689f41dd..2c12ef78 100644 --- a/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift +++ b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift @@ -4,6 +4,8 @@ import SwiftSyntaxMacros @main struct DefaultsMacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ - ObservableDefaultMacro.self + ObservableDefaultMacro.self, + SerdeDefaultMacro.self, + SerdeMacro.self ] } diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Deserialize.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Deserialize.swift new file mode 100644 index 00000000..e0e30ad9 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Deserialize.swift @@ -0,0 +1,119 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Code generator for the `deserialize` function body. + + For native properties: + + ```swift + let property = base["property"] as? PropertyType + ``` + + For serializable properties: + + ```swift + let property = PropertyType.toValue(base["property"] as Any, type: PropertyType.self) + ``` + */ + enum Deserialize: CodeGenerating { + struct Context { + /** + The serialized dictionary parameter identifier being deserialized. + */ + let base: TokenSyntax + + /** + The nominal type being rebuilt. + */ + let typeSyntax: TypeSyntax + } + + /** + Generated deserialization code for one property. + + - `omitted`: Property not in the memberwise initializer — excluded entirely. + - `skipped`: Property marked with `@serde(.skip)` — passed as `nil` in the initializer. + - `assignment`: Normal property with a binding statement and an initializer argument. + */ + enum CodeGenerated { + case omitted + case skipped(identifier: TokenSyntax) + case assignment(identifier: TokenSyntax, statement: CodeBlockItemSyntax) + } + + struct DeserializedCodeGenerated { + let codesGenerated: [CodeGenerated] + let typeSyntax: TypeSyntax + + /** + The list of statements for initializing the properties. + */ + var statements: CodeBlockItemListSyntax { + let statements = codesGenerated.compactMap { code -> CodeBlockItemSyntax? in + guard case .assignment(_, let statement) = code else { + return nil + } + return statement + } + return CodeBlockItemListSyntax( + statements.enumerated().map { index, statement in + statement.with(\.leadingTrivia, index == 0 ? [] : .newline) + } + ) + } + + /** + The initializer expression for the nominal type, using the generated arguments. + */ + var initializer: FunctionCallExprSyntax { + let callee: ExprSyntax = "\(typeSyntax.trimmed)" + let arguments = codesGenerated.compactMap { code -> (label: TokenSyntax, expression: ExprSyntax)? in + switch code { + case .omitted: + return nil + // Properties marked with `@serde(.skip)` are passed as `nil` in the initializer. + case .skipped(let identifier): + return (identifier, ExprSyntax(NilLiteralExprSyntax())) + case .assignment(let identifier, _): + return (identifier, ExprSyntax(identifier.declReference())) + } + } + let labeled = arguments.enumerated().map { index, argument in + LabeledExprSyntax( + label: argument.label, + colon: .colonToken(), + expression: argument.expression, + trailingComma: index < arguments.count - 1 ? .commaToken() : nil + ) + } + return FunctionCallExprSyntax(callee: callee) { + LabeledExprListSyntax(labeled) + } + } + } + + static func codeGeneratedBlock( + from properties: [any Property], + in context: Context + ) -> CodeBlockSyntax { + let codesGenerated = properties.map { property in + property.deserialize(in: context) + } + let codeGenerated = DeserializedCodeGenerated( + codesGenerated: codesGenerated, + typeSyntax: context.typeSyntax + ) + + return CodeBlockSyntax( + """ + { + guard let \(context.base) else { return nil } + \(codeGenerated.statements) + return \(codeGenerated.initializer) + } + """ + ) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Serialize.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Serialize.swift new file mode 100644 index 00000000..d5c8a05f --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeBlock/Serialize.swift @@ -0,0 +1,118 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +extension SerdeDefault { + /** + Code generator for the `serialize` function body. + + For native properties: + + ```swift + serialized["property"] = value.property + ``` + + For serializable properties: + + ```swift + guard let property = PropertyType.toSerializable(value.property) else { return nil } + serialized["property"] = property + ``` + */ + enum Serialize: CodeGenerating { + struct Context { + /** + The value parameter identifier being serialized. + */ + let base: TokenSyntax + + /** + The dictionary identifier that receives serialized values. + */ + let serialized: TokenSyntax + + /** + The expansion context, used to mint collision-free identifiers. + */ + let macroContext: any MacroExpansionContext + + /** + Get a collision-free name for the temporary that holds `identifier`'s value. + + `base` and `serialized` are the only other identifiers declared in the generated function, and property names reach it only as member accesses (`value.name`) or dictionary keys. Minting the temporaries is therefore enough to keep the whole function hygienic — a property named `serialized` would otherwise shadow the dictionary it is being written into. + */ + func makeUniqueName(for identifier: TokenSyntax) -> TokenSyntax { + macroContext.makeUniqueName(identifier.text) + } + } + + /** + Generated serialization code for one property. + + - `omitted`: Property excluded from serialization (e.g. marked with `@serde(.skip)`). + - `basic`: Direct dictionary entries for non-optional native values. + - `optional`: Conditional assignments for optional values. + - `serializable`: Conversion code for values backed by `Defaults.Serializable`. + */ + enum CodeGenerated { + case omitted + case basic(DictionaryElementSyntax) + case optional(CodeBlockItemSyntax) + case serializable(CodeBlockItemSyntax) + } + + struct SerializedCodeGenerated { + let codesGenerated: [CodeGenerated] + + var initializer: ExprSyntax { + let initializerElements = codesGenerated.compactMap { code in + if case .basic(let element) = code { + return element + } + return nil + } + if initializerElements.isEmpty { + return ExprSyntax("[:]") + } + return ExprSyntax( + stringLiteral: "[\(initializerElements.map(\.description).joined(separator: ", "))]" + ) + } + + var assignments: CodeBlockItemListSyntax { + let assignments: [CodeBlockItemSyntax] = codesGenerated.compactMap { code in + switch code { + case .omitted, .basic: + return nil + case .optional(let code), .serializable(let code): + return code + } + } + return CodeBlockItemListSyntax(assignments) + } + } + + static func codeGeneratedBlock( + from properties: [any Property], + in context: Context + ) -> CodeBlockSyntax { + let codesGenerated = properties.map { property in + property.serialize(in: context) + } + let codeGenerated = SerializedCodeGenerated(codesGenerated: codesGenerated) + // Only bind with `var` when something actually mutates the dictionary, otherwise every expansion warns that it was never mutated. + let bindingSpecifier: TokenSyntax = + codeGenerated.assignments.isEmpty ? .keyword(.let) : .keyword(.var) + + return CodeBlockSyntax( + """ + { + guard let \(context.base) else { return nil } + \(bindingSpecifier) \(context.serialized): \(IdentifierTypeSyntax.Serializable) = \(codeGenerated.initializer) + \(codeGenerated.assignments) + return \(context.serialized) + } + """ + ) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeGenerating.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeGenerating.swift new file mode 100644 index 00000000..1c1eeff4 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/CodeGenerating/CodeGenerating.swift @@ -0,0 +1,19 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Protocol for property-code generators. + */ + protocol CodeGenerating { + associatedtype Context + associatedtype CodeGenerated + + /** + Generate a code block for multiple properties from their generated code. + */ + static func codeGeneratedBlock( + from properties: [any Property], + in context: Context + ) -> CodeBlockSyntax + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl+Bridge.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl+Bridge.swift new file mode 100644 index 00000000..b0e5274e --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl+Bridge.swift @@ -0,0 +1,119 @@ +import SwiftSyntax + +extension SerdeDefault.NominalDecl { + /** + The generated bridge struct name. + */ + private var bridgeName: TokenSyntax { + identifier.suffix(IdentifierTypeSyntax.Bridge.name.text) + } + + /** + The access level for generated bridge declarations. + */ + private var bridgeAccessLevel: AccessLevel? { + accessLevel > .internal ? accessLevel : nil + } + + /** + Generate the stored bridge instance for the expanded extension. + + The generated member is `static let bridge = ()`. If the source nominal type is wider than `internal`, that access level is preserved. + + - Returns: The generated `bridge` declaration. + */ + func bridgeVariableDecl() -> VariableDeclSyntax { + let modifiers = bridgeAccessLevel.map { [$0.declModifier, .static] } ?? [.static] + let binding = PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: IdentifierTypeSyntax.bridge.name), + initializer: InitializerClauseSyntax( + value: FunctionCallExprSyntax(callee: bridgeName.declReference()) + ) + ) + return VariableDeclSyntax( + modifiers: DeclModifierListSyntax(modifiers), + bindingSpecifier: .keyword(.let), + bindings: PatternBindingListSyntax([binding]) + ) + } + + /** + Generate the nested `Defaults.Bridge` implementation. + + - Returns: The generated bridge type declaration. + */ + func bridgeStructDecl() -> StructDeclSyntax { + let modifiers = DeclModifierListSyntax(bridgeAccessLevel.map { [$0.declModifier] } ?? []) + let deserializeContext = self.deserializeContext + let valueTypeAlias = TypeAliasDeclSyntax( + modifiers: modifiers, + name: IdentifierTypeSyntax.Value.name, + initializer: TypeInitializerClauseSyntax(value: typeSyntax) + ) + // TODO: Allow overriding this alias and keep this as the default. + let serializableTypeAlias = TypeAliasDeclSyntax( + modifiers: modifiers, + name: IdentifierTypeSyntax.Serializable.name, + initializer: TypeInitializerClauseSyntax( + value: DictionaryTypeSyntax( + key: IdentifierTypeSyntax.String, + value: IdentifierTypeSyntax.Any, + ) + ) + ) + + // Generate `serialize` function. + let serializeCodeBlock = properties.codeGenerated( + using: SerdeDefault.Serialize.self, + in: serializeContext + ) + let serializeFunction = SerdeDefault.FunctionDecl.Builder( + name: IdentifierTypeSyntax.serialize.name + ) + .setAccessLevel(accessLevel: .public) + .addParameter(secondName: serializeContext.base, type: IdentifierTypeSyntax.Value.optional()) + .setReturnType(returnType: IdentifierTypeSyntax.Serializable.optional()) + .build { _ in serializeCodeBlock } + + // Generate `deserialize` function. + let deserializeCodeBlock = properties.codeGenerated( + using: SerdeDefault.Deserialize.self, + in: deserializeContext + ) + let deserializeFunction = SerdeDefault.FunctionDecl.Builder( + name: IdentifierTypeSyntax.deserialize.name + ) + .setAccessLevel(accessLevel: .public) + .addParameter( + secondName: deserializeContext.base, type: IdentifierTypeSyntax.Serializable.optional() + ) + .setReturnType(returnType: IdentifierTypeSyntax.Value.optional()) + .build { _ in deserializeCodeBlock } + + let memberBlock = MemberBlockSyntax( + members: MemberBlockItemListSyntax([ + MemberBlockItemSyntax(decl: DeclSyntax(valueTypeAlias)), + MemberBlockItemSyntax(decl: DeclSyntax(serializableTypeAlias)), + MemberBlockItemSyntax(decl: DeclSyntax(serializeFunction)), + MemberBlockItemSyntax(decl: DeclSyntax(deserializeFunction)) + ]) + ) + + return StructDeclSyntax( + modifiers: modifiers, + name: bridgeName, + inheritanceClause: InheritanceClauseSyntax( + inheritedTypes: InheritedTypeListSyntax([ + InheritedTypeSyntax( + type: MemberTypeSyntax.DefaultsBridge, + trailingComma: .commaToken() + ), + InheritedTypeSyntax( + type: IdentifierTypeSyntax.Sendable + ) + ]) + ), + memberBlock: memberBlock + ) + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl.swift new file mode 100644 index 00000000..65329d46 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/NominalDecl.swift @@ -0,0 +1,97 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +extension SerdeDefault { + /** + Nominal declaration annotated with `@SerdeDefault`. + */ + struct NominalDecl { + /** + The nominal type name. + */ + let identifier: TokenSyntax + + /** + The access level of the nominal type. + + If the declaration does not specify an access level, this defaults to `internal`. + */ + let accessLevel: AccessLevel + + /** + The actual type syntax for the nominal type. + */ + let typeSyntax: TypeSyntax + + /** + The properties of the nominal type. + */ + let properties: Properties + + /** + The context for code generation of the `serialize` function. + */ + let serializeContext: SerdeDefault.Serialize.Context + + /** + The context for code generation of the `deserialize` function. + */ + let deserializeContext: SerdeDefault.Deserialize.Context + + init( + declaration: some SwiftSyntax.DeclGroupSyntax, + type: some TypeSyntaxProtocol, + macroContext: MacroExpansionContext + ) throws(SerdeDefault.Error) { + // TODO: Support class declarations. + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw SerdeDefault.Error.unexpectedNominalType(declaration) + } + try self.init(structDecl: structDecl, type: type, context: macroContext) + } + + private init( + structDecl: StructDeclSyntax, + type: some TypeSyntaxProtocol, + context: MacroExpansionContext + ) throws(SerdeDefault.Error) { + // TODO: Support generic types. This needs the generated extension to carry the right `where` clauses, so reject it outright instead of expanding to code that fails with an unrelated error. + if let genericParameterClause = structDecl.genericParameterClause { + throw SerdeDefault.Error.genericType(genericParameterClause) + } + identifier = structDecl.name + accessLevel = AccessLevel(modifiers: structDecl.modifiers) + typeSyntax = TypeSyntax(fromProtocol: type) + let variables = structDecl.memberBlock.members + .compactMap { + SerdeDefault.VariableDecl(declaration: $0.decl) + } + // Ignore static properties, as they cannot be serialized or deserialized. + .filter { !$0.isStatic() } + let bindings = try Self.bindings(variables: variables) + properties = try Properties(bindings: bindings) + // `base` and `serialized` can be spelled literally because property names reach the generated function only as member accesses (`value.name`) or dictionary keys. The temporaries the properties bind are the part that has to be unique. + serializeContext = SerdeDefault.Serialize.Context( + base: "value", + serialized: IdentifierTypeSyntax.serialized.name, + macroContext: context + ) + deserializeContext = SerdeDefault.Deserialize.Context( + base: context.makeUniqueName("serialized"), + typeSyntax: typeSyntax + ) + } + + /** + Extract stored bindings from the nominal type variables. + */ + static func bindings(variables: [VariableDecl]) throws(SerdeDefault.Error) -> [PatternBinding] { + try variables + .map { variable throws(Error) -> [PatternBinding] in + try variable.typedBindings() + } + .flatMap { $0 } + .filter { $0.isStored() } + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties.swift new file mode 100644 index 00000000..c85bbb40 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties.swift @@ -0,0 +1,34 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Represents the `SerdeDefault` properties of a nominal type. + */ + struct Properties { + let properties: [any Property] + + /** + Create properties from the nominal type bindings. + + - Parameter bindings: Pattern bindings from the nominal type. + */ + init(bindings: [PatternBinding]) throws(Error) { + properties = try bindings.map { binding throws(Error) -> any Property in + try StrategyFinder.resolve(binding: binding) + } + } + + /** + Generate a code block for these properties using the given generator. + + - Parameter generator: The property-code generator to use. + - Parameter context: The context passed to the generator. + */ + func codeGenerated( + using generator: Generator.Type, + in context: Generator.Context + ) -> CodeBlockSyntax { + generator.codeGeneratedBlock(from: properties, in: context) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/BasicProperty.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/BasicProperty.swift new file mode 100644 index 00000000..c175b472 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/BasicProperty.swift @@ -0,0 +1,87 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Property that `Defaults` can serialize and deserialize directly, without a bridge. + + For serialization: + + ```swift + serialized["property"] = value.property + ``` + + For deserialization: + + ```swift + let property = base["property"] as? PropertyType + ``` + */ + struct BasicProperty: Property { + let binding: PatternBinding + let attribute: SerdeAttribute + + static func matches(binding: PatternBinding, attribute _: SerdeAttribute) -> Bool { + binding.typeSyntax.isNativeSupportType() + } + + func serialize(in context: Serialize.Context) -> Serialize.CodeGenerated { + guard !isExcludedFromSerde() else { + return .omitted + } + + // Optional properties are only included in the serialized output if they have a value. + if typeSyntax.isOptional() { + let temporary = context.makeUniqueName(for: identifier) + return .optional( + """ + if let \(temporary) = \(context.base).\(identifier) { + \(context.serialized)["\(identifier)"] = \(temporary) + } + """ + ) + } + + return .basic( + DictionaryElementSyntax( + key: StringLiteralExprSyntax(content: "\(identifier)"), + value: MemberAccessExprSyntax( + base: context.base.declReference(), + name: identifier + ) + ) + ) + } + + func deserialize(in context: Deserialize.Context) -> Deserialize.CodeGenerated { + guard binding.isMemberwiseInitializable() else { + return .omitted + } + + if isExcludedFromSerde() { + return .skipped(identifier: identifier) + } + + let type = unwrappedTypeSyntax + let value: ExprSyntax = "\(context.base)[\(literal: identifier.text)] as? \(type)" + + // A default value wins over optionality, so an optional property with an initializer falls back to that initializer rather than to `nil`. + let statement: CodeBlockItemSyntax + if let initializer = binding.syntax.initializer { + let defaultValue = initializer.value.replacingSelfReferences( + with: IdentifierTypeSyntax.Value.name + ) + statement = "let \(identifier) = \(value) ?? \(defaultValue.trimmed)" + } else if typeSyntax.isOptional() { + statement = "let \(identifier) = \(value)" + } else { + statement = """ + guard let \(identifier) = \(value) else { + return nil + } + """ + } + + return .assignment(identifier: identifier, statement: statement) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/Property.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/Property.swift new file mode 100644 index 00000000..071ca9d1 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/Property.swift @@ -0,0 +1,84 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Stored property prepared for bridge generation. + + Types that conform to this protocol generate the serialization and deserialization code for one kind of property. + + The type should implement `matches(binding:attribute:)` to declare which bindings it applies to. + */ + protocol Property { + /** + The pattern binding this property was created from. + */ + var binding: PatternBinding { get } + + /** + Parsed `@serde` attribute arguments for this property. + */ + var attribute: SerdeAttribute { get } + + /** + Create a property for a binding that `matches(binding:attribute:)` has accepted. + + - Parameter binding: The pattern binding to generate code for. + - Parameter attribute: The parsed `@serde` arguments for `binding`. + */ + init(binding: PatternBinding, attribute: SerdeAttribute) + + /** + Check whether this property kind applies to `binding`. + + - Parameter binding: The pattern binding to inspect. + - Parameter attribute: The parsed `@serde` arguments for `binding`. + */ + static func matches(binding: PatternBinding, attribute: SerdeAttribute) -> Bool + + /** + Generate the serialization code contributed by this property. + */ + func serialize(in context: Serialize.Context) -> Serialize.CodeGenerated + + /** + Generate the deserialization code contributed by this property. + */ + func deserialize(in context: Deserialize.Context) -> Deserialize.CodeGenerated + } +} + +extension SerdeDefault.Property { + /** + Property identifier used as the serialized key. + */ + var identifier: TokenSyntax { binding.identifier } + + /** + Property type used to choose serialization behavior. + */ + var typeSyntax: TypeSyntax { binding.typeSyntax } + + /** + Property type with any optional wrapper removed. + */ + var unwrappedTypeSyntax: TypeSyntax { + guard let wrappedType = typeSyntax.optionalWrappedType() else { + return typeSyntax.trimmed + } + return TypeSyntax(fromProtocol: wrappedType).trimmed + } + + /** + Check whether this property should be excluded from serialization and deserialization. + + For properties: + + - marked with `@serde(.skip)` + - that are constants with default values + + cannot be serialized or deserialized, so they are excluded from the generated code. + */ + func isExcludedFromSerde() -> Bool { + attribute.skip || !binding.isMemberwiseInitializable() + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/SerializableProperty.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/SerializableProperty.swift new file mode 100644 index 00000000..419b6178 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/SerializableProperty.swift @@ -0,0 +1,88 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Property that needs `Defaults.Serializable` conversion through its bridge. + + This is the catch-all strategy: `resolve(binding:)` always succeeds, so it must stay registered last in `StrategyFinder`. + + For serialization: + + ```swift + guard let property = PropertyType.toSerializable(value.property) else { return nil } + serialized["property"] = property + ``` + + For deserialization: + + ```swift + let property = PropertyType.toValue(base["property"] as Any, type: PropertyType.self) + ``` + */ + struct SerializableProperty: Property { + let binding: PatternBinding + let attribute: SerdeAttribute + + static func matches(binding: PatternBinding, attribute _: SerdeAttribute) -> Bool { + !binding.typeSyntax.isNativeSupportType() + } + + func serialize(in context: Serialize.Context) -> Serialize.CodeGenerated { + guard !isExcludedFromSerde() else { + return .omitted + } + + let type = unwrappedTypeSyntax + let temporary = context.makeUniqueName(for: identifier) + if typeSyntax.isOptional() { + return .optional( + """ + if let \(temporary) = \(type).toSerializable(\(context.base).\(identifier)) { + \(context.serialized)["\(identifier)"] = \(temporary) + } + """ + ) + } + + return .serializable( + """ + guard let \(temporary) = \(type).toSerializable(\(context.base).\(identifier)) else { return nil } + \(context.serialized)["\(identifier)"] = \(temporary) + """ + ) + } + + func deserialize(in context: Deserialize.Context) -> Deserialize.CodeGenerated { + guard binding.isMemberwiseInitializable() else { + return .omitted + } + + if isExcludedFromSerde() { + return .skipped(identifier: identifier) + } + + let type = unwrappedTypeSyntax + let value: ExprSyntax = + "\(type).toValue(\(context.base)[\(literal: identifier.text)] as Any, type: \(type).self)" + + // A default value wins over optionality, so an optional property with an initializer falls back to that initializer rather than to `nil`. + let statement: CodeBlockItemSyntax + if let initializer = binding.syntax.initializer { + let defaultValue = initializer.value.replacingSelfReferences( + with: IdentifierTypeSyntax.Value.name + ) + statement = "let \(identifier) = \(value) ?? \(defaultValue.trimmed)" + } else if typeSyntax.isOptional() { + statement = "let \(identifier) = \(value)" + } else { + statement = """ + guard let \(identifier) = \(value) else { + return nil + } + """ + } + + return .assignment(identifier: identifier, statement: statement) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/StrategyFinder.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/StrategyFinder.swift new file mode 100644 index 00000000..7bae845f --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/Properties/StrategyFinder.swift @@ -0,0 +1,35 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Resolver that picks the `Property` implementation for a binding by trying registered strategies in order. + */ + struct StrategyFinder { + /** + Candidate strategies, tried in order. The first one that resolves wins. + + `SerializableProperty` is the catch-all and must stay last. + */ + static var strategies: [any Property.Type] { + [ + BasicProperty.self, + SerializableProperty.self + ] + } + + /** + Resolve the `Property` implementation for `binding`. + + The binding's `@serde` arguments are parsed once up front and handed to every candidate, so strategies selected by an attribute can recognize themselves without re-parsing. + + - Throws: `Error` if the `@serde` arguments are invalid, or `Error.unresolvedProperty` if no registered strategy matches the binding. + */ + static func resolve(binding: PatternBinding) throws(Error) -> any Property { + let attribute = try SerdeAttribute(binding: binding) + for strategy in strategies where strategy.matches(binding: binding, attribute: attribute) { + return strategy.init(binding: binding, attribute: attribute) + } + throw Error.unresolvedProperty(binding.syntax) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/SerdeAttribute.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/SerdeAttribute.swift new file mode 100644 index 00000000..23c66b2a --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Semantics/SerdeAttribute.swift @@ -0,0 +1,101 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Parsed arguments from the `@serde` attribute. + */ + struct SerdeAttribute { + /** + Name of the `@serde` attribute. + */ + static let name = IdentifierTypeSyntax.serde.name + + /** + Whether to skip the property during serialization and deserialization. + */ + let skip: Bool + + /** + Parse and validate `@serde` arguments for a property binding. + */ + init(binding: PatternBinding) throws(Error) { + try self.init(attributes: binding.attributes) + if skip && !binding.typeSyntax.isOptional() { + throw Error.skipOnNonOptional(binding.syntax) + } + } + + /** + Parse `@serde` arguments from an attribute list. + + - Parameter attributes: Attributes to inspect. + - Throws: `Error.mismatch` for invalid argument syntax, or `Error.unknownAttribute` for unsupported arguments. + - Returns: Parsed attribute values. + */ + init(attributes: AttributeListSyntax) throws(Error) { + // Accumulates parsed `@serde` arguments before creating the immutable value. + struct Builder { + var skip: Bool = false + } + var builder = Builder() + for attribute in attributes { + guard + let attribute = attribute.as(AttributeSyntax.self), + case .argumentList(let arguments) = attribute.arguments + else { + continue + } + // Parse arguments only from `@serde(...)` and `@DefaultsMacros.serde(...)`. + guard + attribute.attributeName.isEqual( + to: Self.name, inModule: IdentifierTypeSyntax.DefaultsMacros.name + ) + else { + continue + } + for argument in arguments { + let name = try Self.findAttributeName(argument: argument) + switch name.text { + case IdentifierTypeSyntax.skip.name.text: + builder.skip = true + default: + throw Error.unknownAttribute(argument.expression, name.text) + } + } + } + + self.skip = builder.skip + } + + /** + Find the attribute name from a labeled expression. + + Accepts `@serde(.skip)` and explicitly qualified `SerdeOption.skip` spellings. + */ + static func findAttributeName(argument: LabeledExprSyntax) throws(Error) -> TokenSyntax { + let memberAccess: MemberAccessExprSyntax + if let base = argument.expression.as(FunctionCallExprSyntax.self)?.calledExpression.as( + MemberAccessExprSyntax.self + ) { + memberAccess = base + } else if let base = argument.expression.as(MemberAccessExprSyntax.self) { + memberAccess = base + } else { + throw Error.mismatch(MemberAccessExprSyntax.self, argument.expression) + } + + if let base = memberAccess.base { + guard + base.isEqual( + to: IdentifierTypeSyntax.SerdeOption.name, + inModule: IdentifierTypeSyntax.DefaultsMacros.name + ) + else { + throw Error.unknownAttribute(argument.expression, memberAccess.trimmedDescription) + } + } + + return memberAccess.declName.baseName + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/SerdeDefault+Error.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/SerdeDefault+Error.swift new file mode 100644 index 00000000..fcb0bda4 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/SerdeDefault+Error.swift @@ -0,0 +1,136 @@ +import SwiftDiagnostics +import SwiftSyntax + +extension SerdeDefault { + /** + Diagnostics emitted by `SerdeDefaultMacro`. + */ + enum Error: Swift.Error { + /** + The declaration is not a supported nominal type (`struct` only). + */ + case unexpectedNominalType(any SyntaxProtocol) + + /** + The nominal declaration has generic parameters. + */ + case genericType(any SyntaxProtocol) + + /** + The syntax node does not match the expected type. + */ + case mismatch(Any.Type, any SyntaxProtocol) + + /** + The property binding is missing a type annotation. + */ + case missingType(any SyntaxProtocol) + + /** + The stored property type could not be inferred from its initializer. + */ + case inferredType(any SyntaxProtocol) + + /** + A non-optional property is marked with `@serde(.skip)`, which is unsupported. + */ + case skipOnNonOptional(any SyntaxProtocol) + + /** + An attribute is not recognized. + */ + case unknownAttribute(any SyntaxProtocol, String) + + /** + No registered `Property` strategy could resolve the binding. + + This should be unreachable while a catch-all strategy (`SerializableProperty`) is registered. + */ + case unresolvedProperty(any SyntaxProtocol) + + /** + Get the syntax node where the diagnostic should be displayed. + + Every error retains the node that caused it, allowing callers to attach the diagnostic to the most relevant source location. + */ + func syntax() -> any SyntaxProtocol { + switch self { + case .unexpectedNominalType(let got): + got + case .genericType(let got): + got + case .mismatch(_, let got): + got + case .missingType(let got): + got + case .inferredType(let got): + got + case .skipOnNonOptional(let got): + got + case .unknownAttribute(let got, _): + got + case .unresolvedProperty(let got): + got + } + } + + /** + A stable identifier derived from the error case. + */ + var caseName: String { + switch self { + case .unexpectedNominalType: + "unexpectedNominalType" + case .genericType: + "genericType" + case .mismatch: + "mismatch" + case .missingType: + "missingType" + case .inferredType: + "inferredType" + case .skipOnNonOptional: + "skipOnNonOptional" + case .unknownAttribute: + "unknownAttribute" + case .unresolvedProperty: + "unresolvedProperty" + } + } + } +} + +extension SerdeDefault.Error: DiagnosticMessage { + var message: String { + switch self { + case .unexpectedNominalType(let got): + "Expected a struct declaration. Received: \(got.syntaxNodeType)." + + " In this first-stage implementation, `@SerdeDefault` supports structs only." + case .genericType: + "`@SerdeDefault` does not support generic types. Apply it to a concrete struct instead." + case .mismatch(let expected, let got): + "Expected syntax node type: \(expected). Received: \(got.syntaxNodeType)." + case .missingType: + "Missing type annotation for `@SerdeDefault` stored property." + + " Add an explicit type annotation or use an initializer with a supported literal type." + case .inferredType: + "Could not infer the type of `@SerdeDefault` stored property initializer." + + " Add an explicit type annotation." + case .skipOnNonOptional: + "Non-optional property is marked with `@serde(.skip)`, which is not supported." + + " Please remove the `skip` argument or make the property optional." + case .unknownAttribute(_, let name): + "Unknown attribute found: \(name)." + case .unresolvedProperty: + "Could not resolve a serialization strategy for this property." + } + } + + var diagnosticID: SwiftDiagnostics.MessageID { + MessageID(domain: "com.sindresorhus.SerdeDefaultMacro", id: "\(caseName)") + } + + var severity: SwiftDiagnostics.DiagnosticSeverity { + .error + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/AccessLevel.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/AccessLevel.swift new file mode 100644 index 00000000..7ada99f1 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/AccessLevel.swift @@ -0,0 +1,44 @@ +import SwiftSyntax + +/** +Swift access levels used for generated declarations. +*/ +enum AccessLevel: CaseIterable, Comparable, Sendable { + case `private` + case `fileprivate` + case `internal` + case `package` + case `public` + + init(modifiers: DeclModifierListSyntax) { + for modifier in modifiers { + if let accessLevel = Self.allCases.first(where: { $0.token.text == modifier.name.text }) { + self = accessLevel + return + } + } + self = .internal + } + + var declModifier: DeclModifierSyntax { + DeclModifierSyntax(name: self.token) + } + + /** + The SwiftSyntax token for the access level. + */ + var token: TokenSyntax { + switch self { + case .`public`: + return .keyword(.public) + case .`package`: + return .keyword(.package) + case .`internal`: + return .keyword(.internal) + case .`fileprivate`: + return .keyword(.fileprivate) + case .`private`: + return .keyword(.private) + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/IdentifierTypeSyntax.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/IdentifierTypeSyntax.swift new file mode 100644 index 00000000..66a953e0 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/IdentifierTypeSyntax.swift @@ -0,0 +1,83 @@ +import SwiftSyntax + +extension SwiftSyntax.IdentifierTypeSyntax { + // MARK: Identifiers for generated `Defaults.Serializable` conformance + static let Defaults = Self(name: "Defaults") + static let Serializable = Self(name: "Serializable") + static let Bridge = Self(name: "Bridge") + static let Value = Self(name: "Value") + static let bridge = Self(name: "bridge") + static let serialize = Self(name: "serialize") + static let deserialize = Self(name: "deserialize") + static let serialized = Self(name: "serialized") + + // MARK: Identifiers for Swift and Foundation types + static let Swift = Self(name: "Swift") + static let Foundation = Self(name: "Foundation") + static let CoreGraphics = Self(name: "CoreGraphics") + static let `Optional` = Self(name: "Optional") + static let `Any` = Self(name: "Any") + static let Sendable = Self(name: "Sendable") + static let Bool = Self(name: "Bool") + static let Int = Self(name: "Int") + static let UInt = Self(name: "UInt") + static let Double = Self(name: "Double") + static let Float = Self(name: "Float") + static let String = Self(name: "String") + static let Data = Self(name: "Data") + static let Date = Self(name: "Date") + static let CGFloat = Self(name: "CGFloat") + static let Int8 = Self(name: "Int8") + static let UInt8 = Self(name: "UInt8") + static let Int16 = Self(name: "Int16") + static let UInt16 = Self(name: "UInt16") + static let Int32 = Self(name: "Int32") + static let UInt32 = Self(name: "UInt32") + static let Int64 = Self(name: "Int64") + static let UInt64 = Self(name: "UInt64") + + /** + Identifier for the `@serde` attribute. + */ + static let serde = Self(name: "serde") + + /** + Identifier for the `DefaultsMacros` module used to qualify `SerdeOption`. + */ + static let DefaultsMacros = Self(name: "DefaultsMacros") + + /** + Identifier for the `SerdeOption` enum used by `@serde`. + */ + static let SerdeOption = Self(name: "SerdeOption") + + /** + Identifier for the `skip` argument used in `@serde(.skip)`. + */ + static let skip = Self(name: "skip") + + /** + Types that `Defaults` stores in `UserDefaults` without a bridge. + + Keep this in sync with the `isNativelySupportedType = true` declarations in `Defaults+Extensions.swift`. + */ + static let nativeSupportedTypes: [Self] = [ + .Bool, + .Int, + .UInt, + .Double, + .Float, + .String, + .Data, + .Date, + .CGFloat, + .Int8, + .UInt8, + .Int16, + .UInt16, + .Int32, + .UInt32, + .Int64, + .UInt64 + ] +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/MemberTypeSyntax.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/MemberTypeSyntax.swift new file mode 100644 index 00000000..b18ac2c0 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Constants/MemberTypeSyntax.swift @@ -0,0 +1,24 @@ +import SwiftSyntax + +extension SwiftSyntax.MemberTypeSyntax { + /** + Type syntax for `DefaultsMacros.SerdeOption`. + */ + static let DefaultsMacrosSerdeOption = Self( + baseType: SwiftSyntax.IdentifierTypeSyntax.DefaultsMacros, + name: SwiftSyntax.IdentifierTypeSyntax.SerdeOption.name) + + /** + Type syntax for `Defaults.Bridge`. + */ + static let DefaultsBridge = Self( + baseType: SwiftSyntax.IdentifierTypeSyntax.Defaults, + name: SwiftSyntax.IdentifierTypeSyntax.Bridge.name) + + /** + Type syntax for `Defaults.Serializable`. + */ + static let DefaultsSerializable = Self( + baseType: SwiftSyntax.IdentifierTypeSyntax.Defaults, + name: SwiftSyntax.IdentifierTypeSyntax.Serializable.name) +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/DeclModifierSyntax.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/DeclModifierSyntax.swift new file mode 100644 index 00000000..3cf7877f --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/DeclModifierSyntax.swift @@ -0,0 +1,13 @@ +import SwiftSyntax + +extension SwiftSyntax.DeclModifierSyntax { + /** + Reusable `public` modifier for generated declarations. + */ + static let `public` = Self(name: .keyword(.public)) + + /** + Reusable `static` modifier for generated declarations. + */ + static let `static` = Self(name: .keyword(.static)) +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/ExprSyntax.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/ExprSyntax.swift new file mode 100644 index 00000000..53f7566e --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/ExprSyntax.swift @@ -0,0 +1,102 @@ +import SwiftSyntax + +extension ExprSyntax { + func replacingSelfReferences(with replacement: TokenSyntax) -> ExprSyntax { + SelfReferenceRewriter(replacement: replacement) + .rewrite(self) + .cast(ExprSyntax.self) + } + + /** + Infer a Swift type from simple literals and type expressions. + */ + func inferredType() -> (any TypeSyntaxProtocol)? { + switch self.as(ExprSyntaxEnum.self) { + case .prefixOperatorExpr(let prefixExpr): + switch prefixExpr.operator.text { + case "+", "-": + return prefixExpr.expression.inferredType() + default: + return nil + } + + case .tupleExpr(let tupleExpr): + // only infer a type from a single-element tuple, since multi-element tuples are not supported in `Defaults`. + guard + tupleExpr.elements.count == 1, + let element = tupleExpr.elements.first, + element.label == nil + else { + return nil + } + + return element.expression.inferredType() + + case .arrayExpr(let arrayExpr): + guard + let firstElement = arrayExpr.elements.first, + let type = firstElement.expression.inferredType(), + arrayExpr.elements.dropFirst().allSatisfy({ + $0.expression.inferredType()?.trimmedDescription == type.trimmedDescription + }) + else { + return nil + } + + return ArrayTypeSyntax(element: TypeSyntax(fromProtocol: type)) + + case .dictionaryExpr(let dictionaryExpr): + guard + case .elements(let elements) = dictionaryExpr.content, + let firstElement = elements.first, + let keyType = firstElement.key.inferredType(), + let valueType = firstElement.value.inferredType(), + elements.dropFirst().allSatisfy({ + $0.key.inferredType()?.trimmedDescription == keyType.trimmedDescription + && $0.value.inferredType()?.trimmedDescription == valueType.trimmedDescription + }) + else { + return nil + } + + return DictionaryTypeSyntax( + key: TypeSyntax(fromProtocol: keyType), + value: TypeSyntax(fromProtocol: valueType) + ) + + case .typeExpr(let typeExpr): + return typeExpr.type + + case .booleanLiteralExpr: + return IdentifierTypeSyntax.Bool + + case .integerLiteralExpr: + return IdentifierTypeSyntax.Int + + case .floatLiteralExpr: + return IdentifierTypeSyntax.Double + + case .stringLiteralExpr: + return IdentifierTypeSyntax.String + + default: + return nil + } + } +} + +private final class SelfReferenceRewriter: SyntaxRewriter { + private let replacement: TokenSyntax + + init(replacement: TokenSyntax) { + self.replacement = replacement + } + + override func visit(_ token: TokenSyntax) -> TokenSyntax { + guard token.tokenKind == .keyword(.Self) else { + return token + } + + return token.with(\.tokenKind, .identifier(replacement.text)) + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/OptionalType.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/OptionalType.swift new file mode 100644 index 00000000..5ff04e0c --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/OptionalType.swift @@ -0,0 +1,24 @@ +import SwiftSyntax + +extension SerdeDefault { + /** + Support for wrapping syntax nodes in `OptionalTypeSyntax`. + */ + protocol OptionalType { + /** + Wrap the type in `OptionalTypeSyntax`. + */ + func optional() -> OptionalTypeSyntax + } +} + +extension SerdeDefault.OptionalType where Self: TypeSyntaxProtocol { + func optional() -> OptionalTypeSyntax { + OptionalTypeSyntax(wrappedType: self) + } +} + +/** +Allows identifier types such as `String` and `Int` to be wrapped in `OptionalTypeSyntax`. +*/ +extension IdentifierTypeSyntax: SerdeDefault.OptionalType {} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/SyntaxProtocol.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/SyntaxProtocol.swift new file mode 100644 index 00000000..ceafd4f4 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/SyntaxProtocol.swift @@ -0,0 +1,35 @@ +import SwiftSyntax + +extension SyntaxProtocol { + /** + Check whether the syntax is equal to the given name and module. + + - Parameter name: The name to compare against. + - Parameter module: The module to compare against. + - Returns: `true` if the syntax is equal to the name and module, `false` otherwise. + */ + func isEqual(to name: TokenSyntax, inModule module: TokenSyntax) -> Bool { + switch Syntax(self).as(SyntaxEnum.self) { + case .identifierType(let identifier): + return identifier.name.text == name.text + case .declReferenceExpr(let reference): + return reference.baseName.text == name.text + case .memberType(let memberType): + guard let baseType = memberType.baseType.as(IdentifierTypeSyntax.self) else { + return false + } + + return memberType.name.text == name.text + && baseType.name.text == module.text + case .memberAccessExpr(let memberAccess): + guard let moduleReference = memberAccess.base?.as(DeclReferenceExprSyntax.self) else { + return false + } + + return memberAccess.declName.baseName.text == name.text + && moduleReference.baseName.text == module.text + default: + return false + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TokenSyntax.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TokenSyntax.swift new file mode 100644 index 00000000..2a5a8d20 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TokenSyntax.swift @@ -0,0 +1,28 @@ +import SwiftSyntax + +extension TokenSyntax { + /** + Create a declaration-reference expression from the token text. + */ + func declReference() -> DeclReferenceExprSyntax { + DeclReferenceExprSyntax(leadingTrivia: nil, baseName: self.trimmed, trailingTrivia: nil) + } + + func prefix(prefix: String) -> Self { + TokenSyntax(stringLiteral: "\(prefix)\(self.text)") + } + + /** + Get a token with `suffix` appended. + + - Parameter suffix: The suffix to append. + */ + func suffix(_ suffix: String) -> Self { + let base = if text.hasPrefix("`") && text.hasSuffix("`") { + String(text.dropFirst().dropLast()) + } else { + text + } + return .identifier("\(base)\(suffix)") + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TypeSyntaxProtocol.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TypeSyntaxProtocol.swift new file mode 100644 index 00000000..915a5146 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/Extensions/TypeSyntaxProtocol.swift @@ -0,0 +1,133 @@ +import SwiftSyntax + +extension TypeSyntaxProtocol { + /** + Get the generic arguments from either: + + - `IdentifierTypeSyntax` (for example, `Optional`) + - `MemberTypeSyntax` (for example, `Swift.Optional`) + + Non-generic types return an empty array. + */ + func genericArguments() -> [GenericArgumentSyntax] { + if let memberType = self.as(MemberTypeSyntax.self) { + guard let genericArgumentClause = memberType.genericArgumentClause else { + return [] + } + return genericArgumentClause.arguments.map { GenericArgumentSyntax(argument: $0.argument) } + } + if let identifierType = self.as(IdentifierTypeSyntax.self) { + guard let genericArgumentClause = identifierType.genericArgumentClause else { + return [] + } + return genericArgumentClause.arguments.map { GenericArgumentSyntax(argument: $0.argument) } + } + return [] + } + + /** + Get the wrapped type for any supported optional spelling: + + - `T?` + - `Optional` + - `Swift.Optional` + + Generated `toValue` calls use this to convert to `T` instead of producing nested optionals. + */ + func optionalWrappedType() -> (any TypeSyntaxProtocol)? { + if let optionalType = self.as(OptionalTypeSyntax.self) { + return optionalType.wrappedType + } + if self.isSwiftMemberGenericOptional() || self.isGenericOptional() { + if let genericArgument = self.genericArguments().first { + // `Optional` should always carry a type argument. + if case .type(let type) = genericArgument.argument { + return type + } + } + } + + return nil + } + + /** + Check whether the type is exactly `Swift.Optional`. + + This explicit check avoids accidentally matching unrelated `Optional` symbols from other modules or nested types. + */ + func isSwiftMemberGenericOptional() -> Bool { + if let memberType = self.as(MemberTypeSyntax.self) { + if let identifierType = memberType.baseType.as(IdentifierTypeSyntax.self) { + return identifierType.name.text == IdentifierTypeSyntax.Swift.name.text + && memberType.name.text == IdentifierTypeSyntax.Optional.name.text + } + } + return false + } + + /** + Check whether the type is unqualified `Optional`. + */ + func isGenericOptional() -> Bool { + if let identifierType = self.as(IdentifierTypeSyntax.self) { + if identifierType.name.text == IdentifierTypeSyntax.Optional.name.text { + return true + } + } + return false + } + + /** + Check whether the type is any supported optional spelling. + */ + func isOptional() -> Bool { + self.is(OptionalTypeSyntax.self) || self.isGenericOptional() + || self.isSwiftMemberGenericOptional() + } + + /** + Check whether `Defaults` can store this type without a bridge. + */ + func isNativeSupportType() -> Bool { + if let wrappedType = optionalWrappedType() { + return wrappedType.isNativeSupportType() + } + + switch TypeSyntax(self).as(TypeSyntaxEnum.self) { + case .arrayType(let arrayType): + return arrayType.element.isNativeSupportType() + case .dictionaryType(let dictionaryType): + guard + let keyType = dictionaryType.key.as(IdentifierTypeSyntax.self), + keyType.name.text == IdentifierTypeSyntax.String.name.text + else { + return false + } + return dictionaryType.value.isNativeSupportType() + case .memberType(let memberType): + guard + let baseType = memberType.baseType.as(IdentifierTypeSyntax.self), + IdentifierTypeSyntax.nativeSupportedTypes.contains(where: { + $0.name.text == memberType.name.text + }) + else { + return false + } + + switch memberType.name.text { + case IdentifierTypeSyntax.Data.name.text, IdentifierTypeSyntax.Date.name.text: + return baseType.name.text == IdentifierTypeSyntax.Foundation.name.text + case IdentifierTypeSyntax.CGFloat.name.text: + return baseType.name.text == IdentifierTypeSyntax.CoreGraphics.name.text + default: + return baseType.name.text == IdentifierTypeSyntax.Swift.name.text + } + case .identifierType(let identifierType): + return IdentifierTypeSyntax.nativeSupportedTypes.contains { + $0.name.text == identifierType.name.text + } + default: + return false + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/FunctionDecl.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/FunctionDecl.swift new file mode 100644 index 00000000..71de91d5 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/FunctionDecl.swift @@ -0,0 +1,94 @@ +import SwiftSyntax + +extension SerdeDefault { + struct FunctionDecl { + let syntax: FunctionDeclSyntax + + /** + Convenience builder for constructing a function declaration. + */ + class Builder { + /** + The generated function name. + */ + let name: TokenSyntax + + /** + The generated function access level. + */ + var accessLevel: AccessLevel = .internal + + /** + Parameters collected for the function signature. + */ + var parameters: [FunctionParameterSyntax] = [] + var codeBlockItems: [CodeBlockItemSyntax] = [] + var returnType: ReturnClauseSyntax? + + init(name: TokenSyntax) { + self.name = name + } + + func setAccessLevel(accessLevel: AccessLevel) -> Self { + self.accessLevel = accessLevel + return self + } + + /** + Add a parameter to the function signature. + + - Parameter firstName: The external parameter name. + - Parameter secondName: The internal parameter name. + - Parameter type: The parameter type. + */ + func addParameter( + firstName: TokenSyntax = .wildcardToken(), + secondName: TokenSyntax, + type: some TypeSyntaxProtocol + ) -> Self { + self.parameters.append( + FunctionParameterSyntax( + firstName: firstName, + secondName: secondName, + type: type + )) + return self + } + + /** + Set the return type for the function signature. + + - Parameter returnType: The return type syntax. + */ + func setReturnType(returnType: some TypeSyntaxProtocol) -> Self { + self.returnType = ReturnClauseSyntax(type: returnType) + return self + } + + /** + Build the final function declaration. + + - Returns: The generated function declaration. + */ + func build(_ codeBlock: ([FunctionParameterSyntax]) -> CodeBlockSyntax) + -> FunctionDeclSyntax { + let functionParameters = FunctionParameterClauseSyntax( + parameters: FunctionParameterListSyntax(parameters) + ) + + let body = codeBlock(parameters) + let functionSignature = FunctionSignatureSyntax( + parameterClause: functionParameters, + returnClause: returnType + ) + + return FunctionDeclSyntax( + modifiers: [accessLevel.declModifier], + name: name, + signature: functionSignature, + body: body, + ) + } + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/PatternBinding.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/PatternBinding.swift new file mode 100644 index 00000000..114c8c3f --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/PatternBinding.swift @@ -0,0 +1,70 @@ +import SwiftSyntax + +extension SerdeDefault { + struct PatternBinding { + let specifier: TokenSyntax + let syntax: PatternBindingSyntax + let identifier: TokenSyntax + let typeSyntax: TypeSyntax + let attributes: AttributeListSyntax + + init( + specifier: TokenSyntax, + syntax: PatternBindingSyntax, + type: some TypeSyntaxProtocol, + attributes: AttributeListSyntax? = nil + ) throws(SerdeDefault.Error) { + self.specifier = specifier + self.syntax = syntax + self.identifier = try Self.identifier(in: syntax) + self.typeSyntax = TypeSyntax(fromProtocol: type) + self.attributes = attributes ?? [] + } + + /** + Extract the identifier from a pattern binding. + + - Throws: `SerdeDefault.Error.mismatch` when the pattern is not an identifier. + */ + static func identifier(in syntax: PatternBindingSyntax) throws(SerdeDefault.Error) + -> TokenSyntax { + guard let pattern = syntax.pattern.as(IdentifierPatternSyntax.self) else { + throw SerdeDefault.Error.mismatch(IdentifierPatternSyntax.self, syntax.pattern) + } + return pattern.identifier.trimmed + } + + /** + Check whether this binding can be initialized with the memberwise initializer. + */ + func isMemberwiseInitializable() -> Bool { + isStored() + && !(specifier.tokenKind == .keyword(.let) && syntax.initializer != nil) + } + + /** + Check whether this binding represents a stored property. + + Computed properties with a getter are excluded. Observed properties (`willSet`/`didSet`) are treated as stored. + */ + func isStored() -> Bool { + // Bindings without an accessor block are stored properties. + guard let accessorBlock = self.syntax.accessorBlock else { + return true + } + switch accessorBlock.accessors { + case .getter: + return false + case .accessors(let accessors): + return accessors.allSatisfy { accessor in + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.willSet), .keyword(.didSet): + return true + default: + return false + } + } + } + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/VariableDecl.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/VariableDecl.swift new file mode 100644 index 00000000..f549a842 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefault/Syntax/VariableDecl.swift @@ -0,0 +1,70 @@ +import SwiftSyntax + +extension SerdeDefault { + struct VariableDecl { + let syntax: VariableDeclSyntax + + init?(declaration: some DeclSyntaxProtocol) { + guard let syntax = declaration.as(VariableDeclSyntax.self) else { + return nil + } + self.syntax = syntax + } + + /** + Check whether the variable declaration is static. + */ + func isStatic() -> Bool { + syntax.modifiers.contains { modifier in + modifier.name.text == DeclModifierSyntax.static.name.text + } + } + + /** + Extract typed bindings from the variable declaration. + */ + func typedBindings() throws(Error) -> [PatternBinding] { + let specifier = self.syntax.bindingSpecifier + let attributes = self.syntax.attributes + + return try self.syntax.bindings.enumerated().map { index, binding throws(Error) in + let type = try self.type(for: binding, at: index) + return try PatternBinding( + specifier: specifier, syntax: binding, type: type, attributes: attributes + ) + } + } + + /** + Resolve the type for the binding at `index`. + + If the binding has an initializer, simple literal types are inferred from it. Otherwise, this falls back to the next explicit type in the same declaration (for example, `var a, b: Int`). + */ + private func type(for binding: PatternBindingSyntax, at index: Int) throws(Error) -> TypeSyntax { + if let typeSyntax = binding.typeAnnotation?.type { + return typeSyntax + } + + if let initializer = binding.initializer { + guard let inferredType = initializer.value.inferredType() else { + throw Error.inferredType(initializer.value) + } + + return TypeSyntax(fromProtocol: inferredType) + } + + guard + let typeSyntax = self.syntax.bindings + .map(\.typeAnnotation?.type) + .dropFirst(index) + .lazy + .compactMap({ $0 }) + .first + else { + throw Error.missingType(binding) + } + + return typeSyntax + } + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeDefaultMacro.swift b/Sources/DefaultsMacrosDeclarations/SerdeDefaultMacro.swift new file mode 100644 index 00000000..22fc1636 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeDefaultMacro.swift @@ -0,0 +1,61 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +/// Macro declaration for the ``SerdeDefault`` macro. +/** +The implementation of the `@SerdeDefault` macro. +*/ +public struct SerdeDefaultMacro {} + +enum SerdeDefault {} + +extension SerdeDefaultMacro: ExtensionMacro { + /** + Generates the extension declaration that adds `Defaults.Serializable` conformance. + + - Parameters: + - declaration: The declaration annotated with `@SerdeDefault`. + - type: The concrete type being extended. + + - Returns: The generated extension declaration. + */ + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + let nominalDecl: SerdeDefault.NominalDecl + do { + nominalDecl = try SerdeDefault.NominalDecl( + declaration: declaration, type: type, macroContext: context) + } catch { + context.diagnose(Diagnostic(node: Syntax(fromProtocol: error.syntax()), message: error)) + return [] + } + let inheritanceClause = InheritanceClauseSyntax( + inheritedTypes: InheritedTypeListSyntax([ + InheritedTypeSyntax(type: MemberTypeSyntax.DefaultsSerializable) + ]) + ) + + // Generate the bridge type and store an instance on `static let bridge`. + let bridgeStruct = nominalDecl.bridgeStructDecl() + let bridgeVariable = nominalDecl.bridgeVariableDecl() + + let memberBlock = MemberBlockSyntax( + members: MemberBlockItemListSyntax([ + MemberBlockItemSyntax(decl: DeclSyntax(bridgeStruct)), + MemberBlockItemSyntax(decl: DeclSyntax(bridgeVariable)) + ]) + ) + let extensionDecl = ExtensionDeclSyntax( + extendedType: nominalDecl.typeSyntax, + inheritanceClause: inheritanceClause, + memberBlock: memberBlock + ) + return [extensionDecl] + } +} diff --git a/Sources/DefaultsMacrosDeclarations/SerdeMacro.swift b/Sources/DefaultsMacrosDeclarations/SerdeMacro.swift new file mode 100644 index 00000000..4e966683 --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/SerdeMacro.swift @@ -0,0 +1,21 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +/** +The implementation of the `@serde` macro. +*/ +public struct SerdeMacro {} + +extension SerdeMacro: PeerMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.DeclSyntax] { + guard SerdeDefault.VariableDecl(declaration: declaration) != nil else { + throw SerdeDefault.Error.mismatch(VariableDeclSyntax.self, declaration) + } + + return [] + } +} diff --git a/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift index 3ecaca0b..dca9d9c0 100644 --- a/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift +++ b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift @@ -8,11 +8,11 @@ import XCTest @testable import DefaultsMacros @testable import DefaultsMacrosDeclarations -let testMacros: [String: Macro.Type] = [ +private let testMacros: [String: Macro.Type] = [ "ObservableDefault": ObservableDefaultMacro.self ] #else -let testMacros: [String: Macro.Type] = [:] +private let testMacros: [String: Macro.Type] = [:] #endif final class ObservableDefaultMacroTests: XCTestCase { diff --git a/Tests/DefaultsMacrosDeclarationsTests/SerdeDefaultMacroTests.swift b/Tests/DefaultsMacrosDeclarationsTests/SerdeDefaultMacroTests.swift new file mode 100644 index 00000000..bb0f240a --- /dev/null +++ b/Tests/DefaultsMacrosDeclarationsTests/SerdeDefaultMacroTests.swift @@ -0,0 +1,868 @@ +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +@testable import Defaults + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. +// Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(DefaultsMacrosDeclarations) + @testable import DefaultsMacros + @testable import DefaultsMacrosDeclarations + + private let testMacros: [String: Macro.Type] = [ + "SerdeDefault": SerdeDefaultMacro.self, + "serde": SerdeMacro.self + ] + + private enum Group { + case standalone + } + + private enum Role: String, Defaults.Serializable { + case admin = "Admin" + case guest = "Guest" + } + +#else + private let testMacros: [String: Macro.Type] = [:] +#endif + +final class SerdeDefaultMacroTests: XCTestCase { + private let suite = UserDefaults(suiteName: UUID().uuidString)! + + deinit { + Defaults.removeAll(suite: suite) + } + + func testExpansionWithMemberSyntax() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + var age: UInt + } + """#, + expandedSource: #""" + struct User { + var name: String + var age: UInt + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name, "age": value.age] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + guard let age = __macro_local_10serializedfMu_["age"] as? UInt else { + return nil + } + return User(name: name, age: age) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionWithOptionalMemberType() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + var age: UInt? + } + """#, + expandedSource: #""" + struct User { + var name: String + var age: UInt? + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + var serialized: Serializable = ["name": value.name] + if let __macro_local_3agefMu_ = value.age { + serialized["age"] = __macro_local_3agefMu_ + } + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + let age = __macro_local_10serializedfMu_["age"] as? UInt + return User(name: name, age: age) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionWithQualifiedSerdeAttribute() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + @DefaultsMacros.serde(.skip) + var group: Group? + } + """#, + expandedSource: #""" + struct User { + var name: String + @DefaultsMacros.serde(.skip) + var group: Group? + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + return User(name: name, group: nil) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionWithQualifiedSerdeOptions() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + @serde(SerdeOption.skip) + var group: Group? + @serde(DefaultsMacros.SerdeOption.skip) + var role: Role? + } + """#, + expandedSource: #""" + struct User { + var name: String + var group: Group? + var role: Role? + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + return User(name: name, group: nil, role: nil) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionKeepsDeserializeInitializerArgumentsTogether() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + @DefaultsMacros.serde(.skip) + var group: Group? + let version = 1 + } + """#, + expandedSource: #""" + struct User { + var name: String + @DefaultsMacros.serde(.skip) + var group: Group? + let version = 1 + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + return User(name: name, group: nil) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionInfersLiteralMemberTypes() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Settings { + var name = "Alice" + var isEnabled = true + var count = 1 + var ratio = 0.5 + } + """#, + expandedSource: #""" + struct Settings { + var name = "Alice" + var isEnabled = true + var count = 1 + var ratio = 0.5 + } + + extension Settings: Defaults.Serializable { + struct SettingsBridge: Defaults.Bridge, Sendable { + typealias Value = Settings + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name, "isEnabled": value.isEnabled, "count": value.count, "ratio": value.ratio] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let name = __macro_local_10serializedfMu_["name"] as? String ?? "Alice" + let isEnabled = __macro_local_10serializedfMu_["isEnabled"] as? Bool ?? true + let count = __macro_local_10serializedfMu_["count"] as? Int ?? 1 + let ratio = __macro_local_10serializedfMu_["ratio"] as? Double ?? 0.5 + return Settings(name: name, isEnabled: isEnabled, count: count, ratio: ratio) + } + } + static let bridge = SettingsBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionQualifiesSelfInDefaultValuesAsValue() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Settings { + static let fallback = 1 + var count: Int = Self.fallback + } + """#, + expandedSource: #""" + struct Settings { + static let fallback = 1 + var count: Int = Self.fallback + } + + extension Settings: Defaults.Serializable { + struct SettingsBridge: Defaults.Bridge, Sendable { + typealias Value = Settings + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["count": value.count] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let count = __macro_local_10serializedfMu_["count"] as? Int ?? Value.fallback + return Settings(count: count) + } + } + static let bridge = SettingsBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionInfersWrappedAndSignedLiteralMemberTypes() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Settings { + var negativeCount = -1 + var positiveRatio = +0.5 + var parenthesizedName = ("Alice") + } + """#, + expandedSource: #""" + struct Settings { + var negativeCount = -1 + var positiveRatio = +0.5 + var parenthesizedName = ("Alice") + } + + extension Settings: Defaults.Serializable { + struct SettingsBridge: Defaults.Bridge, Sendable { + typealias Value = Settings + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["negativeCount": value.negativeCount, "positiveRatio": value.positiveRatio, "parenthesizedName": value.parenthesizedName] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let negativeCount = __macro_local_10serializedfMu_["negativeCount"] as? Int ?? -1 + let positiveRatio = __macro_local_10serializedfMu_["positiveRatio"] as? Double ?? +0.5 + let parenthesizedName = __macro_local_10serializedfMu_["parenthesizedName"] as? String ?? ("Alice") + return Settings(negativeCount: negativeCount, positiveRatio: positiveRatio, parenthesizedName: parenthesizedName) + } + } + static let bridge = SettingsBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionInfersCollectionLiteralMemberTypes() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Settings { + var tags = ["swift"] + var scores = ["home": 1] + } + """#, + expandedSource: #""" + struct Settings { + var tags = ["swift"] + var scores = ["home": 1] + } + + extension Settings: Defaults.Serializable { + struct SettingsBridge: Defaults.Bridge, Sendable { + typealias Value = Settings + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["tags": value.tags, "scores": value.scores] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let tags = __macro_local_10serializedfMu_["tags"] as? [String] ?? ["swift"] + let scores = __macro_local_10serializedfMu_["scores"] as? [String: Int] ?? ["home": 1] + return Settings(tags: tags, scores: scores) + } + } + static let bridge = SettingsBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionWithNonNativeMemberType() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Profile { + var name: String + var role: Role + } + """#, + expandedSource: #""" + struct Profile { + var name: String + var role: Role + } + + extension Profile: Defaults.Serializable { + struct ProfileBridge: Defaults.Bridge, Sendable { + typealias Value = Profile + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + var serialized: Serializable = ["name": value.name] + guard let __macro_local_4rolefMu_ = Role.toSerializable(value.role) else { + return nil + } + serialized["role"] = __macro_local_4rolefMu_ + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + guard let role = Role.toValue(__macro_local_10serializedfMu_["role"] as Any, type: Role.self) else { + return nil + } + return Profile(name: name, role: role) + } + } + static let bridge = ProfileBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionWithOptionalNonNativeMemberType() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Profile { + var name: String + var role: Role? + } + """#, + expandedSource: #""" + struct Profile { + var name: String + var role: Role? + } + + extension Profile: Defaults.Serializable { + struct ProfileBridge: Defaults.Bridge, Sendable { + typealias Value = Profile + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + var serialized: Serializable = ["name": value.name] + if let __macro_local_4rolefMu_ = Role.toSerializable(value.role) { + serialized["role"] = __macro_local_4rolefMu_ + } + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + let role = Role.toValue(__macro_local_10serializedfMu_["role"] as Any, type: Role.self) + return Profile(name: name, role: role) + } + } + static let bridge = ProfileBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionUsesMatchingInitializerForMultiBindingDeclaration() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Pair { + var first: Int, second: Int = 1 + } + """#, + expandedSource: #""" + struct Pair { + var first: Int, second: Int = 1 + } + + extension Pair: Defaults.Serializable { + struct PairBridge: Defaults.Bridge, Sendable { + typealias Value = Pair + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["first": value.first, "second": value.second] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let first = __macro_local_10serializedfMu_["first"] as? Int else { + return nil + } + let second = __macro_local_10serializedfMu_["second"] as? Int ?? 1 + return Pair(first: first, second: second) + } + } + static let bridge = PairBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionIgnoresComputedProperties() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String + var displayName: String { + name.uppercased() + } + } + """#, + expandedSource: #""" + struct User { + var name: String + var displayName: String { + name.uppercased() + } + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + let serialized: Serializable = ["name": value.name] + + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + return User(name: name) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionDiagnosesSkipOnNonOptionalProperty() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + @serde(.skip) + var name: String + } + """#, + expandedSource: #""" + struct User { + var name: String + } + """#, + diagnostics: [ + DiagnosticSpec( + message: "Non-optional property is marked with `@serde(.skip)`, which is not supported. Please remove the `skip` argument or make the property optional.", + line: 4, + column: 6 + ) + ], + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionDiagnosesGenericStruct() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Box { + var value: T + } + """#, + expandedSource: #""" + struct Box { + var value: T + } + """#, + diagnostics: [ + DiagnosticSpec( + message: "`@SerdeDefault` does not support generic types. Apply it to a concrete struct instead.", + line: 2, + column: 11 + ) + ], + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionDiagnosesClass() throws { + assertMacroExpansion( + #""" + @SerdeDefault + class User { + var name: String + } + """#, + expandedSource: #""" + class User { + var name: String + } + """#, + diagnostics: [ + DiagnosticSpec( + message: "Expected a struct declaration. Received: ClassDeclSyntax. In this first-stage implementation, `@SerdeDefault` supports structs only.", + line: 1, + column: 1 + ) + ], + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionDiagnosesMixedCollectionLiteralTypes() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct Values { + var numbers = [1, 2.5] + } + """#, + expandedSource: #""" + struct Values { + var numbers = [1, 2.5] + } + """#, + diagnostics: [ + DiagnosticSpec( + message: "Could not infer the type of `@SerdeDefault` stored property initializer. Add an explicit type annotation.", + line: 3, + column: 16 + ) + ], + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testNativeTypeResolution() { + let nativeType: TypeSyntax = "String" + XCTAssertTrue(nativeType.isNativeSupportType()) + + let lookalikeType: TypeSyntax = "Domain.String" + XCTAssertFalse(lookalikeType.isNativeSupportType()) + + let qualifiedType: TypeSyntax = "Swift.String" + XCTAssertTrue(qualifiedType.isNativeSupportType()) + } + + func testSuffixRemovesIdentifierEscaping() { + let identifier = TokenSyntax(stringLiteral: "`actor`") + XCTAssertEqual(identifier.suffix("Bridge").text, "actorBridge") + } + + // Generated temporaries must not shadow properties with the same name. + func testExpansionKeepsSerializeHygienicForCollidingPropertyNames() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var serialized: String? + var name: String + } + """#, + expandedSource: #""" + struct User { + var serialized: String? + var name: String + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + var serialized: Serializable = ["name": value.name] + if let __macro_local_10serializedfMu0_ = value.serialized { + serialized["serialized"] = __macro_local_10serializedfMu0_ + } + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let serialized = __macro_local_10serializedfMu_["serialized"] as? String + guard let name = __macro_local_10serializedfMu_["name"] as? String else { + return nil + } + return User(serialized: serialized, name: name) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } + + func testExpansionUsesPropertyDefaultsWhenDeserializationReturnsNil() throws { + assertMacroExpansion( + #""" + @SerdeDefault + struct User { + var name: String = "Anonymous" + var role: Role = .guest + } + """#, + expandedSource: #""" + struct User { + var name: String = "Anonymous" + var role: Role = .guest + } + + extension User: Defaults.Serializable { + struct UserBridge: Defaults.Bridge, Sendable { + typealias Value = User + typealias Serializable = [String: Any] + public func serialize(_ value: Value?) -> Serializable? { + guard let value else { + return nil + } + var serialized: Serializable = ["name": value.name] + guard let __macro_local_4rolefMu_ = Role.toSerializable(value.role) else { + return nil + } + serialized["role"] = __macro_local_4rolefMu_ + return serialized + } + public func deserialize(_ __macro_local_10serializedfMu_: Serializable?) -> Value? { + guard let __macro_local_10serializedfMu_ else { + return nil + } + let name = __macro_local_10serializedfMu_["name"] as? String ?? "Anonymous" + let role = Role.toValue(__macro_local_10serializedfMu_["role"] as Any, type: Role.self) ?? .guest + return User(name: name, role: role) + } + } + static let bridge = UserBridge() + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + } +} diff --git a/Tests/DefaultsTests/DefaultsAnySeriliazableTests.swift b/Tests/DefaultsTests/DefaultsAnySeriliazableTests.swift index 0d3df98e..3df5ccd1 100644 --- a/Tests/DefaultsTests/DefaultsAnySeriliazableTests.swift +++ b/Tests/DefaultsTests/DefaultsAnySeriliazableTests.swift @@ -2,7 +2,7 @@ import SwiftUI import Testing import Defaults -private let suite_ = createSuite() +private let suite_: UserDefaults = createSuite() private enum mime: String, Defaults.Serializable { case JSON = "application/json" diff --git a/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift b/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift index 2ed693e3..7a169e0c 100644 --- a/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift +++ b/Tests/DefaultsTests/DefaultsCustomBridgeTests.swift @@ -33,7 +33,6 @@ public final class DefaultsUserBridge: Defaults.Bridge { else { return nil } - return User(username: username, password: password) } }