diff --git a/Package.resolved b/Package.resolved index 921fe1c..373ff1c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/omochi/TypeScriptAST", "state" : { - "revision" : "17b0aa596455d76e1e73b3a9c07907b276e791a7", - "version" : "1.2.0" + "revision" : "509c3a20d5f8fe7584d4e9d67acc80690ff2fba0", + "version" : "1.3.0" } } ], diff --git a/Package.swift b/Package.swift index 64610cc..b7168f5 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/omochi/SwiftTypeReader", from: "2.1.1"), - .package(url: "https://github.com/omochi/TypeScriptAST", from: "1.2.0") + .package(url: "https://github.com/omochi/TypeScriptAST", from: "1.3.0") ], targets: [ .target( diff --git a/Sources/CodableToTypeScript/Extensions/STypeEx.swift b/Sources/CodableToTypeScript/Extensions/STypeEx.swift index 6fed2cd..ad9829d 100644 --- a/Sources/CodableToTypeScript/Extensions/STypeEx.swift +++ b/Sources/CodableToTypeScript/Extensions/STypeEx.swift @@ -1,12 +1,12 @@ import SwiftTypeReader extension TypeDecl { - func namePath() -> NamePath { + internal func namePath() -> NamePath { return declaredInterfaceType.namePath() } - func walk(_ body: (any TypeDecl) throws -> Void) rethrows { - try body(self) + public func walk(_ body: (any TypeDecl) throws -> Bool) rethrows { + guard try body(self) else { return } guard let nominal = asNominalType else { return } @@ -17,20 +17,26 @@ extension TypeDecl { } extension NominalTypeDecl { - func isStandardLibraryType(_ name: String) -> Bool { + public func isStandardLibraryType(_ name: String) -> Bool { return moduleContext.name == "Swift" && self.name == name } - func hasStringRawValue() -> Bool { - return inheritedTypes.contains { (type) in - type.isStandardLibraryType("String") + public func isRawRepresentable() -> (any SType)? { + for type in inheritedTypes { + if type.isStandardLibraryType("String") { return type } } + + if let property = find(name: "rawValue") as? VarDecl { + return property.interfaceType + } + + return nil } } extension SType { - var typeDecl: (any TypeDecl)? { + internal var typeDecl: (any TypeDecl)? { switch self { case let type as any NominalType: return type.nominalTypeDecl case let param as GenericParamType: return param.decl @@ -38,7 +44,7 @@ extension SType { } } - var genericArgs: [any SType] { + internal var genericArgs: [any SType] { if let self = self.asNominal { return self.genericArgs } else if let self = self.asError { @@ -54,7 +60,7 @@ extension SType { } } - func namePath() -> NamePath { + internal func namePath() -> NamePath { let repr = toTypeRepr(containsModule: false) if let ident = repr.asIdent { @@ -66,7 +72,7 @@ extension SType { } } - func unwrapOptional(limit: Int?) -> (wrapped: any SType, depth: Int)? { + internal func unwrapOptional(limit: Int?) -> (wrapped: any SType, depth: Int)? { var type: any SType = self var depth = 0 while type.isStandardLibraryType("Optional"), @@ -87,21 +93,21 @@ extension SType { return (wrapped: type, depth: depth) } - func asArray() -> (array: StructType, element: any SType)? { + internal func asArray() -> (array: StructType, element: any SType)? { guard isStandardLibraryType("Array"), let array = self.asStruct, let element = array.genericArgs[safe: 0] else { return nil } return (array: array, element: element) } - func asDictionary() -> (dictionary: StructType, value: any SType)? { + internal func asDictionary() -> (dictionary: StructType, value: any SType)? { guard isStandardLibraryType("Dictionary"), let dict = self.asStruct, let value = dict.genericArgs[safe: 1] else { return nil } return (dictionary: dict, value: value) } - func isStandardLibraryType(_ name: String) -> Bool { + public func isStandardLibraryType(_ name: String) -> Bool { guard let self = self.asNominal else { return false } return self.nominalTypeDecl.isStandardLibraryType(name) } diff --git a/Sources/CodableToTypeScript/Generator/CodeGenerator.swift b/Sources/CodableToTypeScript/Generator/CodeGenerator.swift index eea7a70..5582fce 100644 --- a/Sources/CodableToTypeScript/Generator/CodeGenerator.swift +++ b/Sources/CodableToTypeScript/Generator/CodeGenerator.swift @@ -29,10 +29,6 @@ public final class CodeGenerator { ) } - public func converter(for decl: any TypeDecl) throws -> any TypeConverter { - return try converter(for: decl.declaredInterfaceType) - } - private func implConverter(for type: any SType) throws -> any TypeConverter { return try typeConverterProvider.provide(generator: self, type: type) } diff --git a/Sources/CodableToTypeScript/TypeConverter/DefaultTypeConverter.swift b/Sources/CodableToTypeScript/TypeConverter/DefaultTypeConverter.swift index 2e8dffa..2e6a76b 100644 --- a/Sources/CodableToTypeScript/TypeConverter/DefaultTypeConverter.swift +++ b/Sources/CodableToTypeScript/TypeConverter/DefaultTypeConverter.swift @@ -60,6 +60,14 @@ public struct DefaultTypeConverter { return (type: type, isOptional: false) } + public func phantomType(for target: GenerationTarget, name: String) throws -> any TSType { + let body = try self.converter().type(for: target) + let tag = TSObjectType([ + .init(name: name, type: TSIdentType.never) + ]) + return TSIntersectionType([body, tag]) + } + public func decodeName() throws -> String { let converter = try self.converter() guard try converter.hasDecode() else { @@ -111,7 +119,11 @@ public struct DefaultTypeConverter { public func callDecode(genericArgs: [any SType], json: any TSExpr) throws -> any TSExpr { let converter = try self.converter() guard try converter.hasDecode() else { - return json + var expr = json + if try converter.hasJSONType() { + expr = TSAsExpr(expr, try converter.type(for: .entity)) + } + return expr } let decodeName = try converter.decodeName() return try generator.callDecode( @@ -241,7 +253,11 @@ public struct DefaultTypeConverter { public func callEncode(genericArgs: [any SType], entity: any TSExpr) throws -> any TSExpr { let converter = try self.converter() guard try converter.hasEncode() else { - return entity + var expr = entity + if try converter.hasJSONType() { + expr = TSAsExpr(expr, try converter.type(for: .json)) + } + return expr } let encodeName = try converter.encodeName() return try generator.callEncode( diff --git a/Sources/CodableToTypeScript/TypeConverter/EnumConverter.swift b/Sources/CodableToTypeScript/TypeConverter/EnumConverter.swift index 3384e2d..5fa14ad 100644 --- a/Sources/CodableToTypeScript/TypeConverter/EnumConverter.swift +++ b/Sources/CodableToTypeScript/TypeConverter/EnumConverter.swift @@ -2,12 +2,40 @@ import SwiftTypeReader import TypeScriptAST struct EnumConverter: TypeConverter { + init(generator: CodeGenerator, `enum`: EnumType) { + self.generator = generator + self.`enum` = `enum` + + let decl = `enum`.decl + + if decl.caseElements.isEmpty { + self.kind = .never + return + } + + if let raw = decl.isRawRepresentable() { + if raw.isStandardLibraryType("String") { + self.kind = .string + return + } + } + + self.kind = .normal + } + var generator: CodeGenerator var `enum`: EnumType var swiftType: any SType { `enum` } private var decl: EnumDecl { `enum`.decl } + private var kind: Kind + + enum Kind { + case never + case string + case normal + } func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { switch target { @@ -18,14 +46,15 @@ struct EnumConverter: TypeConverter { let genericParams = try self.genericParams().map { try $0.name(for: target) } - if decl.caseElements.isEmpty { + switch kind { + case .never: return TSTypeDecl( modifiers: [.export], name: try name(for: target), genericParams: genericParams, type: TSIdentType.never ) - } else if decl.hasStringRawValue() { + case .string: let items: [any TSType] = decl.caseElements.map { (ce) in TSStringLiteralType(ce.name) } @@ -36,6 +65,7 @@ struct EnumConverter: TypeConverter { genericParams: genericParams, type: TSUnionType(items) ) + default: break } let items: [any TSType] = try decl.caseElements.map { (ce) in @@ -89,13 +119,11 @@ struct EnumConverter: TypeConverter { } func hasDecode() throws -> Bool { - if decl.caseElements.isEmpty { - return false - } else if decl.hasStringRawValue() { - return false + switch kind { + case .never: return false + case .string: return false + case .normal: return true } - - return true } func decodeDecl() throws -> TSFunctionDecl? { @@ -107,10 +135,10 @@ struct EnumConverter: TypeConverter { } func hasEncode() throws -> Bool { - if decl.caseElements.isEmpty { - return false - } else if decl.hasStringRawValue() { - return false + switch kind { + case .never: return false + case .string: return false + case .normal: break } for caseElement in decl.caseElements { diff --git a/Sources/CodableToTypeScript/TypeConverter/GeneratorProxyConverter.swift b/Sources/CodableToTypeScript/TypeConverter/GeneratorProxyConverter.swift index f9a1e02..bc17b37 100644 --- a/Sources/CodableToTypeScript/TypeConverter/GeneratorProxyConverter.swift +++ b/Sources/CodableToTypeScript/TypeConverter/GeneratorProxyConverter.swift @@ -19,6 +19,10 @@ struct GeneratorProxyConverter: TypeConverter { return try impl.fieldType(for: target) } + func phantomType(for target: GenerationTarget, name: String) throws -> TSType { + return try impl.phantomType(for: target, name: name) + } + func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { return try impl.typeDecl(for: target) } diff --git a/Sources/CodableToTypeScript/TypeConverter/OptionalConverter.swift b/Sources/CodableToTypeScript/TypeConverter/OptionalConverter.swift index 5bc41bc..9f297ea 100644 --- a/Sources/CodableToTypeScript/TypeConverter/OptionalConverter.swift +++ b/Sources/CodableToTypeScript/TypeConverter/OptionalConverter.swift @@ -24,6 +24,14 @@ struct OptionalConverter: TypeConverter { ) } + func phantomType(for target: GenerationTarget, name: String) throws -> any TSType { + let wrapped = try self.wrapped(limit: nil) + return TSUnionType([ + try wrapped.phantomType(for: target, name: name), + TSIdentType.null + ]) + } + func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { throw MessageError("Unsupported type: \(swiftType)") } diff --git a/Sources/CodableToTypeScript/TypeConverter/RawRepresentableConverter.swift b/Sources/CodableToTypeScript/TypeConverter/RawRepresentableConverter.swift new file mode 100644 index 0000000..83fd80f --- /dev/null +++ b/Sources/CodableToTypeScript/TypeConverter/RawRepresentableConverter.swift @@ -0,0 +1,58 @@ +import SwiftTypeReader +import TypeScriptAST + +struct RawRepresentableConverter: TypeConverter { + var generator: CodeGenerator + var swiftType: any SType + var rawValueType: any TypeConverter + + func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { + switch target { + case .entity: break + case .json: + guard try rawValueType.hasJSONType() else { return nil } + } + + let name = try self.name(for: target) + let type = try rawValueType.phantomType(for: target, name: name) + + return TSTypeDecl( + modifiers: [.export], + name: name, + genericParams: try genericParams().map { try $0.name(for: target) }, + type: type + ) + } + + func hasDecode() throws -> Bool { + return try rawValueType.hasJSONType() + } + + func decodeDecl() throws -> TSFunctionDecl? { + guard let decl = try decodeSignature() else { return nil } + + var expr = try rawValueType.callDecode(json: TSIdentExpr("json")) + expr = TSAsExpr(expr, try self.type(for: .entity)) + decl.body.elements.append( + TSReturnStmt(expr) + ) + + return decl + } + + func hasEncode() throws -> Bool { + return try rawValueType.hasJSONType() + } + + func encodeDecl() throws -> TSFunctionDecl? { + guard let decl = try encodeSignature() else { return nil } + + var expr = try rawValueType.callEncode(entity: TSIdentExpr("entity")) + expr = TSAsExpr(expr, try self.type(for: .json)) + decl.body.elements.append( + TSReturnStmt(expr) + ) + + return decl + } +} diff --git a/Sources/CodableToTypeScript/TypeConverter/TypeConverter.swift b/Sources/CodableToTypeScript/TypeConverter/TypeConverter.swift index 70804f2..a1fea95 100644 --- a/Sources/CodableToTypeScript/TypeConverter/TypeConverter.swift +++ b/Sources/CodableToTypeScript/TypeConverter/TypeConverter.swift @@ -8,6 +8,7 @@ public protocol TypeConverter { func hasJSONType() throws -> Bool func type(for target: GenerationTarget) throws -> any TSType func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) + func phantomType(for target: GenerationTarget, name: String) throws -> any TSType func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? func hasDecode() throws -> Bool func decodeName() throws -> String @@ -49,9 +50,9 @@ extension TypeConverter { return try `default`.fieldType(for: target) } -// public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { -// return try `default`.typeDecl(for: target) -// } + public func phantomType(for target: GenerationTarget, name: String) throws -> any TSType { + return try `default`.phantomType(for: target, name: name) + } public func decodeName() throws -> String { return try `default`.decodeName() @@ -107,7 +108,7 @@ extension TypeConverter { return [] } return try genericContext.genericParams.items.map { (param) in - try generator.converter(for: param) + try generator.converter(for: param.declaredInterfaceType) } } @@ -127,6 +128,7 @@ extension TypeConverter { try typeDecl.walk { (type) in let converter = try generator.converter(for: type.declaredInterfaceType) decls += try converter.ownDecls().decls + return true } } diff --git a/Sources/CodableToTypeScript/TypeConverter/TypeConverterProvider.swift b/Sources/CodableToTypeScript/TypeConverter/TypeConverterProvider.swift index 8c40b1c..fdeb3cf 100644 --- a/Sources/CodableToTypeScript/TypeConverter/TypeConverterProvider.swift +++ b/Sources/CodableToTypeScript/TypeConverter/TypeConverterProvider.swift @@ -33,6 +33,14 @@ public struct TypeConverterProvider { } else if let type = type.asEnum { return EnumConverter(generator: generator, enum: type) } else if let type = type.asStruct { + if let raw = type.decl.isRawRepresentable() { + return RawRepresentableConverter( + generator: generator, + swiftType: type, + rawValueType: try generator.converter(for: raw) + ) + } + return StructConverter(generator: generator, struct: type) } else if let type = type.asGenericParam { return GenericParamConverter(generator: generator, param: type) diff --git a/Tests/CodableToTypeScriptTests/Generate/GenerateEncodeTests.swift b/Tests/CodableToTypeScriptTests/Generate/GenerateEncodeTests.swift index 483ec98..2e42c56 100644 --- a/Tests/CodableToTypeScriptTests/Generate/GenerateEncodeTests.swift +++ b/Tests/CodableToTypeScriptTests/Generate/GenerateEncodeTests.swift @@ -101,4 +101,31 @@ export function S_encode(entity: S): S_JSON { """] ) } + + func testAsOperatorIdentityEncode() throws { + try assertGenerate( + source: """ +enum E { + case a(Int) +} + +struct S { + var a: E + var b: Date +} +""", + typeMap: dateTypeMap(), + expecteds: [""" +export function S_encode(entity: S): S_JSON { + return { + a: entity.a as E_JSON, + b: Date_encode(entity.b) + }; +} +""" + ] + ) + + + } } diff --git a/Tests/CodableToTypeScriptTests/Generate/GenerateRawRepresentableTests.swift b/Tests/CodableToTypeScriptTests/Generate/GenerateRawRepresentableTests.swift new file mode 100644 index 0000000..64c3284 --- /dev/null +++ b/Tests/CodableToTypeScriptTests/Generate/GenerateRawRepresentableTests.swift @@ -0,0 +1,345 @@ +import XCTest +import CodableToTypeScript + +final class GenerateRawRepresentableTests: GenerateTestCaseBase { + func dateTypeMap() -> TypeMap { + var typeMap = TypeMap() + typeMap.table["Date"] = .init(name: "Date", decode: "Date_decode", encode: "Date_encode") + return typeMap + } + + func testStoredProperty() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: String +} +""", + expecteds: [""" +export type S = string & { + S: never; +} +"""] + ) + + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: String +} + +struct K { + var a: S +} +""", + expecteds: [""" +export type K = { + a: S; +} +"""], + unexpecteds: [] + ) + } + + func testComputedProperty() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: String { "s" } +} +""", + expecteds: [""" +export type S = string & { + S: never; +} +"""] + ) + } + + func testOptional() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: String? +} +""", + expecteds: [""" +export type S = string & { + S: never; +} | null; +"""] + ) + } + + func testArray() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: [String] +} +""", + expecteds: [""" +export type S = string[] & { + S: never; +} +"""] + ) + } + + func testStruct() throws { + try assertGenerate( + source: """ +struct K { + var a: Int +} + +struct S: RawRepresentable { + var rawValue: K +} +""", + expecteds: [""" +export type S = K & { + S: never; +}; +"""], + unexpecteds: [""" +export type S_JSON +""", """ +export function S_decode +""" + ] + ) + } + + func testEnum() throws { + try assertGenerate( + source: """ +enum E { + case a(Int) +} + +struct S: RawRepresentable { + var rawValue: E +} +""", + expecteds: [""" +export type S = E & { + S: never; +}; +""", """ +export type S_JSON = E_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON): S { + return E_decode(json) as S; +} +""", """ +export function S_encode(entity: S): S_JSON { + return entity as E_JSON as S_JSON; +} +""" + ] + ) + } + + func testEncodeStruct() throws { + try assertGenerate( + source: """ +struct K { + var a: Date +} + +struct S: RawRepresentable { + var rawValue: K +} +""", + typeMap: dateTypeMap(), + expecteds: [""" +export type S = K & { + S: never; +}; +""", """ +export type S_JSON = K_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON): S { + return K_decode(json) as S; +} +""", """ +export function S_encode(entity: S): S_JSON { + return K_encode(entity) as S_JSON; +} +""" + ] + ) + } + + func testMap() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: Date +} +""", + typeMap: dateTypeMap(), + expecteds: [""" +export type S = Date & { + S: never; +}; +""", """ +export type S_JSON = Date_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON): S { + return Date_decode(json) as S; +} +""", """ +export function S_encode(entity: S): S_JSON { + return Date_encode(entity) as S_JSON; +} +""" + ] + ) + } + + func testBoundGeneric() throws { + try assertGenerate( + source: """ +struct K { + var a: T +} + +struct S: RawRepresentable { + var rawValue: K +} +""", + expecteds: [""" +export type S = K & { + S: never; +}; +""", """ +export type S_JSON = K_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON): S { + return K_decode(json, identity) as S; +} +""", """ +export function S_encode(entity: S): S_JSON { + return K_encode(entity, identity) as S_JSON; +} +""" + ] + ) + } + + func testMapGeneric() throws { + try assertGenerate( + source: """ +struct K { + var a: T +} + +struct S: RawRepresentable { + var rawValue: K +} +""", + expecteds: [""" +export type S = K & { + S: never; +}; +""", """ +export type S_JSON = K_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON, U_decode: (json: U_JSON) => U): S { + return K_decode(json, U_decode) as S; +} +""", """ +export function S_encode(entity: S, U_encode: (entity: U) => U_JSON): S_JSON { + return K_encode(entity, U_encode) as S_JSON; +} +""" + ] + ) + } + + func testGenericParam() throws { + try assertGenerate( + source: """ +struct S: RawRepresentable { + var rawValue: T +} +""", + expecteds: [""" +export type S = T & { + S: never; +}; +""", """ +export type S_JSON = T_JSON & { + S_JSON: never; +}; +""", """ +export function S_decode(json: S_JSON, T_decode: (json: T_JSON) => T): S { + return T_decode(json) as S; +} +""", """ +export function S_encode(entity: S, T_encode: (entity: T) => T_JSON): S_JSON { + return T_encode(entity) as S_JSON; +} +""" + ] + ) + } + + func testNestedID() throws { + try assertGenerate( + source: """ +struct User { + struct ID { + var rawValue: String + } + + var id: ID + var date: Date +} +""", + typeMap: dateTypeMap(), + expecteds: [""" +export type User_ID = string & { + User_ID: never; +}; +""", """ +export type User = { + id: User_ID; + date: Date; +}; +""", """ +export type User_JSON = { + id: User_ID; + date: Date_JSON; +}; +""", """ +export function User_decode(json: User_JSON): User { + return { + id: json.id, + date: Date_decode(json.date) + }; +} +""", """ +export function User_encode(entity: User): User_JSON { + return { + id: entity.id, + date: Date_encode(entity.date) + }; +} +""" + ] + ) + } +} diff --git a/Tests/CodableToTypeScriptTests/Generate/GenerateTestCaseBase.swift b/Tests/CodableToTypeScriptTests/Generate/GenerateTestCaseBase.swift index a8ab14f..1bfd905 100644 --- a/Tests/CodableToTypeScriptTests/Generate/GenerateTestCaseBase.swift +++ b/Tests/CodableToTypeScriptTests/Generate/GenerateTestCaseBase.swift @@ -10,7 +10,7 @@ class GenerateTestCaseBase: XCTestCase { case all } // debug - var prints: Prints { .one } + var prints: Prints { .none } func assertGenerate( context: Context? = nil,