diff --git a/build.gradle.kts b/build.gradle.kts index 6c05e78f2e..62653bc3d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,7 +58,8 @@ dependencies { testImplementation("org.assertj:assertj-core:latest.release") - testImplementation("com.google.guava:guava:33.0.0-jre") + testImplementation("com.google.errorprone:error_prone_annotations:latest.release") + testImplementation("com.google.guava:guava:33.4.8-jre") testImplementation("joda-time:joda-time:2.12.3") testImplementation("org.threeten:threeten-extra:1.8.0") diff --git a/src/main/java/org/openrewrite/java/migrate/InlineMethodCalls.java b/src/main/java/org/openrewrite/java/migrate/InlineMethodCalls.java new file mode 100644 index 0000000000..4fbf10a19f --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/InlineMethodCalls.java @@ -0,0 +1,309 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * 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 + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.java.migrate; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.tree.*; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.Collections.emptySet; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +public class InlineMethodCalls extends Recipe { + + private static final String INLINE_ME = "com.google.errorprone.annotations.InlineMe"; + + @Override + public String getDisplayName() { + return "Inline methods annotated with `@InlineMe`"; + } + + @Override + public String getDescription() { + return "Apply inlinings defined by Error Prone's [`@InlineMe` annotation](https://errorprone.info/docs/inlineme)."; + } + + @Override + public TreeVisitor getVisitor() { + // XXX Preconditions can not yet pick up the `@InlineMe` annotation on methods used + return new JavaVisitor() { + @Override + public J visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + J.MethodInvocation mi = (J.MethodInvocation) super.visitMethodInvocation(method, ctx); + InlineMeValues values = findInlineMeValues(mi.getMethodType()); + if (values == null) { + return mi; + } + Template template = values.template(mi); + if (template == null) { + return mi; + } + removeAndAddImports(method, values.getImports(), values.getStaticImports()); + J replacement = JavaTemplate.builder(template.getString()) + .contextSensitive() + .imports(values.getImports().toArray(new String[0])) + .staticImports(values.getStaticImports().toArray(new String[0])) + .javaParser(JavaParser.fromJavaVersion().classpath(JavaParser.runtimeClasspath())) + .build() + .apply(updateCursor(mi), mi.getCoordinates().replace(), template.getParameters()); + return avoidMethodSelfReferences(mi, replacement); + } + + @Override + public J visitNewClass(J.NewClass newClass, ExecutionContext ctx) { + J.NewClass nc = (J.NewClass) super.visitNewClass(newClass, ctx); + InlineMeValues values = findInlineMeValues(nc.getConstructorType()); + if (values == null) { + return nc; + } + Template template = values.template(nc); + if (template == null) { + return nc; + } + removeAndAddImports(newClass, values.getImports(), values.getStaticImports()); + J replacement = JavaTemplate.builder(template.getString()) + .contextSensitive() + .imports(values.getImports().toArray(new String[0])) + .staticImports(values.getStaticImports().toArray(new String[0])) + .javaParser(JavaParser.fromJavaVersion().classpath(JavaParser.runtimeClasspath())) + .build() + .apply(updateCursor(nc), nc.getCoordinates().replace(), template.getParameters()); + return avoidMethodSelfReferences(nc, replacement); + } + + private @Nullable InlineMeValues findInlineMeValues(JavaType.@Nullable Method methodType) { + if (methodType == null) { + return null; + } + List parameterNames = methodType.getParameterNames(); + if (!parameterNames.isEmpty() && "arg0".equals(parameterNames.get(0))) { + return null; // We need `-parameters` before we're able to substitute parameters in the template + } + + List annotations = methodType.getAnnotations(); + for (JavaType.FullyQualified annotation : annotations) { + if (INLINE_ME.equals(annotation.getFullyQualifiedName())) { + return InlineMeValues.parse((JavaType.Annotation) annotation); + } + } + return null; + } + + private void removeAndAddImports(MethodCall method, Set templateImports, Set templateStaticImports) { + Set originalImports = findOriginalImports(method); + + // Remove regular and static imports that are no longer needed + for (String originalImport : originalImports) { + if (!templateImports.contains(originalImport) && + !templateStaticImports.contains(originalImport)) { + maybeRemoveImport(originalImport); + } + } + + // Add new regular imports needed by the template + for (String importStr : templateImports) { + if (!originalImports.contains(importStr)) { + maybeAddImport(importStr); + } + } + + // Add new static imports needed by the template + for (String staticImport : templateStaticImports) { + if (!originalImports.contains(staticImport)) { + int lastDot = staticImport.lastIndexOf('.'); + if (0 < lastDot) { + maybeAddImport( + staticImport.substring(0, lastDot), + staticImport.substring(lastDot + 1)); + } + } + } + } + + private Set findOriginalImports(MethodCall method) { + // Collect all regular and static imports used in the original method call + return new JavaVisitor>() { + @Override + public @Nullable JavaType visitType(@Nullable JavaType javaType, Set strings) { + JavaType jt = super.visitType(javaType, strings); + if (jt instanceof JavaType.FullyQualified) { + strings.add(((JavaType.FullyQualified) jt).getFullyQualifiedName()); + } + return jt; + } + + @Override + public J visitMethodInvocation(J.MethodInvocation methodInvocation, Set staticImports) { + J.MethodInvocation mi = (J.MethodInvocation) super.visitMethodInvocation(methodInvocation, staticImports); + // Check if this is a static method invocation without a select (meaning it might be statically imported) + JavaType.Method methodType = mi.getMethodType(); + if (mi.getSelect() == null && methodType != null && methodType.hasFlags(Flag.Static)) { + staticImports.add(String.format("%s.%s", + methodType.getDeclaringType().getFullyQualifiedName(), + methodType.getName())); + } + return mi; + } + + @Override + public J visitIdentifier(J.Identifier identifier, Set staticImports) { + J.Identifier id = (J.Identifier) super.visitIdentifier(identifier, staticImports); + // Check if this is a static field reference + JavaType.Variable fieldType = id.getFieldType(); + if (fieldType != null && fieldType.hasFlags(Flag.Static)) { + if (fieldType.getOwner() instanceof JavaType.FullyQualified) { + staticImports.add(String.format("%s.%s", + ((JavaType.FullyQualified) fieldType.getOwner()).getFullyQualifiedName(), + fieldType.getName())); + } + } + return id; + } + }.reduce(method, new HashSet<>()); + } + + private J avoidMethodSelfReferences(MethodCall original, J replacement) { + JavaType.Method replacementMethodType = replacement instanceof MethodCall ? + ((MethodCall) replacement).getMethodType() : null; + if (replacementMethodType == null) { + return replacement; + } + + Cursor cursor = getCursor(); + while ((cursor = cursor.getParent()) != null) { + Object value = cursor.getValue(); + + JavaType.Method cursorMethodType; + if (value instanceof MethodCall) { + cursorMethodType = ((MethodCall) value).getMethodType(); + } else if (value instanceof J.MethodDeclaration) { + cursorMethodType = ((J.MethodDeclaration) value).getMethodType(); + } else { + continue; + } + if (TypeUtils.isOfType(replacementMethodType, cursorMethodType)) { + return original; + } + } + return replacement; + } + }; + } + + @Value + private static class InlineMeValues { + private static final Pattern TEMPLATE_IDENTIFIER = Pattern.compile("#\\{(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*):any\\(.*?\\)}"); + + @Getter(AccessLevel.NONE) + String replacement; + + Set imports; + Set staticImports; + + static InlineMeValues parse(JavaType.Annotation annotation) { + Map collect = annotation.getValues().stream().collect(toMap( + e -> ((JavaType.Method) e.getElement()).getName(), + JavaType.Annotation.ElementValue::getValue + )); + // Parse imports and static imports from the annotation values + return new InlineMeValues( + (String) collect.get("replacement"), + parseImports(collect.get("imports")), + parseImports(collect.get("staticImports"))); + } + + private static Set parseImports(@Nullable Object importsValue) { + if (importsValue instanceof List) { + return ((List) importsValue).stream() + .map(Object::toString) + .collect(toSet()); + } + return emptySet(); + } + + @Nullable + Template template(MethodCall original) { + JavaType.Method methodType = original.getMethodType(); + if (methodType == null) { + return null; + } + String templateString = createTemplateString(original, replacement, methodType.getParameterNames()); + List parameters = createParameters(templateString, original); + return new Template(templateString, parameters.toArray(new Object[0])); + } + + private static String createTemplateString(MethodCall original, String replacement, List originalParameterNames) { + String templateString = original instanceof J.MethodInvocation && + ((J.MethodInvocation) original).getSelect() == null && + replacement.startsWith("this.") ? + replacement.replaceFirst("^this.\\b", "") : + replacement.replaceAll("\\bthis\\b", "#{this:any()}"); + for (String parameterName : originalParameterNames) { + // Replace parameter names with their values in the templateString + templateString = templateString.replaceAll( + String.format("\\b%s\\b", parameterName), + String.format("#{%s:any()}", parameterName)); // TODO 2nd, 3rd etc should use shorthand `#{a}` + } + return templateString; + } + + private static List createParameters(String templateString, MethodCall original) { + Map lookup = new HashMap<>(); + if (original instanceof J.MethodInvocation) { + Expression select = ((J.MethodInvocation) original).getSelect(); + if (select != null) { + lookup.put("this", select); + } + } + List originalParameterNames = requireNonNull(original.getMethodType()).getParameterNames(); + for (int i = 0; i < originalParameterNames.size(); i++) { + String originalName = originalParameterNames.get(i); + Expression originalValue = original.getArguments().get(i); + lookup.put(originalName, originalValue); + } + List parameters = new ArrayList<>(); + Matcher matcher = TEMPLATE_IDENTIFIER.matcher(templateString); + while (matcher.find()) { + Expression o = lookup.get(matcher.group(1)); + if (o != null) { + parameters.add(o); + } + } + return parameters; + } + } + + @Value + private static class Template { + String string; + Object[] parameters; + } +} diff --git a/src/main/resources/META-INF/rewrite/no-guava.yml b/src/main/resources/META-INF/rewrite/no-guava.yml index 56e204bf76..87863a9a47 100644 --- a/src/main/resources/META-INF/rewrite/no-guava.yml +++ b/src/main/resources/META-INF/rewrite/no-guava.yml @@ -29,6 +29,7 @@ recipeList: - org.openrewrite.java.migrate.guava.NoGuavaJava21 - org.openrewrite.java.migrate.guava.NoGuavaCreateTempDir - org.openrewrite.java.migrate.guava.NoGuavaDirectExecutor + - org.openrewrite.java.migrate.guava.NoGuavaInlineMeMethods - org.openrewrite.java.migrate.guava.NoGuavaListsNewArrayList - org.openrewrite.java.migrate.guava.NoGuavaListsNewCopyOnWriteArrayList - org.openrewrite.java.migrate.guava.NoGuavaListsNewLinkedList @@ -108,6 +109,20 @@ recipeList: - org.openrewrite.java.migrate.guava.NoMapsAndSetsWithExpectedSize - org.openrewrite.java.migrate.guava.PreferMathClamp +--- +type: specs.openrewrite.org/v1beta/recipe +name: org.openrewrite.java.migrate.guava.NoGuavaInlineMeMethods +displayName: Inline Guava method calls +description: >- + Inline Guava method calls that are annotated with `@InlineMe` to their replacement method. +tags: + - guava +preconditions: + - org.openrewrite.analysis.search.FindMethods: + methodPattern: com.google.common..* *(..) +recipeList: + - org.openrewrite.java.migrate.InlineMethodCalls + --- type: specs.openrewrite.org/v1beta/recipe name: org.openrewrite.java.migrate.guava.PreferJavaNioCharsetStandardCharsets diff --git a/src/test/java/org/openrewrite/java/migrate/InlineMethodCallsTest.java b/src/test/java/org/openrewrite/java/migrate/InlineMethodCallsTest.java new file mode 100644 index 0000000000..a046b214e9 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/InlineMethodCallsTest.java @@ -0,0 +1,440 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * 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 + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.java.migrate; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class InlineMethodCallsTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new InlineMethodCalls()) + .parser(JavaParser.fromJavaVersion().classpath("guava", "error_prone_annotations")); + } + + @DocumentExample + @Test + void inlineMeSimple() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + + class Lib { + @Deprecated + @InlineMe(replacement = "this.replacement()") + public void deprecated() {} + public void replacement() {} + + public static void usage(Lib lib) { + lib.deprecated(); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + + class Lib { + @Deprecated + @InlineMe(replacement = "this.replacement()") + public void deprecated() {} + public void replacement() {} + + public static void usage(Lib lib) { + lib.replacement(); + } + } + """ + ) + ); + } + + @Test + void inlineMeNonStatic() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + + class Lib { + @Deprecated + @InlineMe(replacement = "this.replacement()") + public void deprecated() {} + public void replacement() {} + + public void usage() { + deprecated(); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + + class Lib { + @Deprecated + @InlineMe(replacement = "this.replacement()") + public void deprecated() {} + public void replacement() {} + + public void usage() { + replacement(); + } + } + """ + ) + ); + } + + @Test + void inlineMeChained() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + import java.time.Duration; + + class Lib { + private final Duration deadline; + + public Duration getDeadline() { + return deadline; + } + + @Deprecated + @InlineMe(replacement = "this.getDeadline().toMillis()") + public long getDeadlineMillis() { + return getDeadline().toMillis(); + } + + long usage() { + return getDeadlineMillis(); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + import java.time.Duration; + + class Lib { + private final Duration deadline; + + public Duration getDeadline() { + return deadline; + } + + @Deprecated + @InlineMe(replacement = "this.getDeadline().toMillis()") + public long getDeadlineMillis() { + return getDeadline().toMillis(); + } + + long usage() { + return getDeadline().toMillis(); + } + } + """ + ) + ); + } + + @Test + void instanceMethodWithImports() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + + class MyClass { + private java.time.Duration deadline; + + public void setDeadline(java.time.Duration deadline) { + this.deadline = deadline; + } + + @Deprecated + @InlineMe( + replacement = "this.setDeadline(Duration.ofMillis(millis))", + imports = {"java.time.Duration"}) + public void setDeadline(long millis) { + setDeadline(java.time.Duration.ofMillis(millis)); + } + + void usage() { + setDeadline(1000L); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + + import java.time.Duration; + + class MyClass { + private Duration deadline; + + public void setDeadline(Duration deadline) { + this.deadline = deadline; + } + + @Deprecated + @InlineMe( + replacement = "this.setDeadline(Duration.ofMillis(millis))", + imports = {"java.time.Duration"}) + public void setDeadline(long millis) { + setDeadline(Duration.ofMillis(millis)); + } + + void usage() { + setDeadline(Duration.ofMillis(1000L)); + } + } + """ + ) + ); + } + + @Test + void staticMethodReplacement() { + //language=java + rewriteRun( + java( + """ + package com.google.frobber; + + import com.google.errorprone.annotations.InlineMe; + + class Frobber { + + public static Frobber fromName(String name) { + return new Frobber(); + } + + @Deprecated + @InlineMe( + replacement = "Frobber.fromName(name)", + imports = {"com.google.frobber.Frobber"}) + public static Frobber create(String name) { + return fromName(name); + } + + void usage() { + Frobber f = Frobber.create("test"); + } + } + """, + """ + package com.google.frobber; + + import com.google.errorprone.annotations.InlineMe; + + class Frobber { + + public static Frobber fromName(String name) { + return new Frobber(); + } + + @Deprecated + @InlineMe( + replacement = "Frobber.fromName(name)", + imports = {"com.google.frobber.Frobber"}) + public static Frobber create(String name) { + return fromName(name); + } + + void usage() { + Frobber f = Frobber.fromName("test"); + } + } + """ + ) + ); + } + + @Test + void constructorToFactoryMethod() { + //language=java + rewriteRun( + java( + """ + package com.google.frobber; + + import com.google.errorprone.annotations.InlineMe; + import com.google.errorprone.annotations.InlineMeValidationDisabled; + + class MyClass { + + @InlineMeValidationDisabled + @Deprecated + @InlineMe( + replacement = "MyClass.create()", + imports = {"com.google.frobber.MyClass"}) + public MyClass() { + } + + public static MyClass create() { + return new MyClass(); + } + + void usage() { + MyClass obj = new MyClass(); + } + } + """, + """ + package com.google.frobber; + + import com.google.errorprone.annotations.InlineMe; + import com.google.errorprone.annotations.InlineMeValidationDisabled; + + class MyClass { + + @InlineMeValidationDisabled + @Deprecated + @InlineMe( + replacement = "MyClass.create()", + imports = {"com.google.frobber.MyClass"}) + public MyClass() { + } + + public static MyClass create() { + return new MyClass(); + } + + void usage() { + MyClass obj = MyClass.create(); + } + } + """ + ) + ); + } + + @Test + void multipleParameters() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + + class Calculator { + + public int addAndMultiply(int a, int b, int c) { + return (a + b) * c; + } + + @Deprecated + @InlineMe(replacement = "this.addAndMultiply(x, y, z)") + public int compute(int x, int y, int z) { + return addAndMultiply(x, y, z); + } + + void foo(Calculator calc) { + int result = calc.compute(1, 2, 3); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + + class Calculator { + + public int addAndMultiply(int a, int b, int c) { + return (a + b) * c; + } + + @Deprecated + @InlineMe(replacement = "this.addAndMultiply(x, y, z)") + public int compute(int x, int y, int z) { + return addAndMultiply(x, y, z); + } + + void foo(Calculator calc) { + int result = calc.addAndMultiply(1, 2, 3); + } + } + """ + ) + ); + } + + @Test + void nestedMethodCalls() { + //language=java + rewriteRun( + java( + """ + import com.google.errorprone.annotations.InlineMe; + + class Builder { + + public Builder withName(String name) { + return this; + } + + public Builder withAge(int age) { + return this; + } + + @Deprecated + @InlineMe(replacement = "this.withName(name).withAge(age)") + public Builder configure(String name, int age) { + return withName(name).withAge(age); + } + + void foo(Builder builder) { + builder.configure("John", 30); + } + } + """, + """ + import com.google.errorprone.annotations.InlineMe; + + class Builder { + + public Builder withName(String name) { + return this; + } + + public Builder withAge(int age) { + return this; + } + + @Deprecated + @InlineMe(replacement = "this.withName(name).withAge(age)") + public Builder configure(String name, int age) { + return withName(name).withAge(age); + } + + void foo(Builder builder) { + builder.withName("John").withAge(30); + } + } + """ + ) + ); + } +} diff --git a/src/test/java/org/openrewrite/java/migrate/guava/NoGuavaInlineMeMethods.java b/src/test/java/org/openrewrite/java/migrate/guava/NoGuavaInlineMeMethods.java new file mode 100644 index 0000000000..a39c82d524 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/guava/NoGuavaInlineMeMethods.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * 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 + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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.java.migrate.guava; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class NoGuavaInlineMeMethods implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipeFromResource( + "/META-INF/rewrite/no-guava.yml", + "org.openrewrite.java.migrate.guava.NoGuavaInlineMeMethods"); + } + + @DocumentExample + @Test + void stringsRegular() { + rewriteRun( + java( + """ + import com.google.common.base.Strings; + class Regular { + String repeatString(String s, int n) { + return Strings.repeat(s, n); + } + } + """, + """ + class Regular { + String repeatString(String s, int n) { + return s.repeat(n); + } + } + """ + ) + ); + } + + @Test + void stringsStaticImport() { + rewriteRun( + java( + """ + import static com.google.common.base.Strings.repeat; + class StaticImport { + String repeatString(String s, int n) { + return repeat(s, n); + } + } + """, + """ + class StaticImport { + String repeatString(String s, int n) { + return s.repeat(n); + } + } + """ + ) + ); + } +}