Skip to content

Commit a405f1b

Browse files
committed
Fix: Handle partial Bool coverage in switch statements
- [x] Fix the `Bool` `switch` optimization to only skip the `default` case when BOTH `true` and `false` values are explicitly present in the switch. - Previously, the default case was incorrectly skipped for all `Bool` switches, breaking macro expansion when `@CodedAs` includes `Bool` values but not all `enum` cases have `Bool` mappings (partial coverage). - [x] Add logic to detect both `true` and `false` values before skipping default - [x] Add `CodedAsMixedTypesTests.swift` test for partial `Bool` coverage scenario with mixed types - [x] Update inline documentation to clarify the behavior
1 parent 4a3190a commit a405f1b

2 files changed

Lines changed: 196 additions & 16 deletions

File tree

Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,34 @@ import SwiftSyntaxMacros
1111
protocol TaggedEnumSwitcherVariable: EnumSwitcherVariable {}
1212

1313
extension TaggedEnumSwitcherVariable {
14-
/// Provides the switch expression for decoding.
14+
/// Provides a `switch` expression used to decode a tagged enum variable.
1515
///
16-
/// Based on enum-cases the each case for switch expression is generated.
17-
/// Final expression generated combining all cases with provided parameters.
16+
/// Generates a `switch` over `header` by mapping each eligible enum case in `location`
17+
/// to a corresponding `case` clause. Each generated clause runs `preSyntax` for the
18+
/// matched tag value and then emits the decoding code for that enum case.
1819
///
1920
/// - Parameters:
20-
/// - header: The switch header cases are compared to.
21-
/// - location: The decoding location.
22-
/// - coder: The decoder for cases.
23-
/// - context: The context in which to perform the macro expansion.
24-
/// - default: Whether default case is needed. Note that for Bool type,
25-
/// the default case is automatically skipped since both true and false
26-
/// cases are explicitly handled, avoiding unreachable default warnings.
27-
/// - forceDecodingReturn: Whether to force explicit `return` statements in each
28-
/// switch case. When `true`, adds a `return` statement after the case assignment
29-
/// for early exit. Defaults to `false` for backward compatibility.
30-
/// - preSyntax: The callback to generate case variation data.
21+
/// - header: The expression whose value is matched by the `switch`.
22+
/// - location: The decoding location containing tagged enum cases.
23+
/// - coder: The decoder token used by generated decoding code.
24+
/// - context: The macro expansion context.
25+
/// - default: Whether to include a `default` case.
26+
/// - forceDecodingReturn:
27+
/// - When `true`, emits an explicit `return` after each `case` assignment
28+
/// for early exit.
29+
/// - Defaults to `false` for backward compatibility.
30+
/// - preSyntax: A callback used to generate case-variation syntax for the matched
31+
/// tag value.
3132
///
32-
/// - Returns: The generated switch expression.
33+
/// - Important: For `Bool` tags, the `default` case is omitted only when both `true`
34+
/// and `false` are explicitly covered. With partial coverage (only one of the two
35+
/// values), the `default` case is retained.
36+
///
37+
/// - Returns: A `SwitchExprSyntax` when at least one matching switch case can be
38+
/// generated; otherwise `nil`.
39+
///
40+
/// - Complexity: `O(𝑛)` in the number of tagged cases in `location`.
41+
/// - Plus the number of tag expressions scanned for `Bool` coverage.
3342
func decodeSwitchExpression(
3443
over header: EnumVariable.CaseValue.Expr,
3544
at location: EnumSwitcherLocation,
@@ -40,6 +49,25 @@ extension TaggedEnumSwitcherVariable {
4049
preSyntax: (TokenSyntax) -> CodeBlockItemListSyntax
4150
) -> SwitchExprSyntax? {
4251
var switchable = false
52+
53+
/// For `Bool` type, check if both `true` and `false` values are present
54+
var hasBoolTrue = false
55+
var hasBoolFalse = false
56+
if header.type == .bool {
57+
for (_, value) in location.cases {
58+
let boolValues = value.decodeExprs.filter { $0.type == .bool }
59+
for boolValue in boolValues {
60+
let valueStr = boolValue.syntax.trimmedDescription
61+
if valueStr == "true" {
62+
hasBoolTrue = true
63+
} else if valueStr == "false" {
64+
hasBoolFalse = true
65+
}
66+
}
67+
}
68+
}
69+
let skipDefaultForBool = header.type == .bool && hasBoolTrue && hasBoolFalse
70+
4371
let switchExpr = SwitchExprSyntax(subject: header.syntax) {
4472
for (`case`, value) in location.cases where `case`.decode ?? true {
4573
let values = value.decodeExprs
@@ -62,7 +90,7 @@ extension TaggedEnumSwitcherVariable {
6290
}
6391
}
6492

65-
if `default` && header.type != .bool {
93+
if `default` && !skipDefaultForBool {
6694
SwitchCaseSyntax(label: .default(.init())) {
6795
"break"
6896
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import Foundation
2+
import MetaCodable
3+
import Testing
4+
5+
@testable import PluginCore
6+
7+
/// Tests for `@CodedAs` macro with mixed literal types including partial `Bool` coverage.
8+
///
9+
/// These tests verify that internally tagged enums work correctly when `@CodedAs`
10+
/// specifies multiple literal types (`String`, `Int`, `Bool`) and not all `enum` cases
11+
/// have `Bool` values, resulting in partial `Bool` coverage in `switch` statements.
12+
@Suite("CodedAs Mixed Types Tests")
13+
struct CodedAsMixedTypesTests {
14+
/// Tests macro expansion when `@CodedAs` includes `Bool` values
15+
/// but not all cases have them.
16+
///
17+
/// This scenario requires the generated `Bool` switch to include a `default` case
18+
/// since only `true` is specified (for `load` case) and `false` is not covered.
19+
@Test("Expansion with partial Bool coverage")
20+
func expansionWithPartialBoolCoverage() throws {
21+
assertMacroExpansion(
22+
"""
23+
@Codable
24+
@CodedAt("type")
25+
enum Command {
26+
@CodedAs("load", 12, true)
27+
case load(key: String)
28+
@CodedAs("store", 30)
29+
case store(key: String, value: Int)
30+
}
31+
""",
32+
expandedSource:
33+
"""
34+
enum Command {
35+
case load(key: String)
36+
case store(key: String, value: Int)
37+
}
38+
39+
extension Command: Decodable {
40+
init(from decoder: any Decoder) throws {
41+
var typeContainer: KeyedDecodingContainer<CodingKeys>?
42+
let container = try? decoder.container(keyedBy: CodingKeys.self)
43+
if let container = container {
44+
typeContainer = container
45+
} else {
46+
typeContainer = nil
47+
}
48+
if let typeContainer = typeContainer, let container = container {
49+
let typeBool: Bool?
50+
do {
51+
typeBool = try typeContainer.decodeIfPresent(Bool.self, forKey: CodingKeys.type) ?? nil
52+
} catch {
53+
typeBool = nil
54+
}
55+
if let typeBool = typeBool {
56+
switch typeBool {
57+
case true:
58+
let key: String
59+
let container = try decoder.container(keyedBy: CodingKeys.self)
60+
key = try container.decode(String.self, forKey: CodingKeys.key)
61+
self = .load(key: key)
62+
return
63+
default:
64+
break
65+
}
66+
}
67+
let typeInt: Int?
68+
do {
69+
typeInt = try typeContainer.decodeIfPresent(Int.self, forKey: CodingKeys.type) ?? nil
70+
} catch {
71+
typeInt = nil
72+
}
73+
if let typeInt = typeInt {
74+
switch typeInt {
75+
case 12:
76+
let key: String
77+
key = try container.decode(String.self, forKey: CodingKeys.key)
78+
self = .load(key: key)
79+
return
80+
case 30:
81+
let key: String
82+
let value: Int
83+
key = try container.decode(String.self, forKey: CodingKeys.key)
84+
value = try container.decode(Int.self, forKey: CodingKeys.value)
85+
self = .store(key: key, value: value)
86+
return
87+
default:
88+
break
89+
}
90+
}
91+
let typeString: String?
92+
do {
93+
typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil
94+
} catch {
95+
typeString = nil
96+
}
97+
if let typeString = typeString {
98+
switch typeString {
99+
case "load":
100+
let key: String
101+
key = try container.decode(String.self, forKey: CodingKeys.key)
102+
self = .load(key: key)
103+
return
104+
case "store":
105+
let key: String
106+
let value: Int
107+
key = try container.decode(String.self, forKey: CodingKeys.key)
108+
value = try container.decode(Int.self, forKey: CodingKeys.value)
109+
self = .store(key: key, value: value)
110+
return
111+
default:
112+
break
113+
}
114+
}
115+
}
116+
let context = DecodingError.Context(
117+
codingPath: decoder.codingPath,
118+
debugDescription: "Couldn't match any cases."
119+
)
120+
throw DecodingError.typeMismatch(Self.self, context)
121+
}
122+
}
123+
124+
extension Command: Encodable {
125+
func encode(to encoder: any Encoder) throws {
126+
let container = encoder.container(keyedBy: CodingKeys.self)
127+
var typeContainer = container
128+
switch self {
129+
case .load(key: let key):
130+
try typeContainer.encode("load", forKey: CodingKeys.type)
131+
var container = encoder.container(keyedBy: CodingKeys.self)
132+
try container.encode(key, forKey: CodingKeys.key)
133+
case .store(key: let key, value: let value):
134+
try typeContainer.encode("store", forKey: CodingKeys.type)
135+
var container = encoder.container(keyedBy: CodingKeys.self)
136+
try container.encode(key, forKey: CodingKeys.key)
137+
try container.encode(value, forKey: CodingKeys.value)
138+
}
139+
}
140+
}
141+
142+
extension Command {
143+
enum CodingKeys: String, CodingKey {
144+
case type = "type"
145+
case key = "key"
146+
case value = "value"
147+
}
148+
}
149+
"""
150+
)
151+
}
152+
}

0 commit comments

Comments
 (0)