Skip to content

Commit b18be89

Browse files
authored
Merge pull request swiftwasm#654 from swiftwasm/katei/5460-ts2swift-generat
TS2Swift: emit enums for string literal unions
2 parents 9c092f6 + 002ffd9 commit b18be89

File tree

3 files changed

+119
-0
lines changed

3 files changed

+119
-0
lines changed

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ 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();
5759

5860
/** @type {Set<string>} */
5961
this.visitedDeclarationKeys = new Set();
@@ -145,6 +147,11 @@ export class TypeProcessor {
145147

146148
for (const [type, node] of this.seenTypes) {
147149
this.seenTypes.delete(type);
150+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
151+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
152+
this.emitStringLiteralUnion(type, node);
153+
continue;
154+
}
148155
if (this.isEnumType(type)) {
149156
this.visitEnumType(type, node);
150157
continue;
@@ -314,6 +321,78 @@ export class TypeProcessor {
314321
return (symbol.flags & ts.SymbolFlags.Enum) !== 0;
315322
}
316323

324+
dedupeSwiftEnumCaseNames(items) {
325+
const seen = new Map();
326+
return items.map(item => {
327+
const count = seen.get(item.name) ?? 0;
328+
seen.set(item.name, count + 1);
329+
if (count === 0) return item;
330+
return { ...item, name: `${item.name}_${count + 1}` };
331+
});
332+
}
333+
334+
/**
335+
* Extract string literal values if the type is a union containing only string literals.
336+
* Returns null when any member is not a string literal.
337+
* @param {ts.Type} type
338+
* @returns {string[] | null}
339+
* @private
340+
*/
341+
getStringLiteralUnionLiterals(type) {
342+
if ((type.flags & ts.TypeFlags.Union) === 0) return null;
343+
const symbol = type.getSymbol() ?? type.aliasSymbol;
344+
// Skip enums so we don't double-generate real enum declarations.
345+
if (symbol && (symbol.flags & ts.SymbolFlags.Enum) !== 0) {
346+
return null;
347+
}
348+
/** @type {ts.UnionType} */
349+
// @ts-ignore
350+
const unionType = type;
351+
/** @type {string[]} */
352+
const literals = [];
353+
const seen = new Set();
354+
for (const member of unionType.types) {
355+
if ((member.flags & ts.TypeFlags.StringLiteral) === 0) {
356+
return null;
357+
}
358+
// @ts-ignore value exists for string literal types
359+
const value = String(member.value);
360+
if (seen.has(value)) continue;
361+
seen.add(value);
362+
literals.push(value);
363+
}
364+
return literals;
365+
}
366+
367+
/**
368+
* @param {ts.Type} type
369+
* @param {ts.Node} diagnosticNode
370+
* @private
371+
*/
372+
emitStringLiteralUnion(type, diagnosticNode) {
373+
const typeName = this.deriveTypeName(type);
374+
if (!typeName) return;
375+
if (this.emittedStringLiteralUnionNames.has(typeName)) return;
376+
this.emittedStringLiteralUnionNames.add(typeName);
377+
378+
const literals = this.getStringLiteralUnionLiterals(type);
379+
if (!literals || literals.length === 0) return;
380+
381+
const swiftEnumName = this.renderTypeIdentifier(typeName);
382+
/** @type {{ name: string, raw: string }[]} */
383+
const members = literals.map(raw => ({ name: makeValidSwiftIdentifier(String(raw), { emptyFallback: "_case" }), raw: String(raw) }));
384+
const deduped = this.dedupeSwiftEnumCaseNames(members);
385+
386+
this.emitDocComment(diagnosticNode, { indent: "" });
387+
this.swiftLines.push(`enum ${swiftEnumName}: String {`);
388+
for (const { name, raw } of deduped) {
389+
this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\"")}"`);
390+
}
391+
this.swiftLines.push("}");
392+
this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}`);
393+
this.swiftLines.push("");
394+
}
395+
317396
/**
318397
* @param {ts.EnumDeclaration} node
319398
* @private
@@ -860,6 +939,7 @@ export class TypeProcessor {
860939
* @returns {string}
861940
*/
862941
const convert = (type) => {
942+
const originalType = type;
863943
// Handle nullable/undefined unions (e.g. T | null, T | undefined)
864944
const isUnionType = (type.flags & ts.TypeFlags.Union) !== 0;
865945
if (isUnionType) {
@@ -882,6 +962,15 @@ export class TypeProcessor {
882962
}
883963
return `JSUndefinedOr<${wrapped}>`;
884964
}
965+
966+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
967+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
968+
const typeName = this.deriveTypeName(originalType) ?? this.deriveTypeName(type);
969+
if (typeName) {
970+
this.seenTypes.set(originalType, node);
971+
return this.renderTypeIdentifier(typeName);
972+
}
973+
}
885974
}
886975

887976
/** @type {Record<string, string>} */
@@ -911,6 +1000,12 @@ export class TypeProcessor {
9111000
return this.renderTypeIdentifier(typeName);
9121001
}
9131002

1003+
const stringLiteralUnion = this.getStringLiteralUnionLiterals(type);
1004+
if (stringLiteralUnion && stringLiteralUnion.length > 0) {
1005+
this.seenTypes.set(type, node);
1006+
return this.renderTypeIdentifier(this.deriveTypeName(type) ?? this.checker.typeToString(type));
1007+
}
1008+
9141009
if (this.checker.isTupleType(type) || type.getCallSignatures().length > 0) {
9151010
return "JSObject";
9161011
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,27 @@ extension FeatureFlag: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}
391391
"
392392
`;
393393
394+
exports[`ts2swift > snapshots Swift output for StringLiteralUnion.d.ts > StringLiteralUnion 1`] = `
395+
"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
396+
// DO NOT EDIT.
397+
//
398+
// To update this file, just rebuild your project or run
399+
// \`swift package bridge-js\`.
400+
401+
@_spi(BridgeJS) import JavaScriptKit
402+
403+
@JSFunction func move(_ direction: Direction) throws(JSException) -> Void
404+
405+
enum Direction: String {
406+
case up = "up"
407+
case down = "down"
408+
case left = "left"
409+
case right = "right"
410+
}
411+
extension Direction: _BridgedSwiftEnumNoPayload, _BridgedSwiftRawValueEnum {}
412+
"
413+
`;
414+
394415
exports[`ts2swift > snapshots Swift output for StringParameter.d.ts > StringParameter 1`] = `
395416
"// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
396417
// 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)