Skip to content

JNI: choose what jextract exports with @JavaExport(false) and exportMode - #26

Open
piercifani wants to merge 4 commits into
jni/21-error-valuesfrom
jni/22-export-control
Open

piercifani wants to merge 4 commits into
jni/21-error-valuesfrom
jni/22-export-control

Conversation

@piercifani

Copy link
Copy Markdown

Stack 22/22 · base: jni/21-error-values · previous: #25

Problem

jextract exports every declaration that passes the access level, and the only way to leave one out is a file or type filter in swift-java.config. A large module can't use jextract until it handles all of that module's public API, including Swift-only code that was never meant for Java:

  • Skipped declarations: declarations jextract can't translate are reported as skipped, so failOnSkippedDeclarations (JExtract: report every skipped declaration; add failOnSkippedDeclarations #3) can't be turned on.
  • Build failures: a declaration that is translated incorrectly breaks the build of the whole module, and a prefix operator crashed jextract outright.
  • Size: every exported declaration costs a JNI entrypoint, Java glue and a keep rule.

There's also no way to export only what a module chooses, member by member.

Measured on a production Skip Fuse app (9 Skip modules), with jextract run on all public declarations:

  • 2,011 of the 2,870 skipped declarations, and 151 of the 199 declarations whose Java didn't compile, were in code the app doesn't export to Kotlin today.
  • 6 of the 9 modules export only declarations they mark, one by one.

Separately, JavaExtractDecider meant to skip prefix and postfix operators but checked the name token. prefix static func - has a binary-operator name token and a prefix modifier, so it got through and crashed the thunk printer (Binary operator must have exactly 2 arguments).

Changes

  • @JavaExport(false) keeps a declaration out of the bindings. On a type or an extension it applies to all their members and nested types. @JavaExport keeps working as before; the macro now takes an optional Bool.
  • "exportMode": "annotated" in swift-java.config exports only declarations marked @JavaExport, and the members of a type or extension marked @JavaExport. An unmarked type is still exported, with only its marked members, when one of its members is marked. The default, all, keeps today's behavior.
  • Both are decided in JavaExtractDecider. The nearest marked enclosing declaration is found by walking up the syntax tree, so extensions and nested types need no extra plumbing. Declarations that aren't exported are never translated: they can't be reported as skipped, produce broken Java or crash.
  • New ExtractDecider.mayExtractMembers(of:), default true. An extension of a type jextract can't resolve, such as a Foundation type, is no longer reported as skipped when none of its members can be exported.
  • Operators: prefix and postfix operators are now detected by their modifier.
  • New SwiftJavaAnnotations module and product holding the JavaExport macro, with no JNI dependency, so code that's also built where there's no Java can carry the marker. SwiftJava re-exports it, so existing @JavaExport uses compile unchanged.

Kotlin in this PR

ExportControlTest.kt with ExportControl.swift. The sample builds with failOnSkippedDeclarations, so the opted-out tuple and [String: Any] members, and the prefix operator, would fail the build if they were still extracted:

// public struct SessionInfo { public var userName: String; @JavaExport(false) public func debugPairs() -> [(Int64, String)] }
// @JavaExport(false) extension SessionInfo { public var debugDump: [String: Any] }
assertEquals("ann", SessionInfo.init("ann").userName)
assertTrue("debugPairs" !in SessionInfo::class.java.methods.map { it.name })

Why Skip needs it

Skip lets every module choose its Kotlin API: // SKIP @bridge / @bridgeMembers to opt in, // SKIP @nobridge to opt out, and a per-module default. The app measured above uses about 1,400 opt-ins and 1,600 opt-outs. With jextract run in annotated mode on a copy of that app where those comments became @JavaExport markers, skipped declarations fell from 2,870 to 994, and no module crashed jextract. What remains is code the app does export: the jextract gaps that still need fixing.

Testing

  • New JNIExportControlTests (4 tests):
    • opted-out members, types and extensions are absent;
    • annotated mode exports exactly the marked declarations;
    • neither mode reports a skipped declaration for unexported code, a prefix operator or an extension of an unresolved type.
  • swift test: all suites pass.
  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (51 tests, including ExportControlTest).
  • Config docs regenerated with swift run generate-config-docs; SwiftJavaJextract.md documents both features.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 4, 2026 11:19
`@JavaExport(false)` keeps a declaration, or every member of a type or
extension, out of the Java bindings. `"exportMode": "annotated"` exports only
declarations marked `@JavaExport` and the members of marked types and
extensions. Both are decided in JavaExtractDecider, so declarations that are
not exported are never translated and can't be skipped, miscompiled or crash
jextract. An extension of an unresolved type whose members can't be exported
is no longer reported as skipped.

Prefix and postfix operators are now recognized by their modifier: the
name-token check let `prefix static func -` through to the thunk printer,
which crashed.

The JavaExport macro moves to a new SwiftJavaAnnotations module without the
JNI dependency; SwiftJava re-exports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Nov 4, 2026
@piercifani
piercifani added this pull request to stack #12 November 4, 2026 10:25
piercifani and others added 2 commits November 4, 2026 12:13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rted types

Two gaps in `exportMode: annotated`:

- The `Data`, `Date` and `URL` wrapper types jextract extracts when a module
  uses them come from SwiftExtract's own description of Foundation, which has
  no `@JavaExport` markers, so annotated mode dropped them while exported
  declarations still referred to them. Declarations from SwiftExtract's known
  module sources are no longer filtered by export mode.
- A declaration whose signature used a type that isn't exported, from this
  module or a dependency, produced Java referring to a class that doesn't
  exist. ExtractDecider.isExported(_:) now answers that, using each
  dependency's `exportMode` from its swift-java.config, and such declarations
  are skipped with "uses 'T', which is not exported".

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