From 522c883d9e8fbdc5d998e1aac3bf5e587762d38f Mon Sep 17 00:00:00 2001 From: Wendell Date: Wed, 31 Dec 2025 16:19:38 +0800 Subject: [PATCH 1/3] Make Codable hooks annotation-only - Remove conventional-name fallback hook detection; only @CodableHook is invoked. - Warn when conventional hook methods exist without @CodableHook. - Update README and macro expansion tests. --- README.md | 2 +- Sources/CodableKitMacros/CodeGenCore.swift | 103 ++++++++++++------ .../CodableMacroTests+class_hooks.swift | 21 ++++ .../CodableMacroTests+hooks.swift | 10 +- .../CodableMacroTests+hooks.swift | 17 ++- 5 files changed, 114 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0f8a9e8..9642e2d 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/Sources/CodableKitMacros/CodeGenCore.swift b/Sources/CodableKitMacros/CodeGenCore.swift index f711fbd..4f86137 100644 --- a/Sources/CodableKitMacros/CodeGenCore.swift +++ b/Sources/CodableKitMacros/CodeGenCore.swift @@ -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 = 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: .warning + ) + ) + ) + } + } } func prepareCodeGeneration( @@ -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] = [] @@ -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) } } diff --git a/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift b/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift index 8cd9022..56acd78 100644 --- a/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift +++ b/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift @@ -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: .warning + ), + .init( + message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)", + line: 1, + column: 1, + severity: .warning + ), + .init( + message: "Hook method 'didDecode' will not be invoked unless annotated with @CodableHook(.didDecode)", + line: 1, + column: 1, + severity: .warning + ), + ] ) } } diff --git a/Tests/DecodableKitTests/CodableMacroTests+hooks.swift b/Tests/DecodableKitTests/CodableMacroTests+hooks.swift index 715283e..697fbaf 100644 --- a/Tests/DecodableKitTests/CodableMacroTests+hooks.swift +++ b/Tests/DecodableKitTests/CodableMacroTests+hooks.swift @@ -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: .warning + ) + ] ) } } diff --git a/Tests/EncodableKitTests/CodableMacroTests+hooks.swift b/Tests/EncodableKitTests/CodableMacroTests+hooks.swift index b681301..29cd4ce 100644 --- a/Tests/EncodableKitTests/CodableMacroTests+hooks.swift +++ b/Tests/EncodableKitTests/CodableMacroTests+hooks.swift @@ -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() } } @@ -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: .warning + ), + .init( + message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)", + line: 1, + column: 1, + severity: .warning + ), + ] ) } } From 8a5247b1e365f291e3f82290694a7fc3f9bfa60c Mon Sep 17 00:00:00 2001 From: Wendell Date: Wed, 31 Dec 2025 16:21:13 +0800 Subject: [PATCH 2/3] Add v2 migration guide for hooks Document that hooks are annotation-only in v2 and provide before/after examples. --- MIGRATION.md | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ 2 files changed, 132 insertions(+) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..cfed3b1 --- /dev/null +++ b/MIGRATION.md @@ -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()` are invoked. +- Conventional method names are **not** invoked unless you annotate them. +- The macro will emit a warning 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. + + diff --git a/README.md b/README.md index 9642e2d..ec0551b 100644 --- a/README.md +++ b/README.md @@ -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. From 4a08b171209cc13e6b66e0e7c6c8c556fc68993d Mon Sep 17 00:00:00 2001 From: Wendell Date: Wed, 31 Dec 2025 16:24:33 +0800 Subject: [PATCH 3/3] Error on legacy (unannotated) hooks Conventional hook method names are no longer invoked in v2; make this a compile-time error and update docs/tests. --- MIGRATION.md | 2 +- Sources/CodableKitMacros/CodeGenCore.swift | 2 +- Tests/CodableKitTests/CodableMacroTests+class_hooks.swift | 6 +++--- Tests/DecodableKitTests/CodableMacroTests+hooks.swift | 2 +- Tests/EncodableKitTests/CodableMacroTests+hooks.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cfed3b1..2ace460 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -16,7 +16,7 @@ In v2, hooks are **explicit**: - Only methods annotated with `@CodableHook()` are invoked. - Conventional method names are **not** invoked unless you annotate them. -- The macro will emit a warning when it sees conventional hook methods without `@CodableHook`. +- The macro will emit a **compile-time error** when it sees conventional hook methods without `@CodableHook`. ### What changed (summary) diff --git a/Sources/CodableKitMacros/CodeGenCore.swift b/Sources/CodableKitMacros/CodeGenCore.swift index 4f86137..9486c68 100644 --- a/Sources/CodableKitMacros/CodeGenCore.swift +++ b/Sources/CodableKitMacros/CodeGenCore.swift @@ -395,7 +395,7 @@ extension CodeGenCore { node: node, message: SimpleDiagnosticMessage( message: "Hook method '\(name)' will not be invoked unless annotated with @CodableHook(.\(stage))", - severity: .warning + severity: .error ) ) ) diff --git a/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift b/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift index 56acd78..57e9349 100644 --- a/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift +++ b/Tests/CodableKitTests/CodableMacroTests+class_hooks.swift @@ -162,19 +162,19 @@ import Testing message: "Hook method 'willEncode' will not be invoked unless annotated with @CodableHook(.willEncode)", line: 1, column: 1, - severity: .warning + severity: .error ), .init( message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)", line: 1, column: 1, - severity: .warning + severity: .error ), .init( message: "Hook method 'didDecode' will not be invoked unless annotated with @CodableHook(.didDecode)", line: 1, column: 1, - severity: .warning + severity: .error ), ] ) diff --git a/Tests/DecodableKitTests/CodableMacroTests+hooks.swift b/Tests/DecodableKitTests/CodableMacroTests+hooks.swift index 697fbaf..c9ae89c 100644 --- a/Tests/DecodableKitTests/CodableMacroTests+hooks.swift +++ b/Tests/DecodableKitTests/CodableMacroTests+hooks.swift @@ -179,7 +179,7 @@ import Testing message: "Hook method 'didDecode' will not be invoked unless annotated with @CodableHook(.didDecode)", line: 1, column: 1, - severity: .warning + severity: .error ) ] ) diff --git a/Tests/EncodableKitTests/CodableMacroTests+hooks.swift b/Tests/EncodableKitTests/CodableMacroTests+hooks.swift index 29cd4ce..6f2b5de 100644 --- a/Tests/EncodableKitTests/CodableMacroTests+hooks.swift +++ b/Tests/EncodableKitTests/CodableMacroTests+hooks.swift @@ -105,13 +105,13 @@ import Testing message: "Hook method 'willEncode' will not be invoked unless annotated with @CodableHook(.willEncode)", line: 1, column: 1, - severity: .warning + severity: .error ), .init( message: "Hook method 'didEncode' will not be invoked unless annotated with @CodableHook(.didEncode)", line: 1, column: 1, - severity: .warning + severity: .error ), ] )