diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json index de3af1ab8..0c8a50c48 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/package.json @@ -7,6 +7,7 @@ "ts2swift": "./bin/ts2swift.js" }, "scripts": { - "test": "vitest run" + "test": "vitest run", + "tsc": "tsc --noEmit" } } diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js index 98453ebf1..25d88b828 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js @@ -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]; @@ -73,20 +74,35 @@ class DiagnosticEngine { } function printUsage() { - console.error('Usage: ts2swift -p [--global ]... [-o output.swift]'); + console.error(`Usage: ts2swift [options] + + Path to a .d.ts file, or "-" to read from stdin + +Options: + -o, --output Write Swift to . Use "-" for stdout (default). + -p, --project Path to tsconfig.json (default: tsconfig.json). + --global Add a .d.ts as a global declaration file (repeatable). + --log-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); @@ -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, { @@ -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), }); @@ -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); diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index c71ff6fe5..53216a78c 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -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, @@ -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; @@ -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)}`); + } } } @@ -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; @@ -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("}"); @@ -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}]`; @@ -920,7 +920,7 @@ export class TypeProcessor { * Convert a `Record` 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 @@ -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) { diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js index a65386924..8ca1df7c9 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js @@ -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() { diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/tsconfig.json b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/tsconfig.json new file mode 100644 index 000000000..ed3dcf4bf --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "noEmit": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext" + }, + "include": [ + "src/*.js" + ] +}