From 87afb463c2dae0384bf540bf300d24a93b007098 Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Mon, 10 Aug 2026 13:25:48 +0200 Subject: [PATCH 1/2] UseJavaUtilBase64: leave files it cannot fully migrate unchanged UseJavaUtilBase64 retyped BASE64Encoder and BASE64Decoder to Base64.Encoder and Base64.Decoder across the whole compilation unit while only rewriting the two overloads that have a java.util.Base64 equivalent. Every other legacy call, encode(InputStream, OutputStream) or decodeBuffer(String, OutputStream) for example, stayed on the new receiver type, so the recipe produced source that does not compile. Shapes it never retypes, such as a receiver declared as CharacterEncoder, a subclass of a legacy coder, or a method reference, left behind a sun.misc reference to a class removed in JDK 9. Scan the compilation unit first and return it untouched when a legacy coder type appears anywhere the recipe cannot retype or rewrite. The result is all or nothing: a compiling migration, or no change. The all-or-nothing scope is deliberate and costs some capability. A file that mixes one supported call with one unsupported call is no longer partially migrated, so cases that used to be rewritten in part are now left for a human. No existing test expectation changed; the new tests cover the full overload matrix and each untranslatable shape. --- .../java/migrate/UseJavaUtilBase64.java | 144 ++++++++++++- .../java/migrate/UseJavaUtilBase64Test.java | 191 ++++++++++++++++++ 2 files changed, 333 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java b/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java index 8a75923253..5bc55eb9ce 100644 --- a/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java +++ b/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java @@ -17,20 +17,25 @@ import com.fasterxml.jackson.annotation.JsonCreator; import lombok.Getter; +import org.jspecify.annotations.Nullable; import org.openrewrite.*; import org.openrewrite.java.ChangeType; +import org.openrewrite.java.JavaIsoVisitor; import org.openrewrite.java.JavaTemplate; import org.openrewrite.java.JavaVisitor; import org.openrewrite.java.MethodMatcher; import org.openrewrite.java.search.UsesType; import org.openrewrite.java.template.Semantics; +import org.openrewrite.java.tree.Expression; import org.openrewrite.java.tree.J; import org.openrewrite.java.tree.JavaSourceFile; import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; import org.openrewrite.marker.Markup; import org.openrewrite.staticanalysis.UnnecessaryCatch; import java.util.Base64; +import java.util.concurrent.atomic.AtomicBoolean; public class UseJavaUtilBase64 extends Recipe { private final String sunPackage; @@ -65,6 +70,9 @@ public TreeVisitor getVisitor() { MethodMatcher base64EncodeMethod = new MethodMatcher(sunPackage + ".CharacterEncoder *(byte[])"); MethodMatcher base64DecodeBuffer = new MethodMatcher(sunPackage + ".CharacterDecoder decodeBuffer(String)"); + MethodMatcher anyEncoderMethod = new MethodMatcher(sunPackage + ".CharacterEncoder *(..)", true); + MethodMatcher anyDecoderMethod = new MethodMatcher(sunPackage + ".CharacterDecoder *(..)", true); + MethodMatcher newBase64Encoder = new MethodMatcher(sunPackage + ".BASE64Encoder ()"); MethodMatcher newBase64Decoder = new MethodMatcher(sunPackage + ".BASE64Decoder ()"); @@ -75,6 +83,12 @@ public J visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { return Markup.warn(cu, new IllegalStateException( "Already using a class named Base64 other than java.util.Base64. Manual intervention required.")); } + if (usesLegacyTypeUntranslatably(cu)) { + // A legacy coder type appears somewhere this recipe cannot retype or rewrite. Migrating + // only part of the file would either not compile or leave a sun.misc reference behind, so + // leave the whole file for a human. + return cu; + } J.CompilationUnit c = (J.CompilationUnit) super.visitCompilationUnit(cu, ctx); c = (J.CompilationUnit) new ChangeType(sunPackage + ".BASE64Encoder", "java.util.Base64$Encoder", true) @@ -83,11 +97,137 @@ public J visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { .getVisitor().visitNonNull(c, ctx); } + /** + * True when a legacy coder type appears somewhere this recipe cannot translate. The recipe only + * retypes {@code BASE64Encoder} and {@code BASE64Decoder} themselves and only rewrites the + * overloads that have a {@code java.util.Base64} equivalent, so the file is left alone when: + *
    + *
  • an expression's static type is {@code CharacterEncoder} or {@code CharacterDecoder}, + * for example a receiver declared as the legacy supertype, which {@link ChangeType} never + * retypes;
  • + *
  • a class extends one of the legacy coder types, or instantiates one with an anonymous + * class body, since {@code Base64.Encoder} and {@code Base64.Decoder} have no accessible + * constructor;
  • + *
  • a legacy coder method with no {@code java.util.Base64} equivalent is called, or a + * supported overload is called on a receiver that is not typed as {@code BASE64Encoder} or + * {@code BASE64Decoder}, for example a subclass declared in another compilation unit;
  • + *
  • a legacy coder method or constructor is used as a method reference, which is never + * rewritten;
  • + *
  • a value is passed to a method that keeps a {@code CharacterEncoder} or + * {@code CharacterDecoder} parameter.
  • + *
+ */ + private boolean usesLegacyTypeUntranslatably(J.CompilationUnit cu) { + AtomicBoolean found = new AtomicBoolean(false); + new JavaIsoVisitor() { + @Override + public J.Import visitImport(J.Import anImport, AtomicBoolean found) { + // Imports are rewritten by ChangeType and are not a place a value can flow through + return anImport; + } + + @Override + public Expression visitExpression(Expression expression, AtomicBoolean found) { + if (isLegacySupertype(expression.getType())) { + found.set(true); + } + return super.visitExpression(expression, found); + } + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, AtomicBoolean found) { + if (classDecl.getExtends() != null && isLegacyCoderType(classDecl.getExtends().getType())) { + found.set(true); + } + return super.visitClassDeclaration(classDecl, found); + } + + @Override + public J.NewClass visitNewClass(J.NewClass newClass, AtomicBoolean found) { + // The type of an anonymous class creation is the anonymous class itself, so + // walk the supertype chain to find `new BASE64Encoder() { ... }` as well + if (newClass.getBody() != null && + (TypeUtils.isAssignableTo(sunPackage + ".CharacterEncoder", newClass.getType()) || + TypeUtils.isAssignableTo(sunPackage + ".CharacterDecoder", newClass.getType())) || + takesLegacySupertypeParameter(newClass.getMethodType())) { + found.set(true); + } + return super.visitNewClass(newClass, found); + } + + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, AtomicBoolean found) { + if (isLegacyCoderMethod(method.getMethodType())) { + Expression select = method.getSelect(); + if (!(encodeToString(method) || base64DecodeBuffer.matches(method)) || + select == null || !isRetypedCoderClass(select.getType())) { + found.set(true); + } + } else if (takesLegacySupertypeParameter(method.getMethodType())) { + found.set(true); + } + return super.visitMethodInvocation(method, found); + } + + @Override + public J.MemberReference visitMemberReference(J.MemberReference memberRef, AtomicBoolean found) { + JavaType.Method methodType = memberRef.getMethodType(); + if (isLegacyCoderMethod(methodType) || + methodType == null && isLegacyCoderType(memberRef.getContaining().getType()) || + methodType != null && (isRetypedCoderClass(methodType.getDeclaringType()) || + takesLegacySupertypeParameter(methodType))) { + found.set(true); + } + return super.visitMemberReference(memberRef, found); + } + }.visit(cu, found); + return found.get(); + } + + private boolean isLegacyCoderMethod(JavaType.@Nullable Method methodType) { + return methodType != null && (anyEncoderMethod.matches(methodType) || anyDecoderMethod.matches(methodType)); + } + + private boolean isRetypedCoderClass(@Nullable JavaType type) { + return TypeUtils.isOfClassType(type, sunPackage + ".BASE64Encoder") || + TypeUtils.isOfClassType(type, sunPackage + ".BASE64Decoder"); + } + + private boolean isLegacySupertype(@Nullable JavaType type) { + if (type instanceof JavaType.Array) { + return isLegacySupertype(((JavaType.Array) type).getElemType()); + } + return TypeUtils.isOfClassType(type, sunPackage + ".CharacterEncoder") || + TypeUtils.isOfClassType(type, sunPackage + ".CharacterDecoder"); + } + + private boolean isLegacyCoderType(@Nullable JavaType type) { + if (type instanceof JavaType.Array) { + return isLegacyCoderType(((JavaType.Array) type).getElemType()); + } + return isRetypedCoderClass(type) || isLegacySupertype(type); + } + + private boolean takesLegacySupertypeParameter(JavaType.@Nullable Method methodType) { + if (methodType != null) { + for (JavaType parameterType : methodType.getParameterTypes()) { + if (isLegacySupertype(parameterType)) { + return true; + } + } + } + return false; + } + + private boolean encodeToString(J.MethodInvocation method) { + return base64EncodeMethod.matches(method) && + ("encode".equals(method.getSimpleName()) || "encodeBuffer".equals(method.getSimpleName())); + } + @Override public J visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { J.MethodInvocation m = (J.MethodInvocation) super.visitMethodInvocation(method, ctx); - if (base64EncodeMethod.matches(m) && - ("encode".equals(method.getSimpleName()) || "encodeBuffer".equals(method.getSimpleName()))) { + if (encodeToString(m)) { m = JavaTemplate.builder(useMimeCoder ? "Base64.getMimeEncoder().encodeToString(#{anyArray(byte)})" : "Base64.getEncoder().encodeToString(#{anyArray(byte)})") .imports("java.util.Base64") .build() diff --git a/src/test/java/org/openrewrite/java/migrate/UseJavaUtilBase64Test.java b/src/test/java/org/openrewrite/java/migrate/UseJavaUtilBase64Test.java index d51c70eb3f..718bc74887 100644 --- a/src/test/java/org/openrewrite/java/migrate/UseJavaUtilBase64Test.java +++ b/src/test/java/org/openrewrite/java/migrate/UseJavaUtilBase64Test.java @@ -251,4 +251,195 @@ class Base64 { ) ); } + + @Test + void unsupportedLegacyOverloadsLeaveTheCompilationUnitAlone() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Decoder; + import test.sun.misc.BASE64Encoder; + import java.io.IOException; + import java.io.InputStream; + import java.io.OutputStream; + import java.nio.ByteBuffer; + + class Test { + void encode(InputStream stream, OutputStream output, byte[] bytes, ByteBuffer buffer) throws IOException { + BASE64Encoder encoder = new BASE64Encoder(); + encoder.encode(stream, output); + encoder.encode(bytes, output); + encoder.encode(buffer, output); + String encoded = encoder.encode(buffer); + encoder.encodeBuffer(stream, output); + encoder.encodeBuffer(bytes, output); + encoder.encodeBuffer(buffer, output); + encoded += encoder.encodeBuffer(buffer); + } + + void decode(InputStream stream, OutputStream output, String text) throws IOException { + BASE64Decoder decoder = new BASE64Decoder(); + decoder.decode(stream, output); + decoder.decode(text, output); + decoder.decodeBuffer(stream, output); + decoder.decodeBuffer(text, output); + byte[] first = decoder.decodeBuffer(stream); + } + } + """ + ) + ); + } + + @Test + void oneUnsupportedOverloadSuppressesTheSupportedRewritesInTheSameFile() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Encoder; + import java.io.IOException; + import java.io.OutputStream; + + class Test { + void test(byte[] bBytes, OutputStream output) throws IOException { + BASE64Encoder encoder = new BASE64Encoder(); + String encoded = encoder.encode(bBytes); + encoder.encodeBuffer(bBytes, output); + } + } + """ + ) + ); + } + + @Test + void methodReferenceToLegacyCoderLeavesTheCompilationUnitAlone() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Encoder; + import java.util.function.Function; + + class Test { + Function test() { + BASE64Encoder encoder = new BASE64Encoder(); + return encoder::encode; + } + } + """ + ) + ); + } + + @Test + void receiverDeclaredAsLegacySupertypeLeavesTheCompilationUnitAlone() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Decoder; + import test.sun.misc.BASE64Encoder; + import test.sun.misc.CharacterDecoder; + import test.sun.misc.CharacterEncoder; + import java.io.IOException; + + class Test { + void test(byte[] bBytes, String text) throws IOException { + CharacterEncoder encoder = new BASE64Encoder(); + String encoded = encoder.encode(bBytes); + encoded += encoder.encodeBuffer(bBytes); + CharacterDecoder decoder = new BASE64Decoder(); + byte[] decoded = decoder.decodeBuffer(text); + } + } + """ + ) + ); + } + + @Test + void subclassOfLegacyEncoderLeavesTheCompilationUnitAlone() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Encoder; + + class UrlSafeEncoder extends BASE64Encoder { + } + """ + ) + ); + } + + @Test + void anonymousSubclassOfLegacyDecoderLeavesTheCompilationUnitAlone() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Decoder; + + class Test { + BASE64Decoder decoder = new BASE64Decoder() { + }; + } + """ + ) + ); + } + + @Test + void stillMigratesHelperMethodWithLegacyEncoderParameter() { + rewriteRun( + //language=java + java( + """ + package test.sun.misc; + + import test.sun.misc.BASE64Encoder; + + class Test { + String test(byte[] bBytes) { + return encode(new BASE64Encoder(), bBytes); + } + + String encode(BASE64Encoder encoder, byte[] bBytes) { + return encoder.encode(bBytes); + } + } + """, + """ + package test.sun.misc; + + import java.util.Base64; + + class Test { + String test(byte[] bBytes) { + return encode(Base64.getEncoder(), bBytes); + } + + String encode(Base64.Encoder encoder, byte[] bBytes) { + return encoder.encodeToString(bBytes); + } + } + """ + ) + ); + } } From 9dd36aea6f437c08beb68b7ed97262c04578440a Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Wed, 12 Aug 2026 00:29:06 +0200 Subject: [PATCH 2/2] Trim commentary --- .../java/migrate/UseJavaUtilBase64.java | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java b/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java index 5bc55eb9ce..f8e65a64a5 100644 --- a/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java +++ b/src/main/java/org/openrewrite/java/migrate/UseJavaUtilBase64.java @@ -84,9 +84,8 @@ public J visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { "Already using a class named Base64 other than java.util.Base64. Manual intervention required.")); } if (usesLegacyTypeUntranslatably(cu)) { - // A legacy coder type appears somewhere this recipe cannot retype or rewrite. Migrating - // only part of the file would either not compile or leave a sun.misc reference behind, so - // leave the whole file for a human. + // Migrating only part of the file would either not compile or leave a `sun.misc` reference + // behind, so leave the whole file for a human return cu; } J.CompilationUnit c = (J.CompilationUnit) super.visitCompilationUnit(cu, ctx); @@ -98,31 +97,18 @@ public J visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { } /** - * True when a legacy coder type appears somewhere this recipe cannot translate. The recipe only - * retypes {@code BASE64Encoder} and {@code BASE64Decoder} themselves and only rewrites the - * overloads that have a {@code java.util.Base64} equivalent, so the file is left alone when: - *
    - *
  • an expression's static type is {@code CharacterEncoder} or {@code CharacterDecoder}, - * for example a receiver declared as the legacy supertype, which {@link ChangeType} never - * retypes;
  • - *
  • a class extends one of the legacy coder types, or instantiates one with an anonymous - * class body, since {@code Base64.Encoder} and {@code Base64.Decoder} have no accessible - * constructor;
  • - *
  • a legacy coder method with no {@code java.util.Base64} equivalent is called, or a - * supported overload is called on a receiver that is not typed as {@code BASE64Encoder} or - * {@code BASE64Decoder}, for example a subclass declared in another compilation unit;
  • - *
  • a legacy coder method or constructor is used as a method reference, which is never - * rewritten;
  • - *
  • a value is passed to a method that keeps a {@code CharacterEncoder} or - * {@code CharacterDecoder} parameter.
  • - *
+ * True when a legacy coder type appears somewhere this recipe cannot translate: an expression statically + * typed as {@code CharacterEncoder} or {@code CharacterDecoder}, which {@link ChangeType} never retypes; a + * subclass or anonymous body, since {@code Base64.Encoder} has no accessible constructor; a call with no + * {@code java.util.Base64} equivalent, or on a receiver not typed as one of the two coders; a method + * reference, which is never rewritten; or a value passed to a parameter that stays a legacy coder. */ private boolean usesLegacyTypeUntranslatably(J.CompilationUnit cu) { AtomicBoolean found = new AtomicBoolean(false); new JavaIsoVisitor() { @Override public J.Import visitImport(J.Import anImport, AtomicBoolean found) { - // Imports are rewritten by ChangeType and are not a place a value can flow through + // `ChangeType` rewrites imports, and no value flows through one return anImport; } @@ -144,8 +130,7 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, At @Override public J.NewClass visitNewClass(J.NewClass newClass, AtomicBoolean found) { - // The type of an anonymous class creation is the anonymous class itself, so - // walk the supertype chain to find `new BASE64Encoder() { ... }` as well + // An anonymous creation's type is the anonymous class, so walk its supertypes too if (newClass.getBody() != null && (TypeUtils.isAssignableTo(sunPackage + ".CharacterEncoder", newClass.getType()) || TypeUtils.isAssignableTo(sunPackage + ".CharacterDecoder", newClass.getType())) ||