Skip to content

Commit 8565d3b

Browse files
BridgeJS: Import TypeScript enums into Swift (#521)
* Implemented TypeScript `enum` → Swift `enum` import for BridgeJS (string-valued enums, plus int-valued as a bonus). - `Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js`: emits Swift `enum Name: String { ... }` (or `: Int`) for TS enums, adds `extension Name: _BridgedSwiftEnumNoPayload {}`, and ensures enum-typed parameters/returns stay typed as the enum (not downgraded to `String`). - `Plugins/BridgeJS/Sources/BridgeJSCore/ImportSwiftMacros.swift`: resolves referenced Swift enums/typealiases via `TypeDeclResolver` so `FeatureFlag` becomes `.rawValueEnum("FeatureFlag", .string)` in the imported skeleton. - `Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift`: enables lowering/lifting for `.rawValueEnum` in the `.importTS` context (parameters + returns). - Added coverage: `Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/StringEnum.d.ts` with new snapshots `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.Macros.swift` and `Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.swift`. - Verified with `swift test --package-path ./Plugins/BridgeJS --filter ImportTSTests`. * Added a BridgeJS runtime import test for TS string enums. - Updated `Tests/BridgeJSRuntimeTests/bridge-js.d.ts` with `FeatureFlag` + `jsRoundTripFeatureFlag`. - Added JS implementation in `Tests/prelude.mjs`. - Added XCTest in `Tests/BridgeJSRuntimeTests/ImportAPITests.swift` (`testRoundTripFeatureFlag`). - Regenerated runtime fixtures under `Tests/BridgeJSRuntimeTests/Generated/` (via `BridgeJSTool generate`). - Verified runtime: `make unittest SWIFT_SDK_ID=DEVELOPMENT-SNAPSHOT+MAIN-wasm32-unknown-wasip1-threads` (passes).
1 parent cd3edbe commit 8565d3b

File tree

13 files changed

+562
-3
lines changed

13 files changed

+562
-3
lines changed

Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,7 @@ extension BridgeType {
869869
case .rawValueEnum(_, let rawType):
870870
switch context {
871871
case .importTS:
872-
throw BridgeJSCoreError("Enum types are not yet supported in TypeScript imports")
872+
return LoweringParameterInfo(loweredParameters: [("value", rawType.wasmCoreType ?? .i32)])
873873
case .exportSwift:
874874
// For protocol export we return .i32 for String raw value type instead of nil
875875
return LoweringParameterInfo(loweredParameters: [("value", rawType.wasmCoreType ?? .i32)])
@@ -952,7 +952,7 @@ extension BridgeType {
952952
case .rawValueEnum(_, let rawType):
953953
switch context {
954954
case .importTS:
955-
throw BridgeJSCoreError("Enum types are not yet supported in TypeScript imports")
955+
return LiftingReturnInfo(valueToLift: rawType.wasmCoreType ?? .i32)
956956
case .exportSwift:
957957
// For protocol export we return .i32 for String raw value type instead of nil
958958
return LiftingReturnInfo(valueToLift: rawType.wasmCoreType ?? .i32)

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export class TypeProcessor {
5353
this.seenTypes = new Map();
5454
/** @type {string[]} Collected Swift code lines */
5555
this.swiftLines = [];
56+
/** @type {Set<string>} */
57+
this.emittedEnumNames = new Set();
58+
/** @type {Set<string>} */
59+
this.emittedStructuredTypeNames = new Set();
5660

5761
/** @type {Set<string>} */
5862
this.visitedDeclarationKeys = new Set();
@@ -92,6 +96,10 @@ export class TypeProcessor {
9296

9397
for (const [type, node] of this.seenTypes) {
9498
this.seenTypes.delete(type);
99+
if (this.isEnumType(type)) {
100+
this.visitEnumType(type, node);
101+
continue;
102+
}
95103
const typeString = this.checker.typeToString(type);
96104
const members = type.getProperties();
97105
if (members) {
@@ -119,6 +127,8 @@ export class TypeProcessor {
119127
this.visitFunctionDeclaration(node);
120128
} else if (ts.isClassDeclaration(node)) {
121129
this.visitClassDecl(node);
130+
} else if (ts.isEnumDeclaration(node)) {
131+
this.visitEnumDeclaration(node);
122132
} else if (ts.isExportDeclaration(node)) {
123133
this.visitExportDeclaration(node);
124134
}
@@ -185,6 +195,145 @@ export class TypeProcessor {
185195
}
186196
}
187197

198+
/**
199+
* @param {ts.Type} type
200+
* @returns {boolean}
201+
* @private
202+
*/
203+
isEnumType(type) {
204+
const symbol = type.getSymbol() ?? type.aliasSymbol;
205+
if (!symbol) return false;
206+
return (symbol.flags & ts.SymbolFlags.Enum) !== 0;
207+
}
208+
209+
/**
210+
* @param {ts.EnumDeclaration} node
211+
* @private
212+
*/
213+
visitEnumDeclaration(node) {
214+
const name = node.name?.text;
215+
if (!name) return;
216+
this.emitEnumFromDeclaration(name, node, node);
217+
}
218+
219+
/**
220+
* @param {ts.Type} type
221+
* @param {ts.Node} node
222+
* @private
223+
*/
224+
visitEnumType(type, node) {
225+
const symbol = type.getSymbol() ?? type.aliasSymbol;
226+
const name = symbol?.name;
227+
if (!name) return;
228+
const decl = symbol?.getDeclarations()?.find(d => ts.isEnumDeclaration(d));
229+
if (!decl || !ts.isEnumDeclaration(decl)) {
230+
this.diagnosticEngine.print("warning", `Enum declaration not found for type: ${name}`, node);
231+
return;
232+
}
233+
this.emitEnumFromDeclaration(name, decl, node);
234+
}
235+
236+
/**
237+
* @param {string} enumName
238+
* @param {ts.EnumDeclaration} decl
239+
* @param {ts.Node} diagnosticNode
240+
* @private
241+
*/
242+
emitEnumFromDeclaration(enumName, decl, diagnosticNode) {
243+
if (this.emittedEnumNames.has(enumName)) return;
244+
this.emittedEnumNames.add(enumName);
245+
246+
const members = decl.members ?? [];
247+
if (members.length === 0) {
248+
this.diagnosticEngine.print("warning", `Empty enum is not supported: ${enumName}`, diagnosticNode);
249+
this.swiftLines.push(`typealias ${this.renderIdentifier(enumName)} = String`);
250+
this.swiftLines.push("");
251+
return;
252+
}
253+
254+
/** @type {{ name: string, raw: string }[]} */
255+
const stringMembers = [];
256+
/** @type {{ name: string, raw: number }[]} */
257+
const intMembers = [];
258+
let canBeStringEnum = true;
259+
let canBeIntEnum = true;
260+
let nextAutoValue = 0;
261+
262+
for (const member of members) {
263+
const rawMemberName = member.name.getText();
264+
const unquotedName = rawMemberName.replace(/^["']|["']$/g, "");
265+
const swiftCaseNameBase = makeValidSwiftIdentifier(unquotedName, { emptyFallback: "_case" });
266+
267+
if (member.initializer && ts.isStringLiteral(member.initializer)) {
268+
stringMembers.push({ name: swiftCaseNameBase, raw: member.initializer.text });
269+
canBeIntEnum = false;
270+
continue;
271+
}
272+
273+
if (member.initializer && ts.isNumericLiteral(member.initializer)) {
274+
const rawValue = Number(member.initializer.text);
275+
if (!Number.isInteger(rawValue)) {
276+
canBeIntEnum = false;
277+
} else {
278+
intMembers.push({ name: swiftCaseNameBase, raw: rawValue });
279+
nextAutoValue = rawValue + 1;
280+
canBeStringEnum = false;
281+
continue;
282+
}
283+
}
284+
285+
if (!member.initializer) {
286+
intMembers.push({ name: swiftCaseNameBase, raw: nextAutoValue });
287+
nextAutoValue += 1;
288+
canBeStringEnum = false;
289+
continue;
290+
}
291+
292+
canBeStringEnum = false;
293+
canBeIntEnum = false;
294+
}
295+
const swiftEnumName = this.renderIdentifier(enumName);
296+
const dedupeNames = (items) => {
297+
const seen = new Map();
298+
return items.map(item => {
299+
const count = seen.get(item.name) ?? 0;
300+
seen.set(item.name, count + 1);
301+
if (count === 0) return item;
302+
return { ...item, name: `${item.name}_${count + 1}` };
303+
});
304+
};
305+
306+
if (canBeStringEnum && stringMembers.length > 0) {
307+
this.swiftLines.push(`enum ${swiftEnumName}: String {`);
308+
for (const { name, raw } of dedupeNames(stringMembers)) {
309+
this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\\\"")}"`);
310+
}
311+
this.swiftLines.push("}");
312+
this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload {}`);
313+
this.swiftLines.push("");
314+
return;
315+
}
316+
317+
if (canBeIntEnum && intMembers.length > 0) {
318+
this.swiftLines.push(`enum ${swiftEnumName}: Int {`);
319+
for (const { name, raw } of dedupeNames(intMembers)) {
320+
this.swiftLines.push(` case ${this.renderIdentifier(name)} = ${raw}`);
321+
}
322+
this.swiftLines.push("}");
323+
this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload {}`);
324+
this.swiftLines.push("");
325+
return;
326+
}
327+
328+
this.diagnosticEngine.print(
329+
"warning",
330+
`Unsupported enum (only string or int enums are supported): ${enumName}`,
331+
diagnosticNode
332+
);
333+
this.swiftLines.push(`typealias ${swiftEnumName} = String`);
334+
this.swiftLines.push("");
335+
}
336+
188337
/**
189338
* Visit a function declaration and render Swift code
190339
* @param {ts.FunctionDeclaration} node - The function node
@@ -332,6 +481,9 @@ export class TypeProcessor {
332481
* @private
333482
*/
334483
visitStructuredType(name, members) {
484+
if (this.emittedStructuredTypeNames.has(name)) return;
485+
this.emittedStructuredTypeNames.add(name);
486+
335487
const typeName = this.renderIdentifier(name);
336488
this.swiftLines.push(`@JSClass struct ${typeName} {`);
337489

@@ -415,6 +567,13 @@ export class TypeProcessor {
415567
return typeMap[typeString];
416568
}
417569

570+
const symbol = type.getSymbol() ?? type.aliasSymbol;
571+
if (symbol && (symbol.flags & ts.SymbolFlags.Enum) !== 0) {
572+
const typeName = symbol.name;
573+
this.seenTypes.set(type, node);
574+
return this.renderIdentifier(typeName);
575+
}
576+
418577
if (this.checker.isArrayType(type) || this.checker.isTupleType(type) || type.getCallSignatures().length > 0) {
419578
return "JSObject";
420579
}
@@ -623,3 +782,33 @@ export function isValidSwiftDeclName(name) {
623782
const swiftIdentifierRegex = /^[_\p{ID_Start}][\p{ID_Continue}\u{200C}\u{200D}]*$/u;
624783
return swiftIdentifierRegex.test(name);
625784
}
785+
786+
/**
787+
* Convert an arbitrary string into a valid Swift identifier.
788+
* @param {string} name
789+
* @param {{ emptyFallback?: string }} options
790+
* @returns {string}
791+
*/
792+
function makeValidSwiftIdentifier(name, options = {}) {
793+
const emptyFallback = options.emptyFallback ?? "_";
794+
let result = "";
795+
for (const ch of name) {
796+
const isIdentifierChar = /^[_\p{ID_Continue}\u{200C}\u{200D}]$/u.test(ch);
797+
result += isIdentifierChar ? ch : "_";
798+
}
799+
if (!result) result = emptyFallback;
800+
if (!/^[_\p{ID_Start}]$/u.test(result[0])) {
801+
result = "_" + result;
802+
}
803+
if (!isValidSwiftDeclName(result)) {
804+
result = result.replace(/[^_\p{ID_Continue}\u{200C}\u{200D}]/gu, "_");
805+
if (!result) result = emptyFallback;
806+
if (!/^[_\p{ID_Start}]$/u.test(result[0])) {
807+
result = "_" + result;
808+
}
809+
}
810+
if (isSwiftKeyword(result)) {
811+
result = result + "_";
812+
}
813+
return result;
814+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export enum FeatureFlag {
2+
foo = "foo",
3+
bar = "bar",
4+
}
5+
6+
export function takesFeatureFlag(flag: FeatureFlag): void
7+
8+
export function returnsFeatureFlag(): FeatureFlag
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
2+
// DO NOT EDIT.
3+
//
4+
// To update this file, just rebuild your project or run
5+
// `swift package bridge-js`.
6+
7+
export type Exports = {
8+
}
9+
export type Imports = {
10+
takesFeatureFlag(flag: FeatureFlagTag): void;
11+
returnsFeatureFlag(): FeatureFlagTag;
12+
}
13+
export function createInstantiator(options: {
14+
imports: Imports;
15+
}, swift: any): Promise<{
16+
addImports: (importObject: WebAssembly.Imports) => void;
17+
setInstance: (instance: WebAssembly.Instance) => void;
18+
createExports: (instance: WebAssembly.Instance) => Exports;
19+
}>;

0 commit comments

Comments
 (0)