Kotlin recipe DSL: type the matcher's parameters, not just their count - #8694
Kotlin recipe DSL: type the matcher's parameters, not just their count#8694timtebeek wants to merge 2 commits into
Conversation
A `rewrite { x: Double -> Math.abs(x) } to { x -> kotlin.math.abs(x) }`
recipe matched every same-arity `abs` overload. The K2 plugin's
`computeArgsPattern` emitted `List(jvmArgCount) { "*" }`, so the declared
`Double` reached the after-template (`#{any(kotlin.Double)}`) but never the
`MethodMatcher` spec (`java.lang.Math abs(*)`).
That is a correctness bug, not just over-eager matching. `Math.round(double)`
returns `Long` while `Math.round(float)` returns `Int`, so a recipe targeting
the `Double` overload with `to { x -> x.roundToLong() }` also fired on
`fun r(x: Float): Int = Math.round(x)` and produced a `Long` assigned to an
`Int`. #7737 deliberately scoped itself to arity; the varargs branch added by
#7895 already types its fixed prefix, and this brings the non-varargs branch
in line.
Each value parameter is now rendered by `matcherParamType`. Naming a type
outright is unsafe, because `KotlinTypeMapping` only remaps Kotlin builtins to
their JVM FQN for methods declared in Java: the same `String` parameter reads
`java.lang.String` on a Java-declared callee and `kotlin.String` on a
Kotlin-declared one, and recipe authors routinely write Kotlin stand-in classes
for Java targets. Reference builtins therefore emit a package-wildcard token
(`*..String`) that names both spellings, primitives emit the JVM keyword, and
anything whose `JavaType` spelling isn't predictable keeps the old `*` —
type parameters, nullable primitives (`Int?` boxes), value classes, nested
classes, the `kotlin.` package (collections, arrays, `Function1`), and the
lifted extension-receiver slot.
Verified against moderneinc/recipes-kotlin (964 tests, 244 DSL recipes): green.
Its `UseDoubleRoundToLong` / `UseFloatRoundToInt` pair and the three
same-arity `Math.floorMod` recipes were cross-matching before this change.
Downstream check:
|
| tests | failures | |
|---|---|---|
| baseline | 964 | 0 |
| with this change | 964 | 0 |
Green both ways — but that's because each recipe's test only exercises its own overload, so the over-match is invisible to them. Dumping the generated MethodMatcher specs out of the compiled $KtRecipe classes from each run shows what actually changed:
| recipe | baseline | with this change |
|---|---|---|
UseDoubleRoundToLong |
Math round(*) |
Math round(double) |
UseFloatRoundToInt |
Math round(*) |
Math round(float) |
UseIntMod / UseLongMod / UseLongModInt |
Math floorMod(*,*) ×3 |
(int,int) / (long,long) / (long,int) |
UseIntFloorDiv / UseLongFloorDiv |
Math floorDiv(*,*) ×2 |
(int,int) / (long,long) |
UseAppendLineChar / …CharSequence / …WithValue |
StringsKt appendln(*,*) ×3 |
(*,char) / (*,*..CharSequence) / (*,*..String) |
UseCharLowercaseCharForCharacter |
Character toLowerCase(*) |
(char) |
UseLowercaseWithLocale |
StringsKt toLowerCase(*,*) |
(*,java.util.Locale) |
The round pair is the type-unsafe collision this PR is about: two recipes sharing one matcher but with incompatible after-templates (roundToLong returns Long, roundToInt returns Int), so whichever ran first miscompiled the other's call sites.
One collision survives by design. UseAppendLineAny stays appendln(*,*) and still overlaps the three appendln siblings — kotlin.Any and java.lang.Object share no simple name, so no single token matches both parsers' spellings.
Suggested follow-up for that repo: a test that runs the composite over a source holding several overloads at once. The per-overload tests they have now pass whether or not the matchers collide.
The K2 plugin builds its matcher spec with a wildcard per argument, so the type declared on the before lambda reaches the after-template but not the MethodMatcher. UseKotlinMathAbs therefore also rewrites Math.abs on Int and Long receivers, which openrewrite/rewrite#8694 will change once it ships.
MethodMatcherspec withList(jvmArgCount) { "*" }, so arewrite { x: Double -> Math.abs(x) }recipe's declaredDoublereached the after-template but not the matcher (java.lang.Math abs(*)) and every same-arity overload matched — withMath.round, whose(double)overload returnsLongand(float)returnsInt, that turnedfun r(x: Float): Int = Math.round(x)into aLongassigned to anInt. Kotlin recipe DSL: emit precise arg-count matcher patterns instead of (..) #7737 scoped itself to arity and rewrite-kotlin: variadic-by-default matching in the recipe DSL #7895 already types the varargs branch's fixed prefix, so this just brings the non-varargs branch in line.Naming types outright is unsafe because
KotlinTypeMappingonly remaps Kotlin builtins to their JVM FQN for Java-declared methods — the sameStringparameter readsjava.lang.Stringon a Java-declared callee andkotlin.Stringon a Kotlin-declared one, and authors routinely write Kotlin stand-in classes for Java targets — so reference builtins emit a package-wildcard token (*..String) that names both spellings, primitives emit the JVM keyword, and anything unpredictable (type parameters, nullable primitives, value classes, nested classes, thekotlin.package, the lifted extension-receiver slot) keeps the old*.Four new tests in
RecipePluginRewriteTestcover the narrowed match, an untargeted sibling overload left alone, theMath.roundmiscompile, a Kotlin-declared callee, and the kotlin-recipe-starter recipes still firing; all four fail without the change, and one existing spec assertion moves fromsubstring(*, *, *)tosubstring(*, int, int).Verified downstream against
moderneinc/recipes-kotlin(964 tests, 244 DSL recipes): green, and itsUseDoubleRoundToLong/UseFloatRoundToIntpair plus three same-arityMath.floorModrecipes were silently cross-matching before this.