From 3c7292ccbccc72ad7bdd4714ec725501a8c25eed Mon Sep 17 00:00:00 2001 From: Phoenix Date: Mon, 9 Feb 2026 14:37:33 +0800 Subject: [PATCH 1/2] fix: When the CaseMatcher is explicitly declared, the case name is no longer used. --- Sources/ReerCodableMacros/TypeInfo.swift | 62 ++++++++++++++++--- Tests/ReerCodableTests/EnumTests.swift | 44 +++++++++++-- Tests/ReerCodableTests/MacroExpandTests.swift | 21 ++----- 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index 98b8ef4..49cfe96 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -404,6 +404,35 @@ extension TypeInfo { return (typeStr, valueStr) } + + func isRangeValueString(_ value: String) -> Bool { + let trimmed = value.replacingOccurrences(of: " ", with: "") + return trimmed.contains("...") || trimmed.contains("..<") + } + + func firstMatchValue(for enumCase: EnumCase) -> String? { + let orderedTypes = enumCase.matchOrder.isEmpty + ? Array(enumCase.matches.keys) + : enumCase.matchOrder + for type in orderedTypes { + guard let values = enumCase.matches[type] else { continue } + for value in values { + if !isRangeValueString(value) { + return value + } + } + } + return nil + } + + func firstKeyPathMatchValue(for enumCase: EnumCase) -> String? { + for match in enumCase.keyPathMatches { + if !isRangeValueString(match.value) { + return match.value + } + } + return nil + } /* @Codable enum Phone { @@ -881,7 +910,9 @@ extension TypeInfo { """ } else { var keys = theCase.matches["String"] ?? [] - keys.append("\"\(theCase.caseName)\"") + if keys.isEmpty { + keys.append("\"\(theCase.caseName)\"") + } keys.removeDuplicates() condition = """ let \(hasAssociated ? "nestedContainer" : "_") = try? container.nestedContainer(forKeys: \(keys.joined(separator: ", "))) @@ -940,15 +971,24 @@ extension TypeInfo { } """ } - let tryRaw = """ + let rawCases = enumCases.filter { $0.matches.isEmpty && $0.keyPathMatches.isEmpty } + let tryRaw: String? = rawCases.isEmpty + ? nil + : """ let value = try container.decode(type: \(enumRawType ?? "String").self, enumName: String(describing: Self.self)) switch value { - \(enumCases.compactMap { "case \($0.rawValue): self = .\($0.caseName)" }.joined(separator: "\n")) + \(rawCases.compactMap { "case \($0.rawValue): self = .\($0.caseName)" }.joined(separator: "\n")) default: throw ReerCodableError(text: "Cannot initialize \\(String(describing: Self.self)) from invalid value \\(value)") } try self.didDecode(from: decoder) """ - return (tryDecode.joined(separator: "\n") + "\n" + tryRaw, false) + let notMatched = rawCases.isEmpty + ? "throw ReerCodableError(text: \"No matching case found for \\\\(String(describing: Self.self)).\")" + : nil + let code = ([tryDecode.joined(separator: "\n"), tryRaw, notMatched] + .compactMap { $0?.isEmpty == false ? $0 : nil }) + .joined(separator: "\n") + return (code, false) } else { return ( """ @@ -970,14 +1010,19 @@ extension TypeInfo { let associated = "\($0.associated.compactMap { value in value.variableName }.joined(separator: ","))" let postfix = $0.associated.isEmpty ? "\(associated)" : "(\(associated))" let hasAssociated = !$0.associated.isEmpty + let matchValue = if hasPathValue { + firstKeyPathMatchValue(for: $0) ?? "\"\($0.caseName)\"" + } else { + firstMatchValue(for: $0) ?? "\"\($0.caseName)\"" + } let encodeCase = if hasPathValue { """ - try container.encode(keyPath: AnyCodingKey(\($0.keyPathMatches.first!.path), \($0.keyPathMatches.first!.path.hasDot)), value: "\($0.caseName)") + try container.encode(keyPath: AnyCodingKey(\($0.keyPathMatches.first!.path), \($0.keyPathMatches.first!.path.hasDot)), value: \(matchValue)) """ } else { """ - var nestedContainer = container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: AnyCodingKey("\($0.caseName)")) + var nestedContainer = container.nestedContainer(keyedBy: AnyCodingKey.self, forKey: AnyCodingKey(\(matchValue))) """ } return """ @@ -999,7 +1044,10 @@ extension TypeInfo { } else { return """ switch self { - \(enumCases.compactMap { "case .\($0.caseName): try container.encode(\($0.rawValue))" }.joined(separator: "\n")) + \(enumCases.compactMap { + let encodeValue = firstMatchValue(for: $0) ?? $0.rawValue + return "case .\($0.caseName): try container.encode(\(encodeValue))" + }.joined(separator: "\n")) } """ } diff --git a/Tests/ReerCodableTests/EnumTests.swift b/Tests/ReerCodableTests/EnumTests.swift index 806cd7e..b431c54 100644 --- a/Tests/ReerCodableTests/EnumTests.swift +++ b/Tests/ReerCodableTests/EnumTests.swift @@ -86,6 +86,16 @@ enum Phone: Codable { case oppo } +@Codable +enum ExplicitMatch: Codable { + @CodingCase(match: .string("Test")) + case test +} + +struct UserExplicit: Codable { + let value: ExplicitMatch +} + struct User2: Codable { let phone: Phone } @@ -112,7 +122,7 @@ extension TestReerCodable { if let dict { print(dict) } - #expect(dict.string("phone") == "iPhone") + #expect(dict.bool("phone") == true) } @Test( @@ -135,7 +145,7 @@ extension TestReerCodable { if let dict { print(dict) } - #expect(dict.string("phone") == "xiaomi") + #expect(dict.int("phone") == 12) } @Test( @@ -156,7 +166,24 @@ extension TestReerCodable { if let dict { print(dict) } - #expect(dict.string("phone") == "oppo") + #expect(dict.bool("phone") == false) + } +} + +extension TestReerCodable { + @Test + func enumExplicitMatch() throws { + let json = "{\"value\": \"Test\"}" + let model = try UserExplicit.decoded(from: json.data(using: .utf8)!) + #expect(model.value == .test) + + // Encode + let modelData = try JSONEncoder().encode(model) + let dict = modelData.stringAnyDictionary + #expect(dict.string("value") == "Test") + + let invalid = try? UserExplicit.decoded(from: "{\"value\": \"test\"}".data(using: .utf8)!) + #expect(invalid == nil) } } @@ -224,7 +251,7 @@ extension TestReerCodable { #expect(true) // Encode let modelData = try JSONEncoder().encode(model) - let index = modelData.stringAnyDictionary?.index(forKey: "youTube") + let index = modelData.stringAnyDictionary?.index(forKey: "youtube") #expect(index != nil) } else { Issue.record("Expected youtube") @@ -318,7 +345,7 @@ extension TestReerCodable { // Encode let modelData = try JSONEncoder().encode(model) let dict = modelData.stringAnyDictionary?["type"] as? [String: Any] - #expect(dict.string("middle") == "youTube") + #expect(dict.string("middle") == "youtube") } else { Issue.record("Expected youtube") } @@ -402,7 +429,7 @@ extension TestReerCodable { // Encode let modelData = try JSONEncoder().encode(model) let dict = modelData.stringAnyDictionary - #expect((dict?["type"] as! [String: Any]).string("middle") == "tiktok") + #expect((dict?["type"] as? [String: Any])?.string("middle") == "tiktok") #expect(dict.string("url") == "https://example.com/video.mp4") #expect(dict.string("tag") == "Art") } else { @@ -412,3 +439,8 @@ extension TestReerCodable { } } } +@Codable +enum Foo { + @CodingCase(match: .string("Test")) + case test +} diff --git a/Tests/ReerCodableTests/MacroExpandTests.swift b/Tests/ReerCodableTests/MacroExpandTests.swift index 532c1ee..3eb837d 100644 --- a/Tests/ReerCodableTests/MacroExpandTests.swift +++ b/Tests/ReerCodableTests/MacroExpandTests.swift @@ -172,18 +172,7 @@ final class ReerCodableTests: XCTestCase { break } } - let value = try container.decode(type: String.self, enumName: String(describing: Self.self)) - switch value { - case "apple": - self = .apple - case "mi": - self = .mi - case "oppo": - self = .oppo - default: - throw ReerCodableError(text: "Cannot initialize \(String(describing: Self.self)) from invalid value \(value)") - } - try self.didDecode(from: decoder) + throw ReerCodableError(text: "No matching case found for \\(String(describing: Self.self)).") } @@ -192,11 +181,11 @@ final class ReerCodableTests: XCTestCase { var container = encoder.singleValueContainer() switch self { case .apple: - try container.encode("apple") + try container.encode(true) case .mi: - try container.encode("mi") + try container.encode(12) case .oppo: - try container.encode("oppo") + try container.encode(false) } } } @@ -271,7 +260,7 @@ final class ReerCodableTests: XCTestCase { var container = encoder.container(keyedBy: AnyCodingKey.self) switch self { case .youTube: - try container.encode(keyPath: AnyCodingKey("type.middle", true), value: "youTube") + try container.encode(keyPath: AnyCodingKey("type.middle", true), value: "youtube") case let .vimeo(id, duration, _2): try container.encode(keyPath: AnyCodingKey("type", false), value: "vimeo") From 9b34211ddc30599b5c3a419d1d3a4f0d7dcae38e Mon Sep 17 00:00:00 2001 From: Phoenix Date: Mon, 9 Feb 2026 15:33:05 +0800 Subject: [PATCH 2/2] fix: enum coding without associated value but with the path value --- Sources/ReerCodableMacros/TypeInfo.swift | 9 +- Tests/ReerCodableTests/EnumTests.swift | 109 ++++++++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index 49cfe96..2815958 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -674,7 +674,7 @@ extension TypeInfo { needRequired = true } let hasCodingNested = codingContainer != nil - var container = isEnum && !hasEnumAssociatedValue + var container = isEnum && !hasEnumAssociatedValue && enumCases.contains(where: { $0.keyPathMatches.isEmpty }) ? "let container = try decoder.singleValueContainer()" : "\(hasCodingNested ? "var" : "let") container = try decoder.container(keyedBy: AnyCodingKey.self)" if let codingContainer { @@ -776,7 +776,7 @@ extension TypeInfo { } .joined(separator: "\n") } - var container = isEnum && !hasEnumAssociatedValue + var container = isEnum && !hasEnumAssociatedValue && enumCases.allSatisfy({ $0.keyPathMatches.isEmpty }) ? "var container = encoder.singleValueContainer()" : "var container = encoder.container(keyedBy: AnyCodingKey.self)" if codingContainerWorkForEncoding, let codingContainer { @@ -896,7 +896,8 @@ extension TypeInfo { /// Return: (assignments, shouldAddDidDecode) private func generateEnumDecoderAssignments() -> (String, Bool) { - if hasEnumAssociatedValue { + if hasEnumAssociatedValue + || enumCases.contains(where: { !$0.keyPathMatches.isEmpty }) { let hasPathValue = enumCases.contains { !$0.keyPathMatches.isEmpty } var index = -1 let findCase = enumCases.compactMap { theCase in @@ -1003,7 +1004,7 @@ extension TypeInfo { } private func generateEnumEncoderEncoding() -> String { - if hasEnumAssociatedValue { + if hasEnumAssociatedValue || enumCases.contains(where: { !$0.keyPathMatches.isEmpty }) { let hasPathValue = enumCases.contains { !$0.keyPathMatches.isEmpty } let encodeCase = """ \(enumCases.compactMap { diff --git a/Tests/ReerCodableTests/EnumTests.swift b/Tests/ReerCodableTests/EnumTests.swift index b431c54..17cd4a8 100644 --- a/Tests/ReerCodableTests/EnumTests.swift +++ b/Tests/ReerCodableTests/EnumTests.swift @@ -439,8 +439,113 @@ extension TestReerCodable { } } } + + @Codable -enum Foo { - @CodingCase(match: .string("Test")) +enum Foo123 { + @CodingCase(match: .string("Test123", at: "a.b")) + case test +} + +extension TestReerCodable { + + @Test + func enumWithPath() throws { + let json = """ + { + "a": { + "b": "Test123" + } + } + """ + let model = try Foo123.decoded(from: json.data(using: .utf8)!) + + switch model { + case .test: + if json.contains("Test123") { + #expect(true) + + // Encode + let modelData = try JSONEncoder().encode(model) + let dict = modelData.stringAnyDictionary + #expect((dict?["a"] as? [String: Any])?.string("b") == "Test123") + } else { + Issue.record("Expected Test123") + } + } + } +} + +@Codable +enum Foo333 { + @CodingCase(match: .string("test1", at: "a.b")) case test + + @CodingCase(match: .string("foo1", at: "f.d")) + case foo + + @CodingCase(match: .string("bar1", at: "x")) + case bar +} +extension TestReerCodable { + @Test(arguments: [ + """ + { + "a": { + "b": "test1" + } + } + """, + """ + { + "f": { + "d": "foo1" + } + } + """, + """ + { + "x": "bar1" + } + """ + ]) + func enumWithPath(json: String) throws { + let model = try Foo333.decoded(from: json.data(using: .utf8)!) + + switch model { + case .test: + if json.contains("test1") { + #expect(true) + + // Encode + let modelData = try JSONEncoder().encode(model) + let dict = modelData.stringAnyDictionary?["a"] as? [String: Any] + #expect(dict.string("b") == "test1") + } else { + Issue.record("Expected test1") + } + case .foo: + if json.contains("foo1") { + #expect(true) + + // Encode + let modelData = try JSONEncoder().encode(model) + let dict = modelData.stringAnyDictionary?["f"] as? [String: Any] + #expect(dict.string("d") == "foo1") + } else { + Issue.record("Expected foo1") + } + case .bar: + if json.contains("bar1") { + #expect(true) + + // Encode + let modelData = try JSONEncoder().encode(model) + let dict = modelData.stringAnyDictionary + #expect(dict.string("x") == "bar1") + } else { + Issue.record("Expected bar1") + } + } + } }