Keep char[] and non-Java sources out of String.valueOf conversion - #975
Draft
martinfrancois wants to merge 4 commits into
Draft
Keep char[] and non-Java sources out of String.valueOf conversion#975martinfrancois wants to merge 4 commits into
char[] and non-Java sources out of String.valueOf conversion#975martinfrancois wants to merge 4 commits into
Conversation
…va sources
The recipe rewrote `"" + x` to `String.valueOf(x)` for every non-String
right operand, but the two agree only under Java's string conversion.
For a `char[]`, overload resolution selects `String.valueOf(char[])`,
which renders the array's contents and throws on a null array, while
concatenation renders it like any other `Object`. The visitor also ran
on every `JavaSourceFile`: in Groovy `"" + x` renders a `Map` as
`[a:1]` and an `int[]` as `[1, 2]`, where `String.valueOf(x)` gives
`{a=1}` and a type-hash string. In both cases the rewrite changed the
String the code produces.
Skip a `char[]` right operand, and gate the visitor with
`JavaFileChecker`, as other Java-specific recipes here do.
`char[]` is skipped rather than routed through
`String.valueOf((Object) chars)` because emitting that cast spells a
type name whose resolution the recipe cannot verify: a type named
`Object` in scope, or `java` for a qualified cast, would turn the
output into code that no longer compiles. The recipe therefore loses
the `char[]` case; the recipe description and its generated
`recipes.csv` row now say so. No existing test expectation changed.
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
char[] and non-Java sourceschar[] and non-Java sources out of String.valueOf conversion
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.
Suggested review order: 27 of 52 (Score: 4)
Review first: openrewrite/rewrite-testing-frameworks#1087
What's changed?
ReplaceStringConcatenationWithStringValueOfno longer rewrites"" + xwhenxis achar[]or whenxhas no type attribution, and the recipe no longer runs on source files that are not Java.Before
Actual after the recipe
Expected after the recipe
(unchanged)There are three independent guards:
visitBinaryskips the rewrite when the right operand has no type attribution. Without a known type, the recipe cannot prove that the generatedString.valueOf(...)selects a behavior-preserving overload.visitBinaryskips the rewrite when the right operand is an array whose element type ischar. Every other array is still rewritten: forchar[][],Character[],int[],String[]andObject[]the compiler picks theString.valueOf(Object)overload, which in a Java source file produces exactly the text that"" + xproduces.Preconditions.check(new JavaFileChecker<>(), ...).JavaFileCheckerpasses only aJ.CompilationUnit, so the recipe stops running on Groovy and on every other language whose parser produces its own compilation unit type. Fifteen other recipe classes here, among themUseTryWithResourcesandStringLiteralEquality, already declare themselves Java-only this way. That is a minority of the roughly 170 recipe classes in the repository, so it is an established pattern rather than a universal one.The recipe description and the matching row in
src/main/resources/META-INF/rewrite/recipes.csv, the third file in the diff, now both mention thechar[]case.What's your motivation?
Recipe:
org.openrewrite.staticanalysis.ReplaceStringConcatenationWithStringValueOf.The problem is in the recipe as it stands on
maintoday, and this branch does not introduce it. I reproduced it on v2.39.0, v2.40.0 and currentmain. The recipe is listed incommon-static-analysis.yml, so it reaches everyone who runsCommonStaticAnalysis.Concatenation applies string conversion, and JLS 5.1.11 says a reference value is converted by invoking its
toStringmethod, so achar[]renders throughObject.toString().String.valueOfhas a dedicatedString.valueOf(char[])overload that copies the array contents instead. For achar[]holding{'o', 'k'},"" + charsproduces[C@<identity hash>, where[Cis the JVM type descriptor and the identity hash code in hexadecimal differs from run to run, whileString.valueOf(chars)producesok. For a nullchar[],"" + charsproduces the four character textnullwhileString.valueOf(chars)throwsNullPointerException.On
mainthe recipe also runs on sources that are not Java, because the visitor is a plainJavaVisitorwith no language check, and Groovy gives+different semantics: for anint[]holding{1, 2},"" + intArrayproduces[1, 2]there whileString.valueOf(intArray)produces[I@<identity hash>. That does not contradictint[]still being rewritten, which is a statement about Java sources, where both forms produce[I@<identity hash>.Missing operand type
The missing-type case was first isolated while preparing this pull request.
Before
Actual after the recipe
Expected after the recipe
(unchanged)When type attribution is missing,
holder.chars()can represent achar[]. The two expressions then produce different values. The recipe MUST leave the expression unchanged unless the operand type proves that the selectedString.valueOf(...)overload preserves behavior.Affected code in real projects
JnRouvignac/AutoRefactorStringValueOfRatherThanConcatSample.java: this sample pins down that AutoRefactor's own cleanup must leave"" + charsunchanged for achar[]parameter. The recipe from main rewrites that line toString.valueOf(chars), which selects thevalueOf(char[])overload, so the produced text changes from the array's identity string to its contents and the sample the tool's tests compare against is broken.Anything in particular you'd like reviewers to focus on?
This change adds 5 tests to
ReplaceStringConcatenationWithStringValueOfTest. Without the code change in this pull request, these 4 tests fail:doNotChangeCharArrayConcatenationdoNotChangeCharArrayConcatenationForAnyOperandShape, covering a field, method call, cast, ternary, and parenthesesdoNotChangeGroovySourcesdoNotChangeWhenOperandTypeIsMissingThe missing-type test disables type validation and verifies that an untyped operand remains unchanged.
The fifth test,
replaceOtherArrayConcatenations, passes either way: it pins the other side of the boundary by asserting thatchar[][],int[],String[]andObject[]concatenations are all still rewritten, so the new guard cannot quietly grow and start skipping arrays it is not meant to skip.No existing test expectation changed. The test file has added lines only.
Two limitations remain. Both are present on
maintoday and I left them out of scope:"" + ofor a reference typeothat is not an array ofchar, and that rewrite does not keep the value wheno.toString()returns null:"" + othen produces the four character textnull, whileString.valueOf(o)returns a nullStringreference.visitParenthesesremoves the parentheses around any parenthesizedString.valueOf(...)call, even in a file where the recipe changed nothing else.Have you considered any alternatives or workarounds?
Rewriting to
String.valueOf((Object) chars)would also keep the runtime value, and would keep thechar[]case rewritten. I left the concatenation alone instead, because that cast exists only to pick a different overload and reads as noise. If you prefer the cast, it is a small change tovisitBinaryand to the test expectations.I can also move the Java-only guard into its own pull request if you would rather keep this one about
char[]only.Any additional context
ReplaceStringBuilderWithStringhas achar[]problem that is the mirror image of this one, and the fix there goes in the opposite direction. There the source callsStringBuilder.append(char[]), which appends the characters, and the recipe turns that into concatenation, which does not, so the fix there is to introduceString.valueOf(...). Here the source already concatenates and the recipe replaces that withString.valueOf(chars), so the fix is to stop introducing it. I am sending that change separately, and neither depends on the other.This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
I ran the formatter with the repository's
.editorconfig. It also wanted to re-indent lines that this change does not touch, so I left those alone and kept the diff limited to this change.Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv