rewrite-kotlin: re-expose Java instance methods Kotlin's mapped-type customizer hides#7893
Merged
Merged
Conversation
…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?>(...))`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FirDeclarationGenerationExtension+ IR-phase body generator to the rewrite-kotlin K2 plugin that synthesize top-level extension functions surfacing Java instance methods Kotlin'sJvmBuiltInsCustomizerallow-list hides from mapped-type member scopes.f.formatted("x"),s.transform(...),s.stripIndent(),s.translateEscapes()and other JDK-15+/shadowed Java instance methods onkotlin.String/kotlin.Int/kotlin.List/etc. failed withunresolved referencedespite being callable from idiomatic Kotlin code via(f as java.lang.String).formatted(...)casts.JavaToKotlinClassMap.mappedKotlinClassFqNames(reflective field access) and the host JVM's reflectedgetDeclaredMethods(). 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 —
RecipeFirDslCheckersexplicitly doesn't recurse into pattern lambdas (RecipeFirDslCheckers.kt:226) andRecipeIrGenerationExtensionruns after FIR resolution. It's stock Kotlin behavior:kotlin.String's descriptor lives inkotlin.kotlin_builtinsand 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 throughjava.lang.Stringat every call site or avoid the methods.What changed
RecipeFirMappedTypeFallbackExtension.kt(new, ~370 lines)FirDeclarationGenerationExtension. WalksJavaToKotlinClassMap.mappedKotlinClassFqNames, iterates each Java class'sgetDeclaredMethods(), generates top-level extension functions per public instance method with the Kotlin counterpart as the extension receiver. JVM-erased dedup soList/MutableListcollapse andString.contentEquals(CharSequence)doesn't collide with the(Object)overload.MappedTypeFallbackBodyGenerator.kt(new, ~184 lines)RecipeIrGenerationExtension. Identifies synthesized extensions by origin string match, resolves the Java counterpartIrSimpleFunction, replaces the FIR-default stub body withirReturn(irCall(javaFn, dispatchReceiver = irGet(extReceiver), valueArgs = ...)). Emitsinvokevirtualat JVM level — type-safe becausekotlin.String === java.lang.String.RecipeFirExtensionRegistrar.kt(+6 lines)RecipeDslAdditionalCheckers.RecipeIrGenerationExtension.kt(+6 lines)RecipeMappedTypeFallbackTest.kt(new, ~171 lines)Known caveats
Two gaps in this initial implementation, both addressable in follow-ups:
Object...parameters are emitted as plainArray<Any?>(non-vararg). FIR builder + FIR2IR have a round-tripping bug whereisVararg=truewith element typeAny?tripsIllegalStateException: Vararg expression has incorrect typeduring call-side IR construction. Workaround atRecipeFirMappedTypeFallbackExtension.kt:303-318. Practical impact:f.formatted("x")works;f.formatted()andf.formatted("a","b")needf.formatted(arrayOf<Any?>(...)). JVM bytecode is identical either way.before { }/to { }lambdas.FirDeclarationGenerationExtensiondoesn't expose a per-call visibility gate;FirExpressionResolutionExtensiononly 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 overjava.lang.String.indexOf(int)outsideto { }).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 regressionpattern_replaceMCP tool in moderne-cli successfully runs a Kotlin recipe whoseto { }body callsString.formatted(arrayOf<Any?>(...))