Skip to content
Merged
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
128 changes: 128 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Migration Guide

This guide highlights breaking changes and recommended migrations between major versions of CodableKit.

## Migrating to v2: Codable hooks are now explicit

In v1.x (e.g. `1.7.7`), CodableKit supported **implicit** lifecycle hooks by convention:

- `didDecode(from:)`
- `willEncode(to:)`
- `didEncode(to:)`

These were invoked by the macro even if the methods were defined in an `extension` (including in another file).

In v2, hooks are **explicit**:

- Only methods annotated with `@CodableHook(<stage>)` are invoked.
- Conventional method names are **not** invoked unless you annotate them.
- The macro will emit a **compile-time error** when it sees conventional hook methods without `@CodableHook`.

### What changed (summary)

- **Removed**: conventional-name hook fallback (`didDecode`, `willEncode`, `didEncode`, `willDecode`) as an implicit mechanism.
- **Added**: `@CodableHook` for explicitly marking hook methods.

### How to migrate

#### 1) `didDecode(from:)`

Before (v1.x):

```swift
@Codable
struct User {
var name: String

mutating func didDecode(from decoder: any Decoder) throws {
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
```

After (v2):

```swift
@Codable
struct User {
var name: String

@CodableHook(.didDecode)
mutating func didDecode(from decoder: any Decoder) throws {
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
```

Parameterless variant is also supported:

```swift
@CodableHook(.didDecode)
mutating func didDecode() throws { /* ... */ }
```

#### 2) `willEncode(to:)` / `didEncode(to:)`

Before (v1.x):

```swift
@Encodable
struct User {
var name: String

func willEncode(to encoder: any Encoder) throws { /* ... */ }
func didEncode(to encoder: any Encoder) throws { /* ... */ }
}
```

After (v2):

```swift
@Encodable
struct User {
var name: String

@CodableHook(.willEncode)
func willEncode(to encoder: any Encoder) throws { /* ... */ }

@CodableHook(.didEncode)
func didEncode(to encoder: any Encoder) throws { /* ... */ }
}
```

Parameterless variants are also supported:

```swift
@CodableHook(.willEncode)
func willEncode() throws { /* ... */ }

@CodableHook(.didEncode)
func didEncode() throws { /* ... */ }
```

#### 3) Pre-decode hook (`willDecode`)

v2 adds explicit pre-decode hooks:

```swift
@Decodable
struct User {
let id: Int

@CodableHook(.willDecode)
static func willDecode(from decoder: any Decoder) throws {
// configure decoder.userInfo, validate configuration, etc.
}
}
```

Notes:
- `willDecode` must be `static` or `class`.
- You can also declare a parameterless variant.

### Common gotchas

- **Hooks in extensions**: in v2, hook detection and invocation is based on annotated methods. Prefer putting `@CodableHook` methods in the type body to keep intent local and obvious.
- **Overloads**: if you have both `hook()` and `hook(from:)`, pick one form and annotate it to avoid ambiguity.


6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ struct User {
- `.willEncode` / `.didEncode`: instance methods, signature `to encoder: any Encoder`, run before/after encoding.
- Multiple hooks per stage are supported and called in declaration order.
- You can pick any method names when annotated; the macro invokes the annotated methods.
- If no annotations are present, conventional names are still detected for compatibility.
- In v2, hooks are **explicit**: only `@CodableHook`-annotated methods are invoked.

### Why There’s No Instance `willDecode`

Expand Down Expand Up @@ -445,3 +445,7 @@ extension HashableUser: Hashable { }
## Contributing

Please feel free to contribute to `CodableKit`! Any input and suggestions are always appreciated.

## Migration

If you are upgrading from v1 to v2, see `MIGRATION.md` for hook-related breaking changes and how to update your code.
103 changes: 68 additions & 35 deletions Sources/CodableKitMacros/CodeGenCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,73 @@ extension CodeGenCore {
if hooksPresence[id] == nil {
hooksPresence[id] = Self.detectHooks(in: declaration)
}

// v2 explicit hooks: warn when conventional hook method names exist without @CodableHook.
if emitAdvisories {
let relevantNames: Set<String> = if codableType.contains(.codable) {
["willDecode", "didDecode", "willEncode", "didEncode"]
} else if codableType.contains(.decodable) {
["willDecode", "didDecode"]
} else if codableType.contains(.encodable) {
["willEncode", "didEncode"]
} else {
[]
}

func hasCodableHookAttribute(_ funcDecl: FunctionDeclSyntax) -> Bool {
for attr in funcDecl.attributes {
if let a = AttributeSyntax(attr),
a.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "CodableHook"
{
return true
}
}
return false
}

func stageToken(for conventionalName: String) -> String? {
switch conventionalName {
case "willDecode": "willDecode"
case "didDecode": "didDecode"
case "willEncode": "willEncode"
case "didEncode": "didEncode"
default: nil
}
}

func looksLikeOldHookSignature(_ funcDecl: FunctionDeclSyntax, for name: String) -> Bool {
let params = funcDecl.signature.parameterClause.parameters
if params.count == 0 { return true }
guard params.count == 1, let first = params.first else { return false }
switch name {
case "willDecode", "didDecode":
return first.type.description.contains("Decoder")
case "willEncode", "didEncode":
return first.type.description.contains("Encoder")
default:
return false
}
}

for member in declaration.memberBlock.members {
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue }
let name = funcDecl.name.text
guard relevantNames.contains(name) else { continue }
guard !hasCodableHookAttribute(funcDecl) else { continue }
guard looksLikeOldHookSignature(funcDecl, for: name) else { continue }
guard let stage = stageToken(for: name) else { continue }

context.diagnose(
Diagnostic(
node: node,
message: SimpleDiagnosticMessage(
message: "Hook method '\(name)' will not be invoked unless annotated with @CodableHook(.\(stage))",
severity: .error
)
)
)
}
}
}

func prepareCodeGeneration(
Expand Down Expand Up @@ -501,7 +568,7 @@ extension CodeGenCore {
// MARK: - Hook Detection
extension CodeGenCore {
/// Detect lifecycle hook methods defined in the declaration.
/// Preference order: annotated hooks (@CodableHook) → name-based compatibility.
/// v2 behavior: hooks are explicit — only annotated hooks (@CodableHook) are invoked.
fileprivate static func detectHooks(in declaration: some DeclGroupSyntax) -> HooksPresence {
var willDecode: [Hook] = []
var didDecode: [Hook] = []
Expand Down Expand Up @@ -553,40 +620,6 @@ extension CodeGenCore {
}
}

// Second pass: fallback to conventional names if no annotations collected for that stage
for member in declaration.memberBlock.members {
guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue }
let name = funcDecl.name.text
let params = funcDecl.signature.parameterClause.parameters
let first = params.first
switch name {
case "willDecode":
// fallback only if not annotated for that stage
let isStatic = funcDecl.modifiers.contains(where: { $0.name.text == "static" || $0.name.text == "class" })
if willDecode.isEmpty, isStatic {
let kind: HookArgKind = if let first, typeContains(first, token: "Decoder") { .decoder } else { .none }
willDecode.append(.init(name: name, kind: kind, isStatic: true))
}
case "didDecode":
if didDecode.isEmpty {
let kind: HookArgKind = if let first, typeContains(first, token: "Decoder") { .decoder } else { .none }
didDecode.append(.init(name: name, kind: kind, isStatic: false))
}
case "willEncode":
if willEncode.isEmpty {
let kind: HookArgKind = if let first, typeContains(first, token: "Encoder") { .encoder } else { .none }
willEncode.append(.init(name: name, kind: kind, isStatic: false))
}
case "didEncode":
if didEncode.isEmpty {
let kind: HookArgKind = if let first, typeContains(first, token: "Encoder") { .encoder } else { .none }
didEncode.append(.init(name: name, kind: kind, isStatic: false))
}
default:
continue
}
}

return HooksPresence(willDecode: willDecode, didDecode: didDecode, willEncode: willEncode, didEncode: didEncode)
}
}
21 changes: 21 additions & 0 deletions Tests/CodableKitTests/CodableMacroTests+class_hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,27 @@ import Testing
}
}
"""
,
diagnostics: [
.init(
message: "Hook method 'willEncode' will not be invoked unless annotated with @CodableHook(.willEncode)",
line: 1,
column: 1,
severity: .error
),
.init(
message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)",
line: 1,
column: 1,
severity: .error
),
.init(
message: "Hook method 'didDecode' will not be invoked unless annotated with @CodableHook(.didDecode)",
line: 1,
column: 1,
severity: .error
),
]
)
}
}
10 changes: 9 additions & 1 deletion Tests/DecodableKitTests/CodableMacroTests+hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,18 @@ import Testing
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
age = try container.decode(Int.self, forKey: .age)
try didDecode()
}
}
"""
,
diagnostics: [
.init(
message: "Hook method 'didDecode' will not be invoked unless annotated with @CodableHook(.didDecode)",
line: 1,
column: 1,
severity: .error
)
]
)
}
}
17 changes: 15 additions & 2 deletions Tests/EncodableKitTests/CodableMacroTests+hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,10 @@ import Testing
func didEncode() throws {}

public func encode(to encoder: any Encoder) throws {
try willEncode()
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(age, forKey: .age)
try didEncode()
}
}

Expand All @@ -101,6 +99,21 @@ import Testing
}
}
"""
,
diagnostics: [
.init(
message: "Hook method 'willEncode' will not be invoked unless annotated with @CodableHook(.willEncode)",
line: 1,
column: 1,
severity: .error
),
.init(
message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)",
line: 1,
column: 1,
severity: .error
),
]
)
}
}