Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.build
/.swiftlint-cache
/Packages
xcuserdata
project.xcworkspace
6 changes: 4 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -153,6 +153,8 @@ only_rules:
- xct_specific_matcher
- xctfail_message
- yoda_condition
excluded:
- .build
analyzer_rules:
- capture_variable
- unused_declaration
Expand Down
17 changes: 8 additions & 9 deletions Sources/Defaults/Defaults+Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
1 change: 1 addition & 0 deletions Sources/Defaults/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ struct ContentView: View {

### Customize Storage

- <doc:SerdeDefault>
- <doc:AdvancedUsage>
- ``Defaults/Serializable``
- ``Defaults/Bridge``
Expand Down
84 changes: 84 additions & 0 deletions Sources/Defaults/Documentation.docc/SerdeDefault.md
Original file line number Diff line number Diff line change
@@ -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>(
"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``
5 changes: 4 additions & 1 deletion Sources/Defaults/Documentation.docc/SupportedTypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,15 @@ Defaults[.user].name

## Custom Types

You can easily add support for any custom type. See <doc:AdvancedUsage> for how to add support for your own types.
You can generate ``Defaults/Serializable`` conformance for a struct with the `@SerdeDefault` macro. See <doc:SerdeDefault> to get started.

For complete control over the stored representation, see <doc:AdvancedUsage> to learn how to create a custom bridge.

## Topics

### Making Types Compatible

- <doc:SerdeDefault>
- ``Defaults/Serializable``
- ``Defaults/Bridge``

Expand Down
5 changes: 2 additions & 3 deletions Sources/Defaults/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ extension Defaults.Serializable {
return Value.toValue(anyObject)
```
*/
static func toValue<T: Defaults.Serializable>(_ anyObject: Any, type: T.Type = Self.self) -> T? {
public static func toValue<T: Defaults.Serializable>(_ anyObject: Any, type: T.Type = Self.self) -> T? {
if
T.isNativelySupportedType,
let anyObject = anyObject as? T
Expand Down Expand Up @@ -228,8 +228,7 @@ extension Defaults.Serializable {
set(Value.toSerialize(value), forKey: key)
```
*/
@usableFromInline
static func toSerializable<T: Defaults.Serializable>(_ value: T) -> Any? {
public static func toSerializable<T: Defaults.Serializable>(_ value: T) -> Any? {
guard !T.isNativelySupportedType else {
return value
}
Expand Down
65 changes: 65 additions & 0 deletions Sources/DefaultsMacros/SerdeDefault.swift
Original file line number Diff line number Diff line change
@@ -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>(
"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"
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import SwiftSyntaxMacros
@main
struct DefaultsMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ObservableDefaultMacro.self
ObservableDefaultMacro.self,
SerdeDefaultMacro.self,
SerdeMacro.self
]
}
Original file line number Diff line number Diff line change
@@ -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)
}
"""
)
}
}
}
Loading