From 5ac25c35a405640d22f2daeabdac27e0f124fde9 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 22 Jan 2026 09:44:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Implemented=20TypeScript=20`enum`=20?= =?UTF-8?q?=E2=86=92=20Swift=20`enum`=20import=20for=20BridgeJS=20(string-?= =?UTF-8?q?valued=20enums,=20plus=20int-valued=20as=20a=20bonus).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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`. --- .../Sources/BridgeJSCore/ImportTS.swift | 4 +- .../TS2Swift/JavaScript/src/processor.js | 188 ++++++++++++++ .../BridgeJSToolTests/Inputs/StringEnum.d.ts | 8 + .../BridgeJSLinkTests/StringEnum.Import.d.ts | 19 ++ .../BridgeJSLinkTests/StringEnum.Import.js | 231 ++++++++++++++++++ .../ImportTSTests/StringEnum.Macros.swift | 17 ++ .../ImportTSTests/StringEnum.swift | 33 +++ 7 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/StringEnum.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.Macros.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.swift diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift index 27f5a7c5..acbf995b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift @@ -869,7 +869,7 @@ extension BridgeType { case .rawValueEnum(_, let rawType): switch context { case .importTS: - throw BridgeJSCoreError("Enum types are not yet supported in TypeScript imports") + return LoweringParameterInfo(loweredParameters: [("value", rawType.wasmCoreType ?? .i32)]) case .exportSwift: // For protocol export we return .i32 for String raw value type instead of nil return LoweringParameterInfo(loweredParameters: [("value", rawType.wasmCoreType ?? .i32)]) @@ -952,7 +952,7 @@ extension BridgeType { case .rawValueEnum(_, let rawType): switch context { case .importTS: - throw BridgeJSCoreError("Enum types are not yet supported in TypeScript imports") + return LiftingReturnInfo(valueToLift: rawType.wasmCoreType ?? .i32) case .exportSwift: // For protocol export we return .i32 for String raw value type instead of nil return LiftingReturnInfo(valueToLift: rawType.wasmCoreType ?? .i32) diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index 50f797de..7b5f6ff6 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -53,6 +53,10 @@ export class TypeProcessor { this.seenTypes = new Map(); /** @type {string[]} Collected Swift code lines */ this.swiftLines = []; + /** @type {Set} */ + this.emittedEnumNames = new Set(); + /** @type {Set} */ + this.emittedStructuredTypeNames = new Set(); /** @type {Set} */ this.visitedDeclarationKeys = new Set(); @@ -92,6 +96,10 @@ export class TypeProcessor { for (const [type, node] of this.seenTypes) { this.seenTypes.delete(type); + if (this.isEnumType(type)) { + this.visitEnumType(type, node); + continue; + } const typeString = this.checker.typeToString(type); const members = type.getProperties(); if (members) { @@ -119,6 +127,8 @@ export class TypeProcessor { this.visitFunctionDeclaration(node); } else if (ts.isClassDeclaration(node)) { this.visitClassDecl(node); + } else if (ts.isEnumDeclaration(node)) { + this.visitEnumDeclaration(node); } else if (ts.isExportDeclaration(node)) { this.visitExportDeclaration(node); } @@ -185,6 +195,174 @@ export class TypeProcessor { } } + /** + * @param {ts.Type} type + * @returns {boolean} + * @private + */ + isEnumType(type) { + const symbol = type.getSymbol() ?? type.aliasSymbol; + if (!symbol) return false; + return (symbol.flags & ts.SymbolFlags.Enum) !== 0; + } + + /** + * @param {ts.EnumDeclaration} node + * @private + */ + visitEnumDeclaration(node) { + const name = node.name?.text; + if (!name) return; + this.emitEnumFromDeclaration(name, node, node); + } + + /** + * @param {ts.Type} type + * @param {ts.Node} node + * @private + */ + visitEnumType(type, node) { + const symbol = type.getSymbol() ?? type.aliasSymbol; + const name = symbol?.name; + if (!name) return; + const decl = symbol?.getDeclarations()?.find(d => ts.isEnumDeclaration(d)); + if (!decl || !ts.isEnumDeclaration(decl)) { + this.diagnosticEngine.print("warning", `Enum declaration not found for type: ${name}`, node); + return; + } + this.emitEnumFromDeclaration(name, decl, node); + } + + /** + * @param {string} enumName + * @param {ts.EnumDeclaration} decl + * @param {ts.Node} diagnosticNode + * @private + */ + emitEnumFromDeclaration(enumName, decl, diagnosticNode) { + if (this.emittedEnumNames.has(enumName)) return; + this.emittedEnumNames.add(enumName); + + const members = decl.members ?? []; + if (members.length === 0) { + this.diagnosticEngine.print("warning", `Empty enum is not supported: ${enumName}`, diagnosticNode); + this.swiftLines.push(`typealias ${this.renderIdentifier(enumName)} = String`); + this.swiftLines.push(""); + return; + } + + /** + * Convert a TypeScript enum member name into a valid Swift identifier. + * @param {string} name + * @returns {string} + */ + const toSwiftCaseName = (name) => { + const swiftIdentifierRegex = /^[_\p{ID_Start}][\p{ID_Continue}\u{200C}\u{200D}]*$/u; + let result = ""; + for (const ch of name) { + const isIdentifierChar = /^[_\p{ID_Continue}\u{200C}\u{200D}]$/u.test(ch); + result += isIdentifierChar ? ch : "_"; + } + if (!result) result = "_case"; + if (!/^[_\p{ID_Start}]$/u.test(result[0])) { + result = "_" + result; + } + if (!swiftIdentifierRegex.test(result)) { + result = result.replace(/[^_\p{ID_Continue}\u{200C}\u{200D}]/gu, "_"); + if (!result) result = "_case"; + if (!/^[_\p{ID_Start}]$/u.test(result[0])) { + result = "_" + result; + } + } + if (isSwiftKeyword(result)) { + result = result + "_"; + } + return result; + }; + + /** @type {{ name: string, raw: string }[]} */ + const stringMembers = []; + /** @type {{ name: string, raw: number }[]} */ + const intMembers = []; + let canBeStringEnum = true; + let canBeIntEnum = true; + let nextAutoValue = 0; + + for (const member of members) { + const rawMemberName = member.name.getText(); + const unquotedName = rawMemberName.replace(/^["']|["']$/g, ""); + const swiftCaseNameBase = toSwiftCaseName(unquotedName); + + if (member.initializer && ts.isStringLiteral(member.initializer)) { + stringMembers.push({ name: swiftCaseNameBase, raw: member.initializer.text }); + canBeIntEnum = false; + continue; + } + + if (member.initializer && ts.isNumericLiteral(member.initializer)) { + const rawValue = Number(member.initializer.text); + if (!Number.isInteger(rawValue)) { + canBeIntEnum = false; + } else { + intMembers.push({ name: swiftCaseNameBase, raw: rawValue }); + nextAutoValue = rawValue + 1; + canBeStringEnum = false; + continue; + } + } + + if (!member.initializer) { + intMembers.push({ name: swiftCaseNameBase, raw: nextAutoValue }); + nextAutoValue += 1; + canBeStringEnum = false; + continue; + } + + canBeStringEnum = false; + canBeIntEnum = false; + } + const swiftEnumName = this.renderIdentifier(enumName); + const dedupeNames = (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}` }; + }); + }; + + if (canBeStringEnum && stringMembers.length > 0) { + this.swiftLines.push(`enum ${swiftEnumName}: String {`); + for (const { name, raw } of dedupeNames(stringMembers)) { + this.swiftLines.push(` case ${this.renderIdentifier(name)} = "${raw.replaceAll("\"", "\\\\\"")}"`); + } + this.swiftLines.push("}"); + this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload {}`); + this.swiftLines.push(""); + return; + } + + if (canBeIntEnum && intMembers.length > 0) { + this.swiftLines.push(`enum ${swiftEnumName}: Int {`); + for (const { name, raw } of dedupeNames(intMembers)) { + this.swiftLines.push(` case ${this.renderIdentifier(name)} = ${raw}`); + } + this.swiftLines.push("}"); + this.swiftLines.push(`extension ${swiftEnumName}: _BridgedSwiftEnumNoPayload {}`); + this.swiftLines.push(""); + return; + } + + this.diagnosticEngine.print( + "warning", + `Unsupported enum (only string or int enums are supported): ${enumName}`, + diagnosticNode + ); + this.swiftLines.push(`typealias ${swiftEnumName} = String`); + this.swiftLines.push(""); + } + /** * Visit a function declaration and render Swift code * @param {ts.FunctionDeclaration} node - The function node @@ -332,6 +510,9 @@ export class TypeProcessor { * @private */ visitStructuredType(name, members) { + if (this.emittedStructuredTypeNames.has(name)) return; + this.emittedStructuredTypeNames.add(name); + const typeName = this.renderIdentifier(name); this.swiftLines.push(`@JSClass struct ${typeName} {`); @@ -415,6 +596,13 @@ export class TypeProcessor { return typeMap[typeString]; } + const symbol = type.getSymbol() ?? type.aliasSymbol; + if (symbol && (symbol.flags & ts.SymbolFlags.Enum) !== 0) { + const typeName = symbol.name; + this.seenTypes.set(type, node); + return this.renderIdentifier(typeName); + } + if (this.checker.isArrayType(type) || this.checker.isTupleType(type) || type.getCallSignatures().length > 0) { return "JSObject"; } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/StringEnum.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/StringEnum.d.ts new file mode 100644 index 00000000..10f6dd81 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/StringEnum.d.ts @@ -0,0 +1,8 @@ +export enum FeatureFlag { + foo = "foo", + bar = "bar", +} + +export function takesFeatureFlag(flag: FeatureFlag): void + +export function returnsFeatureFlag(): FeatureFlag diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.d.ts new file mode 100644 index 00000000..bb9f163c --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.d.ts @@ -0,0 +1,19 @@ +// 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`. + +export type Exports = { +} +export type Imports = { + takesFeatureFlag(flag: FeatureFlagTag): void; + returnsFeatureFlag(): FeatureFlagTag; +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.js new file mode 100644 index 00000000..7c30768f --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/StringEnum.Import.js @@ -0,0 +1,231 @@ +// 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`. + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let tmpRetTag; + let tmpRetStrings = []; + let tmpRetInts = []; + let tmpRetF32s = []; + let tmpRetF64s = []; + let tmpParamInts = []; + let tmpParamF32s = []; + let tmpParamF64s = []; + let tmpRetPointers = []; + let tmpParamPointers = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + const imports = options.getImports(importsContext); + bjs["swift_js_return_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + return swift.memory.retain(textDecoder.decode(bytes)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr, len); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_tag"] = function(tag) { + tmpRetTag = tag; + } + bjs["swift_js_push_int"] = function(v) { + tmpRetInts.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + tmpRetF32s.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + tmpRetF64s.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + const value = textDecoder.decode(bytes); + tmpRetStrings.push(value); + } + bjs["swift_js_pop_param_int32"] = function() { + return tmpParamInts.pop(); + } + bjs["swift_js_pop_param_f32"] = function() { + return tmpParamF32s.pop(); + } + bjs["swift_js_pop_param_f64"] = function() { + return tmpParamF64s.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + tmpRetPointers.push(pointer); + } + bjs["swift_js_pop_param_pointer"] = function() { + return tmpParamPointers.pop(); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_takesFeatureFlag"] = function bjs_takesFeatureFlag(flag) { + try { + const flagObject = swift.memory.getObject(flag); + swift.memory.release(flag); + imports.takesFeatureFlag(flagObject); + } catch (error) { + setException(error); + } + } + TestModule["bjs_returnsFeatureFlag"] = function bjs_returnsFeatureFlag() { + try { + let ret = imports.returnsFeatureFlag(); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.Macros.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.Macros.swift new file mode 100644 index 00000000..b0049af2 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.Macros.swift @@ -0,0 +1,17 @@ +// 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(Experimental) import JavaScriptKit + +enum FeatureFlag: String { + case foo = "foo" + case bar = "bar" +} +extension FeatureFlag: _BridgedSwiftEnumNoPayload {} + +@JSFunction func takesFeatureFlag(_ flag: FeatureFlag) throws (JSException) -> Void + +@JSFunction func returnsFeatureFlag() throws (JSException) -> FeatureFlag diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.swift new file mode 100644 index 00000000..3059865a --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/StringEnum.swift @@ -0,0 +1,33 @@ +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_takesFeatureFlag") +fileprivate func bjs_takesFeatureFlag(_ flag: Int32) -> Void +#else +fileprivate func bjs_takesFeatureFlag(_ flag: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif + +func _$takesFeatureFlag(_ flag: FeatureFlag) throws(JSException) -> Void { + let flagValue = flag.bridgeJSLowerParameter() + bjs_takesFeatureFlag(flagValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_returnsFeatureFlag") +fileprivate func bjs_returnsFeatureFlag() -> Int32 +#else +fileprivate func bjs_returnsFeatureFlag() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$returnsFeatureFlag() throws(JSException) -> FeatureFlag { + let ret = bjs_returnsFeatureFlag() + if let error = _swift_js_take_exception() { + throw error + } + return FeatureFlag.bridgeJSLiftReturn(ret) +} \ No newline at end of file From ea68a71a19f42b616412ace11186c95a9fa77bf6 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 26 Jan 2026 15:42:11 +0900 Subject: [PATCH 2/2] 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). --- .../TS2Swift/JavaScript/src/processor.js | 61 ++++++++++--------- .../Generated/BridgeJS.Macros.swift | 8 +++ .../Generated/BridgeJS.swift | 18 ++++++ .../Generated/JavaScript/BridgeJS.json | 20 ++++++ .../BridgeJSRuntimeTests/ImportAPITests.swift | 6 ++ Tests/BridgeJSRuntimeTests/bridge-js.d.ts | 9 ++- Tests/prelude.mjs | 3 + 7 files changed, 94 insertions(+), 31 deletions(-) diff --git a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js index 7b5f6ff6..6a752ab8 100644 --- a/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js +++ b/Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js @@ -251,35 +251,6 @@ export class TypeProcessor { return; } - /** - * Convert a TypeScript enum member name into a valid Swift identifier. - * @param {string} name - * @returns {string} - */ - const toSwiftCaseName = (name) => { - const swiftIdentifierRegex = /^[_\p{ID_Start}][\p{ID_Continue}\u{200C}\u{200D}]*$/u; - let result = ""; - for (const ch of name) { - const isIdentifierChar = /^[_\p{ID_Continue}\u{200C}\u{200D}]$/u.test(ch); - result += isIdentifierChar ? ch : "_"; - } - if (!result) result = "_case"; - if (!/^[_\p{ID_Start}]$/u.test(result[0])) { - result = "_" + result; - } - if (!swiftIdentifierRegex.test(result)) { - result = result.replace(/[^_\p{ID_Continue}\u{200C}\u{200D}]/gu, "_"); - if (!result) result = "_case"; - if (!/^[_\p{ID_Start}]$/u.test(result[0])) { - result = "_" + result; - } - } - if (isSwiftKeyword(result)) { - result = result + "_"; - } - return result; - }; - /** @type {{ name: string, raw: string }[]} */ const stringMembers = []; /** @type {{ name: string, raw: number }[]} */ @@ -291,7 +262,7 @@ export class TypeProcessor { for (const member of members) { const rawMemberName = member.name.getText(); const unquotedName = rawMemberName.replace(/^["']|["']$/g, ""); - const swiftCaseNameBase = toSwiftCaseName(unquotedName); + const swiftCaseNameBase = makeValidSwiftIdentifier(unquotedName, { emptyFallback: "_case" }); if (member.initializer && ts.isStringLiteral(member.initializer)) { stringMembers.push({ name: swiftCaseNameBase, raw: member.initializer.text }); @@ -811,3 +782,33 @@ export function isValidSwiftDeclName(name) { const swiftIdentifierRegex = /^[_\p{ID_Start}][\p{ID_Continue}\u{200C}\u{200D}]*$/u; return swiftIdentifierRegex.test(name); } + +/** + * Convert an arbitrary string into a valid Swift identifier. + * @param {string} name + * @param {{ emptyFallback?: string }} options + * @returns {string} + */ +function makeValidSwiftIdentifier(name, options = {}) { + const emptyFallback = options.emptyFallback ?? "_"; + let result = ""; + for (const ch of name) { + const isIdentifierChar = /^[_\p{ID_Continue}\u{200C}\u{200D}]$/u.test(ch); + result += isIdentifierChar ? ch : "_"; + } + if (!result) result = emptyFallback; + if (!/^[_\p{ID_Start}]$/u.test(result[0])) { + result = "_" + result; + } + if (!isValidSwiftDeclName(result)) { + result = result.replace(/[^_\p{ID_Continue}\u{200C}\u{200D}]/gu, "_"); + if (!result) result = emptyFallback; + if (!/^[_\p{ID_Start}]$/u.test(result[0])) { + result = "_" + result; + } + } + if (isSwiftKeyword(result)) { + result = result + "_"; + } + return result; +} diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.Macros.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.Macros.swift index 2ad11fd9..65a46a3f 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.Macros.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.Macros.swift @@ -22,6 +22,14 @@ @JSFunction func jsThrowOrString(_ shouldThrow: Bool) throws (JSException) -> String +enum FeatureFlag: String { + case foo = "foo" + case bar = "bar" +} +extension FeatureFlag: _BridgedSwiftEnumNoPayload {} + +@JSFunction func jsRoundTripFeatureFlag(_ flag: FeatureFlag) throws (JSException) -> FeatureFlag + @JSClass struct JsGreeter { @JSGetter var name: String @JSSetter func setName(_ value: String) throws (JSException) diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 1b39e77b..51048feb 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -6307,6 +6307,24 @@ func _$jsThrowOrString(_ shouldThrow: Bool) throws(JSException) -> String { return String.bridgeJSLiftReturn(ret) } +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsRoundTripFeatureFlag") +fileprivate func bjs_jsRoundTripFeatureFlag(_ flag: Int32) -> Int32 +#else +fileprivate func bjs_jsRoundTripFeatureFlag(_ flag: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsRoundTripFeatureFlag(_ flag: FeatureFlag) throws(JSException) -> FeatureFlag { + let flagValue = flag.bridgeJSLowerParameter() + let ret = bjs_jsRoundTripFeatureFlag(flagValue) + if let error = _swift_js_take_exception() { + throw error + } + return FeatureFlag.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_runAsyncWorks") fileprivate func bjs_runAsyncWorks() -> Int32 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 727508f3..3dcb3523 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -9070,6 +9070,26 @@ } } }, + { + "name" : "jsRoundTripFeatureFlag", + "parameters" : [ + { + "name" : "flag", + "type" : { + "rawValueEnum" : { + "_0" : "FeatureFlag", + "_1" : "String" + } + } + } + ], + "returnType" : { + "rawValueEnum" : { + "_0" : "FeatureFlag", + "_1" : "String" + } + } + }, { "name" : "runAsyncWorks", "parameters" : [ diff --git a/Tests/BridgeJSRuntimeTests/ImportAPITests.swift b/Tests/BridgeJSRuntimeTests/ImportAPITests.swift index f0112ed1..e31c89cb 100644 --- a/Tests/BridgeJSRuntimeTests/ImportAPITests.swift +++ b/Tests/BridgeJSRuntimeTests/ImportAPITests.swift @@ -35,6 +35,12 @@ class ImportAPITests: XCTestCase { } } + func testRoundTripFeatureFlag() throws { + for v in [FeatureFlag.foo, .bar] { + try XCTAssertEqual(jsRoundTripFeatureFlag(v), v) + } + } + func ensureThrows(_ f: (Bool) throws(JSException) -> T) throws { do { _ = try f(true) diff --git a/Tests/BridgeJSRuntimeTests/bridge-js.d.ts b/Tests/BridgeJSRuntimeTests/bridge-js.d.ts index 86a72ca0..87de440d 100644 --- a/Tests/BridgeJSRuntimeTests/bridge-js.d.ts +++ b/Tests/BridgeJSRuntimeTests/bridge-js.d.ts @@ -7,6 +7,13 @@ export function jsThrowOrNumber(shouldThrow: boolean): number export function jsThrowOrBool(shouldThrow: boolean): boolean export function jsThrowOrString(shouldThrow: boolean): string +export enum FeatureFlag { + foo = "foo", + bar = "bar", +} + +export function jsRoundTripFeatureFlag(flag: FeatureFlag): FeatureFlag + export class JsGreeter { name: string; readonly prefix: string; @@ -15,4 +22,4 @@ export class JsGreeter { changeName(name: string): void; } -export function runAsyncWorks(): Promise; \ No newline at end of file +export function runAsyncWorks(): Promise; diff --git a/Tests/prelude.mjs b/Tests/prelude.mjs index a5611523..7d048be7 100644 --- a/Tests/prelude.mjs +++ b/Tests/prelude.mjs @@ -47,6 +47,9 @@ export async function setupOptions(options, context) { } return "Hello, world!"; }, + "jsRoundTripFeatureFlag": (flag) => { + return flag; + }, JsGreeter: class { /** * @param {string} name