Skip to content

FixStringFormatExpressions: don't strip args with no matched specifiers#943

Open
neil-mushell wants to merge 4 commits into
openrewrite:mainfrom
neil-mushell:neil-mushell/fix-string-format-unresolved-placeholders
Open

FixStringFormatExpressions: don't strip args with no matched specifiers#943
neil-mushell wants to merge 4 commits into
openrewrite:mainfrom
neil-mushell:neil-mushell/fix-string-format-unresolved-placeholders

Conversation

@neil-mushell

Copy link
Copy Markdown

What's changed?

FixStringFormatExpressions now skips its arg-trimming step entirely
whenever zero % conversion specifiers matched in the format string —
not just when the string happens to contain {} tokens. Previously it
deleted every trailing argument in that case, treating them as dead
code. Also fixes a related counting bug: %n and %% don't consume
an argument in java.util.Formatter, but the existing loop counted
them as if they did.

What's your motivation?

Deleting arguments from a format string with zero real specifiers is
only safe if you're sure they're actually unused. That's not a safe
assumption when the string uses a different templating convention
entirely — most commonly SLF4J-style {} placeholders mistakenly used
with String#format instead of a logger:

  String.format("expect {}, actual {}", original, shadow)                                                                                                                                                
  → String.format("expect {}, actual {}")   // args silently dropped                                                                                                                                     

The calling code already had a bug (wrong placeholder syntax for
String#format), but this recipe made it worse: it doesn't fix the
real problem, and it deletes the evidence (the arguments) that would
help a reviewer notice something's off. Real-world case that surfaced
this: a shadow-traffic mismatch reporter whose diagnostic messages all
degraded to the literal string "expect {}, actual {}", with no
values, after this recipe ran on it.

Anything in particular you'd like reviewers to focus on?

Two things beyond the headline fix:

  1. The %n/%% counting fix — without it, a format string mixing
    %n (e.g. from this recipe's own newline replacement) with a
    zero-specifier string would be miscounted as having a real
    specifier on a later recipe cycle, silently defeating the main
    fix. Caught by the newlineStillReplacedWhenArgsAreNotStripped
    test.
  2. As a side effect, this also resolves a separate latent bug in the
    .formatted() path: the trim step's i == 0 keep-clause assumed
    index 0 of the argument list is always the format string, true for
    String.format(fmt, args...) but false for
    "fmt".formatted(args...) (where index 0 is the first real
    argument) — that previously forced one argument to survive even
    when zero should remain. That branch is only reachable when zero
    specifiers matched, which is now always skipped, so no separate
    fix was needed. Covered by
    doNotStripArgsWithNoSpecifiersEvenWithoutPlaceholders.

Anyone you would like to review specifically?

No specific request — open to whoever's around.

Have you considered any alternatives or workarounds?

Yes, two:

  • A narrower fix that only special-cased {}-style tokens specifically
    (detect SLF4J placeholder syntax via a dedicated regex), rather than
    bailing out on any zero-specifier case. Went with the broader
    version instead: it's simpler (no new pattern needed), and it's
    robust to other broken-templating conventions we haven't thought of
    (e.g. MessageFormat-style {0}), not just SLF4J. The tradeoff is
    it also stops trimming genuinely dead args when there's no
    placeholder-like syntax at all (covered by
    doNotStripArgsWithNoSpecifiersEvenWithoutPlaceholders) — a
    reasonable cost for not having to guess.
  • Disabling the whole recipe as a workaround downstream, which is what
    prompted finding this in the first place. Fixing it here is
    obviously preferable.

Any additional context

Full module suite: 2145 tests, 0 regressions from this change (1
pre-existing failure in
AllBranchesIdenticalTest.collapseIdenticalBranchesPython, a local
Python-toolchain environment issue unrelated to this change).

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

Neil Mushell added 3 commits July 16, 2026 13:38
…ders

When a String#format/formatted call's format string has zero real `%`
conversion specifiers, the recipe treated every trailing argument as
dead code and deleted it. That's wrong when the string contains `{}`
tokens (SLF4J-style placeholder syntax mistakenly used with
String#format instead of a logger) -- the arguments are almost
certainly meant to be substituted, just via the wrong API. Deleting
them erases the evidence needed to notice and fix the real bug.

Skip the arg-trim in that case: zero specifiers matched AND the format
string contains `{}`. The unrelated \n-to-%n replacement is unaffected.

Also fixes a related latent bug the above change exposed: `%n` and
`%%` do not consume an argument in java.util.Formatter, but the
existing specifier-counting loop counted them as if they did. Left
uncorrected, a format string mixing `%n` (e.g. from this recipe's own
newline replacement) with `{}` placeholders would be miscounted as
having a real specifier on a second recipe cycle, silently defeating
the new guard.
Broaden and simplify the previous commit's fix. Rather than special-
casing `{}`-style (SLF4J) placeholder syntax specifically, skip the
arg-trim step entirely whenever zero `%` conversion specifiers matched
-- not just when `{}` happens to be present. This recipe should only
delete arguments it has positive evidence are unused (trailing args
beyond what matched specifiers consume); zero specifiers gives no such
evidence either way, so leaving the call untouched is safer than
guessing, regardless of what the format string actually contains.

This also incidentally fixes a separate latent bug this change's test
coverage surfaced: for `"fmt".formatted(args...)`, the trim step's
`i == 0` keep-clause wrongly assumed index 0 of the argument list is
always the format string (true for String.format(fmt, args...), false
here, where index 0 is the first real argument). That forced one
argument to survive even when zero should remain. That branch is only
reachable when zero specifiers matched, which this change now always
skips -- no separate fix needed.
…o tests

- Cover %% combined with a real, argument-consuming specifier (previously
  only tested in isolation from a real specifier).
- Add a near-verbatim reproduction of the motivating production bug: a
  shadow-traffic mismatch reporter using String#format with SLF4J-style
  {} placeholders, matching the actual call shape that was losing its
  arguments before this fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant