diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js index 25d88b82..c7ac7905 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/cli.js @@ -122,14 +122,30 @@ export function run(filePaths, options) { } const program = TypeProcessor.createProgram([...filePaths, ...globalFiles], configParseResult.options); - const diagnostics = program.getSemanticDiagnostics(); - if (diagnostics.length > 0) { + + const formatDiagnostics = (diagnostics, kind) => { + if (diagnostics.length === 0) return null; const message = ts.formatDiagnosticsWithColorAndContext(diagnostics, { getCanonicalFileName: (fileName) => fileName, getNewLine: () => ts.sys.newLine, getCurrentDirectory: () => ts.sys.getCurrentDirectory(), }); - throw new Error(`TypeScript semantic errors:\n${message}`); + return `${kind} errors:\n${message}`; + }; + + const syntaxErrors = formatDiagnostics(program.getSyntacticDiagnostics(), "TypeScript syntax"); + if (syntaxErrors) { + throw new Error(syntaxErrors); + } + + const optionErrors = formatDiagnostics(program.getOptionsDiagnostics(), "TypeScript option"); + if (optionErrors) { + throw new Error(optionErrors); + } + + const semanticErrors = formatDiagnostics(program.getSemanticDiagnostics(), "TypeScript semantic"); + if (semanticErrors) { + throw new Error(semanticErrors); } const prelude = [ @@ -244,6 +260,13 @@ export function main(args) { cleanup(); } } + + if (swiftOutput.length === 0) { + diagnosticEngine.print( + "warning", + "No Swift declarations were generated. This usually means the .d.ts contained constructs that BridgeJS cannot import." + ); + } // Write to file or stdout if (options.values.output && options.values.output !== "-") { if (swiftOutput.length > 0) { diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index 53216a78..4f2883f5 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -54,6 +54,8 @@ export class TypeProcessor { this.emittedEnumNames = new Set(); /** @type {Set} */ this.emittedStructuredTypeNames = new Set(); + /** @type {Set} */ + this.emittedStringLiteralUnionNames = new Set(); /** @type {Set} */ this.visitedDeclarationKeys = new Set(); @@ -145,6 +147,11 @@ export class TypeProcessor { for (const [type, node] of this.seenTypes) { this.seenTypes.delete(type); + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + this.emitStringLiteralUnion(type, node); + continue; + } if (this.isEnumType(type)) { this.visitEnumType(type, node); continue; @@ -261,6 +268,7 @@ export class TypeProcessor { if (!isExported) return; const fromArg = this.renderDefaultJSImportFromArgument(); + const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0; for (const decl of node.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue; @@ -270,13 +278,30 @@ export class TypeProcessor { const swiftVarName = this.renderIdentifier(swiftName); const type = this.checker.getTypeAtLocation(decl); - const swiftType = this.visitType(type, decl); /** @type {string[]} */ const args = []; const jsNameArg = this.renderOptionalJSNameArg(jsName, swiftName); if (jsNameArg) args.push(jsNameArg); if (fromArg) args.push(fromArg); + const callSignatures = type.getCallSignatures(); + + if (isConst && callSignatures.length > 0) { + const signature = callSignatures[0]; + const parameters = signature.getParameters(); + const parameterNameMap = this.buildParameterNameMap(parameters); + const params = this.renderParameters(parameters, decl); + const returnType = this.visitType(signature.getReturnType(), decl); + const effects = this.renderEffects({ isAsync: false }); + const annotation = this.renderMacroAnnotation("JSFunction", args); + + this.emitDocComment(decl, { indent: "", parameterNameMap }); + this.swiftLines.push(`${annotation} func ${swiftVarName}(${params}) ${effects} -> ${returnType}`); + this.swiftLines.push(""); + continue; + } + + const swiftType = this.visitType(type, decl); const annotation = this.renderMacroAnnotation("JSGetter", args); this.emitDocComment(decl, { indent: "" }); @@ -296,6 +321,78 @@ export class TypeProcessor { return (symbol.flags & ts.SymbolFlags.Enum) !== 0; } + dedupeSwiftEnumCaseNames(items) { + const seen = new Map(); + return items.map(item => { + const count = seen.get(item.name) ?? 0; + seen.set(item.name, count + 1); + if (count === 0) return item; + return { ...item, name: `${item.name}_${count + 1}` }; + }); + } + + /** + * Extract string literal values if the type is a union containing only string literals. + * Returns null when any member is not a string literal. + * @param {ts.Type} type + * @returns {string[] | null} + * @private + */ + getStringLiteralUnionLiterals(type) { + if ((type.flags & ts.TypeFlags.Union) === 0) return null; + const symbol = type.getSymbol() ?? type.aliasSymbol; + // Skip enums so we don't double-generate real enum declarations. + if (symbol && (symbol.flags & ts.SymbolFlags.Enum) !== 0) { + return null; + } + /** @type {ts.UnionType} */ + // @ts-ignore + const unionType = type; + /** @type {string[]} */ + const literals = []; + const seen = new Set(); + for (const member of unionType.types) { + if ((member.flags & ts.TypeFlags.StringLiteral) === 0) { + return null; + } + // @ts-ignore value exists for string literal types + const value = String(member.value); + if (seen.has(value)) continue; + seen.add(value); + literals.push(value); + } + return literals; + } + + /** + * @param {ts.Type} type + * @param {ts.Node} diagnosticNode + * @private + */ + emitStringLiteralUnion(type, diagnosticNode) { + const typeName = this.deriveTypeName(type); + if (!typeName) return; + if (this.emittedStringLiteralUnionNames.has(typeName)) return; + this.emittedStringLiteralUnionNames.add(typeName); + + const literals = this.getStringLiteralUnionLiterals(type); + if (!literals || literals.length === 0) return; + + const swiftEnumName = this.renderTypeIdentifier(typeName); + /** @type {{ name: string, raw: string }[]} */ + const members = literals.map(raw => ({ name: makeValidSwiftIdentifier(String(raw), { emptyFallback: "_case" }), raw: String(raw) })); + const deduped = this.dedupeSwiftEnumCaseNames(members); + + this.emitDocComment(diagnosticNode, { indent: "" }); + this.swiftLines.push(`enum ${swiftEnumName}: String {`); + for (const { name, raw } of deduped) { + this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\"")}"`); + } + this.swiftLines.push("}"); + this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}`); + this.swiftLines.push(""); + } + /** * @param {ts.EnumDeclaration} node * @private @@ -636,7 +733,7 @@ export class TypeProcessor { /** * Visit a property declaration and extract metadata * @param {ts.PropertyDeclaration | ts.PropertySignature} node - * @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean } | null} + * @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean, isStatic: boolean } | null} */ visitPropertyDecl(node) { if (!node.name) return null; @@ -656,7 +753,8 @@ export class TypeProcessor { const type = this.checker.getTypeAtLocation(node) const swiftType = this.visitType(type, node); const isReadonly = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false; - return { jsName, swiftName, type: swiftType, isReadonly }; + const isStatic = node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false; + return { jsName, swiftName, type: swiftType, isReadonly, isStatic }; } /** @@ -841,6 +939,7 @@ export class TypeProcessor { * @returns {string} */ const convert = (type) => { + const originalType = type; // Handle nullable/undefined unions (e.g. T | null, T | undefined) const isUnionType = (type.flags & ts.TypeFlags.Union) !== 0; if (isUnionType) { @@ -863,6 +962,15 @@ export class TypeProcessor { } return `JSUndefinedOr<${wrapped}>`; } + + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + const typeName = this.deriveTypeName(originalType) ?? this.deriveTypeName(type); + if (typeName) { + this.seenTypes.set(originalType, node); + return this.renderTypeIdentifier(typeName); + } + } } /** @type {Record} */ @@ -892,6 +1000,12 @@ export class TypeProcessor { return this.renderTypeIdentifier(typeName); } + const stringLiteralUnion = this.getStringLiteralUnionLiterals(type); + if (stringLiteralUnion && stringLiteralUnion.length > 0) { + this.seenTypes.set(type, node); + return this.renderTypeIdentifier(this.deriveTypeName(type) ?? this.checker.typeToString(type)); + } + if (this.checker.isTupleType(type) || type.getCallSignatures().length > 0) { return "JSObject"; } @@ -993,6 +1107,7 @@ export class TypeProcessor { const type = property.type; const swiftName = this.renderIdentifier(property.swiftName); + const isStatic = property.isStatic; const needsJSGetterName = property.jsName !== property.swiftName; // Note: `from: .global` is only meaningful for top-level imports and constructors. // Instance member access always comes from the JS object itself. @@ -1002,10 +1117,11 @@ export class TypeProcessor { if (needsJSGetterName) getterArgs.push(`jsName: "${this.escapeForSwiftStringLiteral(property.jsName)}"`); if (fromArg) getterArgs.push(fromArg); const getterAnnotation = this.renderMacroAnnotation("JSGetter", getterArgs); + const staticKeyword = isStatic ? "static " : ""; // Always render getter this.emitDocComment(node, { indent: " " }); - this.swiftLines.push(` ${getterAnnotation} var ${swiftName}: ${type}`); + this.swiftLines.push(` ${getterAnnotation} ${staticKeyword}var ${swiftName}: ${type}`); // Render setter if not readonly if (!property.isReadonly) { @@ -1018,7 +1134,7 @@ export class TypeProcessor { if (needsJSNameField) setterArgs.push(`jsName: "${this.escapeForSwiftStringLiteral(property.jsName)}"`); if (fromArg) setterArgs.push(fromArg); const annotation = this.renderMacroAnnotation("JSSetter", setterArgs); - this.swiftLines.push(` ${annotation} func ${this.renderIdentifier(setterName)}(_ value: ${type}) ${this.renderEffects({ isAsync: false })}`); + this.swiftLines.push(` ${annotation} ${staticKeyword}func ${this.renderIdentifier(setterName)}(_ value: ${type}) ${this.renderEffects({ isAsync: false })}`); } } diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap index 85d1da0d..7f7a7de6 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/__snapshots__/ts2swift.test.js.snap @@ -48,6 +48,22 @@ exports[`ts2swift > snapshots Swift output for Async.d.ts > Async 1`] = ` " `; +exports[`ts2swift > snapshots Swift output for CallableConst.d.ts > CallableConst 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(BridgeJS) import JavaScriptKit + +@JSFunction func fetch(_ url: String) throws(JSException) -> Response + +@JSClass struct Response { +} +" +`; + exports[`ts2swift > snapshots Swift output for Documentation.d.ts > Documentation 1`] = ` "// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. @@ -338,6 +354,22 @@ exports[`ts2swift > snapshots Swift output for RecordDictionary.d.ts > RecordDic " `; +exports[`ts2swift > snapshots Swift output for StaticProperty.d.ts > StaticProperty 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(BridgeJS) import JavaScriptKit + +@JSClass struct Library { + @JSGetter static var version: String + @JSSetter static func setVersion(_ value: String) throws(JSException) +} +" +`; + exports[`ts2swift > snapshots Swift output for StringEnum.d.ts > StringEnum 1`] = ` "// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. @@ -359,6 +391,27 @@ extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} " `; +exports[`ts2swift > snapshots Swift output for StringLiteralUnion.d.ts > StringLiteralUnion 1`] = ` +"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// \`swift package bridge-js\`. + +@_spi(BridgeJS) import JavaScriptKit + +@JSFunction func move(_ direction: Direction) throws(JSException) -> Void + +enum Direction: String { + case up = "up" + case down = "down" + case left = "left" + case right = "right" +} +extension Direction: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {} +" +`; + exports[`ts2swift > snapshots Swift output for StringParameter.d.ts > StringParameter 1`] = ` "// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, // DO NOT EDIT. diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/CallableConst.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/CallableConst.d.ts new file mode 100644 index 00000000..3df79d59 --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/CallableConst.d.ts @@ -0,0 +1,2 @@ +export interface Response {} +export const fetch: (url: string) => Response; diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StaticProperty.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StaticProperty.d.ts new file mode 100644 index 00000000..afef26b5 --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StaticProperty.d.ts @@ -0,0 +1,3 @@ +export class Library { + static version: string; +} diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts new file mode 100644 index 00000000..2c01a23f --- /dev/null +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/fixtures/StringLiteralUnion.d.ts @@ -0,0 +1,3 @@ +export type Direction = "up" | "down" | "left" | "right"; + +export function move(direction: Direction): void; diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js index 8ca1df7c..d0ccf220 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/test/ts2swift.test.js @@ -1,8 +1,9 @@ // @ts-check import { describe, it, expect } from 'vitest'; -import { readdirSync } from 'fs'; +import { readdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'; import { fileURLToPath } from 'url'; import path from 'path'; +import os from 'os'; import { run } from '../src/cli.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -38,4 +39,15 @@ describe('ts2swift', () => { expect(swiftOutput).toMatchSnapshot(name); }); } + + it('reports TypeScript syntax errors via thrown message', () => { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'ts2swift-invalid-')); + const invalidPath = path.join(tmpDir, 'invalid.d.ts'); + writeFileSync(invalidPath, 'function foo(x'); + try { + expect(() => runTs2Swift(invalidPath)).toThrowError(/TypeScript syntax errors/); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index e0bcf3e0..d7927547 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -30,6 +30,7 @@ const decode = (kind, payload1, payload2, objectSpace) => { case 1: return true; } + // falls through case 2 /* Kind.Number */: return payload2; case 1 /* Kind.String */: @@ -555,7 +556,7 @@ class SwiftRuntime { swjs_call_function: (ref, argv, argc, payload1_ptr, payload2_ptr) => { const memory = this.memory; const func = memory.getObject(ref); - let result = undefined; + let result; try { const args = decodeArray(argv, argc, this.getDataView(), memory); result = func(...args); @@ -590,9 +591,8 @@ class SwiftRuntime { const memory = this.memory; const obj = memory.getObject(obj_ref); const func = memory.getObject(func_ref); - let result = undefined; const args = decodeArray(argv, argc, this.getDataView(), memory); - result = func.apply(obj, args); + const result = func.apply(obj, args); return writeAndReturnKindBits(result, payload1_ptr, payload2_ptr, false, this.getDataView(), this.memory); }, swjs_call_new: (ref, argv, argc) => { @@ -744,9 +744,10 @@ class SwiftRuntime { broker.onReceivingResponse(message); break; } - default: + default: { const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); + } } }); }, @@ -773,9 +774,10 @@ class SwiftRuntime { broker.onReceivingResponse(message); break; } - default: + default: { const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); + } } }); }, diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 1d36c95d..5d6fe258 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -430,7 +430,7 @@ export class SwiftRuntime { ) => { const memory = this.memory; const func = memory.getObject(ref); - let result = undefined; + let result: any; try { const args = JSValue.decodeArray( argv, @@ -534,14 +534,13 @@ export class SwiftRuntime { const memory = this.memory; const obj = memory.getObject(obj_ref); const func = memory.getObject(func_ref); - let result = undefined; const args = JSValue.decodeArray( argv, argc, this.getDataView(), memory, ); - result = func.apply(obj, args); + const result = func.apply(obj, args); return JSValue.writeAndReturnKindBits( result, payload1_ptr, @@ -799,11 +798,12 @@ export class SwiftRuntime { broker.onReceivingResponse(message); break; } - default: + default: { const unknownMessage: never = message; throw new Error( `Unknown message type: ${unknownMessage}`, ); + } } }); }, @@ -838,11 +838,12 @@ export class SwiftRuntime { broker.onReceivingResponse(message); break; } - default: + default: { const unknownMessage: never = message; throw new Error( `Unknown message type: ${unknownMessage}`, ); + } } }); }, diff --git a/Runtime/src/js-value.ts b/Runtime/src/js-value.ts index 26574b53..b044e5cb 100644 --- a/Runtime/src/js-value.ts +++ b/Runtime/src/js-value.ts @@ -31,6 +31,7 @@ export const decode = ( case 1: return true; } + // falls through case Kind.Number: return payload2;