Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"ts2swift": "./bin/ts2swift.js"
},
"scripts": {
"test": "vitest run"
"test": "vitest run",
"tsc": "tsc --noEmit"
}
}
96 changes: 70 additions & 26 deletions Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// @ts-check
import * as fs from 'fs';
import { TypeProcessor } from './processor.js';
import os from 'os';
import path from 'path';
import { parseArgs } from 'util';
import ts from 'typescript';
import path from 'path';
import { TypeProcessor } from './processor.js';

class DiagnosticEngine {
/**
* @param {string} level
* @param {keyof typeof DiagnosticEngine.LEVELS} level
*/
constructor(level) {
const levelInfo = DiagnosticEngine.LEVELS[level];
Expand Down Expand Up @@ -73,20 +74,35 @@ class DiagnosticEngine {
}

function printUsage() {
console.error('Usage: ts2swift <d.ts file path> -p <tsconfig.json path> [--global <d.ts>]... [-o output.swift]');
console.error(`Usage: ts2swift <input> [options]

<input> Path to a .d.ts file, or "-" to read from stdin

Options:
-o, --output <path> Write Swift to <path>. Use "-" for stdout (default).
-p, --project <path> Path to tsconfig.json (default: tsconfig.json).
--global <path> Add a .d.ts as a global declaration file (repeatable).
--log-level <level> One of: verbose, info, warning, error (default: info).
-h, --help Show this help.

Examples:
ts2swift lib.d.ts
ts2swift lib.d.ts -o Generated.swift
ts2swift lib.d.ts -p ./tsconfig.build.json -o Sources/Bridge/API.swift
cat lib.d.ts | ts2swift - -o Generated.swift
ts2swift lib.d.ts --global dom.d.ts --global lib.d.ts
`);
}

/**
* Run ts2swift for a single input file (programmatic API, no process I/O).
* @param {string} filePath - Path to the .d.ts file
* @param {{ tsconfigPath: string, logLevel?: string, globalFiles?: string[] }} options
* @param {string[]} filePaths - Paths to the .d.ts files
* @param {{ tsconfigPath: string, logLevel?: keyof typeof DiagnosticEngine.LEVELS, globalFiles?: string[] }} options
* @returns {string} Generated Swift source
* @throws {Error} on parse/type-check errors (diagnostics are included in the message)
*/
export function run(filePath, options) {
const { tsconfigPath, logLevel = 'info', globalFiles: globalFilesOpt = [] } = options;
const globalFiles = Array.isArray(globalFilesOpt) ? globalFilesOpt : (globalFilesOpt ? [globalFilesOpt] : []);

export function run(filePaths, options) {
const { tsconfigPath, logLevel = 'info', globalFiles = [] } = options;
const diagnosticEngine = new DiagnosticEngine(logLevel);

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

const program = TypeProcessor.createProgram([filePath, ...globalFiles], configParseResult.options);
const program = TypeProcessor.createProgram([...filePaths, ...globalFiles], configParseResult.options);
const diagnostics = program.getSemanticDiagnostics();
if (diagnostics.length > 0) {
const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
Expand All @@ -131,7 +147,7 @@ export function run(filePath, options) {
/** @type {string[]} */
const bodies = [];
const globalFileSet = new Set(globalFiles);
for (const inputPath of [filePath, ...globalFiles]) {
for (const inputPath of [...filePaths, ...globalFiles]) {
const processor = new TypeProcessor(program.getTypeChecker(), diagnosticEngine, {
defaultImportFromGlobal: globalFileSet.has(inputPath),
});
Expand Down Expand Up @@ -169,39 +185,67 @@ export function main(args) {
type: 'string',
default: 'info',
},
help: {
type: 'boolean',
short: 'h',
},
},
allowPositionals: true
})

if (options.positionals.length !== 1) {
if (options.values.help) {
printUsage();
process.exit(1);
process.exit(0);
}

const tsconfigPath = options.values.project;
if (!tsconfigPath) {
if (options.positionals.length !== 1) {
printUsage();
process.exit(1);
}

const filePath = options.positionals[0];
const logLevel = options.values["log-level"] || "info";
/** @type {string[]} */
const globalFiles = Array.isArray(options.values.global)
? options.values.global
: (options.values.global ? [options.values.global] : []);
let filePaths = options.positionals;
/** @type {(() => void)[]} cleanup functions to run after completion */
const cleanups = [];

if (filePaths[0] === '-') {
const content = fs.readFileSync(0, 'utf-8');
const stdinTempPath = path.join(os.tmpdir(), `ts2swift-stdin-${process.pid}-${Date.now()}.d.ts`);
fs.writeFileSync(stdinTempPath, content);
cleanups.push(() => fs.unlinkSync(stdinTempPath));
filePaths = [stdinTempPath];
}
const logLevel = /** @type {keyof typeof DiagnosticEngine.LEVELS} */ ((() => {
const logLevel = options.values["log-level"] || "info";
if (!Object.keys(DiagnosticEngine.LEVELS).includes(logLevel)) {
console.error(`Invalid log level: ${logLevel}. Valid levels are: ${Object.keys(DiagnosticEngine.LEVELS).join(", ")}`);
process.exit(1);
}
return logLevel;
})());
const globalFiles = options.values.global || [];
const tsconfigPath = options.values.project || "tsconfig.json";

const diagnosticEngine = new DiagnosticEngine(logLevel);
diagnosticEngine.print("verbose", `Processing ${filePath}...`);
diagnosticEngine.print("verbose", `Processing ${filePaths.join(", ")}`);

let swiftOutput;
try {
swiftOutput = run(filePath, { tsconfigPath, logLevel, globalFiles });
} catch (err) {
console.error(err.message);
swiftOutput = run(filePaths, { tsconfigPath, logLevel, globalFiles });
} catch (/** @type {unknown} */ err) {
if (err instanceof Error) {
diagnosticEngine.print("error", err.message);
} else {
diagnosticEngine.print("error", String(err));
}
process.exit(1);
} finally {
for (const cleanup of cleanups) {
cleanup();
}
}
if (options.values.output) {
// Write to file or stdout
if (options.values.output && options.values.output !== "-") {
if (swiftOutput.length > 0) {
fs.mkdirSync(path.dirname(options.values.output), { recursive: true });
fs.writeFileSync(options.values.output, swiftOutput);
Expand Down
34 changes: 17 additions & 17 deletions Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,13 @@ import ts from 'typescript';
export class TypeProcessor {
/**
* Create a TypeScript program from a d.ts file
* @param {string} filePath - Path to the d.ts file
* @param {string[]} filePaths - Paths to the d.ts file
* @param {ts.CompilerOptions} options - Compiler options
* @returns {ts.Program} TypeScript program object
*/
static createProgram(filePaths, options) {
const host = ts.createCompilerHost(options);
const roots = Array.isArray(filePaths) ? filePaths : [filePaths];
return ts.createProgram(roots, {
return ts.createProgram(filePaths, {
...options,
noCheck: true,
skipLibCheck: true,
Expand All @@ -39,14 +38,7 @@ export class TypeProcessor {
* @param {DiagnosticEngine} diagnosticEngine - Diagnostic engine
*/
constructor(checker, diagnosticEngine, options = {
inheritIterable: true,
inheritArraylike: true,
inheritPromiselike: true,
addAllParentMembersToClass: true,
replaceAliasToFunction: true,
replaceRankNFunction: true,
replaceNewableFunction: true,
noExtendsInTyprm: false,
defaultImportFromGlobal: false,
}) {
this.checker = checker;
this.diagnosticEngine = diagnosticEngine;
Expand Down Expand Up @@ -163,8 +155,12 @@ export class TypeProcessor {
}
}
});
} catch (error) {
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${error.message}`);
} catch (/** @type {unknown} */ error) {
if (error instanceof Error) {
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${error.message}`);
} else {
this.diagnosticEngine.print("error", `Error processing ${sourceFile.fileName}: ${String(error)}`);
}
}
}

Expand Down Expand Up @@ -388,7 +384,7 @@ export class TypeProcessor {
canBeIntEnum = false;
}
const swiftEnumName = this.renderTypeIdentifier(enumName);
const dedupeNames = (items) => {
const dedupeNames = (/** @type {{ name: string, raw: string | number }[]} */ items) => {
const seen = new Map();
return items.map(item => {
const count = seen.get(item.name) ?? 0;
Expand All @@ -401,6 +397,10 @@ export class TypeProcessor {
if (canBeStringEnum && stringMembers.length > 0) {
this.swiftLines.push(`enum ${swiftEnumName}: String {`);
for (const { name, raw } of dedupeNames(stringMembers)) {
if (typeof raw !== "string") {
this.diagnosticEngine.print("warning", `Invalid string literal: ${raw}`, diagnosticNode);
continue;
}
this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\\\"")}"`);
}
this.swiftLines.push("}");
Expand Down Expand Up @@ -815,7 +815,7 @@ export class TypeProcessor {
visitType(type, node) {
const typeArguments = this.getTypeArguments(type);
if (this.checker.isArrayType(type)) {
const typeArgs = this.checker.getTypeArguments(type);
const typeArgs = this.checker.getTypeArguments(/** @type {ts.TypeReference} */ (type));
if (typeArgs && typeArgs.length > 0) {
const elementType = this.visitType(typeArgs[0], node);
return `[${elementType}]`;
Expand Down Expand Up @@ -920,7 +920,7 @@ export class TypeProcessor {
* Convert a `Record<string, T>` TypeScript type into a Swift dictionary type.
* Falls back to `JSObject` when keys are not string-compatible or type arguments are missing.
* @param {ts.Type} type
* @param {ts.Type[]} typeArguments
* @param {readonly ts.Type[]} typeArguments
* @param {ts.Node} node
* @returns {string | null}
* @private
Expand Down Expand Up @@ -952,7 +952,7 @@ export class TypeProcessor {
/**
* Retrieve type arguments for a given type, including type alias instantiations.
* @param {ts.Type} type
* @returns {ts.Type[]}
* @returns {readonly ts.Type[]}
* @private
*/
getTypeArguments(type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const inputsDir = path.resolve(__dirname, 'fixtures');
const tsconfigPath = path.join(inputsDir, 'tsconfig.json');

function runTs2Swift(dtsPath) {
return run(dtsPath, { tsconfigPath, logLevel: 'error' });
return run([dtsPath], { tsconfigPath, logLevel: 'error' });
}

function collectDtsInputs() {
Expand Down
14 changes: 14 additions & 0 deletions Plugins/BridgeJS/Sources/TS2Swift/JavaScript/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext"
},
"include": [
"src/*.js"
]
}