diff --git a/.golden/kotlinEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/kotlinEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..4e70440 --- /dev/null +++ b/.golden/kotlinEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,2 @@ +@Serializable +sealed class Enum : Parcelable \ No newline at end of file diff --git a/.golden/kotlinEnumSumOfProductWithTaggedObjectStyleSpec/golden b/.golden/kotlinEnumSumOfProductWithTaggedObjectStyleSpec/golden index 0803d9f..1878b3a 100644 --- a/.golden/kotlinEnumSumOfProductWithTaggedObjectStyleSpec/golden +++ b/.golden/kotlinEnumSumOfProductWithTaggedObjectStyleSpec/golden @@ -10,4 +10,9 @@ sealed class Enum : Parcelable { @Serializable @SerialName("dataCons1") data class DataCons1(val contents: Record1) : Enum() + + @Parcelize + @Serializable + @SerialName("dataCons2") + data object DataCons2 : Enum() } \ No newline at end of file diff --git a/.golden/kotlinRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/kotlinRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..19c6cfc --- /dev/null +++ b/.golden/kotlinRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,6 @@ +@Parcelize +@Serializable +data class Record0( + val record0Field0: Int, + val record0Field1: Int, +) : Parcelable \ No newline at end of file diff --git a/.golden/kotlinRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/kotlinRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..e1201f1 --- /dev/null +++ b/.golden/kotlinRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,6 @@ +@Parcelize +@Serializable +data class Record1( + val record1Field0: Int, + val record1Field1: Int, +) : Parcelable \ No newline at end of file diff --git a/.golden/swiftEnumSumOfProductDocSpec/golden b/.golden/swiftEnumSumOfProductDocSpec/golden index 6c1f844..6388a78 100644 --- a/.golden/swiftEnumSumOfProductDocSpec/golden +++ b/.golden/swiftEnumSumOfProductDocSpec/golden @@ -4,4 +4,37 @@ enum Enum: CaseIterable, Hashable, Codable { case dataCons0(Record0) /// Another constructor. case dataCons1(Record1) + + enum CodingKeys: String, CodingKey { + case tag + case contents + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .tag) + switch discriminator { + case "dataCons0": + self = .dataCons0(try container.decode(Record0.self, forKey: .contents)) + case "dataCons1": + self = .dataCons1(try container.decode(Record1.self, forKey: .contents)) + default: + throw DecodingError.typeMismatch( + CodingKeys.self, + .init(codingPath: decoder.codingPath, debugDescription: "Can't decode unknown tag: Enum.\(discriminator)") + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .dataCons0(contents): + try container.encode("dataCons0", forKey: .tag) + try container.encode(contents, forKey: .contents) + case let .dataCons1(contents): + try container.encode("dataCons1", forKey: .tag) + try container.encode(contents, forKey: .contents) + } + } } \ No newline at end of file diff --git a/.golden/swiftEnumSumOfProductSpec/golden b/.golden/swiftEnumSumOfProductSpec/golden index 7d06651..9ea8879 100644 --- a/.golden/swiftEnumSumOfProductSpec/golden +++ b/.golden/swiftEnumSumOfProductSpec/golden @@ -1,4 +1,48 @@ -enum Enum: CaseIterable, Hashable, Codable { +enum Enum: Hashable, Codable { case dataCons0(_ enumField0: Int, _ enumField1: Int) case dataCons1(_ enumField2: String, _ enumField3: String) + + enum CodingKeys: String, CodingKey { + case tag + case enumField0 + case enumField1 + case enumField2 + case enumField3 + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .tag) + switch discriminator { + case "dataCons0": + self = .dataCons0( + try container.decode(Int.self, forKey: .enumField0), + try container.decode(Int.self, forKey: .enumField1) + ) + case "dataCons1": + self = .dataCons1( + try container.decode(String.self, forKey: .enumField2), + try container.decode(String.self, forKey: .enumField3) + ) + default: + throw DecodingError.typeMismatch( + CodingKeys.self, + .init(codingPath: decoder.codingPath, debugDescription: "Can't decode unknown tag: Enum.\(discriminator)") + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .dataCons0: + try container.encode("dataCons0", forKey: .tag) + try container.encode(enumField0, forKey: .enumField0) + try container.encode(enumField1, forKey: .enumField1) + case let .dataCons1: + try container.encode("dataCons1", forKey: .tag) + try container.encode(enumField2, forKey: .enumField2) + try container.encode(enumField3, forKey: .enumField3) + } + } } \ No newline at end of file diff --git a/.golden/swiftEnumSumOfProductWithLinkEnumInterfaceSpec/golden b/.golden/swiftEnumSumOfProductWithLinkEnumInterfaceSpec/golden index cf3e91f..5b69e9c 100644 --- a/.golden/swiftEnumSumOfProductWithLinkEnumInterfaceSpec/golden +++ b/.golden/swiftEnumSumOfProductWithLinkEnumInterfaceSpec/golden @@ -1,4 +1,36 @@ enum Enum: CaseIterable, Hashable, Codable { case dataCons0(Record0) case dataCons1(Record1) + + enum CodingKeys: String, CodingKey { + case tag + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .tag) + switch discriminator { + case "dataCons0": + self = .dataCons0(try Record0.init(from: decoder)) + case "dataCons1": + self = .dataCons1(try Record1.init(from: decoder)) + default: + throw DecodingError.typeMismatch( + CodingKeys.self, + .init(codingPath: decoder.codingPath, debugDescription: "Can't decode unknown tag: Enum.\(discriminator)") + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .dataCons0(value): + try container.encode("dataCons0", forKey: .tag) + try value.encode(to: encoder) + case let .dataCons1(value): + try container.encode("dataCons1", forKey: .tag) + try value.encode(to: encoder) + } + } } \ No newline at end of file diff --git a/.golden/swiftEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/swiftEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..9d8c379 --- /dev/null +++ b/.golden/swiftEnumSumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,44 @@ +enum Enum: Codable { + case dataCons0(Record0) + case dataCons1(Record1) + case dataCons2 + case _unknown + + enum CodingKeys: String, CodingKey { + case tag + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .tag) + switch discriminator { + case "dataCons0": + self = .dataCons0(try Record0.init(from: decoder)) + case "dataCons1": + self = .dataCons1(try Record1.init(from: decoder)) + case "dataCons2": + self = .dataCons2 + default: + self = ._unknown + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .dataCons0(value): + try container.encode("dataCons0", forKey: .tag) + try value.encode(to: encoder) + case let .dataCons1(value): + try container.encode("dataCons1", forKey: .tag) + try value.encode(to: encoder) + case .dataCons2: + try container.encode("dataCons2", forKey: .tag) + case ._unknown: + throw EncodingError.invalidValue( + self, + .init(codingPath: encoder.codingPath, debugDescription: "Can't encode value: Enum._unknown") + ) + } + } +} \ No newline at end of file diff --git a/.golden/swiftEnumSumOfProductWithTaggedObjectStyleSpec/golden b/.golden/swiftEnumSumOfProductWithTaggedObjectStyleSpec/golden new file mode 100644 index 0000000..033df7e --- /dev/null +++ b/.golden/swiftEnumSumOfProductWithTaggedObjectStyleSpec/golden @@ -0,0 +1,42 @@ +enum Enum: Codable { + case dataCons0(Record0) + case dataCons1(Record1) + case dataCons2 + + enum CodingKeys: String, CodingKey { + case tag + case contents + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .tag) + switch discriminator { + case "dataCons0": + self = .dataCons0(try container.decode(Record0.self, forKey: .contents)) + case "dataCons1": + self = .dataCons1(try container.decode(Record1.self, forKey: .contents)) + case "dataCons2": + self = .dataCons2 + default: + throw DecodingError.typeMismatch( + CodingKeys.self, + .init(codingPath: decoder.codingPath, debugDescription: "Can't decode unknown tag: Enum.\(discriminator)") + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .dataCons0(contents): + try container.encode("dataCons0", forKey: .tag) + try container.encode(contents, forKey: .contents) + case let .dataCons1(contents): + try container.encode("dataCons1", forKey: .tag) + try container.encode(contents, forKey: .contents) + case .dataCons2: + try container.encode("dataCons2", forKey: .tag) + } + } +} \ No newline at end of file diff --git a/.golden/swiftRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/swiftRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..89c4fdd --- /dev/null +++ b/.golden/swiftRecord0SumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,4 @@ +struct Record0: Codable { + var record0Field0: Int + var record0Field1: Int +} \ No newline at end of file diff --git a/.golden/swiftRecord0SumOfProductWithTaggedObjectStyleSpec/golden b/.golden/swiftRecord0SumOfProductWithTaggedObjectStyleSpec/golden new file mode 100644 index 0000000..89c4fdd --- /dev/null +++ b/.golden/swiftRecord0SumOfProductWithTaggedObjectStyleSpec/golden @@ -0,0 +1,4 @@ +struct Record0: Codable { + var record0Field0: Int + var record0Field1: Int +} \ No newline at end of file diff --git a/.golden/swiftRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden b/.golden/swiftRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden new file mode 100644 index 0000000..17c6e15 --- /dev/null +++ b/.golden/swiftRecord1SumOfProductWithTaggedFlatObjectStyleSpec/golden @@ -0,0 +1,4 @@ +struct Record1: Codable { + var record1Field0: Int + var record1Field1: Int +} \ No newline at end of file diff --git a/.golden/swiftRecord1SumOfProductWithTaggedObjectStyleSpec/golden b/.golden/swiftRecord1SumOfProductWithTaggedObjectStyleSpec/golden new file mode 100644 index 0000000..17c6e15 --- /dev/null +++ b/.golden/swiftRecord1SumOfProductWithTaggedObjectStyleSpec/golden @@ -0,0 +1,4 @@ +struct Record1: Codable { + var record1Field0: Int + var record1Field1: Int +} \ No newline at end of file diff --git a/.golden/swiftSumOfProductWithTypeParameterSpec/golden b/.golden/swiftSumOfProductWithTypeParameterSpec/golden index 31a552c..cf56b9e 100644 --- a/.golden/swiftSumOfProductWithTypeParameterSpec/golden +++ b/.golden/swiftSumOfProductWithTypeParameterSpec/golden @@ -1,4 +1,37 @@ enum CursorInput: CaseIterable, Hashable, Codable { case nextPage(A?) case previousPage(A) + + enum CodingKeys: String, CodingKey { + case direction + case key + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .direction) + switch discriminator { + case "nextPage": + self = .nextPage(try container.decode(A?.self, forKey: .key)) + case "previousPage": + self = .previousPage(try container.decode(A.self, forKey: .key)) + default: + throw DecodingError.typeMismatch( + CodingKeys.self, + .init(codingPath: decoder.codingPath, debugDescription: "Can't decode unknown direction: CursorInput.\(discriminator)") + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch (self) { + case let .nextPage(key): + try container.encode("nextPage", forKey: .direction) + try container.encode(key, forKey: .key) + case let .previousPage(key): + try container.encode("previousPage", forKey: .direction) + try container.encode(key, forKey: .key) + } + } } \ No newline at end of file diff --git a/moat.cabal b/moat.cabal index 1e064a8..4ffb5a5 100644 --- a/moat.cabal +++ b/moat.cabal @@ -91,6 +91,7 @@ test-suite spec SumOfProductDocSpec SumOfProductSpec SumOfProductWithLinkEnumInterfaceSpec + SumOfProductWithTaggedFlatObjectStyleSpec SumOfProductWithTaggedObjectAndNonConcreteCasesSpec SumOfProductWithTaggedObjectAndSingleNullarySpec SumOfProductWithTaggedObjectStyleSpec diff --git a/src/Moat.hs b/src/Moat.hs index 269bb04..d793bb9 100644 --- a/src/Moat.hs +++ b/src/Moat.hs @@ -78,6 +78,7 @@ module Moat makeBase, sumOfProductEncodingOptions, enumEncodingStyle, + enumUnknownCase, -- * Pretty-printing @@ -759,7 +760,7 @@ consToMoatType o@Options {..} parentName parentDoc instTys variant ts bs = \case cases <- forM cons' (mkCase o) ourMatch <- matchProxy - =<< lift (enumExp parentName parentDoc instTys dataInterfaces dataProtocols dataAnnotations cases dataRawValue ts bs sumOfProductEncodingOptions enumEncodingStyle) + =<< lift (enumExp parentName parentDoc instTys dataInterfaces dataProtocols dataAnnotations cases dataRawValue ts bs sumOfProductEncodingOptions enumEncodingStyle enumUnknownCase) pure [pure ourMatch] else throwError $ MissingStrictCases missingConstructors @@ -914,7 +915,7 @@ mkTypeTag Options {..} typName instTys = \case mkName (nameStr typName ++ "Tag") let tag = tagExp typName parentName field False - matchProxy =<< lift (enumExp parentName Nothing instTys dataInterfaces dataProtocols dataAnnotations [] dataRawValue [tag] (False, Nothing, []) sumOfProductEncodingOptions enumEncodingStyle) + matchProxy =<< lift (enumExp parentName Nothing instTys dataInterfaces dataProtocols dataAnnotations [] dataRawValue [tag] (False, Nothing, []) sumOfProductEncodingOptions enumEncodingStyle enumUnknownCase) _ -> throwError $ NotANewtype typName -- make a newtype into a type alias @@ -955,7 +956,7 @@ mkVoid :: MoatM Match mkVoid Options {..} typName instTys ts = matchProxy - =<< lift (enumExp typName Nothing instTys [] [] [] [] Nothing ts (False, Nothing, []) sumOfProductEncodingOptions enumEncodingStyle) + =<< lift (enumExp typName Nothing instTys [] [] [] [] Nothing ts (False, Nothing, []) sumOfProductEncodingOptions enumEncodingStyle enumUnknownCase) mkNewtype :: () => @@ -1577,14 +1578,16 @@ enumExp :: (Bool, Maybe MoatType, [Protocol]) -> SumOfProductEncodingOptions -> EnumEncodingStyle -> + Maybe String -> Q Exp -enumExp parentName parentDoc tyVars ifaces protos anns cases raw tags bs sop ees = +enumExp parentName parentDoc tyVars ifaces protos anns cases raw tags bs sop ees euc = do enumInterfaces_ <- Syntax.lift ifaces enumAnnotations_ <- Syntax.lift anns enumProtocols_ <- Syntax.lift protos sumOfProductEncodingOptions_ <- Syntax.lift sop enumEnumEncodingStyle_ <- Syntax.lift ees + enumEnumUnknownCase_ <- Syntax.lift euc applyBase bs $ RecConE 'MoatEnum @@ -1600,6 +1603,7 @@ enumExp parentName parentDoc tyVars ifaces protos anns cases raw tags bs sop ees , ('enumTags, ListE tags) , ('enumSumOfProductEncodingOption, sumOfProductEncodingOptions_) , ('enumEnumEncodingStyle, enumEnumEncodingStyle_) + , ('enumEnumUnknownCase, enumEnumUnknownCase_) ] newtypeExp :: diff --git a/src/Moat/Pretty/Swift.hs b/src/Moat/Pretty/Swift.hs index e7f5c58..812366a 100644 --- a/src/Moat/Pretty/Swift.hs +++ b/src/Moat/Pretty/Swift.hs @@ -1,10 +1,15 @@ +{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} + +{-# HLINT ignore "Avoid restricted function" #-} +-- nub, error module Moat.Pretty.Swift ( prettySwiftData, prettyMoatType, ) where -import Data.List (intercalate) +import Data.Functor ((<&>)) +import Data.List (intercalate, nub) import Data.Maybe (catMaybes) import Moat.Pretty.Doc.DocC import Moat.Types @@ -33,11 +38,12 @@ prettySwiftDataWith indent = \case ++ prettyRawValueAndProtocols enumRawValue enumProtocols ++ " {" ++ newlineNonEmpty enumCases - ++ prettyEnumCases indents enumCases + ++ prettyEnumCases indents enumEnumUnknownCase enumCases ++ newlineNonEmpty enumPrivateTypes ++ prettyPrivateTypes indents enumPrivateTypes ++ prettyTags indents enumTags ++ newlineNonEmpty enumTags + ++ prettyEnumCoding indents enumName enumCases enumEnumUnknownCase enumSumOfProductEncodingOption ++ "}" MoatStruct {..} -> prettyTypeDoc "" structDoc [] @@ -79,7 +85,7 @@ prettySwiftDataWith indent = \case ++ " = Tagged<" ++ newtypeName ++ ", " - ++ case (fieldType newtypeField) of + ++ case fieldType newtypeField of Optional t -> prettyMoatType t t -> prettyMoatType t ++ ">\n" @@ -88,9 +94,6 @@ prettySwiftDataWith indent = \case where indents = replicate indent ' ' - newlineNonEmpty [] = "" - newlineNonEmpty _ = "\n" - isConcrete :: Field -> Bool isConcrete = \case (Field _ Concrete {} _) -> True @@ -221,8 +224,8 @@ prettyApp t1 t2 = (args, ret) -> (e1 : args, ret) go e1 e2 = ([e1], e2) -prettyEnumCases :: String -> [EnumCase] -> String -prettyEnumCases indents = go +prettyEnumCases :: String -> Maybe String -> [EnumCase] -> String +prettyEnumCases indents unknown cases = go cases ++ unknownCase where go = \case [] -> "" @@ -243,6 +246,10 @@ prettyEnumCases indents = go ++ ")\n" ++ go xs + unknownCase = case unknown of + Just caseNm -> indents ++ "case " ++ caseNm ++ "\n" + Nothing -> "" + prettyStructFields :: String -> [Field] -> String prettyStructFields indents = go where @@ -276,6 +283,301 @@ prettyPrivateTypes indents = go go [] = "" go (s : ss) = indents ++ "private " ++ unlines (onLast (indents ++) (lines (prettySwiftData s))) ++ go ss +prettyEnumCoding :: + String -> + String -> + [EnumCase] -> + Maybe String -> + SumOfProductEncodingOptions -> + String +prettyEnumCoding indents parentName cases unknownCase SumOfProductEncodingOptions {..} + | isCEnum cases = "" -- TODO Perhaps add Codable implementation for these + | otherwise = + indent $ + prettyCodingKeys + ++ "\n\n" + ++ prettyInit + ++ "\n\n" + ++ prettyEncode + where + indent :: String -> String + indent = indentBy indents + + prettyCodingKeys :: String + prettyCodingKeys = + "enum CodingKeys: String, CodingKey {" + ++ indent + ( case encodingStyle of + TaggedObjectStyle -> prettyTaggedCodingKeys + TaggedFlatObjectStyle -> prettyFlatCodingKeys + ) + ++ "}" + + prettyTaggedCodingKeys :: String + prettyTaggedCodingKeys = + "case " + ++ tagFieldName + ++ "\n" + ++ "case " + ++ contentsFieldName + + -- We need all possible keys in the payload + prettyFlatCodingKeys = + let names = nub $ filter (not . null) (cases >>= enumCaseFields <&> fieldName) + in "case " + ++ tagFieldName + ++ "\n" + ++ intercalate "\n" (map ("case " ++) names) + + prettyInit :: String + prettyInit = + "init(from decoder: any Decoder) throws {" + ++ indent + ( "let container = try decoder.container(keyedBy: CodingKeys.self)\n" + ++ "let discriminator = try container.decode(String.self, forKey: ." + ++ tagFieldName + ++ ")\n" + ++ "switch discriminator {" + ++ indent + ( case encodingStyle of + TaggedObjectStyle -> prettyTaggedInitCases + TaggedFlatObjectStyle -> prettyFlatInitCases + ++ prettyInitUnknownCase + ) + ++ "}" + ) + ++ "}" + + -- TaggedObjectStyle payloads have a single tag and contents field. + prettyTaggedInitCases :: String + prettyTaggedInitCases = + concatMap + ( \case + EnumCase caseNm _ [Field _ caseTy _] -> + "case \"" + ++ caseNm + ++ "\":" + ++ indent + ( "self = ." + ++ caseNm + ++ "(try container.decode(" + ++ prettyMoatType caseTy + ++ ".self, forKey: ." + ++ contentsFieldName + ++ "))" + ) + EnumCase caseNm _ [] -> + "case \"" + ++ caseNm + ++ "\":" + ++ indent + ( "self = ." + ++ caseNm + ) + EnumCase caseNm _ _ -> + error $ + "prettyTaggedEnumCoding: The data constructor " + <> caseNm + <> " can have zero or one concrete type constructor when using TaggedObjectStyle!" + ) + cases + + -- TaggedFlatObjectStyle payloads have a tag field and 0 or more additional fields + -- that are decoded directly into the case type. + prettyFlatInitCases :: String + prettyFlatInitCases = + concatMap + ( \case + EnumCase caseNm _ [] -> + "case \"" + ++ caseNm + ++ "\":" + ++ indent + ( "self = ." + ++ caseNm + ) + EnumCase caseNm _ [Field "" caseTy _] -> + "case \"" + ++ caseNm + ++ "\":" + ++ indent + ( "self = ." + ++ caseNm + ++ "(try " + ++ prettyMoatType caseTy + ++ ".init(from: decoder))" + ) + EnumCase caseNm _ fields -> + "case \"" + ++ caseNm + ++ "\":" + ++ indent + ( "self = ." + ++ caseNm + ++ "(" + ++ indent + ( intercalate + ",\n" + ( fields <&> \(Field {..}) -> + "try container.decode(" + ++ prettyMoatType fieldType + ++ ".self, forKey: ." + ++ fieldName + ++ ")" + ) + ) + ++ ")" + ) + ) + cases + + prettyInitUnknownCase :: String + prettyInitUnknownCase = case unknownCase of + Just caseNm -> + "default:" + ++ indent ("self = ." ++ caseNm) + Nothing -> + "default:" + ++ indent + ( "throw DecodingError.typeMismatch(" + ++ indent + ( "CodingKeys.self,\n" + ++ ".init(codingPath: decoder.codingPath, debugDescription: \"Can't decode unknown " + ++ tagFieldName + ++ ": " + ++ parentName + ++ ".\\(discriminator)\")" + ) + ++ ")" + ) + + prettyEncode :: String + prettyEncode = + "func encode(to encoder: any Encoder) throws {" + ++ indent + ( "var container = encoder.container(keyedBy: CodingKeys.self)\n" + ++ "switch (self) {" + ++ indent + ( case encodingStyle of + TaggedObjectStyle -> prettyEncodeTaggedCases + TaggedFlatObjectStyle -> prettyEncodeFlatCases + ++ prettyEncodeUnknownCase + ) + ++ "}\n" + ) + ++ "}" + + prettyEncodeTaggedCases :: String + prettyEncodeTaggedCases = + concatMap + ( \(EnumCase {..}) -> + case enumCaseFields of + [] -> + "case ." + ++ enumCaseName + ++ ":" + ++ indent + ( "try container.encode(\"" + ++ enumCaseName + ++ "\", forKey: ." + ++ tagFieldName + ++ ")" + ) + [Field "" _ _] -> + "case let ." + ++ enumCaseName + ++ "(" + ++ contentsFieldName + ++ "):" + ++ indent + ( "try container.encode(\"" + ++ enumCaseName + ++ "\", forKey: ." + ++ tagFieldName + ++ ")\ntry container.encode(" + ++ contentsFieldName + ++ ", forKey: ." + ++ contentsFieldName + ++ ")" + ) + _ -> + error $ + "prettyTaggedEnumCoding: The data constructor " + <> enumCaseName + <> " can have zero or one concrete type constructor when using TaggedObjectStyle!" + ) + cases + + prettyEncodeFlatCases :: String + prettyEncodeFlatCases = + concatMap + ( \(EnumCase {..}) -> + case enumCaseFields of + [] -> + "case ." + ++ enumCaseName + ++ ":" + ++ indent + ( "try container.encode(\"" + ++ enumCaseName + ++ "\", forKey: ." + ++ tagFieldName + ++ ")" + ) + [Field "" _ _] -> + "case let ." + ++ enumCaseName + ++ "(value):" + ++ indent + ( "try container.encode(\"" + ++ enumCaseName + ++ "\", forKey: ." + ++ tagFieldName + ++ ")\n" + ++ "try value.encode(to: encoder)" + ) + _ -> + "case let ." + ++ enumCaseName + ++ ":" + ++ indent + ( "try container.encode(\"" + ++ enumCaseName + ++ "\", forKey: ." + ++ tagFieldName + ++ ")\n" + ++ intercalate + "\n" + ( enumCaseFields <&> \(Field {..}) -> + "try container.encode(" + ++ fieldName + ++ ", forKey: ." + ++ fieldName + ++ ")" + ) + ) + ) + cases + + prettyEncodeUnknownCase :: String + prettyEncodeUnknownCase = case unknownCase of + Just caseNm -> + "case ." + ++ caseNm + ++ ":" + ++ indent + ( "throw EncodingError.invalidValue(" + ++ indent + ( "self,\n.init(codingPath: encoder.codingPath, debugDescription: \"Can't encode value: " + ++ parentName + ++ "." + ++ caseNm + ++ "\")" + ) + ++ ")" + ) + Nothing -> "" + -- map a function over everything but the -- first element. onLast :: (a -> a) -> [a] -> [a] @@ -306,3 +608,17 @@ addTyVarBounds tyVars protos = in case synthesizedProtos of [] -> tyVars _ -> map (++ bounds) tyVars + +newlineNonEmpty :: [a] -> String +newlineNonEmpty [] = "" +newlineNonEmpty _ = "\n" + +indentBy :: String -> String -> String +indentBy indents str = "\n" ++ unlines (map indentLine $ lines str) + where + indentLine :: String -> String + indentLine "" = "" + indentLine ln = indents ++ ln + +isCEnum :: [EnumCase] -> Bool +isCEnum = all ((== []) . enumCaseFields) diff --git a/src/Moat/Types.hs b/src/Moat/Types.hs index 434f9c5..0939297 100644 --- a/src/Moat/Types.hs +++ b/src/Moat/Types.hs @@ -188,6 +188,12 @@ data MoatData -- Only used by the Swift backend. , enumSumOfProductEncodingOption :: SumOfProductEncodingOptions , enumEnumEncodingStyle :: EnumEncodingStyle + , enumEnumUnknownCase :: Maybe String + -- ^ Add an enum case to represent values added to this enum in the future. + -- + -- Only used by the Swift backend. Generated 'decode' functions for + -- sum-of-product types will fall back to this case, but clients + -- have to implement fallback logic for simple enums. } | -- | A newtype. -- Kotlin backend: becomes a value class. @@ -460,6 +466,10 @@ data Options = Options -- 'EnumEncodingStyle'. -- -- This option is only meaningful on the Kotlin backend. + , enumUnknownCase :: Maybe String + -- ^ Add an enum case to represent values added to this enum in the future. + -- + -- On the Kotlin backend, this is ignored when using [ValueClassStyle]. } data SumOfProductEncodingOptions = SumOfProductEncodingOptions @@ -477,7 +487,7 @@ data SumOfProductEncodingOptions = SumOfProductEncodingOptions -- style. This is unused in the 'TaggedFlatObjectStyle' , tagFieldName :: String -- ^ The field name to use for the sum, aeson uses "tag" for the TaggedObject - -- style. This is unused in the 'TaggedFlatObjectStyle' + -- style. } deriving stock (Eq, Read, Show, Lift) @@ -545,6 +555,7 @@ data EnumEncodingStyle = EnumClassStyle | ValueClassStyle -- , optionalExpand = False -- , sumOfProductEncodingOptions = defaultSumOfProductEncodingOptions -- , enumEncodingStyle = EnumClassStyle +-- , enumUnknownCase = Nothing -- } -- @ defaultOptions :: Options @@ -574,6 +585,7 @@ defaultOptions = , optionalExpand = False , sumOfProductEncodingOptions = defaultSumOfProductEncodingOptions , enumEncodingStyle = EnumClassStyle + , enumUnknownCase = Nothing } data KeepOrDiscard = Keep | Discard diff --git a/test/SumOfProductSpec.hs b/test/SumOfProductSpec.hs index 022338d..c8d8cc6 100644 --- a/test/SumOfProductSpec.hs +++ b/test/SumOfProductSpec.hs @@ -15,7 +15,7 @@ mobileGenWith ( defaultOptions { dataAnnotations = [Parcelize, Serializable] , dataInterfaces = [Parcelable] - , dataProtocols = [OtherProtocol "CaseIterable", Hashable, Codable] + , dataProtocols = [Hashable, Codable] } ) ''Enum diff --git a/test/SumOfProductWithTaggedFlatObjectStyleSpec.hs b/test/SumOfProductWithTaggedFlatObjectStyleSpec.hs new file mode 100644 index 0000000..4d8366f --- /dev/null +++ b/test/SumOfProductWithTaggedFlatObjectStyleSpec.hs @@ -0,0 +1,74 @@ +module SumOfProductWithTaggedFlatObjectStyleSpec where + +import Common +import Moat +import Test.Hspec +import Test.Hspec.Golden +import Prelude hiding (Enum) + +data Record0 = Record0 + { record0Field0 :: Int + , record0Field1 :: Int + } + +mobileGenWith + ( defaultOptions + { dataAnnotations = [Parcelize, Serializable] + , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] + } + ) + ''Record0 + +data Record1 = Record1 + { record1Field0 :: Int + , record1Field1 :: Int + } + +mobileGenWith + ( defaultOptions + { dataAnnotations = [Parcelize, Serializable] + , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] + } + ) + ''Record1 + +data Enum + = DataCons0 Record0 + | DataCons1 Record1 + | DataCons2 + +mobileGenWith + ( defaultOptions + { dataAnnotations = [Parcelize, Serializable, SerialName] + , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] + , sumOfProductEncodingOptions = + SumOfProductEncodingOptions + { encodingStyle = TaggedFlatObjectStyle + , sumAnnotations = [RawAnnotation "JsonClassDiscriminator(\"tag\")"] + , tagFieldName = "tag" + , contentsFieldName = "contents" + } + , enumUnknownCase = Just "_unknown" + } + ) + ''Enum + +spec :: Spec +spec = + describe "stays golden" $ do + let moduleName = "SumOfProductWithTaggedFlatObjectStyleSpec" + it "kotlin" $ + defaultGolden ("kotlinRecord0" <> moduleName) (showKotlin @Record0) + it "kotlin" $ + defaultGolden ("kotlinRecord1" <> moduleName) (showKotlin @Record1) + it "kotlin" $ + defaultGolden ("kotlinEnum" <> moduleName) (showKotlin @Enum) + it "swift" $ + defaultGolden ("swiftRecord0" <> moduleName) (showSwift @Record0) + it "swift" $ + defaultGolden ("swiftRecord1" <> moduleName) (showSwift @Record1) + it "swift" $ + defaultGolden ("swiftEnum" <> moduleName) (showSwift @Enum) diff --git a/test/SumOfProductWithTaggedObjectStyleSpec.hs b/test/SumOfProductWithTaggedObjectStyleSpec.hs index fe22799..11ad096 100644 --- a/test/SumOfProductWithTaggedObjectStyleSpec.hs +++ b/test/SumOfProductWithTaggedObjectStyleSpec.hs @@ -15,6 +15,7 @@ mobileGenWith ( defaultOptions { dataAnnotations = [Parcelize, Serializable] , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] } ) ''Record0 @@ -28,6 +29,7 @@ mobileGenWith ( defaultOptions { dataAnnotations = [Parcelize, Serializable] , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] } ) ''Record1 @@ -35,11 +37,13 @@ mobileGenWith data Enum = DataCons0 Record0 | DataCons1 Record1 + | DataCons2 mobileGenWith ( defaultOptions { dataAnnotations = [Parcelize, Serializable, SerialName] , dataInterfaces = [Parcelable] + , dataProtocols = [Codable] , sumOfProductEncodingOptions = SumOfProductEncodingOptions { encodingStyle = TaggedObjectStyle @@ -61,3 +65,9 @@ spec = defaultGolden ("kotlinRecord1" <> moduleName) (showKotlin @Record1) it "kotlin" $ defaultGolden ("kotlinEnum" <> moduleName) (showKotlin @Enum) + it "swift" $ + defaultGolden ("swiftRecord0" <> moduleName) (showSwift @Record0) + it "swift" $ + defaultGolden ("swiftRecord1" <> moduleName) (showSwift @Record1) + it "swift" $ + defaultGolden ("swiftEnum" <> moduleName) (showSwift @Enum)