Skip to content

ReplaceStringBuilderWithString: wrap char[] appends in String.valueOf - #977

Merged
timtebeek merged 2 commits into
openrewrite:mainfrom
martinfrancois:fix/string-builder-char-array-rendering
Aug 11, 2026
Merged

ReplaceStringBuilderWithString: wrap char[] appends in String.valueOf#977
timtebeek merged 2 commits into
openrewrite:mainfrom
martinfrancois:fix/string-builder-char-array-rendering

Conversation

@martinfrancois

Copy link
Copy Markdown
Contributor

What's changed?

ReplaceStringBuilderWithString
flattens a chain such as new StringBuilder().append(x).append(y).toString() into the concatenation
x + y. On main that flattening keeps the characters of a char[] argument when the char[] sits
in 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 append call that resolves
to the java.lang.StringBuilder append(char[]) overload is now wrapped in String.valueOf(...).

Input:

String multiple(char[] a, char[] b) {
    return new StringBuilder().append(a).append("-").append(b).toString();
}

Output on current main, which at run time renders the characters of a and not those of b:

return String.valueOf(a) + "-" + b;

Output with this change:

return String.valueOf(a) + "-" + String.valueOf(b);

The two wrappings do not stack. main wraps a non-String first operand in String.valueOf(...) in
a separate step, adjustExpressions; the new wrapping runs earlier, in flatMethodInvocationChain,
and yields an expression whose type is String, so adjustExpressions leaves it alone. A char[]
in the first position comes out as String.valueOf(chars), never as
String.valueOf(String.valueOf(chars)), which charArrayAppendInEveryChainPosition pins.

What's your motivation?

The recipe as it stands on main today produces code that returns a different value at run time than
the code it replaced. StringBuilder.append(char[]) appends the characters of the array. The same
array used as an operand of + goes through string conversion instead, and
JLS 5.1.11 says a
reference value is converted by invoking its toString method, so a non-null array renders as the
JVM type descriptor [C for char[], then @, then the identity hash code in hexadecimal, for
example [C@1b6d3586. A null array renders as the four character text null.

Give both arrays in the input above the value {'o','k'}: the input returns ok-ok and the main
output returns ok-[C@1b6d3586. Now let b be null: the input throws NullPointerException,
because StringBuilder.append(char[]) dereferences the array, while the main output returns
ok-null. The output with this change matches the input in both cases, because
String.valueOf(char[]) copies the characters of a non-null array and throws NullPointerException
for a null one.

The recipe is listed in common-static-analysis.yml, so it runs for everyone who uses
CommonStaticAnalysis. I reproduced this on v2.39.0, v2.40.0 and current main (5785534): the
recipe 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 this
pull request, 3 of them fail: charArrayAppendInEveryChainPosition,
charArrayAppendKeepsCharacterRendering and charArrayAppendOfAnyExpressionShape. The other two,
doNotChangeObjectAppendHoldingCharArray and doNotChangeCharArrayRangeAppend, pass either way and
pin 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:

  • A comment inside the argument list is dropped. For
    new StringBuilder().append("a").append(chars /* the array */).toString() this branch produces
    "a" + String.valueOf(chars) without the comment. Not new here: main drops the same comment,
    producing "a" + chars. Comments elsewhere in the chain are still kept, which the existing
    retainComments test covers.
  • An append(Object) call is not wrapped, even when the value is a char[] at run time, so
    Object o = chars; followed by append("a").append(o) still becomes "a" + o, which renders the
    [C@... text. Wrapping there would not help: the argument's declared type is Object, so
    String.valueOf(...) would select String.valueOf(Object), which calls toString() and produces
    that same text. doNotChangeObjectAppendHoldingCharArray pins that the argument is left alone
    rather than wrapped in a call that changes nothing. Unchanged from main.
  • The three argument overload append(char[], int, int) still stops the whole chain from being
    converted, because the recipe only flattens append calls that take one argument. Wrapping the
    array there would drop the offset and length and change the value, so leaving the chain
    unconverted is the right outcome, and doNotChangeCharArrayRangeAppend pins it. Unchanged from
    main.
  • In a Kotlin source file, a CharArray argument does not match the
    java.lang.StringBuilder append(char[]) matcher, so the new wrapping never fires there. Kotlin
    chains are still flattened and they still lose the characters, exactly as on main:
    StringBuilder().append("a").append(chars).toString() becomes "a" + chars both with and without
    this change.

Have you considered any alternatives or workarounds?

One option is to leave the whole chain unchanged whenever an append(char[]) call is present, rather
than 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: main already produces it for a non-String
first 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 rendering main already gets right.

If you prefer the bail out anyway, it is a two line change in flatMethodInvocationChain and I will
push it.

Any additional context

ReplaceStringConcatenationWithStringValueOf
has a char[] problem that is the mirror image of this one, and the fix there goes in the opposite
direction: that source already concatenates, which renders the array like any other Object, and the
recipe replaces the concatenation with String.valueOf(chars), which copies the characters, so the
fix there is to stop introducing String.valueOf(...) rather than to introduce 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.

Checklist

  • I've added unit tests to cover both positive and negative cases
  • I've read and applied the recipe conventions and best practices
  • I've used the IntelliJ IDEA auto-formatter on affected files

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.

…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.
@timtebeek
timtebeek merged commit 0d8f7d7 into openrewrite:main Aug 11, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 11, 2026
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)
  
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility\_score?dependency-name=org.openrewrite.recipe:rewrite-static-analysis&package-manager=maven&previous-version=2.40.0&new-version=2.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)
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.

2 participants