Skip to content

rewrite-kotlin: re-expose Java instance methods Kotlin's mapped-type customizer hides#7893

Merged
jkschneider merged 1 commit into
mainfrom
kotlin-mapped-type-fallback
Jun 3, 2026
Merged

rewrite-kotlin: re-expose Java instance methods Kotlin's mapped-type customizer hides#7893
jkschneider merged 1 commit into
mainfrom
kotlin-mapped-type-fallback

Conversation

@jkschneider

Copy link
Copy Markdown
Member

Summary

  • Adds a FirDeclarationGenerationExtension + IR-phase body generator to the rewrite-kotlin K2 plugin that synthesize top-level extension functions surfacing Java instance methods Kotlin's JvmBuiltInsCustomizer allow-list hides from mapped-type member scopes.
  • Closes the recipe-DSL friction where f.formatted("x"), s.transform(...), s.stripIndent(), s.translateEscapes() and other JDK-15+/shadowed Java instance methods on kotlin.String/kotlin.Int/kotlin.List/etc. failed with unresolved reference despite being callable from idiomatic Kotlin code via (f as java.lang.String).formatted(...) casts.
  • Catalog is dynamic — walks JavaToKotlinClassMap.mappedKotlinClassFqNames (reflective field access) and the host JVM's reflected getDeclaredMethods(). No hand-enumerated method allowlist.

Tracks upstream as KT-52378 (open since 2022, still open in Kotlin 2.3.20).

Why

The cause isn't this plugin — RecipeFirDslCheckers explicitly doesn't recurse into pattern lambdas (RecipeFirDslCheckers.kt:226) and RecipeIrGenerationExtension runs after FIR resolution. It's stock Kotlin behavior: kotlin.String's descriptor lives in kotlin.kotlin_builtins and exposes only the Java members the customizer's compatibility allow-list lists. JDK 15+ String additions and various older shadowed methods on other mapped types never made the list, so recipe authors had to either cast through java.lang.String at every call site or avoid the methods.

What changed

File Role
RecipeFirMappedTypeFallbackExtension.kt (new, ~370 lines) FirDeclarationGenerationExtension. Walks JavaToKotlinClassMap.mappedKotlinClassFqNames, iterates each Java class's getDeclaredMethods(), generates top-level extension functions per public instance method with the Kotlin counterpart as the extension receiver. JVM-erased dedup so List/MutableList collapse and String.contentEquals(CharSequence) doesn't collide with the (Object) overload.
MappedTypeFallbackBodyGenerator.kt (new, ~184 lines) IR phase invoked from RecipeIrGenerationExtension. Identifies synthesized extensions by origin string match, resolves the Java counterpart IrSimpleFunction, replaces the FIR-default stub body with irReturn(irCall(javaFn, dispatchReceiver = irGet(extReceiver), valueArgs = ...)). Emits invokevirtual at JVM level — type-safe because kotlin.String === java.lang.String.
RecipeFirExtensionRegistrar.kt (+6 lines) Registers the new factory alongside RecipeDslAdditionalCheckers.
RecipeIrGenerationExtension.kt (+6 lines) Invokes the body generator from the existing IR pass.
RecipeMappedTypeFallbackTest.kt (new, ~171 lines) 6 tests covering fallback paths + scope-isolation regression.

Known caveats

Two gaps in this initial implementation, both addressable in follow-ups:

  1. Vararg sugar lost. Object... parameters are emitted as plain Array<Any?> (non-vararg). FIR builder + FIR2IR have a round-tripping bug where isVararg=true with element type Any? trips IllegalStateException: Vararg expression has incorrect type during call-side IR construction. Workaround at RecipeFirMappedTypeFallbackExtension.kt:303-318. Practical impact: f.formatted("x") works; f.formatted() and f.formatted("a","b") need f.formatted(arrayOf<Any?>(...)). JVM bytecode is identical either way.
  2. Scope isolation not enforced. Fallback extensions are globally visible whenever the plugin is on the classpath, not gated to before { } / to { } lambdas. FirDeclarationGenerationExtension doesn't expose a per-call visibility gate; FirExpressionResolutionExtension only adds implicit extension receivers and can't synthesize resolved callees for explicit-receiver calls. The tests confirm Kotlin stdlib extensions still outrank our synthesized top-levels on overload resolution (e.g. String.indexOf(Char) still wins over java.lang.String.indexOf(int) outside to { }).

Test plan

  • :rewrite-kotlin:test — 1235 pass, 16 skipped, 0 fail (no existing recipe test regresses)
  • RecipeMappedTypeFallbackTest — 6 new tests pass, including the scope-isolation regression
  • Downstream consumer: pattern_replace MCP tool in moderne-cli successfully runs a Kotlin recipe whose to { } body calls String.formatted(arrayOf<Any?>(...))

…customizer hides

Adds a `FirDeclarationGenerationExtension` (`RecipeFirMappedTypeFallbackExtension`) and an IR-phase body generator (`MappedTypeFallbackBodyGenerator`) to the rewrite-kotlin K2 plugin. Together they synthesize top-level extension functions that surface Java instance methods which Kotlin's `JvmBuiltInsCustomizer` allow-list hides from the mapped-type member scope.

## Why

Inside a `rewrite { ... } to { ... }` recipe-DSL body, an author writing `f.formatted("x")` (where `f: String`) hits `unresolved reference 'formatted'`. The same goes for `String.transform`, `String.stripIndent`, `String.translateEscapes`, and other JDK additions newer than the Kotlin mapped-type allow-list. The cause isn't this plugin — `RecipeFirDslCheckers` doesn't recurse into pattern lambdas (`RecipeFirDslCheckers.kt:226`) and `RecipeIrGenerationExtension` runs after FIR resolution — it's stock Kotlin behavior. `kotlin.String` is a mapped type; its descriptor lives in `kotlin.kotlin_builtins` and exposes only the Java members the customizer's compatibility allow-list lists. JDK 15+ additions, and various older shadowed methods on other mapped types, never made the list. Tracked upstream as [KT-52378](https://youtrack.jetbrains.com/issue/KT-52378) (open since 2022, still open in Kotlin 2.3.20).

This forces recipe authors to either cast through `java.lang.String` at every call site (`(f as java.lang.String).formatted(...)`) or avoid the methods. Both are friction points the plugin can remove without changing the recipe-author-facing DSL syntax.

## What changed

- `RecipeFirMappedTypeFallbackExtension.kt` (new, ~370 lines). A `FirDeclarationGenerationExtension` that walks `JavaToKotlinClassMap.mappedKotlinClassFqNames` (reflective field access — the property isn't public), and for each Kotlin↔Java pair iterates the Java class's `getDeclaredMethods()` to find public instance methods. For each, it generates a top-level extension function via `createTopLevelFunction` with the Kotlin counterpart as the extension receiver. JVM-erased dedup so `List`/`MutableList` collapse, and so `String.contentEquals(CharSequence)` doesn't collide with the `(Object)` overload.
- `MappedTypeFallbackBodyGenerator.kt` (new, ~184 lines). An IR phase, invoked from the existing `RecipeIrGenerationExtension`. Walks every `IrSimpleFunction`, identifies our synthesized extensions by origin string match (`MappedTypeFallbackKey`), resolves the Java counterpart class, looks up the matching `IrSimpleFunction` on it, and replaces the FIR-default stub body with `irReturn(irCall(javaFn, dispatchReceiver = irGet(extReceiver), valueArgs = ...))`. At JVM bytecode this is `invokevirtual` on the receiver — type-safe because `kotlin.String === java.lang.String` at JVM level.
- `RecipeFirExtensionRegistrar.kt` (+6 lines). Registers the new factory alongside `RecipeDslAdditionalCheckers`.
- `RecipeIrGenerationExtension.kt` (+6 lines). Invokes the body generator from the existing IR pass.
- `RecipeMappedTypeFallbackTest.kt` (new, ~171 lines). 6 tests covering fallback paths + a scope-isolation regression confirming Kotlin stdlib extensions still win where they previously did.

## Known caveats

Two gaps in this initial implementation, both addressable in follow-ups:

1. **Vararg sugar lost.** `Object...` parameters are emitted as plain `Array<Any?>` (non-vararg). FIR builder + FIR2IR have a round-tripping bug where `isVararg=true` with element type `Any?` trips `IllegalStateException: Vararg expression has incorrect type` during call-side IR construction. Workaround in `RecipeFirMappedTypeFallbackExtension.kt:303-318`. Practical impact: `f.formatted("x")` works; `f.formatted()` and `f.formatted("a","b")` need `f.formatted(arrayOf<Any?>(...))`. Bytecode is identical either way.
2. **Scope isolation not enforced.** Fallback extensions are globally visible whenever the plugin is on the classpath. `FirDeclarationGenerationExtension` doesn't expose a per-call visibility gate; `FirExpressionResolutionExtension` only adds implicit extension receivers and can't synthesize resolved callees for explicit-receiver calls. The tests document this and confirm that Kotlin stdlib extensions still outrank our synthesized top-levels on overload resolution (e.g. `String.indexOf(Char)` wins over `java.lang.String.indexOf(int)` outside `to { }`).

## Test plan

- [x] `:rewrite-kotlin:test` — 1235 pass, 16 skipped, 0 fail (no existing recipe test regresses)
- [x] `RecipeMappedTypeFallbackTest` — 6 new tests pass, including the scope-isolation regression
- [ ] Downstream consumer: `pattern_replace` MCP tool in moderne-cli successfully runs a Kotlin recipe whose `to { }` body calls `String.formatted(arrayOf<Any?>(...))`
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Jun 3, 2026
@jkschneider jkschneider merged commit 1d92c3e into main Jun 3, 2026
1 check passed
@jkschneider jkschneider deleted the kotlin-mapped-type-fallback branch June 3, 2026 17:46
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant