diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index 8287a056..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; @@ -314,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 @@ -860,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) { @@ -882,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} */ @@ -911,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"; } 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 a83014e8..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 @@ -391,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/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;