From 1b80c858db70c29747c59c13988889351f2720b6 Mon Sep 17 00:00:00 2001 From: vincentborko Date: Fri, 24 Jul 2026 17:00:29 +0200 Subject: [PATCH] Use Swift-identifier escaping for file-derived bridge function names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #262. That change escaped the Swift `@_cdecl` function name derived from a source file name via `cdeclEscaped`, but `cdeclEscaped` is a JNI-symbol escaper, not a Swift-identifier escaper — the two have different rules, so reusing it leaves two cases wrong: - `.` is preserved by `cdeclEscaped` (it is a separator callers handle), but a `.` is invalid in a Swift identifier. A file such as `Model.generated.swift` still produces an uncompilable declaration: func Model.generatedKt_Swift_i(...) // '.' is not valid - `_` is valid in a Swift identifier but `cdeclEscaped` mangles it to `_1`, so `Model_Extensions.swift` needlessly yields `Model_1ExtensionsKt_Swift_i` on the Swift side. Add a dedicated `swiftIdentifierEscaped` that leaves ASCII alphanumerics and `_` untouched and hex-escapes everything else, and apply it to the Swift function name only; the JNI symbol keeps `cdeclEscaped`. The `+` case from #262 is unchanged (both escapers map `+` to `_0002b`). Also corrects the `cdeclEscaped` doc comment, which claimed `.` -> `_` while the code preserves `.`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Kotlin/KotlinBridgeTransformer.swift | 30 +++++++++--- .../SkipSyntaxTests/FileNameEscapeTests.swift | 46 ++++++++++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/Sources/SkipSyntax/Kotlin/KotlinBridgeTransformer.swift b/Sources/SkipSyntax/Kotlin/KotlinBridgeTransformer.swift index 9e5b6f4e..fc409466 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinBridgeTransformer.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinBridgeTransformer.swift @@ -223,12 +223,11 @@ struct CDeclFunction { } else { var file = translator.syntaxTree.source.file file.extension = "" - // The JNI symbol side (`cdeclTypeName`) is escaped via `cdeclEscaped` at - // the return below. The Swift-identifier side (`typeName`) must be escaped - // here too, since a file name like `Foo+Bar.swift` yields `Foo+BarKt`, and - // the raw `+` produces an invalid Swift function name (issue #63). + // The JNI symbol (`cdeclTypeName`) uses `cdeclEscaped`; the Swift `@_cdecl` + // function name needs Swift-identifier escaping instead — e.g. `Foo+Bar.swift` + // must not leak `+` or `.` into the generated func name (issue #63). cdeclTypeName = file.name + "Kt" - typeName = cdeclTypeName.cdeclEscaped + typeName = cdeclTypeName.swiftIdentifierEscaped } return (cdeclPrefix + cdeclTypeName.cdeclEscaped + "_" + name.cdeclEscaped, typeName + "_" + name) } @@ -286,13 +285,14 @@ extension String { return Self.backtickEscapingIdentifiers.contains(self) ? "`\(self)`" : self } - /// Escape special characters for use in a `@_cdecl` declaration. + /// Escape special characters for use in the JNI symbol of a `@_cdecl` declaration. /// /// As documented at https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/design.html#resolving_native_method_names /// - `_` → `_1` - /// - `.` and `/` → `_` (package/class separator) + /// - `/` → `_` (package/class separator) /// - `;` → `_2` /// - `[` → `_3` + /// - `.` → `.` (preserved; callers replace the separator as needed) /// - Non-ASCII → `_0XXXX` (UTF-16 hex) var cdeclEscaped: String { self.compactMap { ch -> String in @@ -314,6 +314,22 @@ extension String { }.joined() } + /// Escape characters that are invalid in a Swift identifier, leaving ASCII + /// alphanumerics and `_` untouched. Unlike `cdeclEscaped` (JNI symbol), this + /// keeps `_` as-is and escapes `.`, since a `.` is invalid in a Swift `@_cdecl` + /// function name (issue #63). + var swiftIdentifierEscaped: String { + self.compactMap { ch -> String in + if ch == "_" || (ch.isASCII && (ch.isLetter || ch.isNumber)) { + return String(ch) + } else { + // Escape every UTF-16 code unit, so characters outside the BMP + // (encoded as a surrogate pair) are fully represented, not truncated. + return ch.utf16.map { "_0\(String(format: "%04x", $0))" }.joined() + } + }.joined() + } + /// Return this property name as the equivalent Java getter. var getterName: String { guard !isEmpty else { diff --git a/Tests/SkipSyntaxTests/FileNameEscapeTests.swift b/Tests/SkipSyntaxTests/FileNameEscapeTests.swift index f3acd2ee..549e07aa 100644 --- a/Tests/SkipSyntaxTests/FileNameEscapeTests.swift +++ b/Tests/SkipSyntaxTests/FileNameEscapeTests.swift @@ -2,19 +2,28 @@ // Licensed under the GNU Affero General Public License v3.0 // SPDX-License-Identifier: AGPL-3.0-only -import SkipSyntax +@testable import SkipSyntax import XCTest final class FileNameEscapeTests: XCTestCase { + + func testSwiftIdentifierEscaping() { + // Valid Swift identifier characters (ASCII alphanumerics and `_`) pass through. + XCTAssertEqual("Model_ExtensionsKt".swiftIdentifierEscaped, "Model_ExtensionsKt") + // Invalid characters are escaped as `_0XXXX` per UTF-16 code unit. + XCTAssertEqual("Model+BridgingKt".swiftIdentifierEscaped, "Model_0002bBridgingKt") + XCTAssertEqual("Model.generatedKt".swiftIdentifierEscaped, "Model_0002egeneratedKt") + // A character outside the BMP is a UTF-16 surrogate pair; both units escape. + XCTAssertEqual("A🎉Kt".swiftIdentifierEscaped, "A_0d83c_0df89Kt") + } + private var transformers: [KotlinTransformer] { return builtinKotlinTransformers() + [KotlinBridgeTransformer()] } - // A source file whose name contains characters that are invalid in a Swift - // identifier (e.g. the "+" in `Model+Bridging.swift`) is used to derive both - // the JNI symbol (already escaped) and the generated `@_cdecl` Swift function - // name. The Swift-identifier side must be escaped too, or the generated - // bridging code fails to compile (issue #63). + // A file name character that is invalid in a Swift identifier (the "+") must be + // escaped in the generated `@_cdecl` function name, or the bridge fails to + // compile (issue #63). Both the JNI symbol and the Swift name escape it here. func testBridgeFunctionNameEscapesInvalidFileNameCharacters() async throws { try await check(swiftBridge: """ public var i = 1 @@ -37,4 +46,29 @@ final class FileNameEscapeTests: XCTestCase { } """, transformers: transformers) } + + // An underscore is valid in a Swift identifier, so the Swift `@_cdecl` function + // name must keep it as-is; only the JNI symbol mangles it to `_1` (issue #63). + func testBridgeFunctionNamePreservesUnderscoreInFileName() async throws { + try await check(swiftBridge: """ + public var i = 1 + """, swiftBridgeFileName: "Model_Extensions.swift", kotlin: """ + var i: Int + get() = Swift_i() + set(newValue) { + Swift_i_set(newValue) + } + private external fun Swift_i(): Int + private external fun Swift_i_set(value: Int) + """, swiftBridgeSupport: """ + @_cdecl("Java_Model_1ExtensionsKt_Swift_1i") + public func Model_ExtensionsKt_Swift_i(_ Java_env: JNIEnvPointer, _ Java_target: JavaObjectPointer) -> Int32 { + return Int32(i) + } + @_cdecl("Java_Model_1ExtensionsKt_Swift_1i_1set") + public func Model_ExtensionsKt_Swift_i_set(_ Java_env: JNIEnvPointer, _ Java_target: JavaObjectPointer, _ value: Int32) { + i = Int(value) + } + """, transformers: transformers) + } }