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) + } }