Skip to content

Commit 9527aa5

Browse files
TS2Swift: emit enums for string literal unions
1 parent b10bee6 commit 9527aa5

File tree

3 files changed

+122
-0
lines changed

3 files changed

+122
-0
lines changed

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export class TypeProcessor {
5454
this.emittedEnumNames = new Set();
5555
/** @type {Set<string>} */
5656
this.emittedStructuredTypeNames = new Set();
57+
/** @type {Set<string>} */
58+
this.emittedStringLiteralUnionNames = new Set();
59+
/** @type {Set<string>} */
60+
this.emittedStringLiteralUnionNames = new Set();
5761

5862
/** @type {Set<string>} */
5963
this.visitedDeclarationKeys = new Set();
@@ -145,6 +149,11 @@ export class TypeProcessor {
145149

146150
for (const [type, node] of this.seenTypes) {
147151
this.seenTypes.delete(type);
152+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
153+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
154+
this.emitStringLiteralUnion(type, node);
155+
continue;
156+
}
148157
if (this.isEnumType(type)) {
149158
this.visitEnumType(type, node);
150159
continue;
@@ -296,6 +305,73 @@ export class TypeProcessor {
296305
return (symbol.flags & ts.SymbolFlags.Enum) !== 0;
297306
}
298307

308+
dedupeSwiftEnumCaseNames(items) {
309+
const seen = new Map();
310+
return items.map(item => {
311+
const count = seen.get(item.name) ?? 0;
312+
seen.set(item.name, count + 1);
313+
if (count === 0) return item;
314+
return { ...item, name: `${item.name}_${count + 1}` };
315+
});
316+
}
317+
318+
/**
319+
* Extract string literal values if the type is a union containing only string literals.
320+
* Returns null when any member is not a string literal.
321+
* @param {ts.Type} type
322+
* @returns {string[] | null}
323+
* @private
324+
*/
325+
getStringLiteralUnionLiterals(type) {
326+
if ((type.flags & ts.TypeFlags.Union) === 0) return null;
327+
/** @type {ts.UnionType} */
328+
// @ts-ignore
329+
const unionType = type;
330+
/** @type {string[]} */
331+
const literals = [];
332+
const seen = new Set();
333+
for (const member of unionType.types) {
334+
if ((member.flags & ts.TypeFlags.StringLiteral) === 0) {
335+
return null;
336+
}
337+
// @ts-ignore value exists for string literal types
338+
const value = String(member.value);
339+
if (seen.has(value)) continue;
340+
seen.add(value);
341+
literals.push(value);
342+
}
343+
return literals;
344+
}
345+
346+
/**
347+
* @param {ts.Type} type
348+
* @param {ts.Node} diagnosticNode
349+
* @private
350+
*/
351+
emitStringLiteralUnion(type, diagnosticNode) {
352+
const typeName = this.deriveTypeName(type);
353+
if (!typeName) return;
354+
if (this.emittedStringLiteralUnionNames.has(typeName)) return;
355+
this.emittedStringLiteralUnionNames.add(typeName);
356+
357+
const literals = this.getStringLiteralUnionLiterals(type);
358+
if (!literals || literals.length === 0) return;
359+
360+
const swiftEnumName = this.renderTypeIdentifier(typeName);
361+
/** @type {{ name: string, raw: string }[]} */
362+
const members = literals.map(raw => ({ name: makeValidSwiftIdentifier(String(raw), { emptyFallback: "_case" }), raw: String(raw) }));
363+
const deduped = this.dedupeSwiftEnumCaseNames(members);
364+
365+
this.emitDocComment(diagnosticNode, { indent: "" });
366+
this.swiftLines.push(`enum ${swiftEnumName}: String {`);
367+
for (const { name, raw } of deduped) {
368+
this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\"")}"`);
369+
}
370+
this.swiftLines.push("}");
371+
this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}`);
372+
this.swiftLines.push("");
373+
}
374+
299375
/**
300376
* @param {ts.EnumDeclaration} node
301377
* @private
@@ -841,6 +917,7 @@ export class TypeProcessor {
841917
* @returns {string}
842918
*/
843919
const convert = (type) => {
920+
const originalType = type;
844921
// Handle nullable/undefined unions (e.g. T | null, T | undefined)
845922
const isUnionType = (type.flags & ts.TypeFlags.Union) !== 0;
846923
if (isUnionType) {
@@ -863,6 +940,15 @@ export class TypeProcessor {
863940
}
864941
return `JSUndefinedOr<${wrapped}>`;
865942
}
943+
944+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
945+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
946+
const typeName = this.deriveTypeName(originalType) ?? this.deriveTypeName(type);
947+
if (typeName) {
948+
this.seenTypes.set(originalType, node);
949+
return this.renderTypeIdentifier(typeName);
950+
}
951+
}
866952
}
867953

868954
/** @type {Record<string, string>} */
@@ -892,6 +978,12 @@ export class TypeProcessor {
892978
return this.renderTypeIdentifier(typeName);
893979
}
894980

981+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
982+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
983+
this.seenTypes.set(type, node);
984+
return this.renderTypeIdentifier(this.deriveTypeName(type) ?? this.checker.typeToString(type));
985+
}
986+
895987
if (this.checker.isTupleType(type) || type.getCallSignatures().length > 0) {
896988
return "JSObject";
897989
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,37 @@ extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}
355355
356356
@JSFunction func takesFeatureFlag(_ flag: FeatureFlag) throws(JSException) -> Void
357357
358+
enum FeatureFlag: String {
359+
case foo = "foo"
360+
case bar = "bar"
361+
}
362+
extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}
363+
358364
@JSFunction func returnsFeatureFlag() throws(JSException) -> FeatureFlag
359365
"
360366
`;
361367
368+
exports[`ts2swift > snapshots Swift output for StringLiteralUnion.d.ts > StringLiteralUnion 1`] = `
369+
"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
370+
// DO NOT EDIT.
371+
//
372+
// To update this file, just rebuild your project or run
373+
// \`swift package bridge-js\`.
374+
375+
@_spi(BridgeJS) import JavaScriptKit
376+
377+
@JSFunction func move(_ direction: Direction) throws(JSException) -> Void
378+
379+
enum Direction: String {
380+
case up = "up"
381+
case down = "down"
382+
case left = "left"
383+
case right = "right"
384+
}
385+
extension Direction: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}
386+
"
387+
`;
388+
362389
exports[`ts2swift > snapshots Swift output for StringParameter.d.ts > StringParameter 1`] = `
363390
"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
364391
// DO NOT EDIT.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type Direction = "up" | "down" | "left" | "right";
2+
3+
export function move(direction: Direction): void;

0 commit comments

Comments
 (0)