├── demo ├── dist │ └── .keep ├── public │ └── .keep ├── .swift-version ├── README.md ├── src │ ├── vite-env.d.ts │ ├── Gen │ │ ├── global.gen.ts │ │ ├── Generator.gen.ts │ │ ├── common.gen.ts │ │ └── SwiftRuntime.gen.ts │ ├── polyfills.ts │ ├── main.tsx │ ├── Utils.ts │ ├── assets │ │ ├── github-mark-white.svg │ │ └── github-mark.svg │ ├── App.tsx │ ├── index.css │ ├── C2TSContext.tsx │ └── Editors.tsx ├── Sources │ └── C2TS │ │ ├── main.swift │ │ ├── Gen │ │ ├── Install.gen.swift │ │ └── Generator.gen.swift │ │ └── Generator.swift ├── ci │ ├── build.bash │ ├── wabt.bash │ └── apt.bash ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── package.json ├── Package.swift ├── Package.resolved └── Plugins │ └── CodegenPlugin │ └── CodegenPlugin.swift ├── Sources ├── CodableToTypeScript │ ├── Value │ │ ├── GenerationTarget.swift │ │ ├── NamePath.swift │ │ ├── PackageEntry.swift │ │ ├── TSKeyword.swift │ │ ├── TypeOwnDeclarations.swift │ │ └── TypeMap.swift │ ├── Basic │ │ ├── MessageError.swift │ │ ├── Utils.swift │ │ ├── NameProvider.swift │ │ └── MultipleLocatedError.swift │ ├── TypeConverter │ │ ├── ErrorTypeConverter.swift │ │ ├── GenericParamConverter.swift │ │ ├── TypeMapConverter.swift │ │ ├── SetConverter.swift │ │ ├── TypeAliasConverter.swift │ │ ├── ArrayConverter.swift │ │ ├── DictionaryConverter.swift │ │ ├── TypeConverterProvider.swift │ │ ├── GeneratorProxyConverter.swift │ │ ├── RawValueTransferringConverter.swift │ │ ├── OptionalConverter.swift │ │ ├── TypeConverter.swift │ │ ├── StructConverter.swift │ │ └── DefaultTypeConverter.swift │ ├── Extensions │ │ ├── TSASTEx.swift │ │ └── STypeEx.swift │ └── Generator │ │ ├── CodeGenerator.swift │ │ └── PackageGenerator.swift └── TestUtils │ └── TestUtils.swift ├── .gitignore ├── Tests └── CodableToTypeScriptTests │ ├── Utils │ ├── MessageError.swift │ ├── ExternalReference.swift │ ├── StringRandom.swift │ ├── Env.swift │ ├── EasyProcessEx.swift │ ├── AssertText.swift │ ├── TypeSelector.swift │ ├── EasyProcess.swift │ └── PackageBuildTester.swift │ ├── Generate │ ├── GenerateImportTests.swift │ ├── GenerateErrorTests.swift │ ├── GenerateNestedTests.swift │ ├── GenerateCustomTypeConverterTests.swift │ ├── GenerateTestCaseBase.swift │ ├── GenerateTypeAliasTests.swift │ ├── GenerateEncodeTests.swift │ ├── GenerateExampleTests.swift │ ├── GenerateEnumTests.swift │ ├── GenerateCustomTypeMapTests.swift │ └── GenerateStructTests.swift │ ├── SubstitutionTests.swift │ ├── HelperLibraryTests.swift │ └── PackageGeneratorTests.swift ├── .github └── workflows │ ├── test.yml │ └── demo-deploy.yml ├── Docs └── PackageGenerator.md ├── README.md ├── LICENSE ├── Package.resolved └── Package.swift /demo/dist/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/.swift-version: -------------------------------------------------------------------------------- 1 | wasm-5.8.0-RELEASE 2 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # demo 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/Sources/C2TS/main.swift: -------------------------------------------------------------------------------- 1 | import WasmCallableKit 2 | 3 | WasmCallableKit.install() 4 | -------------------------------------------------------------------------------- /demo/ci/build.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -uexo pipefail 3 | cd "$(dirname "$0")/.." 4 | 5 | npm install 6 | npm run swiftbuild 7 | npm run build 8 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/GenerationTarget.swift: -------------------------------------------------------------------------------- 1 | public enum GenerationTarget: Sendable & Hashable { 2 | case entity 3 | case json 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | -------------------------------------------------------------------------------- /Sources/TestUtils/TestUtils.swift: -------------------------------------------------------------------------------- 1 | extension Collection { 2 | public subscript(safe index: Index) -> Element? { 3 | return indices.contains(index) ? self[index] : nil 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/Gen/global.gen.ts: -------------------------------------------------------------------------------- 1 | import { SwiftRuntime } from "./SwiftRuntime.gen.js"; 2 | 3 | export type C2TSExports = {}; 4 | 5 | export const bindC2TS = (swift: SwiftRuntime): C2TSExports => { 6 | return {}; 7 | }; 8 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: "/CodableToTypeScript/", 7 | plugins: [react()], 8 | }) 9 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Basic/MessageError.swift: -------------------------------------------------------------------------------- 1 | struct MessageError: Swift.Error & CustomStringConvertible { 2 | init(_ description: String) { 3 | self.description = description 4 | } 5 | 6 | var description: String 7 | } 8 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/MessageError.swift: -------------------------------------------------------------------------------- 1 | struct MessageError: Swift.Error & CustomStringConvertible { 2 | init(_ description: String) { 3 | self.description = description 4 | } 5 | 6 | var description: String 7 | } 8 | -------------------------------------------------------------------------------- /demo/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { Buffer } from 'buffer'; 3 | window.Buffer = Buffer; 4 | 5 | import * as process from 'process'; 6 | window.process = process; 7 | 8 | if (import.meta.env.DEV) { 9 | window.global = window; 10 | } 11 | -------------------------------------------------------------------------------- /demo/Sources/C2TS/Gen/Install.gen.swift: -------------------------------------------------------------------------------- 1 | import WasmCallableKit 2 | 3 | extension WasmCallableKit { 4 | static func install() { 5 | 6 | registerClassMetadata(meta: [ 7 | buildGeneratorMetadata(), 8 | ]) 9 | } 10 | } -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | 10 | node_modules/ 11 | 12 | dist/* 13 | public/* 14 | temp/* 15 | !*.keep 16 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/NamePath.swift: -------------------------------------------------------------------------------- 1 | struct NamePath: Sendable & Hashable { 2 | var items: [String] 3 | 4 | init(_ items: [String]) { 5 | self.items = items 6 | } 7 | 8 | func convert() -> String { 9 | items.joined(separator: "_") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /demo/ci/wabt.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -uexo pipefail 3 | cd "$(dirname "$0")/.." 4 | 5 | WABT_URL="https://github.com/WebAssembly/wabt/releases/download/1.0.32/wabt-1.0.32-ubuntu.tar.gz" 6 | 7 | mkdir -p temp && cd temp 8 | 9 | curl -sLo wabt.tar.gz "$WABT_URL" 10 | tar xzf wabt.tar.gz 11 | sudo rsync -rlpt wabt-1.0.32/ /usr/local 12 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/ExternalReference.swift: -------------------------------------------------------------------------------- 1 | public struct ExternalReference { 2 | public init( 3 | symbols: Set = [], 4 | code: String = "" 5 | ) { 6 | self.symbols = symbols 7 | self.code = code 8 | } 9 | 10 | public var symbols: Set 11 | public var code: String 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import { C2TSProvider } from './C2TSContext' 5 | import './index.css' 6 | 7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 8 | 9 | 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: swift-actions/setup-swift@v2 14 | with: 15 | swift-version: "5.10" 16 | - uses: actions/checkout@v4 17 | - run: swift package resolve 18 | - run: swift build 19 | - run: swift test 20 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Basic/Utils.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | 3 | extension Optional { 4 | func unwrap(name: String) throws -> Wrapped { 5 | guard let self else { 6 | throw MessageError("\(name) is none") 7 | } 8 | return self 9 | } 10 | } 11 | 12 | extension Collection { 13 | subscript(safe index: Index) -> Element? { 14 | return indices.contains(index) ? self[index] : nil 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CodableToTypeScript 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/ci/apt.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ueo pipefail 3 | cd "$(dirname "$0")/.." 4 | 5 | set -x 6 | sudo apt-get -q update 7 | sudo apt-get -q install \ 8 | binutils \ 9 | git \ 10 | gnupg2 \ 11 | libc6-dev \ 12 | libcurl4 \ 13 | libedit2 \ 14 | libgcc-9-dev \ 15 | libpython2.7 \ 16 | libsqlite3-0 \ 17 | libstdc++-9-dev \ 18 | libxml2 \ 19 | libz3-dev \ 20 | pkg-config \ 21 | tzdata \ 22 | uuid-dev \ 23 | zlib1g-dev \ 24 | rsync \ 25 | build-essential \ 26 | cmake 27 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/PackageEntry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TypeScriptAST 3 | 4 | public struct PackageEntry { 5 | public init( 6 | file: URL, 7 | source: TSSourceFile 8 | ) { 9 | self.file = file 10 | self.source = source 11 | } 12 | 13 | public var file: URL 14 | public var source: TSSourceFile 15 | 16 | public func print() -> String { 17 | source.print() 18 | } 19 | 20 | public func serialize() -> Data { 21 | print().data(using: .utf8)! 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Docs/PackageGenerator.md: -------------------------------------------------------------------------------- 1 | # PackageGenerator 2 | 3 | PackageGeneratorはTypeScriptコードの一括生成器です。 4 | これは `SwiftTypeReader.Module` を受け取り、 5 | そのモジュールに含まれるSwiftのソースファイルをそれぞれTypeScriptのソースファイルに変換し、 6 | ライブラリとして利用可能なディレクトリを生成します。 7 | 8 | モジュールは複数渡すこともできますが、複数のモジュールに同名の型が含まれる状況には対応していません。 9 | 10 | 生成されたディレクトリには、共通ライブラリの`common.ts`と、個別のソースファイルが含まれます。 11 | 例えば読み込んだモジュールに型`a.swift`と`b.swift`があった場合、`a.ts`と`b.ts`が生成されます。 12 | 13 | ## 外部参照 14 | 15 | TypeMapやTypeConverterをカスタムすることによって、外部で定義したTypeScriptコードと連携させる事ができます。 16 | 生成するコードがこれらの外部シンボルを正しく`import`できるように、 17 | 外部シンボルを登録したSymbolTableを渡してください。 18 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/StringRandom.swift: -------------------------------------------------------------------------------- 1 | extension String { 2 | static func random(length: Int) -> String { 3 | return StringRandom().generate(length: length) 4 | } 5 | } 6 | 7 | private struct StringRandom { 8 | static let chars: [Character] = [ 9 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 10 | "abcdefghijklmnopqrstuvwxyz", 11 | "0123456789" 12 | ].flatMap { $0 } 13 | 14 | func generate(length: Int) -> String { 15 | return String((0.. { 4 | if (window.matchMedia) { 5 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 6 | return "dark"; 7 | } 8 | } 9 | return "light"; 10 | } 11 | 12 | export const useColorScheme = (): "dark" | "light" => { 13 | const [scheme, setScheme] = useState(checkColorScheme); 14 | useEffect(() => { 15 | if (window.matchMedia) { 16 | const query = window.matchMedia("(prefers-color-scheme: dark)"); 17 | const callback = () => setScheme(checkColorScheme); 18 | query.addEventListener("change", callback); 19 | return () => query.removeEventListener("change", callback); 20 | } 21 | }, []); 22 | return scheme; 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/demo-deploy.yml: -------------------------------------------------------------------------------- 1 | name: demo-deoloy 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths: 7 | - "demo/**" 8 | pull_request: 9 | paths: 10 | - "demo/**" 11 | workflow_dispatch: 12 | 13 | jobs: 14 | demo-deoloy: 15 | runs-on: ubuntu-22.04 16 | defaults: 17 | run: 18 | working-directory: demo 19 | steps: 20 | - uses: actions/checkout@v3 21 | - run: ci/apt.bash 22 | - uses: swiftwasm/setup-swiftwasm@v1 23 | with: 24 | swift-version: "wasm-5.8.0-RELEASE" 25 | - run: ci/wabt.bash 26 | - run: ci/build.bash 27 | - uses: peaceiris/actions-gh-pages@v3 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | publish_dir: demo/dist 31 | force_orphan: true 32 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/Env.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(Glibc) 4 | @_exported import Glibc 5 | #else 6 | @_exported import Darwin.C 7 | #endif 8 | 9 | enum Env { 10 | static func get(_ name: String) -> String? { 11 | return ProcessInfo.processInfo.environment[name] 12 | } 13 | 14 | static func set(_ name: String, _ value: String) throws { 15 | guard setenv(name, value, 1) == 0 else { 16 | throw MessageError("setenv failed: \(errno)") 17 | } 18 | } 19 | 20 | static func addPath(_ path: String) throws { 21 | var paths = (get("PATH") ?? "").components(separatedBy: ":") 22 | 23 | if paths.contains(path) { return } 24 | 25 | paths.append(path) 26 | 27 | try set("PATH", paths.joined(separator: ":")) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c2tsdemo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "swiftbuild": "swift build --product C2TS --triple wasm32-unknown-wasi -c release && npx wasm-opt -Os .build/release/C2TS.wasm -o public/C2TS.wasm && wasm-strip public/C2TS.wasm" 11 | }, 12 | "dependencies": { 13 | "@monaco-editor/react": "^4.4.6", 14 | "@wasmer/wasi": "^0.12.0", 15 | "@wasmer/wasmfs": "^0.12.0", 16 | "buffer": "^6.0.3", 17 | "process": "^0.11.10", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.26", 23 | "@types/react-dom": "^18.0.9", 24 | "@vitejs/plugin-react": "^3.0.0", 25 | "typescript": "^4.9.4", 26 | "vite": "^4.0.0", 27 | "wasm-opt": "^1.3.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /demo/src/assets/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/assets/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/ErrorTypeConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | struct ErrorTypeConverter: TypeConverter { 5 | var generator: CodeGenerator 6 | var swiftType: any SType 7 | 8 | func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 9 | throw MessageError("Error type can't be converted: \(swiftType)") 10 | } 11 | 12 | func hasDecode() throws -> Bool { 13 | throw MessageError("Error type can't be evaluated: \(swiftType)") 14 | } 15 | 16 | func decodeDecl() throws -> TSFunctionDecl? { 17 | throw MessageError("Error type can't be converted: \(swiftType)") 18 | } 19 | 20 | func hasEncode() throws -> Bool { 21 | throw MessageError("Error type can't be evaluated: \(swiftType)") 22 | } 23 | 24 | func encodeDecl() throws -> TSFunctionDecl? { 25 | throw MessageError("Error type can't be converted: \(swiftType)") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo/Sources/C2TS/Gen/Generator.gen.swift: -------------------------------------------------------------------------------- 1 | import CodableToTypeScript 2 | import Foundation 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | import WasmCallableKit 6 | 7 | func buildGeneratorMetadata() -> ClassMetadata { 8 | let decoder = JSONDecoder() 9 | decoder.dateDecodingStrategy = .millisecondsSince1970 10 | let encoder = JSONEncoder() 11 | encoder.dateEncodingStrategy = .millisecondsSince1970 12 | var meta = ClassMetadata() 13 | meta.inits.append { _ in 14 | return Generator() 15 | } 16 | meta.methods.append { `self`, argData in 17 | struct Params: Decodable { 18 | var _0: String 19 | } 20 | let args = try decoder.decode(Params.self, from: argData) 21 | let ret = try self.tsTypes( 22 | swiftSource: args._0 23 | ) 24 | return try encoder.encode(ret) 25 | } 26 | meta.methods.append { `self`, _ in 27 | let ret = self.commonLib() 28 | return try encoder.encode(ret) 29 | } 30 | return meta 31 | } -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Basic/NameProvider.swift: -------------------------------------------------------------------------------- 1 | import TypeScriptAST 2 | 3 | struct NameProvider { 4 | init() {} 5 | 6 | private var used: Set = [] 7 | 8 | mutating func register(signature: TSFunctionDecl) { 9 | for param in signature.params { 10 | register(name: param.name) 11 | } 12 | } 13 | 14 | mutating func register(name: String) { 15 | used.insert(name) 16 | } 17 | 18 | mutating func provide(base: String) -> String { 19 | if let name = provideIfUnused(name: base) { 20 | return name 21 | } 22 | 23 | var i = 2 24 | while true { 25 | let cand = "\(base)\(i)" 26 | if let name = provideIfUnused(name: cand) { 27 | return name 28 | } 29 | i += 1 30 | } 31 | } 32 | 33 | mutating func provideIfUnused(name: String) -> String? { 34 | if used.contains(name) { 35 | return nil 36 | } 37 | used.insert(name) 38 | return name 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodableToTypeScript 2 | 3 | Generate typescript code for typing JSON from Swift Codable. 4 | 5 | # Live Demo 6 | 7 | https://omochi.github.io/CodableToTypeScript/ 8 | 9 | This demo site runs CodableToTypeScript on your browser locally by [swiftwasm](https://swiftwasm.org) technology. 10 | Its also built by CodableToTypeScript, and [WasmCallableKit](https://github.com/sidepelican/WasmCallableKit). 11 | See [source code](demo). 12 | 13 | # Usage and Example 14 | 15 | See [test cases](Tests/CodableToTypeScriptTests/Generate/GenerateExampleTests.swift). 16 | 17 | # Development guide 18 | 19 | ## Requirements 20 | 21 | It needs nodejs and typescript for testing. 22 | 23 | ``` 24 | $ brew install node 25 | $ npm install -g typescript 26 | ``` 27 | 28 | See below for details. 29 | 30 | ## Testing 31 | 32 | Test cases generate typescript codes and build them to check generated codes validity. 33 | For invocating typescript compiler, it uses `$ npx tsc`. 34 | So you need to install typescript on host globally. 35 | 36 | You can skip this step by defining `SKIP_TSC` environment variable. 37 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/GenericParamConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct GenericParamConverter: TypeConverter { 5 | public init(generator: CodeGenerator, param: GenericParamType) { 6 | self.generator = generator 7 | self.param = param 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var param: GenericParamType 12 | public var swiftType: any SType { param } 13 | 14 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 15 | throw MessageError("Unsupported type: \(swiftType)") 16 | } 17 | 18 | public func hasDecode() throws -> Bool { 19 | return true 20 | } 21 | 22 | public func decodeDecl() throws -> TSFunctionDecl? { 23 | throw MessageError("Unsupported type: \(swiftType)") 24 | } 25 | 26 | public func hasEncode() throws -> Bool { 27 | return true 28 | } 29 | 30 | public func encodeDecl() throws -> TSFunctionDecl? { 31 | throw MessageError("Unsupported type: \(swiftType)") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 omochimetaru 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/TSKeyword.swift: -------------------------------------------------------------------------------- 1 | enum TSKeyword: String, Sendable & Hashable { 2 | case `break` 3 | case `case` 4 | case `catch` 5 | case `class` 6 | case const 7 | case `continue` 8 | case debugger 9 | case `default` 10 | case delete 11 | case `do` 12 | case `else` 13 | case export 14 | case extends 15 | case `false` 16 | case finally 17 | case `for` 18 | case function 19 | case `if` 20 | case `import` 21 | case `in` 22 | case instanceof 23 | case new 24 | case null 25 | case `return` 26 | case `super` 27 | case `switch` 28 | case this 29 | case `throw` 30 | case `true` 31 | case `try` 32 | case typeof 33 | case `var` 34 | case void 35 | case `while` 36 | case with 37 | case `let` 38 | case `static` 39 | case yield 40 | case await 41 | 42 | static func escaped(_ identifier: String) -> String { 43 | if TSKeyword(rawValue: identifier) != nil { 44 | return "_\(identifier)" 45 | } else { 46 | return identifier 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/TypeOwnDeclarations.swift: -------------------------------------------------------------------------------- 1 | import TypeScriptAST 2 | 3 | public struct TypeOwnDeclarations { 4 | public var entityType: TSTypeDecl? 5 | public var jsonType: TSTypeDecl? 6 | public var decodeFunction: TSFunctionDecl? 7 | public var encodeFunction: TSFunctionDecl? 8 | 9 | public init( 10 | entityType: TSTypeDecl?, 11 | jsonType: TSTypeDecl?, 12 | decodeFunction: TSFunctionDecl?, 13 | encodeFunction: TSFunctionDecl? 14 | ) { 15 | self.entityType = entityType 16 | self.jsonType = jsonType 17 | self.decodeFunction = decodeFunction 18 | self.encodeFunction = encodeFunction 19 | } 20 | 21 | public var decls: [any TSDecl] { 22 | var decls: [any TSDecl] = [] 23 | 24 | if let decl = entityType { 25 | decls.append(decl) 26 | } 27 | 28 | if let decl = jsonType { 29 | decls.append(decl) 30 | } 31 | 32 | if let decl = decodeFunction { 33 | decls.append(decl) 34 | } 35 | 36 | if let decl = encodeFunction { 37 | decls.append(decl) 38 | } 39 | 40 | return decls 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Editors } from './Editors'; 2 | import githubBlack from "./assets/github-mark.svg" 3 | import githubWhite from "./assets/github-mark-white.svg" 4 | 5 | function App() { 6 | return ( 7 |
11 |
17 |

18 | CodableToTypeScript DEMO 19 |

20 | 21 | 22 | 23 | GitHub Link 24 | 25 | 26 |
27 |
31 | 32 |
33 |
34 | ) 35 | } 36 | 37 | export default App 38 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections.git", 7 | "state" : { 8 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 9 | "version" : "1.2.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-syntax", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/swiftlang/swift-syntax.git", 16 | "state" : { 17 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 18 | "version" : "601.0.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swifttypereader", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/omochi/SwiftTypeReader.git", 25 | "state" : { 26 | "revision" : "bda0cbe9cb561e70d87e0e5843c1e068d79867ba", 27 | "version" : "3.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "typescriptast", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/omochi/TypeScriptAST.git", 34 | "state" : { 35 | "revision" : "2d4709364a1d80751179efa34bc3c9bb0709bd1b", 36 | "version" : "2.1.0" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /demo/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "demo", 7 | platforms: [.macOS(.v12)], 8 | products: [ 9 | .executable(name: "C2TS", targets: ["C2TS"]), 10 | ], 11 | dependencies: [ 12 | .package(path: ".."), 13 | .package(url: "https://github.com/sidepelican/WasmCallableKit.git", from: "0.3.2"), 14 | ], 15 | targets: [ 16 | .executableTarget( 17 | name: "C2TS", 18 | dependencies: [ 19 | "CodableToTypeScript", 20 | "WasmCallableKit", 21 | ], 22 | linkerSettings: [ 23 | .unsafeFlags([ 24 | "-Xclang-linker", "-mexec-model=reactor", 25 | "-Xlinker", "--export=main", 26 | ]) 27 | ] 28 | ), 29 | .plugin( 30 | name: "CodegenPlugin", 31 | capability: .command( 32 | intent: .custom(verb: "codegen", description: "Generate codes"), 33 | permissions: [.writeToPackageDirectory(reason: "Place generated code")] 34 | ), 35 | dependencies: [ 36 | .product(name: "codegen", package: "WasmCallableKit"), 37 | ] 38 | ), 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | --border-color: #444; 11 | 12 | font-synthesis: none; 13 | text-rendering: optimizeLegibility; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | -webkit-text-size-adjust: 100%; 17 | } 18 | 19 | a { 20 | font-weight: 500; 21 | color: #646cff; 22 | text-decoration: inherit; 23 | } 24 | a:hover { 25 | color: #535bf2; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | display: flex; 31 | place-items: center; 32 | min-width: 100vw; 33 | min-height: 100vh; 34 | } 35 | 36 | h1 { 37 | font-size: 3.2em; 38 | line-height: 1.1; 39 | } 40 | 41 | button { 42 | border-radius: 8px; 43 | border: 1px solid transparent; 44 | padding: 0.6em 1.2em; 45 | font-size: 1em; 46 | font-weight: 500; 47 | font-family: inherit; 48 | background-color: #1a1a1a; 49 | cursor: pointer; 50 | transition: border-color 0.25s; 51 | } 52 | button:hover { 53 | border-color: #646cff; 54 | } 55 | button:focus, 56 | button:focus-visible { 57 | outline: 4px auto -webkit-focus-ring-color; 58 | } 59 | 60 | @media (prefers-color-scheme: light) { 61 | :root { 62 | color: #213547; 63 | background-color: #ffffff; 64 | --border-color: #ddd; 65 | } 66 | a:hover { 67 | color: #747bff; 68 | } 69 | button { 70 | background-color: #f9f9f9; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /demo/Sources/C2TS/Generator.swift: -------------------------------------------------------------------------------- 1 | import CodableToTypeScript 2 | import Foundation 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | 6 | public final class Generator { 7 | private let commonLibSource: TSSourceFile 8 | 9 | public init() { 10 | let tmpContext = SwiftTypeReader.Context() 11 | commonLibSource = CodeGenerator(context: tmpContext).generateHelperLibrary() 12 | } 13 | 14 | public func tsTypes(swiftSource: String) throws -> String { 15 | try withExtendedLifetime(SwiftTypeReader.Context()) { context in 16 | let reader = SwiftTypeReader.Reader(context: context) 17 | let swiftSource = reader.read(source: swiftSource, file: URL(fileURLWithPath: "/Types.swift")) 18 | 19 | let tsSource = try CodeGenerator(context: context) 20 | .convert(source: swiftSource) 21 | 22 | // collect all symbols 23 | var symbolTable = SymbolTable() 24 | symbolTable.add(source: commonLibSource, file: URL(fileURLWithPath: "/common.gen.ts")) 25 | symbolTable.add(source: tsSource, file: URL(fileURLWithPath: "/types.gen.ts")) 26 | 27 | // generate imports 28 | let imports = try tsSource.buildAutoImportDecls( 29 | from: URL(fileURLWithPath: "/types.gen.ts"), 30 | symbolTable: symbolTable, 31 | fileExtension: .js 32 | ) 33 | tsSource.replaceImportDecls(imports) 34 | 35 | return tsSource.print() 36 | } 37 | } 38 | 39 | public func commonLib() -> String { 40 | commonLibSource.print() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Extensions/TSASTEx.swift: -------------------------------------------------------------------------------- 1 | import TypeScriptAST 2 | 3 | extension TSUnionType { 4 | convenience init(_ elements: (any TSType)...) { 5 | self.init(elements) 6 | } 7 | } 8 | 9 | extension TSIntersectionType { 10 | convenience init(_ elements: (any TSType)...) { 11 | self.init(elements) 12 | } 13 | } 14 | 15 | extension TSTupleType { 16 | convenience init(_ elements: (any TSType)...) { 17 | self.init(elements) 18 | } 19 | } 20 | 21 | extension TSObjectType.Field { 22 | static func field( 23 | name: String, isOptional: Bool = false, type: any TSType 24 | ) -> TSObjectType.Field { 25 | let decl = TSFieldDecl(name: name, isOptional: isOptional, type: type) 26 | return .field(decl) 27 | } 28 | 29 | static func index( 30 | name: String, index: any TSType, value: any TSType 31 | ) -> TSObjectType.Field { 32 | let decl = TSIndexDecl(name: name, index: index, value: value) 33 | return .index(decl) 34 | } 35 | } 36 | 37 | extension TSObjectType { 38 | static func dictionary(_ value: any TSType) -> TSObjectType { 39 | return TSObjectType([ 40 | .index(name: "key", index: TSIdentType.string, value: value) 41 | ]) 42 | } 43 | } 44 | 45 | extension TSIdentType { 46 | static func map(_ key: any TSType, _ value: any TSType) -> TSIdentType { 47 | return TSIdentType("Map", genericArgs: [key, value]) 48 | } 49 | } 50 | 51 | extension TSIdentExpr { 52 | static var json: TSIdentExpr { TSIdentExpr("json") } 53 | static var entity: TSIdentExpr { TSIdentExpr("entity") } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/EasyProcessEx.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension EasyProcess { 4 | static func capture(path: URL, args: [String]) throws -> String { 5 | var outData = Data() 6 | var errData = Data() 7 | let process = EasyProcess( 8 | path: path, 9 | args: args, 10 | outSink: { outData.append($0) }, 11 | errorSink: { errData.append($0) } 12 | ) 13 | let status = try process.run() 14 | let out = String(data: outData, encoding: .utf8) ?? "" 15 | let err = String(data: errData, encoding: .utf8) ?? "" 16 | guard status == EXIT_SUCCESS else { 17 | throw MessageError("invalid status: \(status), err=\(err)") 18 | } 19 | return out 20 | } 21 | 22 | static func which(_ name: String) -> String? { 23 | guard let result = try? capture( 24 | path: URL(fileURLWithPath: "/usr/bin/which"), 25 | args: [name] 26 | ) else { return nil } 27 | return result.trimmingCharacters(in: .whitespacesAndNewlines) 28 | } 29 | 30 | static func command(_ args: [String]) throws { 31 | let name = args[0] 32 | guard let path = which(name) else { 33 | throw MessageError("command not found: \(name)") 34 | } 35 | 36 | let process = EasyProcess( 37 | path: URL(fileURLWithPath: path), 38 | args: Array(args[1...]) 39 | ) 40 | let status = try process.run() 41 | guard status == EXIT_SUCCESS else { 42 | throw MessageError("command failed: \(status), \(args)") 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let isLocalDevelopment = false 6 | 7 | let dependencies: [Package.Dependency] = if isLocalDevelopment { 8 | [ 9 | .package(path: "../SwiftTypeReader"), 10 | .package(path: "../TypeScriptAST"), 11 | ] 12 | } else { 13 | [ 14 | .package(url: "https://github.com/omochi/SwiftTypeReader.git", from: "3.2.0"), 15 | .package(url: "https://github.com/omochi/TypeScriptAST.git", from: "2.1.0"), 16 | ] 17 | } 18 | 19 | let package = Package( 20 | name: "CodableToTypeScript", 21 | platforms: [.macOS(.v13)], 22 | products: [ 23 | .library( 24 | name: "CodableToTypeScript", 25 | targets: ["CodableToTypeScript"] 26 | ) 27 | ], 28 | dependencies: dependencies, 29 | targets: [ 30 | .target( 31 | name: "TestUtils" 32 | ), 33 | .target( 34 | name: "CodableToTypeScript", 35 | dependencies: [ 36 | .product(name: "SwiftTypeReader", package: "SwiftTypeReader"), 37 | .product(name: "TypeScriptAST", package: "TypeScriptAST") 38 | ], 39 | swiftSettings: swiftSettings() 40 | ), 41 | .testTarget( 42 | name: "CodableToTypeScriptTests", 43 | dependencies: [ 44 | .target(name: "TestUtils"), 45 | .target(name: "CodableToTypeScript") 46 | ] 47 | ), 48 | ] 49 | ) 50 | 51 | func swiftSettings() -> [SwiftSetting] { 52 | return [ 53 | .enableUpcomingFeature("BareSlashRegexLiterals"), 54 | .enableExperimentalFeature("StrictConcurrency") 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/TypeMapConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct TypeMapConverter: TypeConverter { 5 | public init( 6 | generator: CodeGenerator, 7 | type: any SType, 8 | entry: TypeMap.Entry 9 | ) { 10 | self.generator = generator 11 | self.swiftType = type 12 | self.entry = entry 13 | } 14 | 15 | public var generator: CodeGenerator 16 | public var swiftType: any SType 17 | private var entry: TypeMap.Entry 18 | 19 | public func name(for target: GenerationTarget) throws -> String { 20 | switch target { 21 | case .entity: 22 | return entry.entityType 23 | case .json: 24 | return entry.jsonType 25 | } 26 | } 27 | 28 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 29 | return nil 30 | } 31 | 32 | public func hasDecode() throws -> Bool { 33 | if let _ = entry.decode { 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | public func decodeName() throws -> String { 40 | return try entry.decode.unwrap(name: "entry.decode") 41 | } 42 | 43 | public func decodeDecl() throws -> TSFunctionDecl? { 44 | return nil 45 | } 46 | 47 | public func hasEncode() throws -> Bool { 48 | if let _ = entry.encode { 49 | return true 50 | } 51 | return false 52 | } 53 | 54 | public func encodeName() throws -> String { 55 | return try entry.encode.unwrap(name: "entry.encode") 56 | } 57 | 58 | public func encodeDecl() throws -> TSFunctionDecl? { 59 | return nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/SetConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct SetConverter: TypeConverter { 5 | public init(generator: CodeGenerator, swiftType: any SType) { 6 | self.generator = generator 7 | self.swiftType = swiftType 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var swiftType: any SType 12 | 13 | private func element() throws -> any TypeConverter { 14 | let (_, element) = swiftType.asSet()! 15 | return try generator.converter(for: element) 16 | } 17 | 18 | public func type(for target: GenerationTarget) throws -> any TSType { 19 | switch target { 20 | case .entity: 21 | return try `default`.type(for: target) 22 | case .json: 23 | return TSArrayType(try element().type(for: target)) 24 | } 25 | } 26 | 27 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 28 | throw MessageError("Unsupported type: \(swiftType)") 29 | } 30 | 31 | public func hasDecode() throws -> Bool { 32 | return true 33 | } 34 | 35 | public func decodeName() throws -> String { 36 | return generator.helperLibrary().name(.setDecode) 37 | } 38 | 39 | public func decodeDecl() throws -> TSFunctionDecl? { 40 | throw MessageError("Unsupported type: \(swiftType)") 41 | } 42 | 43 | public func hasEncode() throws -> Bool { 44 | return true 45 | } 46 | 47 | public func encodeName() throws -> String { 48 | return generator.helperLibrary().name(.setEncode) 49 | } 50 | 51 | public func encodeDecl() throws -> TSFunctionDecl? { 52 | throw MessageError("Unsupported type: \(swiftType)") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Basic/MultipleLocatedError.swift: -------------------------------------------------------------------------------- 1 | struct MultipleLocatedError: Error, CustomStringConvertible { 2 | struct Entry: CustomStringConvertible { 3 | var location: [String] 4 | var error: any Error 5 | var description: String { 6 | "\(location.joined(separator: ".")): \(error)" 7 | } 8 | } 9 | var entries: [Entry] 10 | 11 | var description: String { 12 | entries.map(\.description) 13 | .joined(separator: "\n") 14 | } 15 | 16 | class ErrorCollector { 17 | fileprivate var entries: [MultipleLocatedError.Entry] = [] 18 | 19 | func callAsFunction( 20 | at: String? = nil, 21 | _ run: (() throws -> T) 22 | ) -> T? { 23 | do { 24 | return try run() 25 | } catch { 26 | var newEntries: [Entry] 27 | if let multiple = error as? MultipleLocatedError { 28 | newEntries = multiple.entries 29 | } else { 30 | newEntries = [.init(location: [], error: error)] 31 | } 32 | 33 | if let at { 34 | for i in newEntries.indices { 35 | newEntries[i].location.insert(at, at: 0) 36 | } 37 | } 38 | 39 | entries.append(contentsOf: newEntries) 40 | return nil 41 | } 42 | } 43 | } 44 | } 45 | 46 | func withErrorCollector(_ run: (_ `collect`: MultipleLocatedError.ErrorCollector) -> T) throws -> T { 47 | let collector = MultipleLocatedError.ErrorCollector() 48 | let result = run(collector) 49 | if !collector.entries.isEmpty { 50 | throw MultipleLocatedError(entries: collector.entries) 51 | } 52 | return result 53 | } 54 | -------------------------------------------------------------------------------- /demo/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", 9 | "version" : "1.2.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-collections", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-collections", 16 | "state" : { 17 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 18 | "version" : "1.0.4" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-syntax", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-syntax", 25 | "state" : { 26 | "revision" : "cd793adf5680e138bf2bcbaacc292490175d0dcd", 27 | "version" : "508.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swifttypereader", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/omochi/SwiftTypeReader", 34 | "state" : { 35 | "revision" : "dca7ae04b1cc71db4fb82ea713982adaa0c0a695", 36 | "version" : "2.5.1" 37 | } 38 | }, 39 | { 40 | "identity" : "typescriptast", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/omochi/TypeScriptAST", 43 | "state" : { 44 | "revision" : "c64e4a060a3daa9f8c964699ca4d8210235df20b", 45 | "version" : "1.8.7" 46 | } 47 | }, 48 | { 49 | "identity" : "wasmcallablekit", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/sidepelican/WasmCallableKit.git", 52 | "state" : { 53 | "revision" : "6b551cdaf5d8518848b06bcaf345d1a757496045", 54 | "version" : "0.3.2" 55 | } 56 | } 57 | ], 58 | "version" : 2 59 | } 60 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/AssertText.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | private func head(_ string: String) -> String { 4 | let lines = string.split(whereSeparator: { $0.isNewline }) 5 | guard var head = lines.first else { return "" } 6 | if lines.count >= 2 { 7 | head += "..." 8 | } 9 | return String(head) 10 | } 11 | 12 | private struct AssertTextResult { 13 | var text: String 14 | var failureExpecteds: [String] = [] 15 | var failureUnexpecteds: [String] = [] 16 | var file: StaticString 17 | var line: UInt 18 | 19 | func assert() { 20 | if failureExpecteds.isEmpty, 21 | failureUnexpecteds.isEmpty 22 | { 23 | return 24 | } 25 | 26 | var strs: [String] = [] 27 | if !failureExpecteds.isEmpty { 28 | let heads = failureExpecteds.map { head($0).debugDescription } 29 | strs.append("No expected texts: " + heads.joined(separator: ", ")) 30 | } 31 | 32 | if !failureUnexpecteds.isEmpty { 33 | let heads = failureUnexpecteds.map { head($0).debugDescription } 34 | strs.append("Unexpected texts: " + heads.joined(separator: ", ")) 35 | } 36 | 37 | strs.append("Generated:\n" + text) 38 | 39 | let message = strs.joined(separator: "; ") 40 | XCTFail(message, file: file, line: line) 41 | } 42 | } 43 | 44 | func assertText( 45 | text: String, 46 | expecteds: [String] = [], 47 | unexpecteds: [String] = [], 48 | file: StaticString = #file, 49 | line: UInt = #line 50 | ) { 51 | var result = AssertTextResult(text: text, file: file, line: line) 52 | 53 | for expected in expecteds { 54 | if !text.contains(expected) { 55 | result.failureExpecteds.append(expected) 56 | } 57 | } 58 | 59 | for unexpected in unexpecteds { 60 | if text.contains(unexpected) { 61 | result.failureUnexpecteds.append(unexpected) 62 | } 63 | } 64 | 65 | result.assert() 66 | } 67 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/TypeAliasConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct TypeAliasConverter: TypeConverter { 5 | public init(generator: CodeGenerator, typeAlias: TypeAliasType) { 6 | self.generator = generator 7 | self.typeAlias = typeAlias 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var swiftType: any SType { typeAlias } 12 | public var typeAlias: TypeAliasType 13 | 14 | private func underlying() throws -> any TypeConverter { 15 | try generator.converter(for: typeAlias.underlyingType) 16 | } 17 | 18 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 19 | switch target { 20 | case .entity: break 21 | case .json: 22 | guard try hasJSONType() else { return nil } 23 | } 24 | return TSTypeDecl( 25 | modifiers: [.export], 26 | name: try name(for: target), 27 | genericParams: try genericParams().map { 28 | .init(try $0.name(for: target)) 29 | }, 30 | type: try underlying().type(for: target) 31 | ) 32 | } 33 | 34 | public func hasDecode() throws -> Bool { 35 | return try underlying().hasDecode() 36 | } 37 | 38 | public func decodeDecl() throws -> TSFunctionDecl? { 39 | guard let decl = try decodeSignature() else { return nil } 40 | 41 | let expr = try underlying().callDecode(json: TSIdentExpr.json) 42 | decl.body.elements.append( 43 | TSReturnStmt(expr) 44 | ) 45 | 46 | return decl 47 | } 48 | 49 | public func hasEncode() throws -> Bool { 50 | return try underlying().hasEncode() 51 | } 52 | 53 | public func encodeDecl() throws -> TSFunctionDecl? { 54 | guard let decl = try encodeSignature() else { return nil } 55 | 56 | let expr = try underlying().callEncode(entity: TSIdentExpr.entity) 57 | decl.body.elements.append( 58 | TSReturnStmt(expr) 59 | ) 60 | 61 | return decl 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/ArrayConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct ArrayConverter: TypeConverter { 5 | public init(generator: CodeGenerator, swiftType: any SType) { 6 | self.generator = generator 7 | self.swiftType = swiftType 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var swiftType: any SType 12 | 13 | private func element() throws -> any TypeConverter { 14 | let (_, element) = swiftType.asArray()! 15 | return try generator.converter(for: element) 16 | } 17 | 18 | public func type(for target: GenerationTarget) throws -> any TSType { 19 | return TSArrayType( 20 | try element().type(for: target) 21 | ) 22 | } 23 | 24 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 25 | throw MessageError("Unsupported type: \(swiftType)") 26 | } 27 | 28 | public func hasDecode() throws -> Bool { 29 | return try element().hasDecode() 30 | } 31 | 32 | public func decodeName() throws -> String { 33 | return generator.helperLibrary().name(.arrayDecode) 34 | } 35 | 36 | public func callDecode(json: any TSExpr) throws -> any TSExpr { 37 | return try `default`.callDecode( 38 | genericArgs: [try element().swiftType], 39 | json: json 40 | ) 41 | } 42 | 43 | public func decodeDecl() throws -> TSFunctionDecl? { 44 | throw MessageError("Unsupported type: \(swiftType)") 45 | } 46 | 47 | public func hasEncode() throws -> Bool { 48 | return try element().hasEncode() 49 | } 50 | 51 | public func encodeName() throws -> String { 52 | return generator.helperLibrary().name(.arrayEncode) 53 | } 54 | 55 | public func callEncode(entity: any TSExpr) throws -> any TSExpr { 56 | return try `default`.callEncode( 57 | genericArgs: [try element().swiftType], 58 | entity: entity 59 | ) 60 | } 61 | 62 | public func encodeDecl() throws -> TSFunctionDecl? { 63 | throw MessageError("Unsupported type: \(swiftType)") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateImportTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | 4 | final class GenerateImportTests: GenerateTestCaseBase { 5 | func testGenericStruct() throws { 6 | var typeMap = TypeMap.default 7 | 8 | typeMap.table.merge([ 9 | "A": .identity(name: "A"), 10 | "B": .identity(name: "B"), 11 | "C": .identity(name: "C"), 12 | "Y": .identity(name: "Y") 13 | ], uniquingKeysWith: { $1 }) 14 | 15 | try assertGenerate( 16 | source: """ 17 | struct X {} 18 | 19 | struct S { 20 | var a: A? 21 | var b: Int 22 | var c: [B] 23 | var t: T 24 | var xc: X 25 | var xu: X 26 | var yc: Y 27 | var yu: Y 28 | } 29 | """, 30 | typeMap: typeMap, 31 | externalReference: ExternalReference( 32 | symbols: ["A", "B", "C", "Y"], 33 | code: """ 34 | export type A = {}; 35 | export type B = {}; 36 | export type C = {}; 37 | export type Y = {}; 38 | """ 39 | ), 40 | expecteds: [""" 41 | import { 42 | A, 43 | B, 44 | C, 45 | TagRecord, 46 | X, 47 | Y 48 | } 49 | """ 50 | ] 51 | ) 52 | } 53 | 54 | func testDefaultStandardTypes() throws { 55 | try assertGenerate( 56 | source: """ 57 | struct S { 58 | var a: Int 59 | var b: Bool 60 | var c: String 61 | var d: Double? 62 | } 63 | """, 64 | expecteds: [""" 65 | import { TagRecord } from ".."; 66 | """] 67 | ) 68 | } 69 | 70 | func testDecodeInAssociatedValueImport() throws { 71 | try assertGenerate( 72 | source: """ 73 | enum E { case a } 74 | 75 | struct S { var e: E } 76 | 77 | enum X { 78 | case e(E) 79 | case s(S) 80 | } 81 | """, 82 | typeSelector: .name("X"), 83 | expecteds: [""" 84 | import { 85 | E, 86 | E$JSON, 87 | E_decode, 88 | S, 89 | S$JSON, 90 | S_decode, 91 | TagRecord 92 | } 93 | """] 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /demo/src/C2TSContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useContext, useEffect, useState } from "react"; 2 | import { WASI } from "@wasmer/wasi"; 3 | import wasiBindings from "@wasmer/wasi/lib/bindings/browser"; 4 | import { WasmFs } from "@wasmer/wasmfs"; 5 | import { SwiftRuntime } from "./Gen/SwiftRuntime.gen"; 6 | 7 | const wasmPath = "./C2TS.wasm"; 8 | 9 | const startWasiTask = async () => { 10 | const wasmFs = new WasmFs(); 11 | const rawWriteSync = wasmFs.fs.writeSync; 12 | // @ts-ignore 13 | wasmFs.fs.writeSync = (fd, buffer, offset, length, position): number => { 14 | const text = new TextDecoder("utf-8").decode(buffer); 15 | if (text !== "\n") { 16 | switch (fd) { 17 | case 1: 18 | console.log(text); 19 | break; 20 | case 2: 21 | console.error(text); 22 | break; 23 | } 24 | } 25 | return rawWriteSync(fd, buffer, offset, length, position); 26 | }; 27 | 28 | let wasi = new WASI({ 29 | args: [], 30 | env: {}, 31 | bindings: { 32 | ...wasiBindings, 33 | fs: wasmFs.fs 34 | } 35 | }); 36 | 37 | const swift = new SwiftRuntime(); 38 | const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmPath), { 39 | wasi_snapshot_preview1: wasi.wasiImport, 40 | ...swift.callableKitImports, 41 | }); 42 | swift.setInstance(instance); 43 | const { memory, _initialize, main } = instance.exports as any; 44 | wasi.setMemory(memory); 45 | _initialize(); 46 | main(); 47 | 48 | return swift; 49 | }; 50 | 51 | const C2TSContext = React.createContext(null); 52 | 53 | export const C2TSProvider: React.FC> = (props) => { 54 | const [exports, setExports] = useState(); 55 | useEffect(() => { 56 | startWasiTask().then(setExports); 57 | }, []); 58 | 59 | if (exports == null) { 60 | return <>{props.children}; 61 | } 62 | 63 | return 64 | {props.children} 65 | ; 66 | }; 67 | 68 | export const useC2TS = (): { isReady: boolean } => { 69 | const context = useContext(C2TSContext); 70 | return { 71 | isReady: context != null, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/DictionaryConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct DictionaryConverter: TypeConverter { 5 | public init(generator: CodeGenerator, swiftType: any SType) { 6 | self.generator = generator 7 | self.swiftType = swiftType 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var swiftType: any SType 12 | 13 | private func value() throws -> any TypeConverter { 14 | let (_, value) = swiftType.asDictionary()! 15 | return try generator.converter(for: value) 16 | } 17 | 18 | public func type(for target: GenerationTarget) throws -> any TSType { 19 | let value = try self.value().type(for: target) 20 | switch target { 21 | case .entity: 22 | return TSIdentType.map(TSIdentType.string, value) 23 | case .json: 24 | return TSObjectType.dictionary(value) 25 | } 26 | } 27 | 28 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 29 | throw MessageError("Unsupported type: \(swiftType)") 30 | } 31 | 32 | public func hasDecode() throws -> Bool { 33 | return true 34 | } 35 | 36 | public func decodeName() throws -> String { 37 | return generator.helperLibrary().name(.dictionaryDecode) 38 | } 39 | 40 | public func callDecode(json: any TSExpr) throws -> any TSExpr { 41 | return try `default`.callDecode( 42 | genericArgs: [try value().swiftType], 43 | json: json 44 | ) 45 | } 46 | 47 | public func decodeDecl() throws -> TSFunctionDecl? { 48 | throw MessageError("Unsupported type: \(swiftType)") 49 | } 50 | 51 | public func hasEncode() throws -> Bool { 52 | return true 53 | } 54 | 55 | public func encodeName() throws -> String { 56 | return generator.helperLibrary().name(.dictionaryEncode) 57 | } 58 | 59 | public func callEncode(entity: any TSExpr) throws -> any TSExpr { 60 | return try `default`.callEncode( 61 | genericArgs: [try value().swiftType], 62 | entity: entity 63 | ) 64 | } 65 | 66 | public func encodeDecl() throws -> TSFunctionDecl? { 67 | throw MessageError("Unsupported type: \(swiftType)") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateErrorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | 6 | final class GenerateErrorTests: GenerateTestCaseBase { 7 | func testStruct() throws { 8 | XCTAssertThrowsError(try assertGenerate( 9 | source: """ 10 | struct S { 11 | var a: A 12 | struct T { 13 | var b: B 14 | } 15 | } 16 | """ 17 | )) { (error) in 18 | XCTAssertEqual("\(error)", """ 19 | S.a: Error type can't be evaluated: A 20 | S.T.b: Error type can't be evaluated: B 21 | """) 22 | } 23 | } 24 | 25 | func testPackageGenerator() throws { 26 | let context = Context() 27 | let module = Reader(context: context).read(source: """ 28 | struct S { 29 | var t: T 30 | } 31 | """, file: URL(fileURLWithPath: "A.swift")).module 32 | _ = Reader(context: context, module: module).read(source: """ 33 | struct T { 34 | var b: B 35 | } 36 | """, file: URL(fileURLWithPath: "B.swift")) 37 | 38 | let generator = PackageGenerator( 39 | context: context, 40 | symbols: SymbolTable(), 41 | importFileExtension: .js, 42 | outputDirectory: URL(fileURLWithPath: "/dev/null", isDirectory: true) 43 | ) 44 | XCTAssertThrowsError(try generator.generate(modules: [module])) { (error) in 45 | XCTAssertEqual("\(error)", """ 46 | B.swift.T.b: Error type can't be evaluated: B 47 | A.swift.S.t.b: Error type can't be evaluated: B 48 | """) 49 | } 50 | } 51 | 52 | func testEnum() throws { 53 | XCTAssertThrowsError(try assertGenerate( 54 | source: """ 55 | enum S { 56 | case a(A) 57 | enum T { 58 | case b(B) 59 | } 60 | } 61 | """ 62 | )) { (error) in 63 | XCTAssertEqual("\(error)", """ 64 | S.a._0: Error type can't be evaluated: A 65 | S.T.b._0: Error type can't be evaluated: B 66 | """) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/TypeSelector.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftTypeReader 3 | 4 | struct TypeSelector { 5 | var body: (SwiftTypeReader.Module) throws -> any TypeDecl 6 | 7 | func callAsFunction(module: SwiftTypeReader.Module) throws -> any TypeDecl { 8 | try body(module) 9 | } 10 | 11 | static func last( 12 | file: StaticString = #file, 13 | line: UInt = #line 14 | ) -> TypeSelector { 15 | TypeSelector { (module) in 16 | try XCTUnwrap( 17 | module.types.last, 18 | file: file, line: line 19 | ) 20 | } 21 | } 22 | 23 | static func predicate( 24 | _ body: @escaping (any TypeDecl) -> Bool, 25 | file: StaticString = #file, line: UInt = #line 26 | ) -> TypeSelector { 27 | TypeSelector { (module) in 28 | try XCTUnwrap( 29 | module.types.first(where: body), 30 | "predicate", 31 | file: file, line: line 32 | ) 33 | } 34 | } 35 | 36 | static func name( 37 | _ name: String, 38 | recursive: Bool = false, 39 | file: StaticString = #file, line: UInt = #line 40 | ) -> TypeSelector { 41 | func pred(decl: any TypeDecl) -> Bool { 42 | return decl.valueName == name 43 | } 44 | 45 | if recursive { 46 | return self.recursivePredicate(pred, file: file, line: line) 47 | } else { 48 | return self.predicate(pred, file: file, line: line) 49 | } 50 | } 51 | 52 | static func recursivePredicate( 53 | _ body: @escaping (any TypeDecl) -> Bool, 54 | file: StaticString = #file, line: UInt = #line 55 | ) -> TypeSelector { 56 | TypeSelector { (module) in 57 | var result: (any TypeDecl)? = nil 58 | 59 | module.walkTypeDecls { (decl) in 60 | guard result == nil else { return false } 61 | 62 | if body(decl) { 63 | result = decl 64 | return false 65 | } 66 | 67 | return true 68 | } 69 | 70 | return try XCTUnwrap( 71 | result, "recursivePredicate", 72 | file: file, line: line 73 | ) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/SubstitutionTests.swift: -------------------------------------------------------------------------------- 1 | import CodableToTypeScript 2 | @testable import SwiftTypeReader 3 | import XCTest 4 | 5 | final class SubstitutionTests: XCTestCase { 6 | func testStruct() throws { 7 | let context = Context() 8 | let source = Reader(context: context).read(source: """ 9 | struct S { 10 | var value: T 11 | } 12 | struct A { 13 | var foo: S 14 | var bar: S<[String: String]> 15 | var baz: S 16 | } 17 | """, file: URL(fileURLWithPath: "main.swift")) 18 | 19 | let sType = try XCTUnwrap(source.find(name: "S")?.asStruct?.typedDeclaredInterfaceType) 20 | let sConverter = try CodeGenerator(context: context) 21 | .converter(for: sType) 22 | XCTAssertEqual(try sConverter.hasDecode(), true) 23 | XCTAssertEqual(try sConverter.hasEncode(), true) 24 | 25 | let aDecl = try XCTUnwrap(source.find(name: "A")) 26 | 27 | let fooType = try XCTUnwrap(aDecl.asStruct? 28 | .findInNominalTypeDecl(name: "foo", options: LookupOptions())? 29 | .asVar? 30 | .interfaceType) 31 | let fooConverter = try CodeGenerator(context: context) 32 | .converter(for: fooType) 33 | XCTAssertEqual(try fooConverter.hasDecode(), false) 34 | XCTAssertEqual(try fooConverter.hasEncode(), false) 35 | 36 | let barType = try XCTUnwrap(aDecl.asStruct? 37 | .findInNominalTypeDecl(name: "bar", options: LookupOptions())? 38 | .asVar? 39 | .interfaceType) 40 | let barConverter = try CodeGenerator(context: context) 41 | .converter(for: barType) 42 | XCTAssertEqual(try barConverter.hasDecode(), true) 43 | XCTAssertEqual(try barConverter.hasEncode(), true) 44 | 45 | let bazType = try XCTUnwrap(aDecl.asStruct? 46 | .findInNominalTypeDecl(name: "baz", options: LookupOptions())? 47 | .asVar? 48 | .interfaceType) 49 | let bazConverter = try CodeGenerator(context: context) 50 | .converter(for: bazType) 51 | XCTAssertThrowsError(try bazConverter.hasDecode()) { (error) in 52 | XCTAssertTrue("\(error)".contains("Error type can't be evaluated: UNKNOWN"), "rawError: \(error)") 53 | } 54 | XCTAssertThrowsError(try bazConverter.hasEncode()) { (error) in 55 | XCTAssertTrue("\(error)".contains("Error type can't be evaluated: UNKNOWN"), "rawError: \(error)") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateNestedTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | 5 | final class GenerateNestedTests: GenerateTestCaseBase { 6 | func testNestedTypeProperty() throws { 7 | let typeMap = TypeMap { (type) in 8 | let repr = type.toTypeRepr(containsModule: false) 9 | if let ident = repr.asIdent, 10 | ident.elements.last?.name == "ID" 11 | { 12 | return .identity(name: "string") 13 | } 14 | 15 | return nil 16 | } 17 | 18 | try assertGenerate( 19 | source: """ 20 | struct S { 21 | var a: A.ID 22 | } 23 | """, 24 | typeMap: typeMap, 25 | expecteds: [""" 26 | export type S = { 27 | a: string; 28 | } & TagRecord<"S">; 29 | """] 30 | ) 31 | } 32 | 33 | func testNestedStructType() throws { 34 | try assertGenerate( 35 | source: """ 36 | struct A { 37 | struct B { 38 | var a: Int 39 | } 40 | } 41 | """, 42 | expecteds: [""" 43 | export type A = {} & TagRecord<"A">; 44 | """, """ 45 | export type A_B = { 46 | a: number; 47 | } & TagRecord<"A_B">; 48 | """] 49 | ) 50 | } 51 | 52 | func testDoubleNestedType() throws { 53 | try assertGenerate( 54 | source: """ 55 | struct A { 56 | struct B { 57 | struct C { 58 | var a: Int 59 | } 60 | } 61 | } 62 | """, 63 | expecteds: [""" 64 | export type A 65 | """, """ 66 | export type A_B 67 | """, """ 68 | export type A_B_C 69 | """] 70 | ) 71 | } 72 | 73 | func testNestedEnumType() throws { 74 | try assertGenerate( 75 | source: """ 76 | enum A { 77 | enum B { 78 | case c 79 | } 80 | } 81 | """, 82 | expecteds: [""" 83 | export type A 84 | """, """ 85 | export type A_B 86 | """, """ 87 | export type A_B$JSON 88 | """, """ 89 | export function A_B_decode(json: A_B$JSON): A_B 90 | """ 91 | ] 92 | ) 93 | } 94 | 95 | func testNestedTypeRef() throws { 96 | try assertGenerate( 97 | source: """ 98 | struct A { 99 | struct B {} 100 | } 101 | 102 | struct C { 103 | var b: A.B 104 | } 105 | """, 106 | typeSelector: .name("C"), 107 | expecteds: [""" 108 | import { A_B, TagRecord } from ".."; 109 | """, """ 110 | export type C = { 111 | b: A_B; 112 | } & TagRecord<"C">; 113 | """] 114 | ) 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/EasyProcess.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct EasyProcess { 4 | init( 5 | path: URL, 6 | args: [String], 7 | outSink: ((Data) -> Void)? = nil, 8 | errorSink: ((Data) -> Void)? = nil 9 | ) { 10 | self.path = path 11 | self.args = args 12 | self.outSink = outSink ?? Self.defaultOutSink 13 | self.errorSink = errorSink ?? Self.defaultErrorSink 14 | } 15 | 16 | var path: URL 17 | var args: [String] 18 | var outSink: (Data) -> Void 19 | var errorSink: (Data) -> Void 20 | 21 | static func makeFileHandleSink(fileHandle: FileHandle) -> (Data) -> Void { 22 | return { (data) in 23 | try? fileHandle.write(contentsOf: data) 24 | } 25 | } 26 | 27 | static var defaultOutSink: (Data) -> Void { 28 | makeFileHandleSink(fileHandle: .standardOutput) 29 | } 30 | 31 | static var defaultErrorSink: (Data) -> Void { 32 | makeFileHandleSink(fileHandle: .standardError) 33 | } 34 | 35 | @discardableResult 36 | func run() throws -> Int32 { 37 | let queue = DispatchQueue(label: "EasyProcess.run") 38 | 39 | let p = Process() 40 | p.executableURL = path 41 | p.arguments = args 42 | 43 | let outPipe = Pipe() 44 | p.standardOutput = outPipe 45 | 46 | outPipe.fileHandleForReading.readabilityHandler = { (h) in 47 | queue.sync { 48 | let data = h.availableData 49 | if !data.isEmpty { 50 | outSink(data) 51 | } 52 | } 53 | } 54 | 55 | let errPipe = Pipe() 56 | p.standardError = errPipe 57 | 58 | errPipe.fileHandleForReading.readabilityHandler = { (h) in 59 | queue.sync { 60 | let data = h.availableData 61 | if !data.isEmpty { 62 | errorSink(data) 63 | } 64 | } 65 | } 66 | 67 | try p.run() 68 | 69 | p.waitUntilExit() 70 | 71 | outPipe.fileHandleForReading.readabilityHandler = nil 72 | errPipe.fileHandleForReading.readabilityHandler = nil 73 | 74 | try queue.sync { 75 | if let data = try outPipe.fileHandleForReading.readToEnd(), 76 | !data.isEmpty 77 | { 78 | outSink(data) 79 | } 80 | if let data = try errPipe.fileHandleForReading.readToEnd(), 81 | !data.isEmpty 82 | { 83 | errorSink(data) 84 | } 85 | } 86 | 87 | return p.terminationStatus 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /demo/Plugins/CodegenPlugin/CodegenPlugin.swift: -------------------------------------------------------------------------------- 1 | import PackagePlugin 2 | import Foundation 3 | 4 | @main 5 | struct CodegenPlugin: CommandPlugin { 6 | func performCommand( 7 | context: PluginContext, 8 | arguments: [String] 9 | ) async throws { 10 | let codegenTool = try context.tool(named: "codegen") 11 | let codegenExec = URL(fileURLWithPath: codegenTool.path.string) 12 | 13 | let arguments: [String] = [ 14 | "Sources/C2TS", 15 | "--swift_out", "Sources/C2TS/Gen", 16 | "--ts_out", "src/Gen", 17 | ] 18 | 19 | let (stdout, stderr) = try RunProcess.run(exec: codegenExec, arguments: arguments) 20 | if !stdout.isEmpty { 21 | print(stdout) 22 | } 23 | if !stderr.isEmpty { 24 | Diagnostics.error(stderr) 25 | } 26 | } 27 | } 28 | 29 | enum RunProcess { 30 | static func run(exec: URL, arguments: [String]) throws -> (stdout: String, stderr: String) { 31 | var out = Data() 32 | func writeOut(_ data: Data) { 33 | out.append(data) 34 | } 35 | 36 | var err = Data() 37 | func writeError(_ data: Data) { 38 | err.append(data) 39 | } 40 | 41 | let queue = DispatchQueue(label: "runProcess") 42 | 43 | let outPipe = Pipe() 44 | outPipe.fileHandleForReading.readabilityHandler = { (h) in 45 | queue.sync { 46 | let d = h.availableData 47 | writeOut(d) 48 | if d.isEmpty { 49 | outPipe.fileHandleForReading.readabilityHandler = nil 50 | } 51 | } 52 | } 53 | 54 | let errPipe = Pipe() 55 | errPipe.fileHandleForReading.readabilityHandler = { (h) in 56 | queue.sync { 57 | let d = h.availableData 58 | writeError(d) 59 | if d.isEmpty { 60 | errPipe.fileHandleForReading.readabilityHandler = nil 61 | } 62 | } 63 | } 64 | 65 | let p = Process() 66 | p.executableURL = exec 67 | p.arguments = arguments 68 | p.standardOutput = outPipe 69 | p.standardError = errPipe 70 | try p.run() 71 | p.waitUntilExit() 72 | 73 | queue.sync { 74 | writeOut(outPipe.fileHandleForReading.availableData) 75 | writeError(errPipe.fileHandleForReading.availableData) 76 | } 77 | 78 | return ( 79 | stdout: String(data: out, encoding: .utf8)?.trimmingCharacters(in: .newlines) ?? "", 80 | stderr: String(data: err, encoding: .utf8)?.trimmingCharacters(in: .newlines) ?? "" 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /demo/src/Gen/common.gen.ts: -------------------------------------------------------------------------------- 1 | export function identity(json: T): T { 2 | return json; 3 | } 4 | 5 | export function OptionalField_decode(json: T_JSON | undefined, T_decode: (json: T_JSON) => T): T | undefined { 6 | if (json === undefined) return undefined; 7 | return T_decode(json); 8 | } 9 | 10 | export function OptionalField_encode(entity: T | undefined, T_encode: (entity: T) => T_JSON): T_JSON | undefined { 11 | if (entity === undefined) return undefined; 12 | return T_encode(entity); 13 | } 14 | 15 | export function Optional_decode(json: T_JSON | null, T_decode: (json: T_JSON) => T): T | null { 16 | if (json === null) return null; 17 | return T_decode(json); 18 | } 19 | 20 | export function Optional_encode(entity: T | null, T_encode: (entity: T) => T_JSON): T_JSON | null { 21 | if (entity === null) return null; 22 | return T_encode(entity); 23 | } 24 | 25 | export function Array_decode(json: T_JSON[], T_decode: (json: T_JSON) => T): T[] { 26 | return json.map(T_decode); 27 | } 28 | 29 | export function Array_encode(entity: T[], T_encode: (entity: T) => T_JSON): T_JSON[] { 30 | return entity.map(T_encode); 31 | } 32 | 33 | export function Dictionary_decode(json: { 34 | [key: string]: T_JSON; 35 | }, T_decode: (json: T_JSON) => T): Map { 36 | const entity = new Map(); 37 | for (const k in json) { 38 | if (json.hasOwnProperty(k)) { 39 | entity.set(k, T_decode(json[k])); 40 | } 41 | } 42 | return entity; 43 | } 44 | 45 | export function Dictionary_encode(entity: Map, T_encode: (entity: T) => T_JSON): { 46 | [key: string]: T_JSON; 47 | } { 48 | const json: { 49 | [key: string]: T_JSON; 50 | } = {}; 51 | for (const k in entity.keys()) { 52 | json[k] = T_encode(entity.get(k) !!); 53 | } 54 | return json; 55 | } 56 | 57 | export type TagOf = [Type] extends [TagRecord] 58 | ? TAG 59 | : null extends Type 60 | ? "Optional" & [TagOf>] 61 | : Type extends (infer E)[] 62 | ? "Array" & [TagOf] 63 | : Type extends Map 64 | ? "Dictionary" & [TagOf] 65 | : never 66 | ; 67 | 68 | export type TagRecord = Args["length"] extends 0 69 | ? { 70 | $tag?: Name; 71 | } 72 | : { 73 | $tag?: Name & { 74 | [I in keyof Args]: TagOf; 75 | }; 76 | } 77 | ; 78 | 79 | export function Date_encode(d: Date) { 80 | return d.getTime(); 81 | } 82 | 83 | export function Date_decode(unixMilli: number) { 84 | return new Date(unixMilli); 85 | } 86 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/TypeConverterProvider.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | 3 | public struct TypeConverterProvider { 4 | public typealias CustomProvider = (CodeGenerator, any SType) throws -> (any TypeConverter)? 5 | 6 | public init( 7 | typeMap: TypeMap = .default, 8 | customProvider: CustomProvider? = nil 9 | ) { 10 | self.typeMap = typeMap 11 | self.customProvider = customProvider 12 | } 13 | 14 | public var typeMap: TypeMap 15 | public var customProvider: CustomProvider? 16 | 17 | public func provide( 18 | generator: CodeGenerator, 19 | type: any SType 20 | ) throws -> any TypeConverter { 21 | if let customProvider, 22 | let converter = try customProvider(generator, type) 23 | { 24 | return converter 25 | } else if let entry = typeMap.map(type: type) { 26 | return TypeMapConverter(generator: generator, type: type, entry: entry) 27 | } else if let converter = try Self.defaultConverter( 28 | generator: generator, 29 | type: type 30 | ) { 31 | return converter 32 | } else { 33 | throw MessageError("Unsupported type: \(type)") 34 | } 35 | } 36 | 37 | public static func defaultConverter( 38 | generator: CodeGenerator, 39 | type: any SType 40 | ) throws -> (any TypeConverter)? { 41 | if type.isStandardLibraryType("Optional") { 42 | return OptionalConverter(generator: generator, swiftType: type) 43 | } else if type.isStandardLibraryType("Array") { 44 | return ArrayConverter(generator: generator, swiftType: type) 45 | } else if type.isStandardLibraryType("Set") { 46 | return SetConverter(generator: generator, swiftType: type) 47 | } else if type.isStandardLibraryType("Dictionary") { 48 | return DictionaryConverter(generator: generator, swiftType: type) 49 | } else if let type = type.asEnum { 50 | return EnumConverter(generator: generator, enum: type) 51 | } else if let type = type.asStruct { 52 | if let raw = type.rawValueType() { 53 | return try RawValueTransferringConverter( 54 | generator: generator, 55 | swiftType: type, 56 | rawValueType: raw 57 | ) 58 | } 59 | return StructConverter(generator: generator, struct: type) 60 | } else if let type = type.asGenericParam { 61 | return GenericParamConverter(generator: generator, param: type) 62 | } else if let type = type.asTypeAlias { 63 | return TypeAliasConverter(generator: generator, typeAlias: type) 64 | } else if let type = type.asError { 65 | return ErrorTypeConverter(generator: generator, swiftType: type) 66 | } else { 67 | return nil 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/GeneratorProxyConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | // It provides request cache layer 5 | struct GeneratorProxyConverter: TypeConverter { 6 | var generator: CodeGenerator 7 | var swiftType: any SType 8 | var impl: any TypeConverter 9 | 10 | func name(for target: GenerationTarget) throws -> String { 11 | return try impl.name(for: target) 12 | } 13 | 14 | func type(for target: GenerationTarget) throws -> any TSType { 15 | return try impl.type(for: target) 16 | } 17 | 18 | func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) { 19 | return try impl.fieldType(for: target) 20 | } 21 | 22 | func valueToField(value: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 23 | return try impl.valueToField(value: value, for: target) 24 | } 25 | 26 | func fieldToValue(field: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 27 | return try impl.fieldToValue(field: field, for: target) 28 | } 29 | 30 | func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 31 | return try impl.typeDecl(for: target) 32 | } 33 | 34 | func hasDecode() throws -> Bool { 35 | return try generator.context.evaluator( 36 | CodeGenerator.HasDecodeRequest(token: generator.requestToken, type: swiftType) 37 | ) 38 | } 39 | 40 | func decodeName() throws -> String { 41 | return try impl.decodeName() 42 | } 43 | 44 | func boundDecode() throws -> TSExpr { 45 | return try impl.boundDecode() 46 | } 47 | 48 | func callDecode(json: any TSExpr) throws -> any TSExpr { 49 | return try impl.callDecode(json: json) 50 | } 51 | 52 | func callDecodeField(json: any TSExpr) throws -> any TSExpr { 53 | return try impl.callDecodeField(json: json) 54 | } 55 | 56 | func decodeSignature() throws -> TSFunctionDecl? { 57 | return try impl.decodeSignature() 58 | } 59 | 60 | func decodeDecl() throws -> TSFunctionDecl? { 61 | return try impl.decodeDecl() 62 | } 63 | 64 | func hasEncode() throws -> Bool { 65 | return try generator.context.evaluator( 66 | CodeGenerator.HasEncodeRequest(token: generator.requestToken, type: swiftType) 67 | ) 68 | } 69 | 70 | func encodeName() throws -> String { 71 | return try impl.encodeName() 72 | } 73 | 74 | func boundEncode() throws -> TSExpr { 75 | return try impl.boundEncode() 76 | } 77 | 78 | func callEncode(entity: any TSExpr) throws -> any TSExpr { 79 | return try impl.callEncode(entity: entity) 80 | } 81 | 82 | func callEncodeField(entity: any TSExpr) throws -> any TSExpr { 83 | return try impl.callEncodeField(entity: entity) 84 | } 85 | 86 | func encodeSignature() throws -> TSFunctionDecl? { 87 | return try impl.encodeSignature() 88 | } 89 | 90 | func encodeDecl() throws -> TSFunctionDecl? { 91 | return try impl.encodeDecl() 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /demo/src/Editors.tsx: -------------------------------------------------------------------------------- 1 | import Editor, { useMonaco } from "@monaco-editor/react"; 2 | import * as monaco from "monaco-editor"; 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { useC2TS } from "./C2TSContext"; 5 | import { Generator } from "./Gen/Generator.gen"; 6 | import { useColorScheme } from "./Utils"; 7 | 8 | type GeneratorState = { 9 | isReady: false, 10 | generator?: never, 11 | } | { 12 | isReady: true, 13 | generator: Generator, 14 | }; 15 | 16 | const useGenerator = (): GeneratorState => { 17 | const { isReady } = useC2TS(); 18 | const [g, setG] = useState(null); 19 | useEffect(() => { 20 | if (isReady) { 21 | setG(new Generator()); 22 | } 23 | }, [isReady]); 24 | 25 | if (g == null) { 26 | return { isReady: false }; 27 | } 28 | return { isReady: true, generator: g }; 29 | } 30 | 31 | export const Editors: React.FC = () => { 32 | const monaco = useMonaco(); 33 | const { isReady, generator } = useGenerator(); 34 | const theme = useColorScheme() === "light" ? "light" : "vs-dark"; 35 | 36 | const [swiftEditor, setSwiftEditor] = useState(null); 37 | const [tsEditor, setTsEditor] = useState(null); 38 | 39 | const updateTSCode = useCallback(() => { 40 | if (!isReady || !swiftEditor || !tsEditor) return; 41 | try { 42 | const source = generator.tsTypes(swiftEditor.getValue()); 43 | tsEditor.setValue(source); 44 | } catch (e) { 45 | if (e instanceof Error) { 46 | tsEditor.setValue(e.message); 47 | } else { 48 | tsEditor.setValue((e as any).toString()); 49 | } 50 | } 51 | }, [isReady, swiftEditor, tsEditor]); 52 | 53 | useEffect(() => { 54 | // when all components ready, convert default initial code 55 | if (isReady && swiftEditor && tsEditor) { 56 | // install common library 57 | try { 58 | monaco?.editor.createModel( 59 | generator.commonLib(), 60 | "typescript", 61 | monaco.Uri.from({ scheme: "file", path: "./common.gen.ts" }) 62 | ); 63 | } catch (e) {console.error(e);} 64 | updateTSCode(); 65 | } 66 | }, [isReady, swiftEditor, tsEditor, updateTSCode]); 67 | 68 | return <> 69 |
70 | 83 |
84 |
85 | 97 |
98 | 99 | } 100 | 101 | const defaultSwiftCode = `// Write swift code here! 102 | 103 | enum Language: String, Codable { 104 | case swift 105 | case typescript 106 | } 107 | 108 | struct User: Codable { 109 | var name: String 110 | var favorite: Language 111 | } 112 | `; 113 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/HelperLibraryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | 5 | final class HelperLibraryTests: XCTestCase { 6 | func testHelperLibrary() { 7 | let gen = CodeGenerator(context: Context()) 8 | let code = gen.generateHelperLibrary() 9 | 10 | let actual = code.print() 11 | 12 | assertText( 13 | text: actual, 14 | expecteds: [""" 15 | export function identity(json: T): T { 16 | return json; 17 | } 18 | """, """ 19 | export function OptionalField_decode(json: T$JSON | undefined, T_decode: (json: T$JSON) => T): T | undefined { 20 | if (json === undefined) return undefined; 21 | return T_decode(json); 22 | } 23 | """, """ 24 | export function OptionalField_encode(entity: T | undefined, T_encode: (entity: T) => T$JSON): T$JSON | undefined { 25 | if (entity === undefined) return undefined; 26 | return T_encode(entity); 27 | } 28 | """, """ 29 | export function Optional_decode(json: T$JSON | null, T_decode: (json: T$JSON) => T): T | null { 30 | if (json === null) return null; 31 | return T_decode(json); 32 | } 33 | """, """ 34 | export function Optional_encode(entity: T | null, T_encode: (entity: T) => T$JSON): T$JSON | null { 35 | if (entity === null) return null; 36 | return T_encode(entity); 37 | } 38 | """, """ 39 | export function Array_decode(json: T$JSON[], T_decode: (json: T$JSON) => T): T[] { 40 | return json.map(T_decode); 41 | } 42 | """, """ 43 | export function Array_encode(entity: T[], T_encode: (entity: T) => T$JSON): T$JSON[] { 44 | return entity.map(T_encode); 45 | } 46 | """, """ 47 | export function Set_decode(json: T$JSON[], T_decode: (json: T$JSON) => T): Set { 48 | return new Set(json.map(T_decode)); 49 | } 50 | """, """ 51 | export function Set_encode(entity: Set, T_encode: (entity: T) => T$JSON): T$JSON[] { 52 | return [... entity].map(T_encode); 53 | } 54 | """, """ 55 | export function Dictionary_decode(json: { 56 | [key: string]: T$JSON; 57 | }, T_decode: (json: T$JSON) => T): Map { 58 | const entity = new Map(); 59 | for (const k in json) { 60 | if (json.hasOwnProperty(k)) { 61 | entity.set(k, T_decode(json[k])); 62 | } 63 | } 64 | return entity; 65 | } 66 | """, """ 67 | export function Dictionary_encode(entity: Map, T_encode: (entity: T) => T$JSON): { 68 | [key: string]: T$JSON; 69 | } { 70 | const json: { 71 | [key: string]: T$JSON; 72 | } = {}; 73 | for (const k in entity.keys()) { 74 | json[k] = T_encode(entity.get(k) !!); 75 | } 76 | return json; 77 | } 78 | """, """ 79 | export type TagOf = [Type] extends [TagRecord] 80 | ? TAG 81 | : null extends Type 82 | ? "Optional" & [TagOf>] 83 | : Type extends (infer E)[] 84 | ? "Array" & [TagOf] 85 | : Type extends Map 86 | ? "Dictionary" & [TagOf] 87 | : never 88 | ; 89 | """, """ 90 | export type TagRecord = Args["length"] extends 0 91 | ? { 92 | $tag?: Name; 93 | } 94 | : { 95 | $tag?: Name & { 96 | [I in keyof Args]: TagOf; 97 | }; 98 | } 99 | ; 100 | """ 101 | ] 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/RawValueTransferringConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct RawValueTransferringConverter: TypeConverter { 5 | public init( 6 | generator: CodeGenerator, 7 | swiftType: any SType, 8 | rawValueType raw: any SType 9 | ) throws { 10 | let map = swiftType.contextSubstitutionMap() 11 | let substituted = raw.subst(map: map) 12 | 13 | self.generator = generator 14 | self.swiftType = swiftType 15 | self.rawValueType = try generator.converter(for: substituted) 16 | } 17 | 18 | public var generator: CodeGenerator 19 | public var swiftType: any SType 20 | var rawValueType: any TypeConverter 21 | 22 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 23 | let name = try self.name(for: target) 24 | let genericParams = try genericParams() 25 | let tsGenericParams: [TSTypeParameterNode] = try genericParams.map { 26 | .init(try $0.name(for: target)) 27 | } 28 | switch target { 29 | case .entity: 30 | let fieldType = try rawValueType.fieldType(for: .entity) 31 | 32 | let field: TSObjectType.Field = .field( 33 | name: "rawValue", isOptional: fieldType.isOptional, type: fieldType.type 34 | ) 35 | 36 | var type: any TSType = TSObjectType([field]) 37 | 38 | let tag = try generator.tagRecord( 39 | name: name, 40 | genericArgs: try genericParams.map { (param) in 41 | TSIdentType(try param.name(for: .entity)) 42 | } 43 | ) 44 | 45 | type = TSIntersectionType(type, tag) 46 | 47 | return TSTypeDecl( 48 | modifiers: [.export], 49 | name: name, 50 | genericParams: tsGenericParams, 51 | type: type 52 | ) 53 | case .json: 54 | return TSTypeDecl( 55 | modifiers: [.export], 56 | name: name, 57 | genericParams: tsGenericParams, 58 | type: try rawValueType.type(for: target) 59 | ) 60 | } 61 | } 62 | 63 | public func hasDecode() throws -> Bool { 64 | return true 65 | } 66 | 67 | public func decodeDecl() throws -> TSFunctionDecl? { 68 | guard let decl = try decodeSignature() else { return nil } 69 | 70 | let value = try rawValueType.callDecode(json: TSIdentExpr.json) 71 | let field = try rawValueType.valueToField(value: value, for: .entity) 72 | 73 | let object = TSObjectExpr([ 74 | .named(name: "rawValue", value: field) 75 | ]) 76 | 77 | decl.body.elements.append( 78 | TSReturnStmt(object) 79 | ) 80 | 81 | return decl 82 | } 83 | 84 | public func hasEncode() throws -> Bool { 85 | return true 86 | } 87 | 88 | public func encodeDecl() throws -> TSFunctionDecl? { 89 | guard let decl = try encodeSignature() else { return nil } 90 | 91 | let field = try rawValueType.callEncodeField( 92 | entity: TSMemberExpr(base: TSIdentExpr.entity, name: "rawValue") 93 | ) 94 | let value = try rawValueType.fieldToValue(field: field, for: .json) 95 | 96 | decl.body.elements.append( 97 | TSReturnStmt(value) 98 | ) 99 | 100 | return decl 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Value/TypeMap.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | 3 | public struct TypeMap: Sendable { 4 | public struct Entry: Sendable { 5 | public static func identity(name: String) -> Entry { 6 | return Entry( 7 | entityType: name, 8 | jsonType: name 9 | ) 10 | } 11 | 12 | public static func coding( 13 | entityType: String, 14 | jsonType: String, 15 | decode: String?, 16 | encode: String? 17 | ) -> Entry { 18 | return Entry( 19 | entityType: entityType, 20 | jsonType: jsonType, 21 | decode: decode, 22 | encode: encode 23 | ) 24 | } 25 | 26 | public var entityType: String 27 | public var jsonType: String 28 | public var decode: String? 29 | public var encode: String? 30 | 31 | public var symbols: Set { 32 | var result: Set = [ 33 | entityType, 34 | jsonType 35 | ] 36 | if let t = decode { 37 | result.insert(t) 38 | } 39 | if let t = encode { 40 | result.insert(t) 41 | } 42 | return result 43 | } 44 | } 45 | 46 | public typealias MapFunction = @Sendable (any SType) -> Entry? 47 | 48 | public static let `default` = TypeMap( 49 | table: TypeMap.defaultTable 50 | ) 51 | 52 | public static let defaultTable: [String: Entry] = [ 53 | "Void": .identity(name: "void"), 54 | "Bool": .identity(name: "boolean"), 55 | "Int": .identity(name: "number"), 56 | "Int8": .identity(name: "number"), 57 | "Int16": .identity(name: "number"), 58 | "Int32": .identity(name: "number"), 59 | "Int64": .identity(name: "number"), 60 | "UInt8": .identity(name: "number"), 61 | "UInt16": .identity(name: "number"), 62 | "UInt32": .identity(name: "number"), 63 | "UInt64": .identity(name: "number"), 64 | "Float": .identity(name: "number"), 65 | "Float32": .identity(name: "number"), 66 | "Float64": .identity(name: "number"), 67 | "Double": .identity(name: "number"), 68 | "String": .identity(name: "string"), 69 | ] 70 | 71 | public init( 72 | table: [String: Entry]? = nil, 73 | mapFunction: MapFunction? = nil 74 | ) { 75 | self.table = table ?? Self.defaultTable 76 | self.mapFunction = mapFunction 77 | } 78 | 79 | public var table: [String: Entry] 80 | public var mapFunction: MapFunction? 81 | 82 | public var entries: [Entry] { Array(table.values) } 83 | 84 | public func map(type: any SType) -> Entry? { 85 | if let entry = mapFunction?(type) { 86 | return entry 87 | } 88 | 89 | let repr = type.toTypeRepr(containsModule: false) 90 | 91 | if let type = mapFromTable(repr: repr) { 92 | return type 93 | } 94 | 95 | return nil 96 | } 97 | 98 | private func mapFromTable(repr: any TypeRepr) -> Entry? { 99 | guard let key = tableMapKey(repr: repr) else { return nil } 100 | return table[key] 101 | } 102 | 103 | private func tableMapKey(repr: any TypeRepr) -> String? { 104 | if let ident = repr.asIdent, 105 | let element = ident.elements.last { 106 | return element.name 107 | } else { 108 | return nil 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/OptionalConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct OptionalConverter: TypeConverter { 5 | public init(generator: CodeGenerator, swiftType: any SType) { 6 | self.generator = generator 7 | self.swiftType = swiftType 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var swiftType: any SType 12 | 13 | private func wrapped(limit: Int?) throws -> any TypeConverter { 14 | let (wrapped, _) = swiftType.unwrapOptional(limit: limit)! 15 | return try generator.converter(for: wrapped) 16 | } 17 | 18 | public func type(for target: GenerationTarget) throws -> any TSType { 19 | return TSUnionType( 20 | try wrapped(limit: nil).type(for: target), 21 | TSIdentType.null 22 | ) 23 | } 24 | 25 | public func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) { 26 | return ( 27 | type: try wrapped(limit: 1).type(for: target), 28 | isOptional: true 29 | ) 30 | } 31 | 32 | public func valueToField(value: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 33 | return TSInfixOperatorExpr(value, "??", TSIdentExpr.undefined) 34 | } 35 | 36 | public func fieldToValue(field: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 37 | return TSInfixOperatorExpr(field, "??", TSNullLiteralExpr()) 38 | } 39 | 40 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 41 | throw MessageError("Unsupported type: \(swiftType)") 42 | } 43 | 44 | public func hasDecode() throws -> Bool { 45 | return try wrapped(limit: nil).hasDecode() 46 | } 47 | 48 | public func decodeName() throws -> String { 49 | return generator.helperLibrary().name(.optionalDecode) 50 | } 51 | 52 | public func callDecode(json: any TSExpr) throws -> any TSExpr { 53 | return try `default`.callDecode( 54 | genericArgs: [try wrapped(limit: nil).swiftType], 55 | json: json 56 | ) 57 | } 58 | 59 | public func callDecodeField(json: any TSExpr) throws -> any TSExpr { 60 | guard try hasDecode() else { return json } 61 | let decodeName = generator.helperLibrary().name(.optionalFieldDecode) 62 | return try generator.callDecode( 63 | callee: TSIdentExpr(decodeName), 64 | genericArgs: [try wrapped(limit: 1).swiftType], 65 | json: json 66 | ) 67 | } 68 | 69 | public func decodeDecl() throws -> TSFunctionDecl? { 70 | throw MessageError("Unsupported type: \(swiftType)") 71 | } 72 | 73 | public func hasEncode() throws -> Bool { 74 | return try wrapped(limit: nil).hasEncode() 75 | } 76 | 77 | public func encodeName() throws -> String { 78 | return generator.helperLibrary().name(.optionalEncode) 79 | } 80 | 81 | public func callEncode(entity: any TSExpr) throws -> any TSExpr { 82 | return try `default`.callEncode( 83 | genericArgs: [try wrapped(limit: nil).swiftType], 84 | entity: entity 85 | ) 86 | } 87 | 88 | public func callEncodeField(entity: any TSExpr) throws -> any TSExpr { 89 | guard try hasEncode() else { return entity } 90 | let encodeName = generator.helperLibrary().name(.optionalFieldEncode) 91 | return try generator.callEncode( 92 | callee: TSIdentExpr(encodeName), 93 | genericArgs: [try wrapped(limit: 1).swiftType], 94 | entity: entity 95 | ) 96 | } 97 | 98 | public func encodeDecl() throws -> TSFunctionDecl? { 99 | throw MessageError("Unsupported type: \(swiftType)") 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateCustomTypeConverterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | 6 | final class GenerateCustomTypeConverterTests: GenerateTestCaseBase { 7 | /* 8 | Use TypeMap instead of custom TypeConverter as much as possible 9 | */ 10 | struct CustomConverter: TypeConverter { 11 | var generator: CodeGenerator 12 | var swiftType: any SType 13 | 14 | func name(for target: GenerationTarget) throws -> String { 15 | switch target { 16 | case .entity: return "Custom" 17 | case .json: return try `default`.name(for: .json) 18 | } 19 | } 20 | 21 | func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 22 | return nil 23 | } 24 | 25 | func hasDecode() throws -> Bool { 26 | return true 27 | } 28 | 29 | func decodeName() throws -> String { 30 | return "Custom_decode" 31 | } 32 | 33 | func decodeDecl() throws -> TSFunctionDecl? { 34 | return nil 35 | } 36 | 37 | func hasEncode() throws -> Bool { 38 | return true 39 | } 40 | 41 | func encodeName() throws -> String { 42 | return "Custom_encode" 43 | } 44 | 45 | func encodeDecl() throws -> TSFunctionDecl? { 46 | return nil 47 | } 48 | } 49 | 50 | func testCustomTypeConverter() throws { 51 | let typeConverterProvider = TypeConverterProvider { (gen, type) in 52 | let repr = type.toTypeRepr(containsModule: false) 53 | if let ident = repr.asIdent, 54 | let element = ident.elements.last, 55 | element.name == "Custom" 56 | { 57 | return CustomConverter(generator: gen, swiftType: type) 58 | } 59 | return nil 60 | } 61 | 62 | try assertGenerate( 63 | source: """ 64 | struct S { 65 | var a: Custom 66 | var b: [Custom] 67 | var c: [[Custom]] 68 | } 69 | """, 70 | typeConverterProvider: typeConverterProvider, 71 | externalReference: ExternalReference( 72 | symbols: ["Custom", "Custom$JSON", "Custom_decode", "Custom_encode"], 73 | code: """ 74 | export type Custom = {}; 75 | export type Custom$JSON = string; 76 | export function Custom_decode(json: Custom$JSON): Custom { throw 0; } 77 | export function Custom_encode(entity: Custom): Custom$JSON { throw 0; } 78 | """ 79 | ), 80 | expecteds: [""" 81 | export type S = { 82 | a: Custom; 83 | b: Custom[]; 84 | c: Custom[][]; 85 | } & TagRecord<"S">; 86 | """, """ 87 | export type S$JSON = { 88 | a: Custom$JSON; 89 | b: Custom$JSON[]; 90 | c: Custom$JSON[][]; 91 | }; 92 | """, """ 93 | export function S_decode(json: S$JSON): S { 94 | const a = Custom_decode(json.a); 95 | const b = Array_decode(json.b, Custom_decode); 96 | const c = Array_decode(json.c, (json: Custom$JSON[]): Custom[] => { 97 | return Array_decode(json, Custom_decode); 98 | }); 99 | return { 100 | a: a, 101 | b: b, 102 | c: c 103 | }; 104 | } 105 | """, """ 106 | export function S_encode(entity: S): S$JSON { 107 | const a = Custom_encode(entity.a); 108 | const b = Array_encode(entity.b, Custom_encode); 109 | const c = Array_encode(entity.c, (entity: Custom[]): Custom$JSON[] => { 110 | return Array_encode(entity, Custom_encode); 111 | }); 112 | return { 113 | a: a, 114 | b: b, 115 | c: c 116 | }; 117 | } 118 | """ 119 | ] 120 | ) 121 | }} 122 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateTestCaseBase.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftTypeReader 3 | import CodableToTypeScript 4 | import TypeScriptAST 5 | 6 | class GenerateTestCaseBase: XCTestCase { 7 | enum Prints { 8 | case none 9 | case one 10 | case all 11 | } 12 | // debug 13 | var prints: Prints { .none } 14 | 15 | func dateTypeMap() -> TypeMap { 16 | var typeMap = TypeMap() 17 | typeMap.table["Date"] = .coding( 18 | entityType: "Date", jsonType: "string", 19 | decode: "Date_decode", encode: "Date_encode" 20 | ) 21 | return typeMap 22 | } 23 | 24 | func dateTypeExternal() -> ExternalReference { 25 | return ExternalReference( 26 | symbols: [ 27 | "Date_decode", "Date_encode" 28 | ], 29 | code: """ 30 | export function Date_decode(json: string): Date { throw 0; } 31 | export function Date_encode(date: Date): string { throw 0; } 32 | """ 33 | ) 34 | } 35 | 36 | func assertGenerate( 37 | context: Context? = nil, 38 | source: String, 39 | typeSelector: TypeSelector = .last(file: #file, line: #line), 40 | typeMap: TypeMap? = nil, 41 | typeConverterProvider: TypeConverterProvider? = nil, 42 | externalReference: ExternalReference? = nil, 43 | expecteds: [String] = [], 44 | unexpecteds: [String] = [], 45 | file: StaticString = #file, 46 | line: UInt = #line, 47 | function: StaticString = #function 48 | ) throws { 49 | let context = context ?? Context() 50 | 51 | try withExtendedLifetime(context) { context in 52 | let module = context.getOrCreateModule(name: "main") 53 | _ = Reader(context: context, module: module) 54 | .read(source: source, file: URL(fileURLWithPath: "main.swift")) 55 | 56 | let typeMap = typeMap ?? .default 57 | 58 | let typeConverterProvider = typeConverterProvider ?? TypeConverterProvider( 59 | typeMap: typeMap 60 | ) 61 | 62 | let packageTester = PackageBuildTester( 63 | context: context, 64 | typeConverterProvider: typeConverterProvider, 65 | externalReference: externalReference, 66 | file: file, 67 | line: line, 68 | function: function 69 | ) 70 | 71 | let gen = packageTester.packageGenerator.codeGenerator 72 | 73 | func generate(type: any TypeDecl) throws -> TSSourceFile { 74 | let code = TSSourceFile( 75 | try gen.converter(for: type.declaredInterfaceType).decls() 76 | ) 77 | let imports = try code.buildAutoImportDecls( 78 | from: URL(fileURLWithPath: "test.ts"), 79 | symbolTable: SymbolTable(), 80 | fileExtension: packageTester.packageGenerator.importFileExtension, 81 | defaultFile: ".." 82 | ) 83 | code.replaceImportDecls(imports) 84 | return code 85 | } 86 | 87 | if case .all = prints { 88 | for swType in module.types { 89 | print("// \(swType.typeName ?? "?")") 90 | let code = try generate(type: swType) 91 | print(code.print()) 92 | } 93 | } 94 | 95 | let swType = try typeSelector(module: module) 96 | let code = try generate(type: swType) 97 | 98 | if case .one = prints { 99 | print(code.print()) 100 | } 101 | 102 | let actual = code.print() 103 | 104 | assertText( 105 | text: actual, 106 | expecteds: expecteds, 107 | unexpecteds: unexpecteds, 108 | file: file, line: line 109 | ) 110 | 111 | XCTAssertNoThrow( 112 | try packageTester.build(module: module), 113 | "generate and build typescript: dir=\(packageTester.directory.path)", 114 | file: file, line: line 115 | ) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateTypeAliasTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | 4 | final class GenerateTypeAliasTests: GenerateTestCaseBase { 5 | func testTrivial() throws { 6 | try assertGenerate( 7 | source: """ 8 | typealias A = Int 9 | """, 10 | expecteds: [""" 11 | export type A = number; 12 | """ 13 | ], 14 | unexpecteds: [""" 15 | export type A$JSON 16 | """] 17 | ) 18 | } 19 | 20 | func testDecode() throws { 21 | try assertGenerate( 22 | source: """ 23 | enum E { case a } 24 | struct S { var e: E } 25 | 26 | typealias A = S 27 | """, 28 | expecteds: [""" 29 | export type A = S; 30 | """, """ 31 | export type A$JSON = S$JSON; 32 | """, """ 33 | export function A_decode(json: A$JSON): A { 34 | return S_decode(json); 35 | } 36 | """] 37 | ) 38 | } 39 | 40 | func testGenericParamTransfer() throws { 41 | try assertGenerate( 42 | source: """ 43 | struct S { var a: T } 44 | 45 | typealias A = S 46 | """, 47 | expecteds: [""" 48 | export type A = S; 49 | """, """ 50 | export type A$JSON = S$JSON; 51 | """, """ 52 | export function A_decode(json: A$JSON, T_decode: (json: T$JSON) => T): A { 53 | return S_decode(json, T_decode); 54 | } 55 | """, """ 56 | export function A_encode(entity: A, T_encode: (entity: T) => T$JSON): A$JSON { 57 | return S_encode(entity, T_encode); 58 | } 59 | """ 60 | ]) 61 | } 62 | 63 | func testGenericParamDrop() throws { 64 | try assertGenerate( 65 | source: """ 66 | enum E { case a } 67 | struct S { var e: E } 68 | 69 | typealias A = S 70 | """, 71 | expecteds: [""" 72 | export type A = S; 73 | """, """ 74 | export type A$JSON = S$JSON; 75 | """, """ 76 | export function A_decode(json: A$JSON, T_decode: (json: T$JSON) => T): A { 77 | return S_decode(json); 78 | } 79 | """ 80 | ]) 81 | } 82 | 83 | func testNested() throws { 84 | try assertGenerate( 85 | source: """ 86 | enum E { case a(X) } 87 | 88 | struct S { 89 | typealias A = E 90 | } 91 | """, 92 | typeSelector: .name("A", recursive: true), 93 | expecteds: [""" 94 | export type S_A = E; 95 | """, """ 96 | export type S_A$JSON = E$JSON; 97 | """, """ 98 | export function S_A_decode(json: S_A$JSON, T_decode: (json: T$JSON) => T): S_A { 99 | return E_decode(json, T_decode); 100 | } 101 | """, """ 102 | export function S_A_encode(entity: S_A, T_encode: (entity: T) => T$JSON): S_A$JSON { 103 | return E_encode(entity, T_encode); 104 | } 105 | """ 106 | ] 107 | ) 108 | } 109 | 110 | func testInheritGenericParam() throws { 111 | let source = """ 112 | struct E {} 113 | 114 | struct S { 115 | typealias A = E 116 | typealias B = E 117 | } 118 | """ 119 | 120 | try assertGenerate( 121 | source: source, 122 | typeSelector: .name("A", recursive: true), 123 | expecteds: [""" 124 | export type S_A = E; 125 | """] 126 | ) 127 | 128 | try assertGenerate( 129 | source: source, 130 | typeSelector: .name("B", recursive: true), 131 | expecteds: [""" 132 | export type S_B = E; 133 | """] 134 | ) 135 | } 136 | 137 | func testRawRepr() throws { 138 | try assertGenerate( 139 | source: """ 140 | struct GenericID: RawRepresentable { 141 | var rawValue: String 142 | } 143 | 144 | struct User { 145 | typealias ID = GenericID 146 | 147 | var id: ID 148 | } 149 | """, 150 | expecteds: [""" 151 | export type User = { 152 | id: User_ID; 153 | } & TagRecord<"User">; 154 | """, """ 155 | export type User$JSON = { 156 | id: User_ID$JSON; 157 | }; 158 | """, """ 159 | export type User_ID = GenericID; 160 | """, """ 161 | export type User_ID$JSON = GenericID$JSON; 162 | """, """ 163 | export function User_ID_decode(json: User_ID$JSON): User_ID { 164 | return GenericID_decode(json, User_decode); 165 | } 166 | """ 167 | ] 168 | ) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /demo/src/Gen/SwiftRuntime.gen.ts: -------------------------------------------------------------------------------- 1 | class Memory { 2 | readonly rawMemory: WebAssembly.Memory; 3 | constructor(exports: WebAssembly.Exports) { 4 | this.rawMemory = exports.memory as WebAssembly.Memory; 5 | } 6 | bytes(): Uint8Array { 7 | return new Uint8Array(this.rawMemory.buffer); 8 | } 9 | writeBytes(ptr: number, bytes: Uint8Array): void { 10 | this.bytes().set(bytes, ptr); 11 | } 12 | } 13 | 14 | type WasmCallableKitExported = { 15 | ck_send: (functionID: number, argumentBufferLength: number) => number; 16 | ck_class_init: (classID: number, initilizerID: number, argumentBufferLength: number) => number; 17 | ck_class_send: (instanceID: number, functionID: number, argumentBufferLength: number) => number; 18 | ck_class_free: (instanceID: number) => void; 19 | }; 20 | 21 | export var globalRuntime: SwiftRuntime; 22 | 23 | export class SwiftRuntime { 24 | #_instance: WebAssembly.Instance | null = null; 25 | #_memory: Memory | null = null; 26 | 27 | #nextArgument: Uint8Array | null = null; 28 | #nextReturn: string | null = null; 29 | 30 | #textDecoder = new TextDecoder("utf-8"); 31 | #textEncoder = new TextEncoder(); 32 | 33 | #pool = new FinalizationRegistry((instanceID: number) => { 34 | this.#callableKitExports.ck_class_free(instanceID); 35 | }); 36 | 37 | constructor() { 38 | globalRuntime = this; 39 | } 40 | 41 | setInstance(instance: WebAssembly.Instance) { 42 | this.#_instance = instance; 43 | } 44 | 45 | get #instance(): WebAssembly.Instance { 46 | if (!this.#_instance) 47 | throw new Error("WebAssembly instance is not set yet"); 48 | return this.#_instance; 49 | } 50 | 51 | get #memory(): Memory { 52 | if (!this.#_memory) { 53 | this.#_memory = new Memory(this.#instance.exports); 54 | } 55 | return this.#_memory; 56 | } 57 | 58 | get #callableKitExports(): WasmCallableKitExported { 59 | return this.#instance.exports as WasmCallableKitExported; 60 | } 61 | 62 | get callableKitImports(): WebAssembly.Imports { 63 | const callable_kit = { 64 | receive_arg: (buffer: number) => { 65 | this.#memory.writeBytes(buffer, this.#nextArgument!!); 66 | this.#nextArgument = null; 67 | }, 68 | write_ret: (buffer: number, length: number) => { 69 | const bytes = this.#memory.bytes().subarray(buffer, buffer + length); 70 | this.#nextReturn = this.#textDecoder.decode(bytes); 71 | }, 72 | }; 73 | return { callable_kit }; 74 | } 75 | 76 | #pushArg(argument: unknown): number { 77 | const argJsonString = JSON.stringify(argument) + '\0'; 78 | const argBytes = this.#textEncoder.encode(argJsonString); 79 | this.#nextArgument = argBytes; 80 | return argBytes.length; 81 | } 82 | 83 | #popReturn(): string | null { 84 | const returnValue = this.#nextReturn!!; 85 | this.#nextReturn = null; 86 | return returnValue; 87 | } 88 | 89 | send(functionID: number, argument: unknown): unknown { 90 | const argLen = this.#pushArg(argument); 91 | const out = this.#callableKitExports.ck_send(functionID, argLen); 92 | const returnValue = this.#popReturn()!!; 93 | switch (out) { 94 | case 0: 95 | if (returnValue === "") return; 96 | return JSON.parse(returnValue); 97 | case -1: 98 | throw new Error(returnValue); 99 | default: 100 | throw new Error("unexpected"); 101 | } 102 | } 103 | 104 | classInit(classID: number, initializerID: number, argument: unknown): number { 105 | const argLen = this.#pushArg(argument); 106 | const out = this.#callableKitExports.ck_class_init(classID, initializerID, argLen); 107 | switch (out) { 108 | case -1: 109 | throw new Error(this.#popReturn()!!); 110 | default: 111 | return out; 112 | } 113 | } 114 | 115 | classSend(instanceID: number, functionID: number, argument: unknown): unknown { 116 | const argLen = this.#pushArg(argument); 117 | const out = this.#callableKitExports.ck_class_send(instanceID, functionID, argLen); 118 | const returnValue = this.#popReturn()!!; 119 | switch (out) { 120 | case 0: 121 | if (returnValue === "") return; 122 | return JSON.parse(returnValue); 123 | case -1: 124 | throw new Error(returnValue); 125 | default: 126 | throw new Error("unexpected"); 127 | } 128 | } 129 | 130 | autorelease(obj: object, instanceID: number): void { 131 | this.#pool.register(obj, instanceID); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateEncodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | 4 | final class GenerateEncodeTests: GenerateTestCaseBase { 5 | func testEnum() throws { 6 | try assertGenerate( 7 | source: """ 8 | enum E { 9 | case a 10 | case b(Date) 11 | } 12 | """, 13 | typeMap: dateTypeMap(), 14 | externalReference: dateTypeExternal(), 15 | expecteds: [""" 16 | export type E = ({ 17 | kind: "a"; 18 | a: {}; 19 | } | { 20 | kind: "b"; 21 | b: { 22 | _0: Date; 23 | }; 24 | }) & TagRecord<"E">; 25 | """, """ 26 | export type E$JSON = { 27 | a: {}; 28 | } | { 29 | b: { 30 | _0: string; 31 | }; 32 | }; 33 | """, """ 34 | export function E_encode(entity: E): E$JSON { 35 | switch (entity.kind) { 36 | case "a": 37 | { 38 | return { 39 | a: {} 40 | }; 41 | } 42 | case "b": 43 | { 44 | const e = entity.b; 45 | const _0 = Date_encode(e._0); 46 | return { 47 | b: { 48 | _0: _0 49 | } 50 | }; 51 | } 52 | default: 53 | const check: never = entity; 54 | throw new Error("invalid case: " + check); 55 | } 56 | } 57 | """] 58 | ) 59 | } 60 | 61 | func testStruct() throws { 62 | try assertGenerate( 63 | source: """ 64 | struct S { 65 | var a: Date 66 | var b: Date? 67 | var c: Date?? 68 | var d: [Date] 69 | } 70 | """, 71 | typeMap: dateTypeMap(), 72 | externalReference: dateTypeExternal(), 73 | expecteds: [""" 74 | export type S = { 75 | a: Date; 76 | b?: Date; 77 | c?: Date | null; 78 | d: Date[]; 79 | } & TagRecord<"S">; 80 | """, """ 81 | export type S$JSON = { 82 | a: string; 83 | b?: string; 84 | c?: string | null; 85 | d: string[]; 86 | }; 87 | """, """ 88 | export function S_encode(entity: S): S$JSON { 89 | const a = Date_encode(entity.a); 90 | const b = OptionalField_encode(entity.b, Date_encode); 91 | const c = OptionalField_encode(entity.c, (entity: Date | null): string | null => { 92 | return Optional_encode(entity, Date_encode); 93 | }); 94 | const d = Array_encode(entity.d, Date_encode); 95 | return { 96 | a: a, 97 | b: b, 98 | c: c, 99 | d: d 100 | }; 101 | } 102 | """] 103 | ) 104 | } 105 | 106 | func testAsOperatorIdentityEncode() throws { 107 | try assertGenerate( 108 | source: """ 109 | enum E { 110 | case a(Int) 111 | } 112 | 113 | struct S { 114 | var a: E 115 | var b: Date 116 | } 117 | """, 118 | typeMap: dateTypeMap(), 119 | externalReference: dateTypeExternal(), 120 | expecteds: [""" 121 | export function S_encode(entity: S): S$JSON { 122 | const a = entity.a as E$JSON; 123 | const b = Date_encode(entity.b); 124 | return { 125 | a: a, 126 | b: b 127 | }; 128 | } 129 | """ 130 | ] 131 | ) 132 | } 133 | 134 | func testVariableNameEscaping() throws { 135 | try assertGenerate( 136 | source: """ 137 | struct S { 138 | var `class`: T 139 | } 140 | """, 141 | expecteds: [""" 142 | export function S_decode(json: S$JSON, T_decode: (json: T$JSON) => T): S { 143 | const _class = T_decode(json.class); 144 | return { 145 | class: _class 146 | }; 147 | } 148 | """, """ 149 | export function S_encode(entity: S, T_encode: (entity: T) => T$JSON): S$JSON { 150 | const _class = T_encode(entity.class); 151 | return { 152 | class: _class 153 | }; 154 | } 155 | """] 156 | ) 157 | 158 | try assertGenerate( 159 | source: """ 160 | enum E { 161 | case `class`(break: T) 162 | } 163 | """, 164 | expecteds: [""" 165 | export function E_decode(json: E$JSON, T_decode: (json: T$JSON) => T): E { 166 | if ("class" in json) { 167 | const j = json.class; 168 | const _break = T_decode(j.break); 169 | return { 170 | kind: "class", 171 | class: { 172 | break: _break 173 | } 174 | }; 175 | } else { 176 | throw new Error("unknown kind"); 177 | } 178 | } 179 | """, """ 180 | export function E_encode(entity: E, T_encode: (entity: T) => T$JSON): E$JSON { 181 | const e = entity.class; 182 | const _break = T_encode(e.break); 183 | return { 184 | class: { 185 | break: _break 186 | } 187 | }; 188 | } 189 | """] 190 | ) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Utils/PackageBuildTester.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | 6 | struct PackageBuildTester { 7 | private static func makeLaunchName() -> String { 8 | let now = Date() 9 | let formatter = DateFormatter() 10 | formatter.locale = Locale(identifier: "en_US_POSIX") 11 | formatter.timeZone = .current 12 | formatter.dateFormat = "yyyyMMddHHmmss" 13 | return formatter.string(from: now) 14 | } 15 | 16 | private static func addPath() throws { 17 | if isAddPathDone { return } 18 | isAddPathDone = true 19 | try Env.addPath("/usr/local/bin") 20 | } 21 | private static var isAddPathDone = false 22 | 23 | private static func launchDirectory(fileManager: FileManager) -> URL { 24 | if let dir = launchDirectoryCache { return dir } 25 | 26 | let dir = fileManager.temporaryDirectory 27 | .appendingPathComponent("CodableToTypeScriptTests") 28 | .appendingPathComponent(makeLaunchName()) 29 | print("[PackageBuildTester]: launchDir=\(dir.path)") 30 | launchDirectoryCache = dir 31 | return dir 32 | } 33 | private static var launchDirectoryCache: URL? 34 | 35 | init( 36 | fileManager: FileManager = .default, 37 | context: Context, 38 | typeConverterProvider: TypeConverterProvider, 39 | externalReference: ExternalReference?, 40 | file: StaticString, 41 | line: UInt, 42 | function: StaticString 43 | ) { 44 | self.context = context 45 | self.typeConverterProvider = typeConverterProvider 46 | self.fileManager = fileManager 47 | 48 | self.directory = Self.launchDirectory(fileManager: fileManager) 49 | .appendingPathComponent(Self.testName(file: file)) 50 | .appendingPathComponent(Self.funcName(function: function) + "L\(line)") 51 | 52 | self.externalReference = externalReference 53 | 54 | let outDir = directory.appendingPathComponent("src") 55 | 56 | var symbols = SymbolTable() 57 | for symbol in externalReference?.symbols ?? [] { 58 | symbols.add( 59 | symbol: symbol, 60 | file: .file(outDir.appendingPathComponent("externals.ts")) 61 | ) 62 | } 63 | 64 | self.packageGenerator = PackageGenerator( 65 | context: context, 66 | fileManager: fileManager, 67 | typeConverterProvider: typeConverterProvider, 68 | symbols: symbols, 69 | importFileExtension: .none, 70 | outputDirectory: outDir 71 | ) 72 | 73 | self.isSkipped = Env.get("SKIP_TSC") != nil 74 | } 75 | 76 | static func testName(file: StaticString) -> String { 77 | let name = (file.description as NSString).lastPathComponent 78 | return (name as NSString).deletingPathExtension 79 | } 80 | 81 | static func funcName(function: StaticString) -> String { 82 | var s = function.description 83 | if let i = s.firstIndex(of: "(") { 84 | s = String(s[..; 27 | } & TagRecord<"S">; 28 | """] 29 | ) 30 | } 31 | 32 | func testEnum() throws { 33 | try assertGenerate( 34 | source: """ 35 | enum E { 36 | case a(x: Int, y: Int) 37 | case b([String]) 38 | """, 39 | expecteds: [""" 40 | export type E = ({ 41 | kind: "a"; 42 | a: { 43 | x: number; 44 | y: number; 45 | }; 46 | } | { 47 | kind: "b"; 48 | b: { 49 | _0: string[]; 50 | }; 51 | }) & TagRecord<"E">; 52 | """, """ 53 | export type E$JSON = { 54 | a: { 55 | x: number; 56 | y: number; 57 | }; 58 | } | { 59 | b: { 60 | _0: string[]; 61 | }; 62 | }; 63 | """, """ 64 | export function E_decode(json: E$JSON): E { 65 | if ("a" in json) { 66 | const j = json.a; 67 | const x = j.x; 68 | const y = j.y; 69 | return { 70 | kind: "a", 71 | a: { 72 | x: x, 73 | y: y 74 | } 75 | }; 76 | } else if ("b" in json) { 77 | const j = json.b; 78 | const _0 = j._0 as string[]; 79 | return { 80 | kind: "b", 81 | b: { 82 | _0: _0 83 | } 84 | }; 85 | } else { 86 | throw new Error("unknown kind"); 87 | } 88 | } 89 | """]) 90 | } 91 | 92 | func testEnumInStruct() throws { 93 | try assertGenerate( 94 | source: """ 95 | enum E1 { 96 | case a 97 | } 98 | 99 | enum E2: String { 100 | case a 101 | } 102 | 103 | struct S { 104 | var x: E1 105 | var y: E2 106 | } 107 | """, 108 | expecteds: [""" 109 | import { 110 | E1, 111 | E1$JSON, 112 | E1_decode, 113 | E2, 114 | TagRecord 115 | } from ".."; 116 | """, """ 117 | export type S = { 118 | x: E1; 119 | y: E2; 120 | } & TagRecord<"S">; 121 | """, """ 122 | export type S$JSON = { 123 | x: E1$JSON; 124 | y: E2; 125 | }; 126 | """, """ 127 | export function S_decode(json: S$JSON): S { 128 | const x = E1_decode(json.x); 129 | const y = json.y; 130 | return { 131 | x: x, 132 | y: y 133 | }; 134 | } 135 | """] 136 | ) 137 | } 138 | 139 | func testGenericResponse() throws { 140 | try assertGenerate( 141 | source: """ 142 | enum E { 143 | case a 144 | case b 145 | } 146 | 147 | enum R { 148 | case s(T) 149 | case f(E) 150 | } 151 | """, 152 | expecteds: [""" 153 | import { 154 | E, 155 | E$JSON, 156 | E_decode, 157 | TagRecord 158 | } from ".."; 159 | """, """ 160 | export type R = ({ 161 | kind: "s"; 162 | s: { 163 | _0: T; 164 | }; 165 | } | { 166 | kind: "f"; 167 | f: { 168 | _0: E; 169 | }; 170 | }) & TagRecord<"R", [T]>; 171 | """, """ 172 | export type R$JSON = { 173 | s: { 174 | _0: T$JSON; 175 | }; 176 | } | { 177 | f: { 178 | _0: E$JSON; 179 | }; 180 | }; 181 | """, """ 182 | export function R_decode(json: R$JSON, T_decode: (json: T$JSON) => T): R { 183 | if ("s" in json) { 184 | const j = json.s; 185 | const _0 = T_decode(j._0); 186 | return { 187 | kind: "s", 188 | s: { 189 | _0: _0 190 | } 191 | }; 192 | } else if ("f" in json) { 193 | const j = json.f; 194 | const _0 = E_decode(j._0); 195 | return { 196 | kind: "f", 197 | f: { 198 | _0: _0 199 | } 200 | }; 201 | } else { 202 | throw new Error("unknown kind"); 203 | } 204 | } 205 | """] 206 | ) 207 | } 208 | 209 | func testNestedType() throws { 210 | try assertGenerate( 211 | source: """ 212 | enum E { case a } 213 | 214 | struct S { 215 | struct K { 216 | var a: E 217 | } 218 | 219 | var a: D 220 | var b: K 221 | } 222 | """, 223 | typeMap: TypeMap { (type) in 224 | let repr = type.toTypeRepr(containsModule: false) 225 | 226 | if let ident = repr.asIdent, 227 | ident.elements.last?.name == "D" 228 | { 229 | return .identity(name: "string") 230 | } 231 | 232 | return nil 233 | }, 234 | expecteds: [""" 235 | export type S = { 236 | a: string; 237 | b: S_K; 238 | } & TagRecord<"S">; 239 | """, """ 240 | export type S$JSON = { 241 | a: string; 242 | b: S_K$JSON; 243 | }; 244 | """, """ 245 | export function S_decode(json: S$JSON): S { 246 | const a = json.a; 247 | const b = S_K_decode(json.b); 248 | return { 249 | a: a, 250 | b: b 251 | }; 252 | } 253 | """, """ 254 | export type S_K = { 255 | a: E; 256 | } & TagRecord<"S_K">; 257 | """, """ 258 | export type S_K$JSON = { 259 | a: E$JSON; 260 | }; 261 | """, """ 262 | export function S_K_decode(json: S_K$JSON): S_K { 263 | const a = E_decode(json.a); 264 | return { 265 | a: a 266 | }; 267 | } 268 | """ 269 | ] 270 | ) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Generator/CodeGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftTypeReader 3 | import TypeScriptAST 4 | 5 | public final class CodeGenerator { 6 | internal final class RequestToken: HashableFromIdentity { 7 | unowned let generator: CodeGenerator 8 | init(generator: CodeGenerator) { 9 | self.generator = generator 10 | } 11 | } 12 | 13 | internal var requestToken: RequestToken! 14 | public let context: SwiftTypeReader.Context 15 | private let typeConverterProvider: TypeConverterProvider 16 | 17 | public init( 18 | context: SwiftTypeReader.Context, 19 | typeConverterProvider: TypeConverterProvider = TypeConverterProvider() 20 | ) { 21 | self.context = context 22 | self.typeConverterProvider = typeConverterProvider 23 | self.requestToken = RequestToken(generator: self) 24 | } 25 | 26 | public func convert(source: SourceFile) throws -> TSSourceFile { 27 | let tsSource = TSSourceFile([]) 28 | 29 | try withErrorCollector { collect in 30 | for type in source.types { 31 | if let typeConverter = try? converter( 32 | for: type.declaredInterfaceType 33 | ) { 34 | collect { 35 | tsSource.elements += try typeConverter.decls() 36 | } 37 | } 38 | } 39 | } 40 | 41 | return tsSource 42 | } 43 | 44 | public func converter(for type: any SType) throws -> any TypeConverter { 45 | return try context.evaluator( 46 | ConverterRequest(token: requestToken, type: type) 47 | ) 48 | } 49 | 50 | private func implConverter(for type: any SType) throws -> any TypeConverter { 51 | return try typeConverterProvider.provide(generator: self, type: type) 52 | } 53 | 54 | internal struct ConverterRequest: Request { 55 | var token: RequestToken 56 | @AnyTypeStorage var type: any SType 57 | private var generator: CodeGenerator { token.generator } 58 | 59 | func evaluate(on evaluator: RequestEvaluator) throws -> any TypeConverter { 60 | let impl = try generator.implConverter(for: type) 61 | return GeneratorProxyConverter(generator: generator, swiftType: type, impl: impl) 62 | } 63 | } 64 | 65 | internal struct HasDecodeRequest: Request { 66 | var token: RequestToken 67 | @AnyTypeStorage var type: any SType 68 | 69 | func evaluate(on evaluator: RequestEvaluator) throws -> Bool { 70 | do { 71 | let converter = try token.generator.implConverter(for: type) 72 | return try converter.hasDecode() 73 | } catch { 74 | switch error { 75 | case is CycleRequestError: return true 76 | default: throw error 77 | } 78 | } 79 | } 80 | } 81 | 82 | internal struct HasEncodeRequest: Request { 83 | var token: RequestToken 84 | @AnyTypeStorage var type: any SType 85 | 86 | func evaluate(on evaluator: RequestEvaluator) throws -> Bool { 87 | do { 88 | let converter = try token.generator.implConverter(for: type) 89 | return try converter.hasEncode() 90 | } catch { 91 | switch error { 92 | case is CycleRequestError: return true 93 | default: throw error 94 | } 95 | } 96 | } 97 | } 98 | 99 | func helperLibrary() -> HelperLibraryGenerator { 100 | return HelperLibraryGenerator(generator: self) 101 | } 102 | 103 | public func generateHelperLibrary() -> TSSourceFile { 104 | return helperLibrary().generate() 105 | } 106 | 107 | public func callDecode( 108 | callee: any TSExpr, 109 | genericArgs: [any SType], 110 | json: any TSExpr 111 | ) throws -> any TSExpr { 112 | let genericArgs = try genericArgs.map { 113 | try converter(for: $0) 114 | } 115 | 116 | var args: [any TSExpr] = [json] 117 | 118 | args += try genericArgs.map { (arg) in 119 | return try arg.boundDecode() 120 | } 121 | 122 | let callGenericArgs: [any TSType] = try genericArgs.flatMap { (arg) in 123 | return [ 124 | try arg.type(for: .entity), 125 | try arg.type(for: .json) 126 | ] 127 | } 128 | 129 | return TSCallExpr( 130 | callee: callee, 131 | genericArgs: callGenericArgs, 132 | args: args 133 | ) 134 | } 135 | 136 | public func callEncode( 137 | callee: any TSExpr, 138 | genericArgs: [any SType], 139 | entity: any TSExpr 140 | ) throws -> any TSExpr { 141 | let genericArgs = try genericArgs.map { 142 | try converter(for: $0) 143 | } 144 | 145 | var args: [any TSExpr] = [entity] 146 | 147 | args += try genericArgs.map { (arg) in 148 | return try arg.boundEncode() 149 | } 150 | 151 | let callGenericArgs: [any TSType] = try genericArgs.flatMap { (arg) in 152 | return [ 153 | try arg.type(for: .entity), 154 | try arg.type(for: .json) 155 | ] 156 | } 157 | 158 | return TSCallExpr( 159 | callee: callee, 160 | genericArgs: callGenericArgs, 161 | args: args 162 | ) 163 | } 164 | 165 | public func tagRecord( 166 | name: String, 167 | genericArgs: [any TSType] 168 | ) throws -> TSIdentType { 169 | var recordArgs: [any TSType] = [ 170 | TSStringLiteralType(name) 171 | ] 172 | 173 | if !genericArgs.isEmpty { 174 | recordArgs.append(TSTupleType(genericArgs)) 175 | } 176 | 177 | return TSIdentType( 178 | "TagRecord", 179 | genericArgs: recordArgs 180 | ) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/TypeConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public protocol TypeConverter { 5 | var generator: CodeGenerator { get } 6 | var swiftType: any SType { get } 7 | func name(for target: GenerationTarget) throws -> String 8 | func hasJSONType() throws -> Bool 9 | func type(for target: GenerationTarget) throws -> any TSType 10 | func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) 11 | func valueToField(value: any TSExpr, for target: GenerationTarget) throws -> any TSExpr 12 | func fieldToValue(field: any TSExpr, for target: GenerationTarget) throws -> any TSExpr 13 | func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? 14 | func hasDecode() throws -> Bool 15 | func decodeName() throws -> String 16 | func boundDecode() throws -> any TSExpr 17 | func callDecode(json: any TSExpr) throws -> any TSExpr 18 | func callDecodeField(json: any TSExpr) throws -> any TSExpr 19 | func decodeSignature() throws -> TSFunctionDecl? 20 | func decodeDecl() throws -> TSFunctionDecl? 21 | func hasEncode() throws -> Bool 22 | func encodeName() throws -> String 23 | func boundEncode() throws -> any TSExpr 24 | func callEncode(entity: any TSExpr) throws -> any TSExpr 25 | func callEncodeField(entity: any TSExpr) throws -> any TSExpr 26 | func encodeSignature() throws -> TSFunctionDecl? 27 | func encodeDecl() throws -> TSFunctionDecl? 28 | func ownDecls() throws -> TypeOwnDeclarations 29 | func decls() throws -> [any TSDecl] 30 | } 31 | 32 | extension TypeConverter { 33 | // MARK: - defaults 34 | public var `default`: DefaultTypeConverter { 35 | return DefaultTypeConverter(generator: generator, type: swiftType) 36 | } 37 | 38 | public func name(for target: GenerationTarget) throws -> String { 39 | return try `default`.name(for: target) 40 | } 41 | 42 | public func hasJSONType() throws -> Bool { 43 | return try `default`.hasJSONType() 44 | } 45 | 46 | public func type(for target: GenerationTarget) throws -> any TSType { 47 | return try `default`.type(for: target) 48 | } 49 | 50 | public func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) { 51 | return try `default`.fieldType(for: target) 52 | } 53 | 54 | public func valueToField(value: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 55 | return try `default`.valueToField(value: value, for: target) 56 | } 57 | 58 | public func fieldToValue(field: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 59 | return try `default`.fieldToValue(field: field, for: target) 60 | } 61 | 62 | public func decodeName() throws -> String { 63 | return try `default`.decodeName() 64 | } 65 | 66 | public func boundDecode() throws -> any TSExpr { 67 | return try `default`.boundDecode() 68 | } 69 | 70 | public func callDecode(json: any TSExpr) throws -> any TSExpr { 71 | return try `default`.callDecode(json: json) 72 | } 73 | 74 | public func callDecodeField(json: any TSExpr) throws -> any TSExpr { 75 | return try `default`.callDecodeField(json: json) 76 | } 77 | 78 | public func decodeSignature() throws -> TSFunctionDecl? { 79 | return try `default`.decodeSignature() 80 | } 81 | 82 | public func encodeName() throws -> String { 83 | return try `default`.encodeName() 84 | } 85 | 86 | public func boundEncode() throws -> any TSExpr { 87 | return try `default`.boundEncode() 88 | } 89 | 90 | public func callEncode(entity: any TSExpr) throws -> any TSExpr { 91 | return try `default`.callEncode(entity: entity) 92 | } 93 | 94 | public func callEncodeField(entity: any TSExpr) throws -> any TSExpr { 95 | return try `default`.callEncodeField(entity: entity) 96 | } 97 | 98 | public func encodeSignature() throws -> TSFunctionDecl? { 99 | return try `default`.encodeSignature() 100 | } 101 | 102 | // MARK: - extensions 103 | public func genericArgs() throws -> [any TypeConverter] { 104 | return try swiftType.tsGenericArgs.map { (type) in 105 | try generator.converter(for: type) 106 | } 107 | } 108 | 109 | public func genericParams() throws -> [any TypeConverter] { 110 | return try genericParams(stype: self.swiftType) 111 | } 112 | 113 | private func genericParams(stype: any SType) throws -> [any TypeConverter] { 114 | let parentParams = if let parent = stype.typeDecl?.parentContext, 115 | let parentType = parent.selfInterfaceType { 116 | try genericParams(stype: parentType) 117 | } else { 118 | [] as [any TypeConverter] 119 | } 120 | 121 | guard let decl = stype.typeDecl, 122 | let genericContext = decl as? any GenericContext else 123 | { 124 | return parentParams 125 | } 126 | return parentParams + (try genericContext.genericParams.items.map { (param) in 127 | try generator.converter(for: param.declaredInterfaceType) 128 | }) 129 | } 130 | 131 | public func ownDecls() throws -> TypeOwnDeclarations { 132 | return TypeOwnDeclarations( 133 | entityType: try typeDecl(for: .entity), 134 | jsonType: try typeDecl(for: .json), 135 | decodeFunction: try decodeDecl(), 136 | encodeFunction: try encodeDecl() 137 | ) 138 | } 139 | 140 | public func decls() throws -> [any TSDecl] { 141 | var decls: [any TSDecl] = [] 142 | 143 | if let typeDecl = swiftType.typeDecl { 144 | try withErrorCollector { collect in 145 | typeDecl.walkTypeDecls { (type) in 146 | if let converter = try? generator.converter(for: type.declaredInterfaceType) { 147 | collect(at: "\(type.declaredInterfaceType)") { 148 | decls += try converter.ownDecls().decls 149 | } 150 | } 151 | 152 | return true 153 | } 154 | } 155 | } 156 | 157 | return decls 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Extensions/STypeEx.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | 3 | extension TypeDecl { 4 | internal func namePath() -> NamePath { 5 | return declaredInterfaceType.namePath() 6 | } 7 | 8 | public func walkTypeDecls(_ body: (any TypeDecl) throws -> Bool) rethrows { 9 | guard try body(self) else { return } 10 | 11 | let types: [any GenericTypeDecl] 12 | switch self { 13 | case let decl as any NominalTypeDecl: 14 | types = decl.types 15 | case let decl as Module: 16 | types = decl.types 17 | default: 18 | return 19 | } 20 | 21 | for type in types { 22 | try type.walkTypeDecls(body) 23 | } 24 | } 25 | 26 | public var typeName: String? { 27 | switch self { 28 | case let decl as any NominalTypeDecl: return decl.name 29 | case let decl as TypeAliasDecl: return decl.name 30 | case let decl as Module: return decl.name 31 | default: return nil 32 | } 33 | } 34 | } 35 | 36 | extension NominalTypeDecl { 37 | public func isStandardLibraryType(_ name: String) -> Bool { 38 | return moduleContext.name == "Swift" && 39 | self.name == name 40 | } 41 | 42 | public func isStandardLibraryType(_ regex: some RegexComponent) -> Bool { 43 | return moduleContext.name == "Swift" && 44 | !self.name.matches(of: regex).isEmpty 45 | } 46 | } 47 | 48 | extension EnumType { 49 | public func rawValueType() -> (any SType)? { 50 | for type in decl.inheritedTypes { 51 | if type.isStandardLibraryType(/^(U?Int(8|16|32|64)?|Bool|String)$/) { return type } 52 | } 53 | 54 | return nil 55 | } 56 | } 57 | 58 | extension StructType { 59 | public func rawValueType(requiresTransferringRawValueType: Bool = true) -> (any SType)? { 60 | guard decl.inheritedTypes.contains(where: { (t) in t.asProtocol?.name == "RawRepresentable" }) else { 61 | return nil 62 | } 63 | 64 | let rawValueType: (any SType)? 65 | if let alias = decl.findType(name: "RawValue")?.asTypeAlias { 66 | rawValueType = alias.underlyingType 67 | } else if let property = decl.find(name: "rawValue")?.asVar { 68 | rawValueType = property.interfaceType 69 | } else { 70 | rawValueType = nil 71 | } 72 | guard let rawValueType else { return nil } 73 | 74 | if !requiresTransferringRawValueType || rawValueType.isTransferringRawValueType() { 75 | let map = contextSubstitutionMap() 76 | let resolved = rawValueType.subst(map: map) 77 | return resolved 78 | } 79 | 80 | return nil 81 | } 82 | } 83 | 84 | extension SType { 85 | internal var tsGenericArgs: [any SType] { 86 | switch self { 87 | case let type as any NominalType: 88 | if let parent = type.parent { 89 | return parent.tsGenericArgs + type.genericArgs 90 | } 91 | return type.genericArgs 92 | case let type as TypeAliasType: 93 | if let parent = type.parent { 94 | return parent.tsGenericArgs + type.genericArgs 95 | } 96 | return type.genericArgs 97 | case let type as ErrorType: 98 | guard let repr = type.repr as? IdentTypeRepr, 99 | let element = repr.elements.last, 100 | let context = type.context else 101 | { 102 | return [] 103 | } 104 | return element.genericArgs.map { $0.resolve(from: context) } 105 | default: return [] 106 | } 107 | } 108 | 109 | internal func namePath() -> NamePath { 110 | let repr = toTypeRepr(containsModule: false) 111 | 112 | if let ident = repr.asIdent { 113 | return NamePath( 114 | ident.elements.map { $0.name } 115 | ) 116 | } else { 117 | return NamePath([repr.description]) 118 | } 119 | } 120 | 121 | internal func unwrapOptional(limit: Int?) -> (wrapped: any SType, depth: Int)? { 122 | var type: any SType = self 123 | var depth = 0 124 | while type.isStandardLibraryType("Optional"), 125 | let optional = type.asEnum, 126 | let wrapped = optional.genericArgs[safe: 0] 127 | { 128 | if let limit = limit, 129 | depth >= limit 130 | { 131 | break 132 | } 133 | 134 | type = wrapped 135 | depth += 1 136 | } 137 | 138 | if depth == 0 { return nil } 139 | return (wrapped: type, depth: depth) 140 | } 141 | 142 | internal func asArray() -> (array: StructType, element: any SType)? { 143 | guard isStandardLibraryType("Array"), 144 | let array = self.asStruct, 145 | let element = array.genericArgs[safe: 0] else { return nil } 146 | return (array: array, element: element) 147 | } 148 | 149 | internal func asSet() -> (set: StructType, element: any SType)? { 150 | guard isStandardLibraryType("Set"), 151 | let `set` = self.asStruct, 152 | let element = `set`.genericArgs[safe: 0] else { return nil } 153 | return (set: `set`, element: element) 154 | } 155 | 156 | internal func asDictionary() -> (dictionary: StructType, value: any SType)? { 157 | guard isStandardLibraryType("Dictionary"), 158 | let dict = self.asStruct, 159 | let value = dict.genericArgs[safe: 1] else { return nil } 160 | return (dictionary: dict, value: value) 161 | } 162 | 163 | public func isStandardLibraryType(_ name: String) -> Bool { 164 | guard let self = self.asNominal else { return false } 165 | return self.nominalTypeDecl.isStandardLibraryType(name) 166 | } 167 | 168 | public func isStandardLibraryType(_ regex: some RegexComponent) -> Bool { 169 | guard let self = self.asNominal else { return false } 170 | return self.nominalTypeDecl.isStandardLibraryType(regex) 171 | } 172 | 173 | public func isTransferringRawValueType() -> Bool { 174 | isStandardLibraryType(/^(U?Int(8|16|32|64)?|Bool|String|Double|Float)$/) 175 | } 176 | } 177 | 178 | extension CaseParamDecl { 179 | var index: Int { 180 | if let caseElement = parentContext?.asEnumCaseElement { 181 | return caseElement.associatedValues.firstIndex(of: self)! 182 | } 183 | fatalError() 184 | } 185 | 186 | var codableLabel: String { 187 | if let name = self.name { 188 | return name 189 | } 190 | return "_\(index)" 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/StructConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | public struct StructConverter: TypeConverter { 5 | public init(generator: CodeGenerator, `struct`: StructType) { 6 | self.generator = generator 7 | self.`struct` = `struct` 8 | } 9 | 10 | public var generator: CodeGenerator 11 | public var `struct`: StructType 12 | public var swiftType: any SType { `struct` } 13 | 14 | private var decl: StructDecl { `struct`.decl } 15 | 16 | public func typeDecl(for target: GenerationTarget) throws -> TSTypeDecl? { 17 | switch target { 18 | case .entity: break 19 | case .json: 20 | guard try hasJSONType() else { return nil } 21 | } 22 | 23 | var fields: [TSObjectType.Field] = [] 24 | 25 | try withErrorCollector { collect in 26 | for property in decl.storedProperties.instances { 27 | collect(at: "\(property.name)") { 28 | let (type, isOptional) = try generator.converter(for: property.interfaceType) 29 | .fieldType(for: target) 30 | fields.append( 31 | .field( 32 | name: property.name, 33 | isOptional: isOptional, 34 | type: type 35 | ) 36 | ) 37 | } 38 | } 39 | } 40 | 41 | let name = try self.name(for: target) 42 | let genericParams = try genericParams() 43 | 44 | var type: any TSType = TSObjectType(fields) 45 | switch target { 46 | case .entity: 47 | let tag = try generator.tagRecord( 48 | name: name, 49 | genericArgs: try genericParams.map { try $0.type(for: .entity) } 50 | ) 51 | type = TSIntersectionType(type, tag) 52 | case .json: break 53 | } 54 | 55 | return TSTypeDecl( 56 | modifiers: [.export], 57 | name: name, 58 | genericParams: try genericParams.map { 59 | .init(try $0.name(for: target)) 60 | }, 61 | type: type 62 | ) 63 | } 64 | 65 | public func hasDecode() throws -> Bool { 66 | let map = `struct`.contextSubstitutionMap() 67 | 68 | var result = false 69 | try withErrorCollector { collect in 70 | for p in decl.storedProperties.instances { 71 | result = result || collect(at: "\(p.name)") { 72 | let converter = try generator.converter(for: p.interfaceType.subst(map: map)) 73 | return try converter.hasDecode() 74 | } ?? false 75 | } 76 | } 77 | return result 78 | } 79 | 80 | public func decodeDecl() throws -> TSFunctionDecl? { 81 | guard let function = try decodeSignature() else { return nil } 82 | 83 | var nameProvider = NameProvider() 84 | nameProvider.register(signature: function) 85 | var varNames: [String: String] = [:] 86 | 87 | try withErrorCollector { collect in 88 | for field in decl.storedProperties.instances { 89 | var expr: any TSExpr = TSMemberExpr( 90 | base: TSIdentExpr.json, 91 | name: field.name 92 | ) 93 | collect(at: "\(field.name)") { 94 | expr = try generator.converter(for: field.interfaceType) 95 | .callDecodeField(json: expr) 96 | } 97 | 98 | let varName = nameProvider.provide(base: TSKeyword.escaped(field.name)) 99 | varNames[field.name] = varName 100 | 101 | let def = TSVarDecl( 102 | kind: .const, name: varName, 103 | initializer: expr 104 | ) 105 | 106 | function.body.elements.append(def) 107 | } 108 | } 109 | 110 | var fields: [TSObjectExpr.Field] = [] 111 | for field in decl.storedProperties.instances { 112 | let varName = try varNames[field.name].unwrap(name: "var name") 113 | let expr = TSIdentExpr(varName) 114 | 115 | fields.append( 116 | .named( 117 | name: field.name, 118 | value: expr 119 | ) 120 | ) 121 | } 122 | 123 | function.body.elements.append( 124 | TSReturnStmt(TSObjectExpr(fields)) 125 | ) 126 | 127 | return function 128 | } 129 | 130 | public func hasEncode() throws -> Bool { 131 | let map = `struct`.contextSubstitutionMap() 132 | 133 | var result = false 134 | try withErrorCollector { collect in 135 | for p in decl.storedProperties.instances { 136 | result = result || collect(at: "\(p.name)") { 137 | let converter = try generator.converter(for: p.interfaceType.subst(map: map)) 138 | return try converter.hasEncode() 139 | } ?? false 140 | } 141 | } 142 | return result 143 | } 144 | 145 | public func encodeDecl() throws -> TSFunctionDecl? { 146 | guard let function = try encodeSignature() else { return nil } 147 | 148 | var nameProvider = NameProvider() 149 | nameProvider.register(signature: function) 150 | var varNames: [String: String] = [:] 151 | 152 | for field in decl.storedProperties.instances { 153 | var expr: any TSExpr = TSMemberExpr( 154 | base: TSIdentExpr.entity, 155 | name: field.name 156 | ) 157 | 158 | expr = try generator.converter(for: field.interfaceType) 159 | .callEncodeField(entity: expr) 160 | 161 | let varName = nameProvider.provide(base: TSKeyword.escaped(field.name)) 162 | varNames[field.name] = varName 163 | 164 | let def = TSVarDecl( 165 | kind: .const, name: varName, 166 | initializer: expr 167 | ) 168 | 169 | function.body.elements.append(def) 170 | } 171 | 172 | var fields: [TSObjectExpr.Field] = [] 173 | for field in decl.storedProperties.instances { 174 | let varName = try varNames[field.name].unwrap(name: "var name") 175 | let expr = TSIdentExpr(varName) 176 | 177 | fields.append( 178 | .named( 179 | name: field.name, 180 | value: expr 181 | ) 182 | ) 183 | } 184 | 185 | function.body.elements.append( 186 | TSReturnStmt(TSObjectExpr(fields)) 187 | ) 188 | 189 | return function 190 | } 191 | 192 | } 193 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/Generator/PackageGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftTypeReader 3 | import TypeScriptAST 4 | 5 | #if !os(WASI) 6 | public final class PackageGenerator { 7 | public init( 8 | context: SwiftTypeReader.Context, 9 | fileManager: FileManager = .default, 10 | typeConverterProvider: TypeConverterProvider = TypeConverterProvider(), 11 | symbols: SymbolTable, 12 | importFileExtension: ImportFileExtension, 13 | outputDirectory: URL, 14 | typeScriptExtension: String = "ts", 15 | pathPrefixReplacements: PathPrefixReplacements = [] 16 | ) { 17 | self.context = context 18 | self.fileManager = fileManager 19 | self.codeGenerator = CodeGenerator( 20 | context: context, 21 | typeConverterProvider: typeConverterProvider 22 | ) 23 | self.symbols = symbols 24 | self.importFileExtension = importFileExtension 25 | self.outputDirectory = URL( 26 | fileURLWithPath: outputDirectory.path, 27 | isDirectory: true, relativeTo: outputDirectory.baseURL 28 | ) 29 | self.typeScriptExtension = typeScriptExtension 30 | self.pathPrefixReplacements = pathPrefixReplacements 31 | } 32 | 33 | public let context: SwiftTypeReader.Context 34 | public let fileManager: FileManager 35 | public let codeGenerator: CodeGenerator 36 | public let symbols: SymbolTable 37 | public let importFileExtension: ImportFileExtension 38 | public let outputDirectory: URL 39 | public let typeScriptExtension: String 40 | public let pathPrefixReplacements: PathPrefixReplacements 41 | @available(*, deprecated, renamed: "didConvertSource") 42 | public var didGenerateEntry: ((SourceFile, PackageEntry) throws -> Void)? { 43 | get { didConvertSource } 44 | set { didConvertSource = newValue } 45 | } 46 | public var didConvertSource: ((SourceFile, PackageEntry) throws -> Void)? 47 | 48 | public var didWrite: ((URL, Data) throws -> Void)? 49 | 50 | public struct GenerateResult { 51 | public var entries: [PackageEntry] 52 | public var symbols: SymbolTable 53 | } 54 | 55 | public func generate(modules: [Module]) throws -> GenerateResult { 56 | let helperEntry = PackageEntry( 57 | file: self.path("common.\(typeScriptExtension)"), 58 | source: codeGenerator.generateHelperLibrary() 59 | ) 60 | 61 | var symbolToSource: [String: SourceFile] = [:] 62 | var convertedSources: [SourceFile: TSSourceFile] = [:] 63 | 64 | // collect symbols included in for each swift source file 65 | for module in context.modules.filter({ $0 !== context.swiftModule }) { 66 | for source in module.sources { 67 | guard let tsSource = try? codeGenerator.convert(source: source) else { 68 | continue 69 | } 70 | convertedSources[source] = tsSource 71 | for declaredName in tsSource.memberDeclaredNames { 72 | symbolToSource[declaredName] = source 73 | } 74 | } 75 | } 76 | 77 | // convert collected symbols to SymbolTable for use of buildAutoImportDecls 78 | var allSymbols = self.symbols 79 | allSymbols.add(source: helperEntry.source, file: helperEntry.file) 80 | for (symbol, source) in symbolToSource { 81 | allSymbols.add( 82 | symbol: symbol, 83 | file: .file(try tsPath(module: source.module, file: source.file)) 84 | ) 85 | } 86 | 87 | var targetSources: [SourceFile] = modules.flatMap(\.sources) 88 | var generatedSources: Set = [] 89 | var generatedEntries: [PackageEntry] = [helperEntry] 90 | 91 | try withErrorCollector { collect in 92 | while let source = targetSources.popLast() { 93 | guard generatedSources.insert(source).inserted else { 94 | continue 95 | } 96 | 97 | collect(at: source.file.lastPathComponent) { 98 | let tsSource = try convertedSources[source] ?? (codeGenerator.convert(source: source)) 99 | 100 | let entry = PackageEntry( 101 | file: try tsPath(module: source.module, file: source.file), 102 | source: tsSource 103 | ) 104 | try didConvertSource?(source, entry) 105 | if entry.isEmpty { 106 | return 107 | } 108 | 109 | generatedEntries.append(.init( 110 | file: entry.file, 111 | source: entry.source 112 | )) 113 | 114 | let imports = try tsSource.buildAutoImportDecls( 115 | from: entry.file, 116 | symbolTable: allSymbols, 117 | fileExtension: importFileExtension, 118 | pathPrefixReplacements: pathPrefixReplacements 119 | ) 120 | tsSource.replaceImportDecls(imports) 121 | for importedSymbolName in imports.flatMap(\.names) { 122 | // Add a file that is used but not included in the generation target 123 | if let source = symbolToSource[importedSymbolName], !generatedSources.contains(source) { 124 | targetSources.append(source) 125 | } 126 | } 127 | } 128 | } 129 | } 130 | 131 | return GenerateResult( 132 | entries: generatedEntries, 133 | symbols: allSymbols 134 | ) 135 | } 136 | 137 | private func tsPath(module: Module, file: URL) throws -> URL { 138 | if file.baseURL == nil { 139 | throw MessageError("needs relative path: \(file.path)") 140 | } 141 | 142 | return self.path( 143 | module.name + "/" + 144 | URLs.replacingPathExtension(of: file, to: typeScriptExtension).relativePath 145 | ) 146 | } 147 | 148 | private func path(_ name: String) -> URL { 149 | return URL(fileURLWithPath: name, relativeTo: outputDirectory) 150 | } 151 | 152 | public func write( 153 | entry: PackageEntry 154 | ) throws { 155 | let path = entry.file 156 | let data = entry.serialize() 157 | 158 | if let old = try? Data(contentsOf: path), 159 | old == data 160 | { 161 | return 162 | } 163 | 164 | try fileManager.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) 165 | try data.write(to: path, options: .atomic) 166 | try didWrite?(path, data) 167 | } 168 | 169 | public func write( 170 | entries: [PackageEntry] 171 | ) throws { 172 | for entry in entries { 173 | try write(entry: entry) 174 | } 175 | } 176 | } 177 | #endif 178 | 179 | extension PackageEntry { 180 | fileprivate var isEmpty: Bool { 181 | source.elements.isEmpty 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/PackageGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftTypeReader 3 | import TypeScriptAST 4 | import CodableToTypeScript 5 | 6 | final class PackageGeneratorTests: XCTestCase { 7 | func testEmptyModule() throws { 8 | let context = Context() 9 | let module = Reader(context: context).read(source: """ 10 | protocol P { 11 | func f() 12 | } 13 | """, file: URL(fileURLWithPath: "A.swift")).module 14 | 15 | // case1: empty for C2TS 16 | let generator = PackageGenerator( 17 | context: context, 18 | symbols: SymbolTable(), 19 | importFileExtension: .js, 20 | outputDirectory: URL(fileURLWithPath: "/dev/null", isDirectory: true) 21 | ) 22 | let result = try generator.generate(modules: [module]) 23 | XCTAssertEqual(result.entries.count, 1) // helper library anytime generated 24 | 25 | // case2: empty for C2TS, but not for the user 26 | let expectation = self.expectation(description: "didConvertSource called") 27 | generator.didConvertSource = { source, entry in 28 | entry.source.elements.append(TSCustomDecl(text: "/* hello */")) 29 | expectation.fulfill() 30 | } 31 | let result2 = try generator.generate(modules: [module]) 32 | 33 | wait(for: [expectation], timeout: 3) 34 | XCTAssertEqual(result2.entries.count, 2) 35 | } 36 | 37 | func testDependentModule() throws { 38 | let context = Context() 39 | 40 | _ = Reader( 41 | context: context, 42 | module: context.getOrCreateModule(name: "A") 43 | ).read(source: """ 44 | struct A: Codable {} 45 | """, file: URL(fileURLWithPath: "A.swift")) 46 | 47 | _ = Reader( 48 | context: context, 49 | module: context.getOrCreateModule(name: "A") 50 | ).read(source: """ 51 | struct UnusedA: Codable {} 52 | """, file: URL(fileURLWithPath: "A+Unused.swift")) 53 | 54 | _ = Reader( 55 | context: context, 56 | module: context.getOrCreateModule(name: "B") 57 | ).read(source: """ 58 | import A 59 | 60 | struct B: Codable { 61 | var a: A 62 | } 63 | """, file: URL(fileURLWithPath: "B.swift")).module 64 | 65 | _ = Reader( 66 | context: context, 67 | module: context.getOrCreateModule(name: "C") 68 | ).read(source: """ 69 | struct NotTSConvertibleC: Codable { 70 | var a: UnknownType 71 | } 72 | """, file: URL(fileURLWithPath: "C.swift")) 73 | 74 | let dModule = Reader( 75 | context: context, 76 | module: context.getOrCreateModule(name: "D") 77 | ).read(source: """ 78 | import B 79 | 80 | struct D: Codable { 81 | var b: B 82 | } 83 | """, file: URL(fileURLWithPath: "D.swift")).module 84 | 85 | let generator = PackageGenerator( 86 | context: context, 87 | symbols: SymbolTable(), 88 | importFileExtension: .js, 89 | outputDirectory: URL(fileURLWithPath: "/dev/null", isDirectory: true) 90 | ) 91 | let result = try generator.generate(modules: [dModule]) 92 | let rootElements = result.entries.flatMap(\.source.elements) 93 | XCTAssertTrue(rootElements.contains(where: { element in 94 | return element.asDecl?.asType?.name == "A" 95 | })) 96 | XCTAssertFalse(rootElements.contains(where: { element in 97 | return element.asDecl?.asType?.name == "UnusedA" 98 | })) 99 | XCTAssertTrue(rootElements.contains(where: { element in 100 | return element.asDecl?.asType?.name == "B" 101 | })) 102 | XCTAssertFalse(rootElements.contains(where: { element in 103 | return element.asDecl?.asType?.name == "NotTSConvertibleC" 104 | })) 105 | XCTAssertTrue(rootElements.contains(where: { element in 106 | return element.asDecl?.asType?.name == "D" 107 | })) 108 | } 109 | 110 | func testCustomMappedTypeIngored() throws { 111 | let context = Context() 112 | let module = Reader(context: context).read(source: """ 113 | struct AbsoluteURL: Codable, RawRepresentable { 114 | var rawValue: String 115 | 116 | func encode(to encoder: any Encoder) throws { 117 | var c = encoder.singleValueContainer() 118 | try c.encode(rawValue) 119 | } 120 | 121 | init(from decoder: any Decoder) throws { 122 | let c = try decoder.singleValueContainer() 123 | self.rawValue = try c.decode(RawValue.self) 124 | } 125 | } 126 | """, file: URL(fileURLWithPath: "A.swift")).module 127 | 128 | let typeMap = TypeMap(mapFunction: { stype in 129 | if stype.description == "AbsoluteURL" { 130 | return .identity(name: "string") 131 | } 132 | return nil 133 | }) 134 | 135 | let generator = PackageGenerator( 136 | context: context, 137 | typeConverterProvider: TypeConverterProvider(typeMap: typeMap), 138 | symbols: SymbolTable(), 139 | importFileExtension: .js, 140 | outputDirectory: URL(fileURLWithPath: "/dev/null", isDirectory: true) 141 | ) 142 | let result = try generator.generate(modules: [module]) 143 | XCTAssertEqual(result.entries.count, 1) // helper library only 144 | let hasTSAbsoluteURL = result.entries.contains(where: { entry in 145 | entry.source.memberDeclaredNames.contains(where: { name in 146 | return name == "AbsoluteURL" 147 | }) 148 | }) 149 | XCTAssertFalse(hasTSAbsoluteURL) 150 | } 151 | 152 | func testGenerationsNotDuplicates() throws { 153 | let context = Context() 154 | 155 | let aModule = Reader( 156 | context: context, 157 | module: context.getOrCreateModule(name: "A") 158 | ).read(source: """ 159 | struct A: Codable {} 160 | """, file: URL(fileURLWithPath: "A.swift")).module 161 | 162 | let bModule = Reader( 163 | context: context, 164 | module: context.getOrCreateModule(name: "B") 165 | ).read(source: """ 166 | import A 167 | 168 | struct B: Codable { 169 | var a: A 170 | } 171 | """, file: URL(fileURLWithPath: "B.swift")).module 172 | 173 | let generator = PackageGenerator( 174 | context: context, 175 | symbols: SymbolTable(), 176 | importFileExtension: .js, 177 | outputDirectory: URL(fileURLWithPath: "/dev/null", isDirectory: true) 178 | ) 179 | 180 | var convertedEntries: [PackageEntry] = [] 181 | generator.didConvertSource = { (source, entry) in 182 | convertedEntries.append(entry) 183 | } 184 | _ = try generator.generate(modules: [aModule, bModule]) // the order is important! 185 | 186 | XCTAssertEqual(convertedEntries.map(\.file.lastPathComponent), [ 187 | "B.ts", 188 | "A.ts", 189 | ]) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateEnumTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | 4 | final class GenerateEnumTests: GenerateTestCaseBase { 5 | func testBasic1() throws { 6 | try assertGenerate( 7 | source: """ 8 | enum E { 9 | case a 10 | case b(Int) 11 | } 12 | """, 13 | expecteds: [""" 14 | export type E = ({ 15 | kind: "a"; 16 | a: {}; 17 | } | { 18 | kind: "b"; 19 | b: { 20 | _0: number; 21 | }; 22 | }) & TagRecord<"E">; 23 | """,""" 24 | export type E$JSON = { 25 | a: {}; 26 | } | { 27 | b: { 28 | _0: number; 29 | }; 30 | }; 31 | """,""" 32 | export function E_decode(json: E$JSON): E { 33 | if ("a" in json) { 34 | return { 35 | kind: "a", 36 | a: {} 37 | }; 38 | } else if ("b" in json) { 39 | const j = json.b; 40 | const _0 = j._0; 41 | return { 42 | kind: "b", 43 | b: { 44 | _0: _0 45 | } 46 | }; 47 | } else { 48 | throw new Error("unknown kind"); 49 | } 50 | } 51 | """ 52 | ] 53 | ) 54 | } 55 | 56 | func testBasic2() throws { 57 | try assertGenerate( 58 | source: """ 59 | enum E { 60 | case a(x: Int) 61 | case b(y: String, Int) 62 | } 63 | """, 64 | expecteds: [""" 65 | export type E = ({ 66 | kind: "a"; 67 | a: { 68 | x: number; 69 | }; 70 | } | { 71 | kind: "b"; 72 | b: { 73 | y: string; 74 | _1: number; 75 | }; 76 | }) & TagRecord<"E">; 77 | """, """ 78 | export type E$JSON = { 79 | a: { 80 | x: number; 81 | }; 82 | } | { 83 | b: { 84 | y: string; 85 | _1: number; 86 | }; 87 | } 88 | """] 89 | ) 90 | } 91 | 92 | func testSingleCase() throws { 93 | try assertGenerate( 94 | source: """ 95 | enum E { 96 | case a 97 | } 98 | """, 99 | expecteds: [""" 100 | export type E = { 101 | kind: "a"; 102 | a: {}; 103 | } & TagRecord<"E">; 104 | """] 105 | ) 106 | } 107 | 108 | func testOptional() throws { 109 | try assertGenerate( 110 | source: """ 111 | enum E { 112 | case a(Int, Int?, Int??, Int???) 113 | } 114 | """, 115 | expecteds: [""" 116 | export type E = { 117 | kind: "a"; 118 | a: { 119 | _0: number; 120 | _1?: number; 121 | _2?: number | null; 122 | _3?: number | null; 123 | }; 124 | } & TagRecord<"E">; 125 | """, """ 126 | export type E$JSON = { 127 | a: { 128 | _0: number; 129 | _1?: number; 130 | _2?: number | null; 131 | _3?: number | null; 132 | }; 133 | }; 134 | """, """ 135 | export function E_decode(json: E$JSON): E { 136 | if ("a" in json) { 137 | const j = json.a; 138 | const _0 = j._0; 139 | const _1 = j._1; 140 | const _2 = j._2; 141 | const _3 = j._3; 142 | return { 143 | kind: "a", 144 | a: { 145 | _0: _0, 146 | _1: _1, 147 | _2: _2, 148 | _3: _3 149 | } 150 | }; 151 | } else { 152 | throw new Error("unknown kind"); 153 | } 154 | } 155 | """] 156 | ) 157 | } 158 | 159 | func testArray() throws { 160 | try assertGenerate( 161 | source: """ 162 | enum E { 163 | case a([Int], [[Int]], [Int]?, [Int?]) 164 | } 165 | """, 166 | expecteds: [""" 167 | { 168 | a: { 169 | _0: number[]; 170 | _1: number[][]; 171 | _2?: number[]; 172 | _3: (number | null)[]; 173 | }; 174 | } 175 | """] 176 | ) 177 | } 178 | 179 | func testDictionary() throws { 180 | try assertGenerate( 181 | source: """ 182 | enum E { 183 | case a([String: Int], [String: Int?]) 184 | } 185 | """, 186 | expecteds: [ 187 | """ 188 | { 189 | a: { 190 | _0: { 191 | [key: string]: number; 192 | }; 193 | _1: { 194 | [key: string]: number | null; 195 | }; 196 | }; 197 | } 198 | """] 199 | ) 200 | } 201 | 202 | func testStringRawValueCase() throws { 203 | try assertGenerate( 204 | source: """ 205 | enum E: String { 206 | case a 207 | case b 208 | } 209 | """, 210 | expecteds: [""" 211 | export type E = "a" | "b" 212 | """] 213 | ) 214 | 215 | try assertGenerate( 216 | source: """ 217 | enum E: String { 218 | case a 219 | case b 220 | case c 221 | } 222 | """, 223 | expecteds: [""" 224 | export type E = "a" | "b" | "c" 225 | """] 226 | ) 227 | 228 | try assertGenerate( 229 | source: """ 230 | enum E: String { 231 | case a 232 | case b 233 | case c 234 | case d 235 | } 236 | """, 237 | expecteds: [""" 238 | export type E = "a" | 239 | "b" | 240 | "c" | 241 | "d" 242 | """] 243 | ) 244 | } 245 | 246 | func testIntRawValueCase() throws { 247 | try assertGenerate( 248 | source: """ 249 | enum E: Int, Codable, Sendable { 250 | case a 251 | case b = -100 252 | case c 253 | } 254 | """, 255 | expecteds: [""" 256 | export type E = "a" | "b" | "c"; 257 | """, """ 258 | export type E$JSON = 0 | -100 | -99; 259 | """, """ 260 | export function E_decode(json: E$JSON): E { 261 | switch (json) { 262 | case 0: 263 | return "a"; 264 | case -100: 265 | return "b"; 266 | case -99: 267 | return "c"; 268 | } 269 | } 270 | """, """ 271 | export function E_encode(entity: E): E$JSON { 272 | switch (entity) { 273 | case "a": 274 | return 0; 275 | case "b": 276 | return -100; 277 | case "c": 278 | return -99; 279 | } 280 | } 281 | """] 282 | ) 283 | } 284 | 285 | func testAssociatedValueDecode() throws { 286 | try assertGenerate( 287 | source: """ 288 | enum K { 289 | case a 290 | } 291 | 292 | struct S { 293 | var k: K 294 | } 295 | 296 | struct C {} 297 | 298 | enum E { 299 | case k(K) 300 | case s(S) 301 | case c(C) 302 | } 303 | """, 304 | typeSelector: .name("E"), 305 | expecteds: [""" 306 | export function E_decode(json: E$JSON): E { 307 | if ("k" in json) { 308 | const j = json.k; 309 | const _0 = K_decode(j._0); 310 | return { 311 | kind: "k", 312 | k: { 313 | _0: _0 314 | } 315 | }; 316 | } else if ("s" in json) { 317 | const j = json.s; 318 | const _0 = S_decode(j._0); 319 | return { 320 | kind: "s", 321 | s: { 322 | _0: _0 323 | } 324 | }; 325 | } else if ("c" in json) { 326 | const j = json.c; 327 | const _0 = j._0; 328 | return { 329 | kind: "c", 330 | c: { 331 | _0: _0 332 | } 333 | }; 334 | } else { 335 | throw new Error("unknown kind"); 336 | } 337 | } 338 | """ 339 | ] 340 | ) 341 | } 342 | 343 | func testConflictPropertyName() throws { 344 | try assertGenerate( 345 | source: """ 346 | enum E { 347 | case entity(entity: String, json: String, e: String, j: String) 348 | case json 349 | case t(T) 350 | } 351 | """, 352 | expecteds: [ 353 | // decode 354 | """ 355 | const json2 = j.json; 356 | """, """ 357 | const j2 = j.j; 358 | """, """ 359 | json: json2 360 | """, """ 361 | j: j2 362 | """, 363 | 364 | // encode 365 | """ 366 | const entity2 = e.entity; 367 | """, """ 368 | const e2 = e.e; 369 | """, """ 370 | entity: entity2, 371 | """, """ 372 | e: e2 373 | """ 374 | ], 375 | unexpecteds: [] 376 | ) 377 | } 378 | 379 | func testEmptyEnumNever() throws { 380 | try assertGenerate( 381 | source: """ 382 | enum E {} 383 | """, 384 | expecteds: [ 385 | """ 386 | export type E = never; 387 | """ 388 | ] 389 | ) 390 | } 391 | 392 | func testEmptyEnumVoid() throws { 393 | let typeConverterProvider = TypeConverterProvider { (gen, ty) in 394 | guard let cnv = try TypeConverterProvider.defaultConverter(generator: gen, type: ty) else { 395 | return nil 396 | } 397 | 398 | if let cnv = cnv as? EnumConverter { 399 | return EnumConverter(generator: gen, enum: cnv.enum, emptyEnumStrategy: .void) 400 | } 401 | 402 | return nil 403 | } 404 | 405 | try assertGenerate( 406 | source: """ 407 | enum E {} 408 | """, 409 | typeConverterProvider: typeConverterProvider, 410 | expecteds: [ 411 | """ 412 | export type E = void & TagRecord<"E">; 413 | """ 414 | ] 415 | ) 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateCustomTypeMapTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | import SwiftTypeReader 4 | import TypeScriptAST 5 | 6 | final class GenerateCustomTypeMapTests: GenerateTestCaseBase { 7 | func testCustomName() throws { 8 | var typeMap = TypeMap.default 9 | typeMap.table["URL"] = .identity(name: "string") 10 | 11 | try assertGenerate( 12 | source: """ 13 | struct S { 14 | var a: URL 15 | var b: [URL] 16 | var c: [[URL]] 17 | } 18 | """, 19 | typeMap: typeMap, 20 | expecteds: [""" 21 | export type S = { 22 | a: string; 23 | b: string[]; 24 | c: string[][]; 25 | } & TagRecord<"S">; 26 | """ 27 | ] 28 | ) 29 | } 30 | 31 | func testCustomDecodeSimple() throws { 32 | var typeMap = TypeMap.default 33 | typeMap.table["Date"] = .coding( 34 | entityType: "Date", jsonType: "string", 35 | decode: "Date_decode", encode: nil 36 | ) 37 | 38 | try assertGenerate( 39 | source: """ 40 | struct S { 41 | var a: Date 42 | } 43 | """, 44 | typeMap: typeMap, 45 | externalReference: ExternalReference( 46 | symbols: ["Date_decode"], 47 | code: """ 48 | export function Date_decode(json: string): Date { throw 0; } 49 | """ 50 | ), 51 | expecteds: [""" 52 | export type S = { 53 | a: Date; 54 | } & TagRecord<"S">; 55 | """, """ 56 | export type S$JSON = { 57 | a: string; 58 | }; 59 | """, """ 60 | export function S_decode(json: S$JSON): S { 61 | const a = Date_decode(json.a); 62 | return { 63 | a: a 64 | }; 65 | } 66 | """ 67 | ], 68 | unexpecteds: [""" 69 | export function S_encode 70 | """] 71 | ) 72 | } 73 | 74 | func testCustomDecodeComplex() throws { 75 | var typeMap = TypeMap.default 76 | typeMap.table["Date"] = .coding( 77 | entityType: "Date", jsonType: "string", 78 | decode: "Date_decode", encode: nil 79 | ) 80 | 81 | try assertGenerate( 82 | source: """ 83 | struct S { 84 | var a: Date 85 | var b: [Date] 86 | var c: [[Date]] 87 | } 88 | """, 89 | typeMap: typeMap, 90 | externalReference: ExternalReference( 91 | symbols: ["Date_decode"], 92 | code: """ 93 | export function Date_decode(json: string): Date { throw 0; } 94 | """ 95 | ), 96 | expecteds: [""" 97 | export type S = { 98 | a: Date; 99 | b: Date[]; 100 | c: Date[][]; 101 | } & TagRecord<"S">; 102 | """, """ 103 | export type S$JSON = { 104 | a: string; 105 | b: string[]; 106 | c: string[][]; 107 | }; 108 | """, """ 109 | export function S_decode(json: S$JSON): S { 110 | const a = Date_decode(json.a); 111 | const b = Array_decode(json.b, Date_decode); 112 | const c = Array_decode(json.c, (json: string[]): Date[] => { 113 | return Array_decode(json, Date_decode); 114 | }); 115 | return { 116 | a: a, 117 | b: b, 118 | c: c 119 | }; 120 | } 121 | """ 122 | ] 123 | ) 124 | } 125 | 126 | func testCustomEncode() throws { 127 | var typeMap = TypeMap.default 128 | typeMap.table["Date"] = .coding( 129 | entityType: "Date", jsonType: "string", 130 | decode: nil, encode: "Date_encode" 131 | ) 132 | 133 | try assertGenerate( 134 | source: """ 135 | struct S { 136 | var a: Date 137 | } 138 | """, 139 | typeMap: typeMap, 140 | externalReference: ExternalReference( 141 | symbols: ["Date_encode"], 142 | code: """ 143 | export function Date_encode(date: Date): string { throw 0; } 144 | """ 145 | ), 146 | expecteds: [""" 147 | export type S = { 148 | a: Date; 149 | } & TagRecord<"S">; 150 | """, """ 151 | export type S$JSON = { 152 | a: string; 153 | }; 154 | """, """ 155 | export function S_encode(entity: S): S$JSON { 156 | const a = Date_encode(entity.a); 157 | return { 158 | a: a 159 | }; 160 | } 161 | """ 162 | ], 163 | unexpecteds: [""" 164 | export function S_decode 165 | """] 166 | ) 167 | } 168 | 169 | func testCustomCoding() throws { 170 | var typeMap = TypeMap.default 171 | typeMap.table["Date"] = .coding( 172 | entityType: "Date", jsonType: "string", 173 | decode: "Date_decode", encode: "Date_encode" 174 | ) 175 | 176 | try assertGenerate( 177 | source: """ 178 | struct S { 179 | var a: Date 180 | } 181 | """, 182 | typeMap: typeMap, 183 | externalReference: ExternalReference( 184 | symbols: ["Date_decode", "Date_encode"], 185 | code: """ 186 | export function Date_decode(json: string): Date { throw 0; } 187 | export function Date_encode(date: Date): string { throw 0; } 188 | """ 189 | ), 190 | expecteds: [""" 191 | import { Date_decode, Date_encode, TagRecord } 192 | """, """ 193 | export type S = { 194 | a: Date; 195 | } & TagRecord<"S">; 196 | """, """ 197 | export type S$JSON = { 198 | a: string; 199 | }; 200 | """, """ 201 | export function S_decode(json: S$JSON): S { 202 | const a = Date_decode(json.a); 203 | return { 204 | a: a 205 | }; 206 | } 207 | """, """ 208 | export function S_encode(entity: S): S$JSON { 209 | const a = Date_encode(entity.a); 210 | return { 211 | a: a 212 | }; 213 | } 214 | """ 215 | ] 216 | ) 217 | } 218 | 219 | func testCustomGenericDecode() throws { 220 | var typeMap = TypeMap.default 221 | typeMap.table["Date"] = .coding( 222 | entityType: "Date", jsonType: "string", 223 | decode: "Date_decode", encode: "Date_encode" 224 | ) 225 | typeMap.table["Vector2"] = .coding( 226 | entityType: "Vector2", jsonType: "Vector2$JSON", 227 | decode: "Vector2_decode", encode: "Vector2_encode" 228 | ) 229 | 230 | try assertGenerate( 231 | source: """ 232 | struct S { 233 | var a: Vector2 234 | var b: Vector2 235 | var c: [Vector2>] 236 | } 237 | """, 238 | typeMap: typeMap, 239 | externalReference: ExternalReference( 240 | symbols: [ 241 | "Date_decode", "Date_encode", 242 | "Vector2", "Vector2$JSON", 243 | "Vector2_decode", "Vector2_encode" 244 | ], 245 | code: """ 246 | export function Date_decode(json: string): Date { throw 0; } 247 | export function Date_encode(date: Date): string { throw 0; } 248 | export type Vector2 = {}; 249 | export type Vector2$JSON = string; 250 | export function Vector2_decode(json: Vector2$JSON, t: (j: TJ) => T): Vector2 { throw 0; } 251 | export function Vector2_encode(date: Vector2, t: (e: T) => TJ): Vector2$JSON { throw 0; } 252 | """ 253 | ), 254 | expecteds: [""" 255 | export type S = { 256 | a: Vector2; 257 | b: Vector2; 258 | c: Vector2>[]; 259 | } & TagRecord<"S">; 260 | """, """ 261 | export type S$JSON = { 262 | a: Vector2$JSON; 263 | b: Vector2$JSON; 264 | c: Vector2$JSON>[]; 265 | }; 266 | """, """ 267 | export function S_decode(json: S$JSON): S { 268 | const a = Vector2_decode(json.a, identity); 269 | const b = Vector2_decode(json.b, Date_decode); 270 | const c = Array_decode>, Vector2$JSON>>(json.c, (json: Vector2$JSON>): Vector2> => { 271 | return Vector2_decode, Vector2$JSON>(json, (json: Vector2$JSON): Vector2 => { 272 | return Vector2_decode(json, identity); 273 | }); 274 | }); 275 | return { 276 | a: a, 277 | b: b, 278 | c: c 279 | }; 280 | } 281 | """ 282 | ] 283 | ) 284 | } 285 | 286 | func testCustomIDDecode() throws { 287 | var typeMap = TypeMap.default 288 | typeMap.mapFunction = { (type) in 289 | let repr = type.toTypeRepr(containsModule: false) 290 | guard let repr = repr.asIdent, 291 | let element = repr.elements.last else { return nil } 292 | if element.name.hasSuffix("ID") { 293 | return .identity(name: "string") 294 | } 295 | return nil 296 | } 297 | 298 | try assertGenerate( 299 | source: """ 300 | struct S { 301 | var a: UserID 302 | var b: [UserID] 303 | var c: [[UserID]] 304 | } 305 | """, 306 | typeMap: typeMap, 307 | expecteds: [""" 308 | export type S = { 309 | a: string; 310 | b: string[]; 311 | c: string[][]; 312 | } & TagRecord<"S">; 313 | """ 314 | ] 315 | ) 316 | } 317 | 318 | func testMapUserType() throws { 319 | var typeMap = TypeMap() 320 | typeMap.table["S"] = .identity(name: "V") 321 | try assertGenerate( 322 | source: """ 323 | struct S { 324 | var a: Int 325 | } 326 | """, 327 | typeMap: typeMap, 328 | unexpecteds: [""" 329 | export type S 330 | """ 331 | ] 332 | ) 333 | } 334 | 335 | func testMapUserTypeCodec() throws { 336 | var typeMap = TypeMap() 337 | typeMap.table["S"] = .coding( 338 | entityType: "V", jsonType: "V$JSON", 339 | decode: "V_decode", encode: "V_encode" 340 | ) 341 | try assertGenerate( 342 | source: """ 343 | struct S { 344 | var a: Int 345 | } 346 | """, 347 | typeMap: typeMap, 348 | unexpecteds: [""" 349 | export type S 350 | """ 351 | ] 352 | ) 353 | } 354 | 355 | func testMapNestedUserType() throws { 356 | var typeMap = TypeMap() 357 | typeMap.table["K"] = .identity(name: "V") 358 | try assertGenerate( 359 | source: """ 360 | struct S { 361 | struct K { 362 | } 363 | var a: Int 364 | } 365 | """, 366 | typeMap: typeMap, 367 | expecteds: [""" 368 | export type S 369 | """], 370 | unexpecteds: [""" 371 | export type S_K 372 | """] 373 | ) 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /Tests/CodableToTypeScriptTests/Generate/GenerateStructTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CodableToTypeScript 3 | 4 | final class GenerateStructTests: GenerateTestCaseBase { 5 | func testSimple() throws { 6 | try assertGenerate( 7 | source: """ 8 | struct S { 9 | var a: Int 10 | var b: String 11 | } 12 | """, 13 | expecteds: [""" 14 | export type S = { 15 | a: number; 16 | b: string; 17 | } & TagRecord<"S">; 18 | """ 19 | ], 20 | unexpecteds: [""" 21 | export function S_decode 22 | """] 23 | ) 24 | } 25 | 26 | func testEmptyDecode() throws { 27 | try assertGenerate( 28 | source: """ 29 | struct K { 30 | var a: Int 31 | } 32 | struct S { 33 | var k: K 34 | } 35 | """, 36 | typeSelector: .name("S") 37 | ) 38 | } 39 | 40 | func testEnumInStruct() throws { 41 | try assertGenerate( 42 | source: """ 43 | enum E { 44 | case a 45 | } 46 | 47 | struct S { 48 | var a: Int 49 | var b: E 50 | } 51 | """, 52 | typeSelector: .name("S"), 53 | expecteds: [""" 54 | export type S = { 55 | a: number; 56 | b: E; 57 | } & TagRecord<"S">; 58 | """, """ 59 | export type S$JSON = { 60 | a: number; 61 | b: E$JSON; 62 | }; 63 | """, """ 64 | export function S_decode(json: S$JSON): S { 65 | const a = json.a; 66 | const b = E_decode(json.b); 67 | return { 68 | a: a, 69 | b: b 70 | }; 71 | } 72 | """] 73 | ) 74 | } 75 | 76 | func testDecodeOptional() throws { 77 | try assertGenerate( 78 | source: """ 79 | enum E { case a } 80 | 81 | struct S { 82 | var e1: E? 83 | var e2: E?? 84 | var e3: E??? 85 | } 86 | """, 87 | typeSelector: .name("S"), 88 | expecteds: [""" 89 | export type S = { 90 | e1?: E; 91 | e2?: E | null; 92 | e3?: E | null; 93 | } & TagRecord<"S">; 94 | """, """ 95 | export type S$JSON = { 96 | e1?: E$JSON; 97 | e2?: E$JSON | null; 98 | e3?: E$JSON | null; 99 | }; 100 | """, """ 101 | export function S_decode(json: S$JSON): S { 102 | const e1 = OptionalField_decode(json.e1, E_decode); 103 | const e2 = OptionalField_decode(json.e2, (json: E$JSON | null): E | null => { 104 | return Optional_decode(json, E_decode); 105 | }); 106 | const e3 = OptionalField_decode(json.e3, (json: E$JSON | null): E | null => { 107 | return Optional_decode(json, E_decode); 108 | }); 109 | return { 110 | e1: e1, 111 | e2: e2, 112 | e3: e3 113 | }; 114 | } 115 | """ 116 | ] 117 | ) 118 | } 119 | 120 | func testEmptyDecodeOptional() throws { 121 | try assertGenerate( 122 | source: """ 123 | struct S { 124 | var e1: Int? 125 | var e2: Int?? 126 | var e3: Int??? 127 | """, 128 | typeSelector: .name("S"), 129 | unexpecteds: [""" 130 | export function S_decode 131 | """ 132 | ] 133 | ) 134 | } 135 | 136 | 137 | func testDecodeArray() throws { 138 | try assertGenerate( 139 | source: """ 140 | enum E { case a } 141 | 142 | struct S { 143 | var e1: [E] 144 | var e2: [[E]] 145 | var e3: [[[E]]] 146 | } 147 | """ 148 | , 149 | typeSelector: .name("S"), 150 | expecteds: [""" 151 | export function S_decode(json: S$JSON): S { 152 | const e1 = Array_decode(json.e1, E_decode); 153 | const e2 = Array_decode(json.e2, (json: E$JSON[]): E[] => { 154 | return Array_decode(json, E_decode); 155 | }); 156 | const e3 = Array_decode(json.e3, (json: E$JSON[][]): E[][] => { 157 | return Array_decode(json, (json: E$JSON[]): E[] => { 158 | return Array_decode(json, E_decode); 159 | }); 160 | }); 161 | return { 162 | e1: e1, 163 | e2: e2, 164 | e3: e3 165 | }; 166 | } 167 | """] 168 | ) 169 | } 170 | 171 | func testEmptyDecodeArray() throws { 172 | try assertGenerate( 173 | source: """ 174 | struct S { 175 | var e1: [Int] 176 | var e2: [[Int]] 177 | var e3: [[[Int]]] 178 | } 179 | """ 180 | , 181 | typeSelector: .name("S"), 182 | unexpecteds: [""" 183 | export function S_decode 184 | """] 185 | ) 186 | } 187 | 188 | func testDecodeOptionalAndArray() throws { 189 | try assertGenerate( 190 | source: """ 191 | enum E { case a } 192 | 193 | struct S { 194 | var e1: [E]? 195 | var e2: [E?] 196 | var e3: [E]? 197 | var e4: [E?]? 198 | } 199 | """, 200 | typeSelector: .name("S"), 201 | expecteds: [""" 202 | export function S_decode(json: S$JSON): S { 203 | const e1 = OptionalField_decode(json.e1, (json: E$JSON[]): E[] => { 204 | return Array_decode(json, E_decode); 205 | }); 206 | const e2 = Array_decode(json.e2, (json: E$JSON | null): E | null => { 207 | return Optional_decode(json, E_decode); 208 | }); 209 | const e3 = OptionalField_decode(json.e3, (json: E$JSON[]): E[] => { 210 | return Array_decode(json, E_decode); 211 | }); 212 | const e4 = OptionalField_decode<(E | null)[], (E$JSON | null)[]>(json.e4, (json: (E$JSON | null)[]): (E | null)[] => { 213 | return Array_decode(json, (json: E$JSON | null): E | null => { 214 | return Optional_decode(json, E_decode); 215 | }); 216 | }); 217 | return { 218 | e1: e1, 219 | e2: e2, 220 | e3: e3, 221 | e4: e4 222 | }; 223 | } 224 | """] 225 | ) 226 | } 227 | 228 | func testSet() throws { 229 | try assertGenerate( 230 | source: """ 231 | enum E { case a } 232 | 233 | struct S { 234 | var e1: Set 235 | } 236 | """, 237 | typeSelector: .name("S"), 238 | expecteds: [""" 239 | export type S = { 240 | e1: Set; 241 | } & TagRecord<"S">; 242 | 243 | export type S$JSON = { 244 | e1: E$JSON[]; 245 | }; 246 | 247 | export function S_decode(json: S$JSON): S { 248 | const e1 = Set_decode(json.e1, E_decode); 249 | return { 250 | e1: e1 251 | }; 252 | } 253 | 254 | export function S_encode(entity: S): S$JSON { 255 | const e1 = Set_encode(entity.e1, identity); 256 | return { 257 | e1: e1 258 | }; 259 | } 260 | """] 261 | ) 262 | } 263 | 264 | func testDecodeDictionary() throws { 265 | try assertGenerate( 266 | source: """ 267 | enum E { case a } 268 | 269 | struct S { 270 | var e1: [String: E] 271 | var e2: [String: [E?]] 272 | var e3: [String: Int] 273 | } 274 | """, 275 | expecteds: [""" 276 | export type S = { 277 | e1: Map; 278 | e2: Map; 279 | e3: Map; 280 | } & TagRecord<"S">; 281 | """, """ 282 | export type S$JSON = { 283 | e1: { 284 | [key: string]: E$JSON; 285 | }; 286 | e2: { 287 | [key: string]: (E$JSON | null)[]; 288 | }; 289 | e3: { 290 | [key: string]: number; 291 | }; 292 | }; 293 | """, """ 294 | export function S_decode(json: S$JSON): S { 295 | const e1 = Dictionary_decode(json.e1, E_decode); 296 | const e2 = Dictionary_decode<(E | null)[], (E$JSON | null)[]>(json.e2, (json: (E$JSON | null)[]): (E | null)[] => { 297 | return Array_decode(json, (json: E$JSON | null): E | null => { 298 | return Optional_decode(json, E_decode); 299 | }); 300 | }); 301 | const e3 = Dictionary_decode(json.e3, identity); 302 | return { 303 | e1: e1, 304 | e2: e2, 305 | e3: e3 306 | }; 307 | } 308 | """, """ 309 | export function S_encode(entity: S): S$JSON { 310 | const e1 = Dictionary_encode(entity.e1, identity); 311 | const e2 = Dictionary_encode<(E | null)[], (E$JSON | null)[]>(entity.e2, identity); 312 | const e3 = Dictionary_encode(entity.e3, identity); 313 | return { 314 | e1: e1, 315 | e2: e2, 316 | e3: e3 317 | }; 318 | } 319 | """] 320 | ) 321 | } 322 | 323 | func testDoubleDictionary() throws { 324 | try assertGenerate( 325 | source: """ 326 | struct S { 327 | var a: [String: [String: Int]] 328 | } 329 | """, 330 | expecteds: [""" 331 | export type S = { 332 | a: Map>; 333 | } & TagRecord<"S">; 334 | """, """ 335 | export type S$JSON = { 336 | a: { 337 | [key: string]: { 338 | [key: string]: number; 339 | }; 340 | }; 341 | }; 342 | """, """ 343 | export function S_decode(json: S$JSON): S { 344 | const a = Dictionary_decode, { 345 | [key: string]: number; 346 | }>(json.a, (json: { 347 | [key: string]: number; 348 | }): Map => { 349 | return Dictionary_decode(json, identity); 350 | }); 351 | return { 352 | a: a 353 | }; 354 | } 355 | """, """ 356 | export function S_encode(entity: S): S$JSON { 357 | const a = Dictionary_encode, { 358 | [key: string]: number; 359 | }>(entity.a, (entity: Map): { 360 | [key: string]: number; 361 | } => { 362 | return Dictionary_encode(entity, identity); 363 | }); 364 | return { 365 | a: a 366 | }; 367 | } 368 | """ 369 | ] 370 | ) 371 | } 372 | 373 | func testRecursive() throws { 374 | if true || true { 375 | throw XCTSkip("unsupported") 376 | } 377 | try assertGenerate( 378 | source: """ 379 | indirect enum E: Codable { 380 | case a(E) 381 | case none 382 | } 383 | """ 384 | ) 385 | } 386 | 387 | func testUnresolvedFailure() throws { 388 | XCTAssertThrowsError( 389 | try assertGenerate( 390 | source: """ 391 | struct S { 392 | var a: A 393 | } 394 | """) 395 | ) 396 | } 397 | 398 | func testStaticProperty() throws { 399 | try assertGenerate( 400 | source: """ 401 | struct S { 402 | static var k: Int = 0 403 | 404 | var a: Int 405 | } 406 | """, 407 | expecteds: [""" 408 | export type S = { 409 | a: number; 410 | } & TagRecord<"S">; 411 | """ 412 | ] 413 | ) 414 | } 415 | 416 | func testUnknownStaticProperty() throws { 417 | try assertGenerate( 418 | source: """ 419 | struct S { 420 | static var k: Unknown = 0 421 | 422 | var a: Int 423 | } 424 | """, 425 | expecteds: [""" 426 | export type S = { 427 | a: number; 428 | } & TagRecord<"S">; 429 | """ 430 | ] 431 | ) 432 | } 433 | 434 | func testConflictPropertyName() throws { 435 | try assertGenerate( 436 | source: """ 437 | struct S { 438 | var entity: String 439 | var json: String 440 | var t: T 441 | } 442 | """, 443 | expecteds: [ 444 | // decode 445 | """ 446 | const json2 = json.json; 447 | """, """ 448 | json: json2, 449 | """, 450 | 451 | // encode 452 | """ 453 | const entity2 = entity.entity; 454 | """, """ 455 | entity: entity2, 456 | """ 457 | ], 458 | unexpecteds: [] 459 | ) 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /Sources/CodableToTypeScript/TypeConverter/DefaultTypeConverter.swift: -------------------------------------------------------------------------------- 1 | import SwiftTypeReader 2 | import TypeScriptAST 3 | 4 | /* 5 | It provides default impls of TypeConverter. 6 | 7 | Don't call other method from own methods. 8 | Call original methods via `converter()` object. 9 | */ 10 | public struct DefaultTypeConverter { 11 | public init(generator: CodeGenerator, type: any SType) { 12 | self.generator = generator 13 | self.swiftType = type 14 | } 15 | 16 | private var generator: CodeGenerator 17 | public var swiftType: any SType 18 | 19 | private func converter() throws -> any TypeConverter { 20 | return try generator.converter(for: swiftType) 21 | } 22 | 23 | public func name(for target: GenerationTarget) throws -> String { 24 | switch target { 25 | case .entity: 26 | return swiftType.namePath().convert() 27 | case .json: 28 | let converter = try self.converter() 29 | 30 | let entityName = try converter.name(for: .entity) 31 | 32 | guard try converter.hasJSONType() else { 33 | return entityName 34 | } 35 | 36 | return Self.jsonName(entityName: entityName) 37 | } 38 | } 39 | 40 | public static func jsonName(entityName: String) -> String { 41 | return "\(entityName)$JSON" 42 | } 43 | 44 | public func hasJSONType() throws -> Bool { 45 | let converter = try self.converter() 46 | if try converter.hasDecode() { 47 | return true 48 | } 49 | if try converter.hasEncode() { 50 | return true 51 | } 52 | return false 53 | } 54 | 55 | public func type(for target: GenerationTarget) throws -> any TSType { 56 | let converter = try self.converter() 57 | let name = try converter.name(for: target) 58 | let args = try converter.genericArgs().map { 59 | try $0.type(for: target) 60 | } 61 | return TSIdentType(name, genericArgs: args) 62 | } 63 | 64 | public func fieldType(for target: GenerationTarget) throws -> (type: any TSType, isOptional: Bool) { 65 | let type = try self.converter().type(for: target) 66 | return (type: type, isOptional: false) 67 | } 68 | 69 | public func valueToField(value: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 70 | return value 71 | } 72 | 73 | public func fieldToValue(field: any TSExpr, for target: GenerationTarget) throws -> any TSExpr { 74 | return field 75 | } 76 | 77 | public func decodeName() throws -> String { 78 | let converter = try self.converter() 79 | guard try converter.hasDecode() else { 80 | throw MessageError("no decode") 81 | } 82 | let entityName = try converter.name(for: .entity) 83 | return Self.decodeName(entityName: entityName) 84 | } 85 | 86 | public static func decodeName(entityName: String) -> String { 87 | return "\(entityName)_decode" 88 | } 89 | 90 | public func boundDecode() throws -> any TSExpr { 91 | let converter = try self.converter() 92 | 93 | guard try converter.hasDecode() else { 94 | return generator.helperLibrary().access(.identity) 95 | } 96 | 97 | func makeClosure() throws -> any TSExpr { 98 | let param = TSFunctionType.Param( 99 | name: "json", 100 | type: try converter.type(for: .json) 101 | ) 102 | let result = try converter.type(for: .entity) 103 | let expr = try converter.callDecode(json: TSIdentExpr.json) 104 | return TSClosureExpr( 105 | params: [param], 106 | result: result, 107 | body: TSBlockStmt([ 108 | TSReturnStmt(expr) 109 | ]) 110 | ) 111 | } 112 | 113 | if !swiftType.tsGenericArgs.isEmpty { 114 | return try makeClosure() 115 | } 116 | return TSIdentExpr( 117 | try converter.decodeName() 118 | ) 119 | } 120 | 121 | public func callDecode(json: any TSExpr) throws -> any TSExpr { 122 | return try callDecode(genericArgs: swiftType.tsGenericArgs, json: json) 123 | } 124 | 125 | public func callDecode(genericArgs: [any SType], json: any TSExpr) throws -> any TSExpr { 126 | let converter = try self.converter() 127 | guard try converter.hasDecode() else { 128 | var expr = json 129 | if try converter.hasJSONType() || !genericArgs.isEmpty { 130 | expr = TSAsExpr(expr, try converter.type(for: .entity)) 131 | } 132 | return expr 133 | } 134 | let decodeName = try converter.decodeName() 135 | return try generator.callDecode( 136 | callee: TSIdentExpr(decodeName), 137 | genericArgs: genericArgs, 138 | json: json 139 | ) 140 | } 141 | 142 | public func callDecodeField(json: any TSExpr) throws -> any TSExpr { 143 | return try converter().callDecode(json: json) 144 | } 145 | 146 | public func decodeSignature() throws -> TSFunctionDecl? { 147 | let converter = try self.converter() 148 | 149 | guard try converter.hasDecode() else { return nil } 150 | 151 | let entityName = try converter.name(for: .entity) 152 | let jsonName = try converter.name(for: .json) 153 | 154 | let genericParams = try converter.genericParams() 155 | 156 | var entityArgs: [any TSType] = [] 157 | var jsonArgs: [any TSType] = [] 158 | 159 | for param in genericParams { 160 | entityArgs.append( 161 | TSIdentType(try param.name(for: .entity)) 162 | ) 163 | jsonArgs.append( 164 | TSIdentType(try param.name(for: .json)) 165 | ) 166 | } 167 | 168 | var decodeGenericParams: [TSTypeParameterNode] = [] 169 | for param in genericParams { 170 | decodeGenericParams += [ 171 | .init(try param.name(for: .entity)), 172 | .init(try param.name(for: .json)) 173 | ] 174 | } 175 | 176 | var params: [TSFunctionType.Param] = [ 177 | .init( 178 | name: "json", 179 | type: TSIdentType(jsonName, genericArgs: jsonArgs) 180 | ) 181 | ] 182 | let result: any TSType = TSIdentType(entityName, genericArgs: entityArgs) 183 | 184 | for param in genericParams { 185 | let jsonName = try param.name(for: .json) 186 | let decodeType: any TSType = TSFunctionType( 187 | params: [.init(name: "json", type: TSIdentType(jsonName))], 188 | result: TSIdentType(try param.name(for: .entity)) 189 | ) 190 | 191 | let decodeName = try param.decodeName() 192 | 193 | params.append( 194 | .init( 195 | name: decodeName, 196 | type: decodeType 197 | ) 198 | ) 199 | } 200 | 201 | return TSFunctionDecl( 202 | modifiers: [.export], 203 | name: try decodeName(), 204 | genericParams: decodeGenericParams, 205 | params: params, 206 | result: result, 207 | body: TSBlockStmt() 208 | ) 209 | } 210 | 211 | public func encodeName() throws -> String { 212 | let converter = try self.converter() 213 | guard try converter.hasEncode() else { 214 | throw MessageError("no encode") 215 | } 216 | let entityName = try converter.name(for: .entity) 217 | return Self.encodeName(entityName: entityName) 218 | } 219 | 220 | public static func encodeName(entityName: String) -> String { 221 | return "\(entityName)_encode" 222 | } 223 | 224 | public func boundEncode() throws -> any TSExpr { 225 | let converter = try self.converter() 226 | 227 | guard try converter.hasEncode() else { 228 | return generator.helperLibrary().access(.identity) 229 | } 230 | 231 | func makeClosure() throws -> any TSExpr { 232 | let param = TSFunctionType.Param( 233 | name: "entity", 234 | type: try converter.type(for: .entity) 235 | ) 236 | let result = try converter.type(for: .json) 237 | let expr = try converter.callEncode(entity: TSIdentExpr.entity) 238 | return TSClosureExpr( 239 | params: [param], 240 | result: result, 241 | body: TSBlockStmt([ 242 | TSReturnStmt(expr) 243 | ]) 244 | ) 245 | } 246 | 247 | if !swiftType.tsGenericArgs.isEmpty { 248 | return try makeClosure() 249 | } 250 | return TSIdentExpr( 251 | try converter.encodeName() 252 | ) 253 | } 254 | 255 | public func callEncode(entity: any TSExpr) throws -> any TSExpr { 256 | return try callEncode(genericArgs: swiftType.tsGenericArgs, entity: entity) 257 | } 258 | 259 | public func callEncode(genericArgs: [any SType], entity: any TSExpr) throws -> any TSExpr { 260 | let converter = try self.converter() 261 | guard try converter.hasEncode() else { 262 | var expr = entity 263 | if try converter.hasJSONType() || !genericArgs.isEmpty { 264 | expr = TSAsExpr(expr, try converter.type(for: .json)) 265 | } 266 | return expr 267 | } 268 | let encodeName = try converter.encodeName() 269 | return try generator.callEncode( 270 | callee: TSIdentExpr(encodeName), 271 | genericArgs: genericArgs, 272 | entity: entity 273 | ) 274 | } 275 | 276 | public func callEncodeField(entity: any TSExpr) throws -> any TSExpr { 277 | return try converter().callEncode(entity: entity) 278 | } 279 | 280 | public func encodeSignature() throws -> TSFunctionDecl? { 281 | let converter = try self.converter() 282 | 283 | guard try converter.hasEncode() else { return nil } 284 | 285 | let entityName = try converter.name(for: .entity) 286 | let jsonName = try converter.name(for: .json) 287 | 288 | let genericParams = try converter.genericParams() 289 | 290 | var entityArgs: [any TSType] = [] 291 | var jsonArgs: [any TSType] = [] 292 | 293 | for param in genericParams { 294 | entityArgs.append( 295 | TSIdentType(try param.name(for: .entity)) 296 | ) 297 | jsonArgs.append( 298 | TSIdentType(try param.name(for: .json)) 299 | ) 300 | } 301 | 302 | var encodeGenericParams: [TSTypeParameterNode] = [] 303 | for param in genericParams { 304 | encodeGenericParams += [ 305 | .init(try param.name(for: .entity)), 306 | .init(try param.name(for: .json)) 307 | ] 308 | } 309 | 310 | var params: [TSFunctionType.Param] = [ 311 | .init( 312 | name: "entity", 313 | type: TSIdentType(entityName, genericArgs: entityArgs) 314 | ) 315 | ] 316 | let result: any TSType = TSIdentType(jsonName, genericArgs: jsonArgs) 317 | 318 | for param in genericParams { 319 | let entityName = try param.name(for: .entity) 320 | let encodeType: any TSType = TSFunctionType( 321 | params: [.init(name: "entity", type: TSIdentType(entityName))], 322 | result: TSIdentType(try param.name(for: .json)) 323 | ) 324 | 325 | let encodeName = try param.encodeName() 326 | 327 | params.append( 328 | .init( 329 | name: encodeName, 330 | type: encodeType 331 | ) 332 | ) 333 | } 334 | 335 | return TSFunctionDecl( 336 | modifiers: [.export], 337 | name: try encodeName(), 338 | genericParams: encodeGenericParams, 339 | params: params, 340 | result: result, 341 | body: TSBlockStmt() 342 | ) 343 | } 344 | } 345 | --------------------------------------------------------------------------------