Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@file:Suppress("UnstableApiUsage")

import java.io.InputStream
import org.gradle.process.CommandLineArgumentProvider
import org.gradle.process.ExecOperations
import org.gradle.kotlin.dsl.support.serviceOf

Expand Down Expand Up @@ -57,11 +58,16 @@ dependencies {
// into the same `~/.npm/_npx` directory, and the resulting overlap leaves the package half-written, so
// the tests fail with "RPC process shut down early". Installing once up front keeps every spawn a
// cache hit.
//
// The marker is present once that install succeeded and the tests can spawn the package, and absent
// when it did not happen, whether for a missing npx or a version npm does not have.
val javaScriptRpcMarker = layout.buildDirectory.file("tmp/warmJavaScriptRpcCache/version.txt")

val warmJavaScriptRpcCache by tasks.registering {
description = "Installs the npm package that the JavaScript RPC tests spawn, so they never race on a cold npx cache."
val rewriteJavaScriptJars = configurations.named("testRuntimeClasspath")
.map { classpath -> classpath.filter { it.name.startsWith("rewrite-javascript-") } }
val marker = layout.buildDirectory.file("tmp/warmJavaScriptRpcCache/version.txt")
val marker = javaScriptRpcMarker
val npx = if (System.getProperty("os.name").lowercase().contains("windows")) "npx.cmd" else "npx"
val execOperations = serviceOf<ExecOperations>()

Expand Down Expand Up @@ -109,6 +115,13 @@ val warmJavaScriptRpcCache by tasks.registering {
tasks.withType<Test> {
jvmArgs("-Xmx1g", "-Xms512m")
dependsOn(warmJavaScriptRpcCache)
// A published rewrite-javascript snapshot pins an exact @openrewrite/rewrite version, and the npm
// release of that version can lag the Maven one, leaving nothing for the RPC process to run. Tests
// annotated with @RequiresJavaScriptRpc skip rather than fail the build over that gap upstream.
val marker = javaScriptRpcMarker
jvmArgumentProviders.add(CommandLineArgumentProvider {
listOf("-DjavaScriptRpcAvailable=${marker.get().asFile.isFile}")
})
}

tasks.withType<JavaCompile> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.staticanalysis;

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.ExecutionContext;
import org.openrewrite.Option;
import org.openrewrite.Preconditions;
import org.openrewrite.Recipe;
import org.openrewrite.TreeVisitor;
import org.openrewrite.internal.ListUtils;
import org.openrewrite.java.JavaIsoVisitor;
import org.openrewrite.java.TypeMatcher;
import org.openrewrite.java.search.SemanticallyEqual;
import org.openrewrite.java.search.UsesType;
import org.openrewrite.java.tree.J;
import org.openrewrite.java.tree.JavaType;
import org.openrewrite.java.tree.TypeUtils;

import java.util.ArrayList;
import java.util.List;

@EqualsAndHashCode(callSuper = false)
@Value
public class RemoveDuplicateAnnotations extends Recipe {

@Option(displayName = "Annotation type",
description = "The type of annotation to deduplicate, as a type pattern. " +
"Defaults to any annotation.",
example = "org.jspecify.annotations.*",
required = false)
@Nullable
String annotationType;

String displayName = "Remove duplicate annotations";

String description = "Remove annotations that are repeated on the same element, keeping only the first occurrence. " +
"Duplicates typically arise when several distinct annotations are migrated to a single new annotation, " +
"such as when both `javax.annotation.Nullable` and `javax.annotation.CheckForNull` become " +
"`org.jspecify.annotations.Nullable`. " +
"`@Repeatable` annotations are left alone, as repeating those is meaningful.";

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
JavaIsoVisitor<ExecutionContext> visitor = new JavaIsoVisitor<ExecutionContext>() {
final @Nullable TypeMatcher typeMatcher = annotationType == null ? null : new TypeMatcher(annotationType);

@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) {
J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, ctx);
return cd.withLeadingAnnotations(removeDuplicates(cd.getLeadingAnnotations()));
}

@Override
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) {
J.MethodDeclaration md = super.visitMethodDeclaration(method, ctx);
return md.withLeadingAnnotations(removeDuplicates(md.getLeadingAnnotations()));
}

@Override
public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, ExecutionContext ctx) {
J.VariableDeclarations mv = super.visitVariableDeclarations(multiVariable, ctx);
return mv.withLeadingAnnotations(removeDuplicates(mv.getLeadingAnnotations()));
}

@Override
public J.Modifier visitModifier(J.Modifier modifier, ExecutionContext ctx) {
J.Modifier m = super.visitModifier(modifier, ctx);
return m.withAnnotations(removeDuplicates(m.getAnnotations()));
}

@Override
public J.AnnotatedType visitAnnotatedType(J.AnnotatedType annotatedType, ExecutionContext ctx) {
J.AnnotatedType at = super.visitAnnotatedType(annotatedType, ctx);
return at.withAnnotations(removeDuplicates(at.getAnnotations()));
}

@Override
public J.ArrayType visitArrayType(J.ArrayType arrayType, ExecutionContext ctx) {
J.ArrayType at = super.visitArrayType(arrayType, ctx);
return at.withAnnotations(removeDuplicates(at.getAnnotations()));
}

@Override
public J.Identifier visitIdentifier(J.Identifier identifier, ExecutionContext ctx) {
J.Identifier id = super.visitIdentifier(identifier, ctx);
return id.withAnnotations(removeDuplicates(id.getAnnotations()));
}

private @Nullable List<J.Annotation> removeDuplicates(@Nullable List<J.Annotation> annotations) {
if (annotations == null || annotations.size() < 2) {
return annotations;
}
List<J.Annotation> kept = new ArrayList<>(annotations.size());
return ListUtils.filter(annotations, annotation -> {
if (isDeduplicable(annotation) &&
kept.stream().anyMatch(earlier -> SemanticallyEqual.areEqual(earlier, annotation))) {
return false;
}
kept.add(annotation);
return true;
});
}

private boolean isDeduplicable(J.Annotation annotation) {
JavaType.FullyQualified fq = TypeUtils.asFullyQualified(annotation.getType());
return fq != null && !isRepeatable(fq) && (typeMatcher == null || typeMatcher.matches(fq));
}

private boolean isRepeatable(JavaType.FullyQualified annotationType) {
for (JavaType.FullyQualified metaAnnotation : annotationType.getAnnotations()) {
//noinspection ConstantValue
if (metaAnnotation != null && TypeUtils.isOfClassType(metaAnnotation, "java.lang.annotation.Repeatable")) {
return true;
}
}
return false;
}
};
return annotationType == null ? visitor : Preconditions.check(new UsesType<>(annotationType, null), visitor);
}
}
Loading
Loading