Skip to content

JNI: payloadlessEnumsAsJavaEnums generates Java enums - #17

Open
piercifani wants to merge 2 commits into
jni/12-optional-nested-arraysfrom
jni/13-java-enums
Open

piercifani wants to merge 2 commits into
jni/12-optional-nested-arraysfrom
jni/13-java-enums

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 13/21 · base: jni/12-optional-nested-arrays · previous: #16 · next: #18 (jni/14-default-arguments)

Problem

A Swift enum whose cases have no associated values becomes a Java class that wraps a Swift value. Kotlin code can't use it the way it uses an enum:

  • No exhaustive when: when (status) { … } can't be exhaustive. Callers must go through getDiscriminator() or getCase().
  • No constants: there's nothing like OrderStatus.FILLED, only factories such as OrderStatus.filled() that allocate a new Swift value on each call.
  • No identity: two values of the same case are different objects.
  • No values() or entries to stand in for CaseIterable.

Changes

A new configuration option, payloadlessEnumsAsJavaEnums (default false), generates these enums as Java enums with one constant per case (IDLE, LOADING, DONE, uppercased like the existing Discriminator). It's opt-in because it changes the Java API of every such enum.

The rest of the generator doesn't change, because the enum keeps the two entry points every conversion goes through:

  • Java to Swift: $memoryAddress() returns the Swift value that belongs to the constant. Each constant creates its value once, on first use, with the existing case native ($idle()), and keeps it for the life of the process. So parameters, arrays, optionals, struct properties and bridges pass it as before, without allocating.
  • Swift to Java: wrapMemoryAddressUnsafe(selfPointer[, arena]) reads the case with the existing SwiftObjects.getRawDiscriminator, destroys the Swift value it received (the caller transferred ownership), and returns the constant. So results, array elements, enum payloads, closure arguments and async results all produce the constant.
  • What's left out: the enum has no constructor or arena registration, and it doesn't override equals/hashCode, which are final on Java enums. One constant per case already gives Swift's equality. $cleanup() returns a cleanup that never runs.

Everything else the class had is unchanged: methods, properties, rawValue, init(rawValue:), getDiscriminator(), getCase(), the case factories and toString().

Excluded, so they stay classes: generic enums, Error enums (which extend SwiftError), enums without cases (namespaces), and enums with any associated value.

Most of the diff in JavaBindingsPrinting is existing code re-indented under the new if; GitHub's "Hide whitespace" shows the real change.

Kotlin in this PR

JavaEnumsTest.kt with OrderStatus.swift; the library module turns the option on:

val label = when (Orders.fill(OrderStatus.PENDING)) {   // exhaustive, no else
    OrderStatus.PENDING -> "pending"
    OrderStatus.FILLED -> "filled"
    OrderStatus.CANCELLED -> "cancelled"
}
assertSame(OrderStatus.FILLED, Orders.fill(OrderStatus.PENDING))
assertEquals(OrderStatus.entries, Orders.all().toList())   // CaseIterable

The test also gets the constant back from every place a value crosses: optional result, dictionary value, struct property, enum case payload, @Sendable closure argument, async result, plus 10,000 round trips.

Why Skip needs it

Skip turns these enums into Kotlin enum classes. A production Skip Fuse app (16 Skip modules) bridges 524 of them, and its Kotlin code relies on exhaustive when over them about 730 times and on allCases 56 times (entries here).

One difference remains: Skip keeps Swift's case names (OrderStatus.filled), while this follows jextract's existing uppercase Discriminator names (OrderStatus.FILLED).

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (37 tests).
  • New JNIJavaEnumTests (3 tests, 5 cases): the Java enum with its constants, Swift value table, wrapMemoryAddressUnsafe and $memoryAddress, and no equals/hashCode/constructor; a class without the option; payload, Error and empty enums stay classes with it.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox (661 tests) and ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test (292 tests) pass. The JNI sample doesn't turn the option on, so its existing enum tests cover the default.
  • swift run generate-config-docs --check (the new option is documented in SwiftJavaConfigFile.md); swift format lint --strict on changed files; git diff --check.

Not in this PR

  • Keeping Swift's case names instead of uppercasing them.
  • Turning the option on by default.
  • FFM mode.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 17:53
With the new `payloadlessEnumsAsJavaEnums` option, a Swift enum whose
cases have no associated values is generated as a Java `enum` with one
constant per case, so Kotlin gets exhaustive `when`, identity and
`entries`.

The enum keeps the two entry points every conversion uses:
`$memoryAddress()` returns a Swift value each constant creates once with
its case native, and `wrapMemoryAddressUnsafe` maps a received Swift value
to its case's constant and destroys it. Java enums don't override the
final `equals`/`hashCode`. Generic, `Error` and caseless enums stay
classes. Off by default: it changes the Java API of these enums.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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