ReplaceStringBuilderWithString: wrap char[] appends in String.valueOf - #977
Merged
timtebeek merged 2 commits intoAug 11, 2026
Merged
Conversation
…eOf`
`StringBuilder.append(char[])` appends the characters of the array and
throws a `NullPointerException` for a null array. The same expression
used as a String concatenation operand gets Object-style conversion
instead, so a non-null array renders as its identity string
(`[C@1b6d3586`) and a null array renders as `"null"`.
Only the first expression of the flattened chain was made explicit with
`String.valueOf`, so a chain of `append("prefix:")` and `append(chars)`
became `"prefix:" + chars`, changing both the rendered text and the null
behavior.
Wrap any argument whose attributed invocation selects `append(char[])`
in `String.valueOf(...)`, wherever it sits in the chain. Java selects
`String.valueOf(char[])` over `String.valueOf(Object)` there, which
keeps the rendered characters and the `NullPointerException` for a null
array, so no null guard is needed or wanted.
Two limits are deliberate: `append(Object)` arguments keep Object-style
rendering even when the runtime value happens to be a `char[]`, and
`append(char[], int, int)` chains are still left alone, because
preserving their range checks and exceptions needs its own change.
4 tasks
mergify Bot
added a commit
to robfrank/linklift
that referenced
this pull request
Aug 20, 2026
… 2.40.0 to 2.41.0 [skip ci] Bumps [org.openrewrite.recipe:rewrite-static-analysis](https://github.com/openrewrite/rewrite-static-analysis) from 2.40.0 to 2.41.0. Release notes *Sourced from [org.openrewrite.recipe:rewrite-static-analysis's releases](https://github.com/openrewrite/rewrite-static-analysis/releases).* > 2.41.0 > ------ > > What's Changed > -------------- > > * Pre-install the JavaScript RPC npm package before running tests by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#956](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/956) > * UnnecessaryExplicitTypeArguments: retain witness for a return-only ty… by [`@neil-mushell`](https://github.com/neil-mushell) in [openrewrite/rewrite-static-analysis#958](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/958) > * Do not delete expressions that may have side effects by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#959](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/959) > * OpenRewrite recipe best practices by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#960](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/960) > * UseLambdaForFunctionalInterface: only convert when the anonymous class implements the SAM by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#962](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/962) > * ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977) > * RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/971) > * Make sure ModifierOrder doesn't alter Python's def quasi-modifier by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/981) > * NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/968) > * `FallThrough` to work only for Java files by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/982) > * Retry a failed npx warm, and survive a machine without Node by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/984) > * Make `DefaultComesLast` apply only to Java by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/983) > > New Contributors > ---------------- > > * [`@martinfrancois`](https://github.com/martinfrancois) made their first contribution in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977) > > **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0> Commits * [`43b51de`](openrewrite/rewrite-static-analysis@43b51de) Make DefaultComesLast apply only to Java ([#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/983)) * [`5d08edd`](openrewrite/rewrite-static-analysis@5d08edd) Retry a failed npx warm, and survive a machine without Node ([#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/984)) * [`49ddf78`](openrewrite/rewrite-static-analysis@49ddf78) FallThrough to work only for Java files ([#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/982)) * [`9699a7e`](openrewrite/rewrite-static-analysis@9699a7e) NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE ([#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/968)) * [`0c6f970`](openrewrite/rewrite-static-analysis@0c6f970) Make sure ModifierOrder doesn't alter Python's def quasi-modifier ([#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/981)) * [`6ef24c5`](openrewrite/rewrite-static-analysis@6ef24c5) RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides ([#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/971)) * [`0d8f7d7`](openrewrite/rewrite-static-analysis@0d8f7d7) ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` ([#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/977)) * [`9a15a92`](openrewrite/rewrite-static-analysis@9a15a92) OpenRewrite recipe best practices * [`a394f54`](openrewrite/rewrite-static-analysis@a394f54) UnnecessaryExplicitTypeArguments: regression test for witness on varargs meth... * [`0ecb6d8`](openrewrite/rewrite-static-analysis@0ecb6d8) Update Gradle wrapper to 9.7.0 * Additional commits viewable in [compare view](openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0) [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- Dependabot commands and options You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
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.
What's changed?
ReplaceStringBuilderWithStringflattens a chain such as
new StringBuilder().append(x).append(y).toString()into the concatenationx + y. Onmainthat flattening keeps the characters of achar[]argument when thechar[]sitsin the first position of the chain, and loses them in every later position. This change makes every
position behave the way the first one already does: the argument of every
appendcall that resolvesto the
java.lang.StringBuilder append(char[])overload is now wrapped inString.valueOf(...).Input:
Output on current
main, which at run time renders the characters ofaand not those ofb:Output with this change:
The two wrappings do not stack.
mainwraps a non-Stringfirst operand inString.valueOf(...)ina separate step,
adjustExpressions; the new wrapping runs earlier, inflatMethodInvocationChain,and yields an expression whose type is
String, soadjustExpressionsleaves it alone. Achar[]in the first position comes out as
String.valueOf(chars), never asString.valueOf(String.valueOf(chars)), whichcharArrayAppendInEveryChainPositionpins.What's your motivation?
The recipe as it stands on
maintoday produces code that returns a different value at run time thanthe code it replaced.
StringBuilder.append(char[])appends the characters of the array. The samearray used as an operand of
+goes through string conversion instead, andJLS 5.1.11 says a
reference value is converted by invoking its
toStringmethod, so a non-null array renders as theJVM type descriptor
[Cforchar[], then@, then the identity hash code in hexadecimal, forexample
[C@1b6d3586. A null array renders as the four character textnull.Give both arrays in the input above the value
{'o','k'}: the input returnsok-okand themainoutput returns
ok-[C@1b6d3586. Now letbbe null: the input throwsNullPointerException,because
StringBuilder.append(char[])dereferences the array, while themainoutput returnsok-null. The output with this change matches the input in both cases, becauseString.valueOf(char[])copies the characters of a non-null array and throwsNullPointerExceptionfor a null one.
The recipe is listed in
common-static-analysis.yml, so it runs for everyone who usesCommonStaticAnalysis. I reproduced this on v2.39.0, v2.40.0 and currentmain(5785534): therecipe file is the same git blob (794eb20c) in all three.
Anything in particular you'd like reviewers to focus on?
This change adds 5 tests to
ReplaceStringBuilderWithStringTest. Without the code change in thispull request, 3 of them fail:
charArrayAppendInEveryChainPosition,charArrayAppendKeepsCharacterRenderingandcharArrayAppendOfAnyExpressionShape. The other two,doNotChangeObjectAppendHoldingCharArrayanddoNotChangeCharArrayRangeAppend, pass either way andpin the two cases the new wrapping deliberately leaves alone, described in the limitations below.
No existing test expectation changed. The test file has 176 added lines and no deleted lines, and the
10 tests that were already in the class are untouched by this diff.
Four limitations are worth your attention:
new StringBuilder().append("a").append(chars /* the array */).toString()this branch produces"a" + String.valueOf(chars)without the comment. Not new here:maindrops the same comment,producing
"a" + chars. Comments elsewhere in the chain are still kept, which the existingretainCommentstest covers.append(Object)call is not wrapped, even when the value is achar[]at run time, soObject o = chars;followed byappend("a").append(o)still becomes"a" + o, which renders the[C@...text. Wrapping there would not help: the argument's declared type isObject, soString.valueOf(...)would selectString.valueOf(Object), which callstoString()and producesthat same text.
doNotChangeObjectAppendHoldingCharArraypins that the argument is left alonerather than wrapped in a call that changes nothing. Unchanged from
main.append(char[], int, int)still stops the whole chain from beingconverted, because the recipe only flattens
appendcalls that take one argument. Wrapping thearray there would drop the offset and length and change the value, so leaving the chain
unconverted is the right outcome, and
doNotChangeCharArrayRangeAppendpins it. Unchanged frommain.CharArrayargument does not match thejava.lang.StringBuilder append(char[])matcher, so the new wrapping never fires there. Kotlinchains are still flattened and they still lose the characters, exactly as on
main:StringBuilder().append("a").append(chars).toString()becomes"a" + charsboth with and withoutthis change.
Have you considered any alternatives or workarounds?
One option is to leave the whole chain unchanged whenever an
append(char[])call is present, ratherthan wrapping the argument. I did not pick it, for two reasons. First, the
String.valueOf(...)wrapper is not a new shape of output for this recipe:
mainalready produces it for a non-Stringfirst operand, so wrapping the later positions the same way extends output the recipe already
produces. Second, the bail out would take away a conversion that works today, the chain whose
char[]is in the first position and whose renderingmainalready gets right.If you prefer the bail out anyway, it is a two line change in
flatMethodInvocationChainand I willpush it.
Any additional context
ReplaceStringConcatenationWithStringValueOfhas a
char[]problem that is the mirror image of this one, and the fix there goes in the oppositedirection: that source already concatenates, which renders the array like any other
Object, and therecipe replaces the concatenation with
String.valueOf(chars), which copies the characters, so thefix there is to stop introducing
String.valueOf(...)rather than to introduce it. I am sending thatchange 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.
Checklist
I ran the formatter with the repository's
.editorconfig. It also wanted to re-indent lines that thischange does not touch, so I left those alone and kept the diff limited to this change.