Skip to content

JNI: fix Int closures and inout thunks; infer unannotated variable types - #6

Open
piercifani wants to merge 2 commits into
jni/04-enum-case-name-collisionfrom
jni/05-codegen-fixes
Open

piercifani wants to merge 2 commits into
jni/04-enum-case-name-collisionfrom
jni/05-codegen-fixes

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 5/21 · base: jni/04-enum-case-name-collision · previous: #5 · next: #7

Three small JNI code-generation fixes and one analysis improvement, each found by building small modules shaped like Skip Fuse code in Swift 6 mode.

1. (Int) -> Int closures emit Java that doesn't compile

For a non-escaping closure, the parameter translation picks the Java interface with KnownJavaFunctionalInterface.find(SwiftFunctionType). That lookup only knows Int32/Int64, so (Int) -> Int gets its own interface (SwiftModule.apply.f). The helper-class printer instead used the lookup by Java type, where Int is long, matched LongUnaryOperator, and skipped printing the interface. The method then referenced an interface that didn't exist. The Swift upcall had the same mismatch: it looked the method up by JNI signature ((J)J → applyAsLong) on an object that only has apply, which would crash in GetMethodID(...)!.

Fix: the helper printer and the upcall both use the Swift-type lookup. The method name to call travels with the closureLowering conversion from where the interface is chosen. The Java API for Int closures is unchanged (its own interface); Int64 closures still use LongUnaryOperator.

Once these closures compiled, 32-bit Android (armv7) exposed one more bug: closure lowering converted Int with Int(fromJNI:) and getJValue, which assume Int.JNIType is jlong; on 32-bit it's jint. Int/UInt closure arguments and results now go through Int64/UInt64, matching the Java long. A result that doesn't fit in 32 bits traps, as converting the integer in Swift would.

2. inout parameters produce Swift that doesn't compile

The thunk passed a converted temporary to an inout parameter (cannot pass immutable value as inout argument). Java has no inout, so functions with inout parameters are now skipped with inoutParametersNotSupported. With #3 they're reported as a warning, or fail the build with failOnSkippedDeclarations.

3. Unannotated variables are inferred when unambiguous

public static let answer = 42 was dropped with missingTypeAnnotation. Analysis now infers the type when the initializer settles it without type checking:

  • literals: integer (including negated) → Int, float → Double, string → String, true/false → Bool
  • initializer calls T(...) / A.B(...) whose callee resolves to a nominal type (not an unresolved placeholder)

Everything else still needs an annotation, including T.member, T.make() and makeValue(), because the type can't be known without the type checker.

Why Skip needs it

In a production Skip Fuse app (16 Skip modules), the bridged modules have 594 public stored properties without a type annotation. Most are literals or T() initializers, such as public var isLoading = false.

Kotlin in this PR

InferredTypesTest.kt with InferredTypes.swift: Kotlin lambdas as (Int) -> Int and (Int, Int) -> Int closures, and unannotated properties (= 42, = false, = "Untitled", = 0.5, = Item(...)) read and written from Kotlin. With strict mode (#3) on, the sample's build would fail if any were still dropped.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes at this point in the stack.
  • New JNICodegenFixesTests, 8 tests, one of which checks the 64-bit widening of Int closure arguments and results. The tests for the fixes fail on the base branch; the 2 controls pass on both (an Int64 closure still uses LongUnaryOperator, and T.make(), makeValue() and T.member are still skipped).
  • JNI sample: applyIntClosure and ClosureConstants.answer with Java tests.
  • A standalone Swift 6 package passes Kotlin tests on the JVM for (Int) -> Int, (Int, Int) -> Int, a throwing closure and an untyped static let. A module with an inout function builds and reports the skip.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox and ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test pass.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

  • A Java closure that throws isn't propagated into a throwing Swift closure; () throws -> T closures are called as non-throwing.
  • inout remains unsupported (a mapping such as a one-element array is a design question).

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 17:35
- `(Int) -> Int` closures: the parameter translation chose the closure's own
  interface (by Swift type), but the helper printer matched `LongUnaryOperator`
  by Java type and skipped printing it, so the Java didn't compile; the upcall
  likewise looked up `applyAsLong` by JNI signature on an object that only has
  `apply`. Both now use the Swift-type lookup, and the method name travels with
  the `closureLowering` conversion.
- `inout` parameters: the thunk passed a converted temporary, which doesn't
  compile. They have no Java mapping, so such functions are skipped with
  `inoutParametersNotSupported` (and reported).
- Unannotated variables: infer the type from literals (`Int`, `Double`,
  `String`, `Bool`) and from `T(...)` / `A.B(...)` initializer calls whose callee
  resolves to a nominal type, instead of dropping them with
  `missingTypeAnnotation`. Other initializers still need an annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@piercifani
piercifani force-pushed the jni/05-codegen-fixes branch from 37460af to 1810a9f Compare November 3, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant