Skip to content

Support Error Prone @Matches and @NotMatches on @BeforeTemplate parameters - #188

Open
juherr wants to merge 2 commits into
openrewrite:mainfrom
juherr:juherr/matches-notmatches-refaster-rules
Open

Support Error Prone @Matches and @NotMatches on @BeforeTemplate parameters#188
juherr wants to merge 2 commits into
openrewrite:mainfrom
juherr:juherr/matches-notmatches-refaster-rules

Conversation

@juherr

@juherr juherr commented Jul 26, 2026

Copy link
Copy Markdown

Note

This is a draft to open a discussion about the approach, not a finished feature.

What I'd mainly like feedback on is the mechanism: resolving Error Prone matchers to OpenRewrite
equivalents through a registry, and skipping the rule when there is no equivalent. That seemed
easier to judge against a real diff and one working end-to-end example than described in the
abstract, so only a single matcher is mapped here.

If the shape holds up, the remaining ten matchers are mechanical follow-ups. If it doesn't, very
little work is thrown away — and I'd rather find that out now than after porting eleven of them.

Origin

This came out of PicnicSupermarket/error-prone-support#2276, where AssertJObjectRules was rewriting assertThat(null != x) into non-compiling assertThat(null).isNotSameAs(x) under OpenRewrite. One of the fixes suggested there was to guard the same-reference templates so they don't match a null literal operand — which is exactly what @Matches / @NotMatches express, and what led here. openrewrite/rewrite-testing-frameworks#1067 has since resolved that particular case natively.

Building this surfaced a separate and larger consequence of the missing support: a rule carrying either annotation is skipped in full, so it is silently absent from the published error-prone-contrib:recipes jar. Reported upstream as PicnicSupermarket/error-prone-support#2286.

What

Refaster rules that guard a @BeforeTemplate parameter with Error Prone's com.google.errorprone.refaster.annotation.@Matches / @NotMatches are currently skipped entirely: both annotations sit in RefasterTemplateProcessor.UNSUPPORTED_ANNOTATIONS. The last count posted on #47 was @Matches=30, @NotMatches=7.

This adds the mechanism to support them, plus the first matcher mapping.

Why it can't just call the Error Prone matcher

@Matches takes a Class<? extends com.google.errorprone.matchers.Matcher<? super ExpressionTree>>. Those matchers operate on javac trees and a VisitorState; a generated recipe only ever sees an OpenRewrite LST, with no javac compilation in sight. So the matcher class named in the annotation cannot be invoked at recipe runtime.

Instead, each supported Error Prone matcher is mapped onto an equivalent org.openrewrite.java.template.Matcher shipped here, and the existing guard-generation path is reused unchanged. The two flavors of the annotations now converge on one code path.

How

  • MatcherRegistry (new, package-private) resolves either flavor of the annotation to the OpenRewrite matcher to instantiate, going through a static Error Prone → OpenRewrite table for the Error Prone flavor.

  • TemplateDescriptor.validate() accepts the Error Prone annotations on parameters when the named matcher has a known equivalent. They stay unsupported on methods, which Error Prone also targets. A template naming an unmapped matcher is still skipped, now with a note that names it:

    @Matches(tech.picnic.errorprone.refaster.matchers.IsEmpty) is currently not supported:
    no OpenRewrite Matcher equivalent is registered
    

    Generating the recipe without the guard would silently widen it, so skipping remains the conservative default.

  • RecipeWriter.generateTemplateMatchBlock gained no new emission logic — the two near-identical existing branches were folded into appendMatcherGuard(...), now shared by all four annotations.

  • The registry is seeded with a single entry: tech.picnic.errorprone.refaster.matchers.IsLambdaExpressionOrMethodReferenceorg.openrewrite.java.template.matchers.IsLambdaExpressionOrMethodReference.

    It is the most-used matcher in error-prone-support (20 occurrences, tied for first) and the most trivial to port, which makes it a good proof of the mechanism. The remaining ten are deliberately left for follow-ups so this PR stays reviewable.

Registry values are class names, not class literals, on purpose: this code runs on the annotation processor path, where rewrite-java is usually absent, so loading a Matcher implementation there would fail. MatcherRegistryTest compensates by asserting every mapped name resolves, implements Matcher, and is instantiable via new X() — exactly what generated code does.

First commit: parameter annotations no longer pollute the template classpath

TemplateCode fed whole parameter declarations to ClasspathJarNameDetector, annotations included. That is why the detector already had to blacklist rewrite-templating and error_prone_core — the pre-existing @Matches support was tripping it. With the Error Prone flavor it got worse: @Matches(IsLambdaExpressionOrMethodReference.class) pulled error_prone_check_api into the generated classpathFromResources(...).

Parameters render as #{name:any(Type)}, so their annotations are never part of the template. ClasspathJarNameDetector.scanParameter(...) now skips them. The body scan is deliberately left alone: TemplateCodePrinter extends javac's Pretty, so an annotation appearing in a template body is rendered into the template string and does need its jar. A first attempt skipped annotations everywhere and broke that; detectAnnotationInTemplateBody now pins it down.

Side effect: two golden files lose a jspecify-1 entry that came from @Nullable on parameters and was never needed.

Drive-by in the same method: Pattern.compile was being called per resolved class symbol per template. Hoisted to a constant. Happy to drop that hunk if you'd rather keep it separate.

Verification

Beyond the unit tests, this was run end-to-end against error-prone-support (0.30.1-SNAPSHOT), comparing rewrite-templating 1.42.1 with this branch:

1.42.1 this branch
Recipes generated from error-prone-contrib 1024 1040 (+16)
Rule files using the mapped matcher 0 3

Newly unblocked rules include StreamAnyMatch, StreamAllMatchWithPredicate, StreamMapToIntSum, AssertThatThrownBy* and AssertThatCode*, across StreamRules, JUnitToAssertJRules and TestNGToAssertJRules.

Warning

Heads-up for the error-prone-support side. Their generate-recipes execution runs with failOnWarning=true. Three of the newly generated StreamRules recipes need a return-type widening guard, which emits the compile warning added in #184, and that fails their build. The generated code itself compiles fine — adding -Arewrite.suppressWarnings=true (also from #184) to their compilerArgs makes the build green again. Flagging it so it isn't a surprise on the next bump.

Test coverage

  • refaster/ErrorProneMatching.java → golden ErrorProneMatchingRecipes.java, covering both polarities.
  • errorProneMatcherWithoutEquivalent — unmapped matcher produces the note and zero recipes.
  • errorProneMatcherOnMethod — method-level @Matches is still rejected.
  • MatcherRegistryTest — every mapped name resolves to an instantiable Matcher.
  • IsLambdaExpressionOrMethodReferenceTest — lambda / method reference / identifier / method call.
  • ClasspathJarNameDetectorTest — annotations in a body still contribute jars; annotations on a parameter do not. Both fail if the skip is widened to the body.

Known gap

An unmapped matcher on a parameter that the template never uses currently skips the whole rule, whereas the OpenRewrite flavor just logs Ignoring annotation and generates the recipe. The annotation is inert in that case, so this is stricter than it needs to be. Left alone because validate() would need to compute used parameters across every arity — happy to fix it here if you'd prefer.

Follow-ups

The remaining ten Picnic matchers, roughly by value/effort (usage counts across the 78 *Rules.java files):

IsIdentityOperation (20, easy) → IsArray / IsList / IsCharacter / IsMultidimensionalArray (9 total, trivial) → IsRefasterAsVarargs (3, trivial) → RequiresComputation (2, easy) → IsEmpty (16, large) → ReturnsMono (6, medium) → ThrowsCheckedException (2, hard — needs thrown-exception analysis over a lambda body and functional-method lookup without Types.findDescriptorType).

Part of #47

Template parameters render as `#{name:any(Type)}`, so the annotations on them
are never part of the generated `JavaTemplate`, and the types those annotations
reference are not needed to parse it. Feeding them to `ClasspathJarNameDetector`
is why it already had to blacklist `rewrite-templating` and `error_prone_core`.

Scan parameter declarations without their annotations instead. Annotations in a
template *body* are still scanned, as `TemplateCodePrinter` extends javac's
`Pretty` and renders those into the template string.

Two generated recipes lose a `jspecify-1` entry that came from `@Nullable` on a
parameter and was never needed to parse the template.

Also hoist the jar name `Pattern` out of `addJarNameFor`, which recompiled it
for every resolved class symbol of every template.
… parameters

Refaster rules guarding a parameter with
`com.google.errorprone.refaster.annotation.@Matches` or `@NotMatches` were
skipped entirely, as both annotations sat in `UNSUPPORTED_ANNOTATIONS`.

Those annotations take a `com.google.errorprone.matchers.Matcher`, which operates
on javac trees and a `VisitorState`. A generated recipe only ever sees an
OpenRewrite LST, so the matcher class named in the annotation cannot be invoked
at recipe runtime. `MatcherRegistry` therefore maps each supported Error Prone
matcher onto an equivalent `org.openrewrite.java.template.Matcher` shipped here,
letting both flavors of the annotations share the existing guard generation.

The annotations remain unsupported on methods, which Error Prone also targets. A
template naming a matcher without a known equivalent is still skipped, now with a
note that names the matcher, since generating the recipe without its guard would
silently widen it.

The registry is seeded with `IsLambdaExpressionOrMethodReference`, the most used
matcher in error-prone-support. Compiling `error-prone-contrib` against this
raises the number of generated recipes from 1024 to 1040.

Part of openrewrite#47
@juherr
juherr force-pushed the juherr/matches-notmatches-refaster-rules branch from 864d4d3 to 6bde58d Compare July 26, 2026 18:33
@juherr
juherr marked this pull request as ready for review July 26, 2026 18:59
@timtebeek
timtebeek self-requested a review July 26, 2026 19:03
@timtebeek

Copy link
Copy Markdown
Member

I went through what this actually unblocks upstream: all 17 rule classes on error-prone-support@master that carry @Matches(IsLambdaExpressionOrMethodReference.class), and whether OpenRewrite already covers them another way.

The AssertJ half is a regression fix, not new coverage. Those annotations were added to JUnitToAssertJRules/TestNGToAssertJRules in Unify Refaster rule definitions (#2225), which shipped in v0.30.0. The 11 recipes were published through v0.29.0 and are gone as of v0.30.0 — and org.openrewrite.java.testing.assertj.Assertj / ...testng.TestNgToAssertj reference JUnitToAssertJRulesRecipes and TestNGToAssertJRulesRecipes directly (assertj.yml:530, testng.yml:35), so those lists quietly got weaker on the last bump. The StreamRules half has carried the annotation for years and has never been generated, so that part is genuinely new.

Rule(s) Rewrite Already covered in OpenRewrite?
JUnitToAssertJRules.AssertThatThrownByIsInstanceOf assertThrows(X.class, exec)assertThatThrownBy(exec).isInstanceOf(X.class) Overlaps. assertj.JUnitAssertThrowsToAssertExceptionType produces assertThatExceptionOfType(X).isThrownBy(exec) and sits 9 lines earlier in the same list (assertj.yml:521 vs :530), so it wins in practice
…WithFailMessageIsInstanceOf{String,Supplier} (2) + .withFailMessage(msg) Partial. Same recipe maps the 3-arg form to .as(msg) — a description, not a fail message
…IsExactlyInstanceOf + 2 fail-message variants (3) assertThrowsExactly(X.class, exec)assertThatThrownBy(exec).isExactlyInstanceOf(X.class) No. Nothing in OpenRewrite matches assertThrowsExactly
AssertThatCodeDoesNotThrowAnyException + 2 fail-message variants (3) assertDoesNotThrow(exec)assertThatCode(exec).doesNotThrowAnyException() No. Note junit5.RemoveTryCatchFailBlocks (in JUnit5BestPractices, which Assertj runs first) produces assertDoesNotThrow, so this would newly chain onto it
TestNGToAssertJRules.AssertThrows, AssertThrowsWithType (2) org.testng.Assert.assertThrows(...)assertThatThrownBy(...)[.isInstanceOf(X.class)] No. No TestNG assertThrows recipe exists
StreamRules.StreamAnyMatch, StreamAllMatchWithPredicate (2) stream.map(f).anyMatch(b -> b)stream.anyMatch(f) (and allMatch) No for Stream<T>. IntStreamRules/LongStreamRules/DoubleStreamRules already ship equivalents for the primitive streams
StreamRules.StreamMapTo{Int,Double,Long}Sum (3) stream.map(f).reduce(0, Integer::sum) and stream.collect(summingInt(f))stream.mapToInt(f).sum() No. Nothing in the catalog rewrites to mapToInt(
StreamRules.StreamNoneMatchWithPredicate (1) stream.noneMatch(p) No — and still skipped after this PR: its first @BeforeTemplate nests Refaster.anyOf inside Refaster.anyOf. That's why 17 rules yield +16 recipes

Three things that stood out:

  • Coverage check method: grepped the whole published catalog (rewrite-docs). Outside the Picnic recipes, nothing in OpenRewrite rewrites to noneMatch(, mapToInt(, or assertThatCode(, and nothing matches assertThrowsExactly or TestNG assertThrows. The only non-Picnic anyMatch/allMatch producers are migrate.guava.NoGuavaIterables{All,AnyFilter} — different input shape.
  • AssertThatCode* is knowingly unsound upstream. Picnic's own XXX at JUnitToAssertJRules.java:46-51 notes that the ThrowingSupplier<?> before-templates can match a lambda like () -> "constant" that then fails to compile as ThrowingCallable in the after-template — and says explicitly that @Matches(IsLambdaExpressionOrMethodReference.class) does not address it. Unblocking those three recipes can emit non-compiling code; a dedicated void-compatibility matcher is what's actually needed.
  • The assertThrows overlap is decided by list ordering, not by anything explicit. Once AssertThatThrownByIsInstanceOf comes back, JUnitToAssertj has two recipes racing for the same input with different outputs (assertThatExceptionOfType(...).isThrownBy(...) vs assertThatThrownBy(...).isInstanceOf(...)). Worth deciding which form we want before this lands.

@juherr

juherr commented Jul 27, 2026

Copy link
Copy Markdown
Author

Thanks — that's a much better picture of the impact than I opened with. I checked your three claims and they all hold:

  • JUnitToAssertJRulesRecipes goes from 134 nested recipes in 0.29.0 to 116 in 0.30.0; the 9 AssertThatThrownBy*/AssertThatCode* and the 2 TestNGToAssertJRulesRecipes$AssertThrows* are among the ones that vanish. That's your 11.
  • The XXX at JUnitToAssertJRules.java:46-51 says the unsoundness outright, including that @Matches(IsLambdaExpressionOrMethodReference.class) doesn't address it.
  • StreamNoneMatchWithPredicate nests Refaster.anyOf inside Refaster.anyOf, which is still rejected — that's the 17th rule, hence +16.

On AssertThatCode*: I agree those three shouldn't come back as-is. But I don't think withholding @Matches support is the right lever — that discards the mechanism to work around three bad rules. The two levers you'd actually want are the ones you already used: skip the faulty rules, or rewrite them natively as in rewrite-testing-frameworks#1067.

Worth noting the first is already how this works: a rule whose matcher has no registered equivalent is skipped. So once upstream adds the dedicated void-compatibility matcher the XXX asks for, those three stay off by construction, while the other 13 generate.

What I'd mainly like to know is whether the design holds up — the registry, and skipping rather than degrading when a matcher is unknown. If it does, I'll carry on with the remaining matchers and the other unsupported annotations from #47. If you'd rather not take this layer on, say so and I'll close it.

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.

2 participants