Skip to content

Commit 0d5b431

Browse files
Merge pull request #637 from swiftwasm/yt/handy-ts2swift
TS2Swift: Make it easier to use ts2swift directly
2 parents 118db02 + 8abb79d commit 0d5b431

File tree

5 files changed

+104
-45
lines changed

5 files changed

+104
-45
lines changed

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"ts2swift": "./bin/ts2swift.js"
88
},
99
"scripts": {
10-
"test": "vitest run"
10+
"test": "vitest run",
11+
"tsc": "tsc --noEmit"
1112
}
1213
}

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// @ts-check
22
import * as fs from 'fs';
3-
import { TypeProcessor } from './processor.js';
3+
import os from 'os';
4+
import path from 'path';
45
import { parseArgs } from 'util';
56
import ts from 'typescript';
6-
import path from 'path';
7+
import { TypeProcessor } from './processor.js';
78

89
class DiagnosticEngine {
910
/**
10-
* @param {string} level
11+
* @param {keyof typeof DiagnosticEngine.LEVELS} level
1112
*/
1213
constructor(level) {
1314
const levelInfo = DiagnosticEngine.LEVELS[level];
@@ -73,20 +74,35 @@ class DiagnosticEngine {
7374
}
7475

7576
function printUsage() {
76-
console.error('Usage: ts2swift <d.ts file path> -p <tsconfig.json path> [--global <d.ts>]... [-o output.swift]');
77+
console.error(`Usage: ts2swift <input> [options]
78+
79+
<input> Path to a .d.ts file, or "-" to read from stdin
80+
81+
Options:
82+
-o, --output <path> Write Swift to <path>. Use "-" for stdout (default).
83+
-p, --project <path> Path to tsconfig.json (default: tsconfig.json).
84+
--global <path> Add a .d.ts as a global declaration file (repeatable).
85+
--log-level <level> One of: verbose, info, warning, error (default: info).
86+
-h, --help Show this help.
87+
88+
Examples:
89+
ts2swift lib.d.ts
90+
ts2swift lib.d.ts -o Generated.swift
91+
ts2swift lib.d.ts -p ./tsconfig.build.json -o Sources/Bridge/API.swift
92+
cat lib.d.ts | ts2swift - -o Generated.swift
93+
ts2swift lib.d.ts --global dom.d.ts --global lib.d.ts
94+
`);
7795
}
7896

7997
/**
8098
* Run ts2swift for a single input file (programmatic API, no process I/O).
81-
* @param {string} filePath - Path to the .d.ts file
82-
* @param {{ tsconfigPath: string, logLevel?: string, globalFiles?: string[] }} options
99+
* @param {string[]} filePaths - Paths to the .d.ts files
100+
* @param {{ tsconfigPath: string, logLevel?: keyof typeof DiagnosticEngine.LEVELS, globalFiles?: string[] }} options
83101
* @returns {string} Generated Swift source
84102
* @throws {Error} on parse/type-check errors (diagnostics are included in the message)
85103
*/
86-
export function run(filePath, options) {
87-
const { tsconfigPath, logLevel = 'info', globalFiles: globalFilesOpt = [] } = options;
88-
const globalFiles = Array.isArray(globalFilesOpt) ? globalFilesOpt : (globalFilesOpt ? [globalFilesOpt] : []);
89-
104+
export function run(filePaths, options) {
105+
const { tsconfigPath, logLevel = 'info', globalFiles = [] } = options;
90106
const diagnosticEngine = new DiagnosticEngine(logLevel);
91107

92108
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
@@ -105,7 +121,7 @@ export function run(filePath, options) {
105121
throw new Error(`TypeScript config/parse errors:\n${message}`);
106122
}
107123

108-
const program = TypeProcessor.createProgram([filePath, ...globalFiles], configParseResult.options);
124+
const program = TypeProcessor.createProgram([...filePaths, ...globalFiles], configParseResult.options);
109125
const diagnostics = program.getSemanticDiagnostics();
110126
if (diagnostics.length > 0) {
111127
const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
@@ -131,7 +147,7 @@ export function run(filePath, options) {
131147
/** @type {string[]} */
132148
const bodies = [];
133149
const globalFileSet = new Set(globalFiles);
134-
for (const inputPath of [filePath, ...globalFiles]) {
150+
for (const inputPath of [...filePaths, ...globalFiles]) {
135151
const processor = new TypeProcessor(program.getTypeChecker(), diagnosticEngine, {
136152
defaultImportFromGlobal: globalFileSet.has(inputPath),
137153
});
@@ -169,39 +185,67 @@ export function main(args) {
169185
type: 'string',
170186
default: 'info',
171187
},
188+
help: {
189+
type: 'boolean',
190+
short: 'h',
191+
},
172192
},
173193
allowPositionals: true
174194
})
175195

176-
if (options.positionals.length !== 1) {
196+
if (options.values.help) {
177197
printUsage();
178-
process.exit(1);
198+
process.exit(0);
179199
}
180200

181-
const tsconfigPath = options.values.project;
182-
if (!tsconfigPath) {
201+
if (options.positionals.length !== 1) {
183202
printUsage();
184203
process.exit(1);
185204
}
186205

187-
const filePath = options.positionals[0];
188-
const logLevel = options.values["log-level"] || "info";
189206
/** @type {string[]} */
190-
const globalFiles = Array.isArray(options.values.global)
191-
? options.values.global
192-
: (options.values.global ? [options.values.global] : []);
207+
let filePaths = options.positionals;
208+
/** @type {(() => void)[]} cleanup functions to run after completion */
209+
const cleanups = [];
210+
211+
if (filePaths[0] === '-') {
212+
const content = fs.readFileSync(0, 'utf-8');
213+
const stdinTempPath = path.join(os.tmpdir(), `ts2swift-stdin-${process.pid}-${Date.now()}.d.ts`);
214+
fs.writeFileSync(stdinTempPath, content);
215+
cleanups.push(() => fs.unlinkSync(stdinTempPath));
216+
filePaths = [stdinTempPath];
217+
}
218+
const logLevel = /** @type {keyof typeof DiagnosticEngine.LEVELS} */ ((() => {
219+
const logLevel = options.values["log-level"] || "info";
220+
if (!Object.keys(DiagnosticEngine.LEVELS).includes(logLevel)) {
221+
console.error(`Invalid log level: ${logLevel}. Valid levels are: ${Object.keys(DiagnosticEngine.LEVELS).join(", ")}`);
222+
process.exit(1);
223+
}
224+
return logLevel;
225+
})());
226+
const globalFiles = options.values.global || [];
227+
const tsconfigPath = options.values.project || "tsconfig.json";
193228

194229
const diagnosticEngine = new DiagnosticEngine(logLevel);
195-
diagnosticEngine.print("verbose", `Processing ${filePath}...`);
230+
diagnosticEngine.print("verbose", `Processing ${filePaths.join(", ")}`);
196231

197232
let swiftOutput;
198233
try {
199-
swiftOutput = run(filePath, { tsconfigPath, logLevel, globalFiles });
200-
} catch (err) {
201-
console.error(err.message);
234+
swiftOutput = run(filePaths, { tsconfigPath, logLevel, globalFiles });
235+
} catch (/** @type {unknown} */ err) {
236+
if (err instanceof Error) {
237+
diagnosticEngine.print("error", err.message);
238+
} else {
239+
diagnosticEngine.print("error", String(err));
240+
}
202241
process.exit(1);
242+
} finally {
243+
for (const cleanup of cleanups) {
244+
cleanup();
245+
}
203246
}
204-
if (options.values.output) {
247+
// Write to file or stdout
248+
if (options.values.output && options.values.output !== "-") {
205249
if (swiftOutput.length > 0) {
206250
fs.mkdirSync(path.dirname(options.values.output), { recursive: true });
207251
fs.writeFileSync(options.values.output, swiftOutput);

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@ import ts from 'typescript';
2020
export class TypeProcessor {
2121
/**
2222
* Create a TypeScript program from a d.ts file
23-
* @param {string} filePath - Path to the d.ts file
23+
* @param {string[]} filePaths - Paths to the d.ts file
2424
* @param {ts.CompilerOptions} options - Compiler options
2525
* @returns {ts.Program} TypeScript program object
2626
*/
2727
static createProgram(filePaths, options) {
2828
const host = ts.createCompilerHost(options);
29-
const roots = Array.isArray(filePaths) ? filePaths : [filePaths];
30-
return ts.createProgram(roots, {
29+
return ts.createProgram(filePaths, {
3130
...options,
3231
noCheck: true,
3332
skipLibCheck: true,
@@ -39,14 +38,7 @@ export class TypeProcessor {
3938
* @param {DiagnosticEngine} diagnosticEngine - Diagnostic engine
4039
*/
4140
constructor(checker, diagnosticEngine, options = {
42-
inheritIterable: true,
43-
inheritArraylike: true,
44-
inheritPromiselike: true,
45-
addAllParentMembersToClass: true,
46-
replaceAliasToFunction: true,
47-
replaceRankNFunction: true,
48-
replaceNewableFunction: true,
49-
noExtendsInTyprm: false,
41+
defaultImportFromGlobal: false,
5042
}) {
5143
this.checker = checker;
5244
this.diagnosticEngine = diagnosticEngine;
@@ -163,8 +155,12 @@ export class TypeProcessor {
163155
}
164156
}
165157
});
166-
} catch (error) {
167-
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${error.message}`);
158+
} catch (/** @type {unknown} */ error) {
159+
if (error instanceof Error) {
160+
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${error.message}`);
161+
} else {
162+
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${String(error)}`);
163+
}
168164
}
169165
}
170166

@@ -388,7 +384,7 @@ export class TypeProcessor {
388384
canBeIntEnum = false;
389385
}
390386
const swiftEnumName = this.renderTypeIdentifier(enumName);
391-
const dedupeNames = (items) => {
387+
const dedupeNames = (/** @type {{ name: string, raw: string | number }[]} */ items) => {
392388
const seen = new Map();
393389
return items.map(item => {
394390
const count = seen.get(item.name) ?? 0;
@@ -401,6 +397,10 @@ export class TypeProcessor {
401397
if (canBeStringEnum && stringMembers.length > 0) {
402398
this.swiftLines.push(`enum ${swiftEnumName}: String {`);
403399
for (const { name, raw } of dedupeNames(stringMembers)) {
400+
if (typeof raw !== "string") {
401+
this.diagnosticEngine.print("warning", `Invalid string literal: ${raw}`, diagnosticNode);
402+
continue;
403+
}
404404
this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\\\"")}"`);
405405
}
406406
this.swiftLines.push("}");
@@ -815,7 +815,7 @@ export class TypeProcessor {
815815
visitType(type, node) {
816816
const typeArguments = this.getTypeArguments(type);
817817
if (this.checker.isArrayType(type)) {
818-
const typeArgs = this.checker.getTypeArguments(type);
818+
const typeArgs = this.checker.getTypeArguments(/** @type {ts.TypeReference} */ (type));
819819
if (typeArgs && typeArgs.length > 0) {
820820
const elementType = this.visitType(typeArgs[0], node);
821821
return `[${elementType}]`;
@@ -920,7 +920,7 @@ export class TypeProcessor {
920920
* Convert a `Record<string, T>` TypeScript type into a Swift dictionary type.
921921
* Falls back to `JSObject` when keys are not string-compatible or type arguments are missing.
922922
* @param {ts.Type} type
923-
* @param {ts.Type[]} typeArguments
923+
* @param {readonly ts.Type[]} typeArguments
924924
* @param {ts.Node} node
925925
* @returns {string | null}
926926
* @private
@@ -952,7 +952,7 @@ export class TypeProcessor {
952952
/**
953953
* Retrieve type arguments for a given type, including type alias instantiations.
954954
* @param {ts.Type} type
955-
* @returns {ts.Type[]}
955+
* @returns {readonly ts.Type[]}
956956
* @private
957957
*/
958958
getTypeArguments(type) {

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const inputsDir = path.resolve(__dirname, 'fixtures');
1212
const tsconfigPath = path.join(inputsDir, 'tsconfig.json');
1313

1414
function runTs2Swift(dtsPath) {
15-
return run(dtsPath, { tsconfigPath, logLevel: 'error' });
15+
return run([dtsPath], { tsconfigPath, logLevel: 'error' });
1616
}
1717

1818
function collectDtsInputs() {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"allowJs": true,
5+
"skipLibCheck": true,
6+
"noEmit": true,
7+
"target": "ES2022",
8+
"module": "NodeNext",
9+
"moduleResolution": "nodenext"
10+
},
11+
"include": [
12+
"src/*.js"
13+
]
14+
}

0 commit comments

Comments
 (0)