Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert Lombok @Value classes to Java records #319

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ff0706f
#141 add draft of lombok value to java record conversion
kevin0x90 Oct 14, 2023
e7a02fc
License headers
timtebeek Oct 15, 2023
c27510f
#141 extend implementation with further acceptance criterias
kevin0x90 Oct 15, 2023
95ebc61
Merge branch 'main' into feature/141-migrate-from-lombok-value-to-jav…
kevin0x90 Oct 15, 2023
6636bd3
#141 add useExactToString option to generate an exact version of the …
kevin0x90 Oct 16, 2023
9003c9f
#141 format files
kevin0x90 Oct 16, 2023
1c005f4
#141 minor refactoring
kevin0x90 Oct 16, 2023
b66f1cb
#141 re-order static member
kevin0x90 Oct 16, 2023
8409899
Merge branch 'main' into feature/141-migrate-from-lombok-value-to-jav…
kevin0x90 Oct 19, 2023
5a7a199
#141 review feedback adjustments
kevin0x90 Oct 19, 2023
c03d6c6
#141 code review feedback, only convert classes without member field …
kevin0x90 Oct 19, 2023
0f3ad83
#141 code review feedback, refactor to ScanningRecipe
kevin0x90 Oct 19, 2023
7876243
#141 add lombok value to record conversion to lombok recipes
kevin0x90 Oct 19, 2023
225212e
#141 remove final modifiers in order to align more to already existin…
kevin0x90 Oct 19, 2023
7641253
#141 adjusts precondition check to usage of use type in favor of Mayb…
kevin0x90 Oct 19, 2023
cf0c7ff
Merge branch 'main' into feature/141-migrate-from-lombok-value-to-jav…
kevin0x90 Oct 20, 2023
a2b313e
Use AnnotationMatcher in more places
timtebeek Oct 23, 2023
98b0196
Add a test with a static field that breaks
timtebeek Oct 23, 2023
08df668
Merge branch 'main' into feature/141-migrate-from-lombok-value-to-jav…
kevin0x90 Oct 23, 2023
2ecddaf
#141 do not convert classes with static members
kevin0x90 Oct 23, 2023
f2bf78a
Collapse checks that all statements are record compatible fields
timtebeek Oct 23, 2023
eae7561
Extract a separate ScannerVisitor
timtebeek Oct 23, 2023
82f7a46
Add TODO to add type to record constructor post conversion
timtebeek Oct 23, 2023
1df3cd8
Any member assignment disqualifies, not just literal
timtebeek Oct 24, 2023
2e8725d
Polish
timtebeek Oct 24, 2023
12fc12b
Polish
timtebeek Oct 24, 2023
cdb90dd
Polish
timtebeek Oct 24, 2023
714adc3
Merge branch 'main' into feature/141-migrate-from-lombok-value-to-jav…
kevin0x90 Oct 26, 2023
2ce2445
#141 address review feedback
kevin0x90 Oct 26, 2023
416eb53
Adopt Boolean for `useExactToString`
timtebeek Oct 27, 2023
15e4eb4
Make `useExactToString` optional
timtebeek Oct 27, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
/*
* Copyright 2023 the original author or authors.
* <p>
* Licensed under the Apache License, Version 2.0 (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://www.apache.org/licenses/LICENSE-2.0
* <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.java.migrate.lombok;

import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.openrewrite.*;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.java.AnnotationMatcher;
import org.openrewrite.java.JavaIsoVisitor;
import org.openrewrite.java.JavaTemplate;
import org.openrewrite.java.RemoveAnnotationVisitor;
import org.openrewrite.java.search.UsesJavaVersion;
import org.openrewrite.java.search.UsesType;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;
import org.openrewrite.java.tree.JavaType;
import org.openrewrite.java.tree.Statement;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toList;

@Value
@EqualsAndHashCode(callSuper = false)
public class LombokValueToRecord extends ScanningRecipe<Map<String, Set<String>>> {

private static final AnnotationMatcher LOMBOK_VALUE_MATCHER = new AnnotationMatcher("@lombok.Value");

@Option(displayName = "Add a `toString()` implementation matching Lombok",
description = "When set the `toString` format from Lombok is used in the migrated record.",
required = false)
@Nullable
Boolean useExactToString;

public String getDisplayName() {
return "Convert `@lombok.Value` class to Record";
}

@Override
public String getDescription() {
return "Convert Lombok `@Value` annotated classes to standard Java Records.";
}

@Override
public Set<String> getTags() {
return Collections.singleton("lombok");
}

@Override
public Map<String, Set<String>> getInitialValue(ExecutionContext ctx) {
return new ConcurrentHashMap<>();
}

@Override
public TreeVisitor<?, ExecutionContext> getScanner(Map<String, Set<String>> acc) {
TreeVisitor<?, ExecutionContext> check = Preconditions.and(
new UsesJavaVersion<>(17),
new UsesType<>("lombok.Value", false)
);
return Preconditions.check(check, new ScannerVisitor(acc));
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor(Map<String, Set<String>> recordTypesToMembers) {
return new LombokValueToRecord.LombokValueToRecordVisitor(useExactToString, recordTypesToMembers);
}


@RequiredArgsConstructor
private static class ScannerVisitor extends JavaIsoVisitor<ExecutionContext> {
private final Map<String, Set<String>> acc;

@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) {
J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, ctx);
if (!isRelevantClass(cd)) {
return cd;
}

List<J.VariableDeclarations> memberVariables = findAllClassFields(cd).collect(toList());
if (hasMemberVariableAssignments(memberVariables)) {
return cd;
}

assert cd.getType() != null : "Class type must not be null";
acc.putIfAbsent(
cd.getType().getFullyQualifiedName(),
getMemberVariableNames(memberVariables));

return cd;
}

private boolean isRelevantClass(J.ClassDeclaration classDeclaration) {
return classDeclaration.getType() != null
&& !J.ClassDeclaration.Kind.Type.Record.equals(classDeclaration.getKind())
&& classDeclaration.getAllAnnotations().stream().allMatch(LOMBOK_VALUE_MATCHER::matches)
&& !hasGenericTypeParameter(classDeclaration)
&& classDeclaration.getBody().getStatements().stream().allMatch(this::isRecordCompatibleField);
}

private boolean hasGenericTypeParameter(J.ClassDeclaration classDeclaration) {
List<J.TypeParameter> typeParameters = classDeclaration.getTypeParameters();
return typeParameters != null && !typeParameters.isEmpty();
}

private boolean isRecordCompatibleField(Statement statement) {
if (!(statement instanceof J.VariableDeclarations)) {
return false;
}
J.VariableDeclarations variableDeclarations = (J.VariableDeclarations) statement;
if (variableDeclarations.getModifiers().stream().anyMatch(modifier -> modifier.getType() == J.Modifier.Type.Static)) {
return false;
}
if (!variableDeclarations.getAllAnnotations().isEmpty()) {
return false;
}
return true;
}

private boolean hasMemberVariableAssignments(List<J.VariableDeclarations> memberVariables) {
return memberVariables
.stream()
.map(J.VariableDeclarations::getVariables)
.flatMap(List::stream)
.map(J.VariableDeclarations.NamedVariable::getInitializer)
.anyMatch(Objects::nonNull);
}

}

private static class LombokValueToRecordVisitor extends JavaIsoVisitor<ExecutionContext> {
private static final JavaTemplate TO_STRING_TEMPLATE = JavaTemplate
.builder("@Override public String toString() { return \"#{}(\" +\n#{}\n\")\"; }")
.contextSensitive()
.build();

private static final String TO_STRING_MEMBER_LINE_PATTERN = "\"%s=\" + %s +";
private static final String TO_STRING_MEMBER_DELIMITER = "\", \" +\n";
private static final String STANDARD_GETTER_PREFIX = "get";

private final Boolean useExactToString;
private final Map<String, Set<String>> recordTypeToMembers;

public LombokValueToRecordVisitor(Boolean useExactToString, Map<String, Set<String>> recordTypeToMembers) {
this.useExactToString = useExactToString;
this.recordTypeToMembers = recordTypeToMembers;
}

@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
J.MethodInvocation methodInvocation = super.visitMethodInvocation(method, ctx);

if (!isMethodInvocationOnRecordTypeClassMember(methodInvocation)) {
return methodInvocation;
}

J.Identifier methodName = methodInvocation.getName();
return methodInvocation
.withName(methodName
.withSimpleName(getterMethodNameToFluentMethodName(methodName.getSimpleName()))
);
}

private boolean isMethodInvocationOnRecordTypeClassMember(J.MethodInvocation methodInvocation) {
Expression expression = methodInvocation.getSelect();
if (!isClassExpression(expression)) {
return false;
}

JavaType.Class classType = (JavaType.Class) expression.getType();
if (classType == null) {
return false;
}

String methodName = methodInvocation.getName().getSimpleName();
String classFqn = classType.getFullyQualifiedName();

return recordTypeToMembers.containsKey(classFqn)
&& methodName.startsWith(STANDARD_GETTER_PREFIX)
&& recordTypeToMembers.get(classFqn).contains(getterMethodNameToFluentMethodName(methodName));
}

private static boolean isClassExpression(@Nullable Expression expression) {
return expression != null && (expression.getType() instanceof JavaType.Class);
}

private static String getterMethodNameToFluentMethodName(String methodName) {
StringBuilder fluentMethodName = new StringBuilder(
methodName.replace(STANDARD_GETTER_PREFIX, ""));

if (fluentMethodName.length() == 0) {
return "";
}

char firstMemberChar = fluentMethodName.charAt(0);
fluentMethodName.setCharAt(0, Character.toLowerCase(firstMemberChar));

return fluentMethodName.toString();
}

private static List<Statement> mapToConstructorArguments(List<J.VariableDeclarations> memberVariables) {
return memberVariables
.stream()
.map(it -> it
.withModifiers(Collections.emptyList())
.withVariables(it.getVariables())
)
.map(Statement.class::cast)
.collect(toList());
}

private J.ClassDeclaration addExactToStringMethod(J.ClassDeclaration classDeclaration,
List<J.VariableDeclarations> memberVariables) {
return classDeclaration.withBody(TO_STRING_TEMPLATE
.apply(new Cursor(getCursor(), classDeclaration.getBody()),
classDeclaration.getBody().getCoordinates().lastStatement(),
classDeclaration.getSimpleName(),
memberVariablesToString(getMemberVariableNames(memberVariables))));
}

private static String memberVariablesToString(Set<String> memberVariables) {
return memberVariables
.stream()
.map(member -> String.format(TO_STRING_MEMBER_LINE_PATTERN, member, member))
.collect(Collectors.joining(TO_STRING_MEMBER_DELIMITER));
}

private static JavaType.Class buildRecordType(J.ClassDeclaration classDeclaration) {
assert classDeclaration.getType() != null : "Class type must not be null";
String className = classDeclaration.getType().getFullyQualifiedName();

return JavaType.ShallowClass.build(className)
.withKind(JavaType.FullyQualified.Kind.Record);
}

@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, ExecutionContext ctx) {
J.ClassDeclaration classDeclaration = super.visitClassDeclaration(cd, ctx);
JavaType.FullyQualified classType = classDeclaration.getType();

if (classType == null || !recordTypeToMembers.containsKey(classType.getFullyQualifiedName())) {
return classDeclaration;
}

List<J.VariableDeclarations> memberVariables = findAllClassFields(classDeclaration)
.collect(toList());

List<Statement> bodyStatements = new ArrayList<>(classDeclaration.getBody().getStatements());
bodyStatements.removeAll(memberVariables);

doAfterVisit(new RemoveAnnotationVisitor(LOMBOK_VALUE_MATCHER));
classDeclaration = classDeclaration
.withKind(J.ClassDeclaration.Kind.Type.Record)
.withType(buildRecordType(classDeclaration))
.withBody(classDeclaration.getBody()
.withStatements(bodyStatements)
)
.withPrimaryConstructor(mapToConstructorArguments(memberVariables));

if (useExactToString != null && useExactToString) {
classDeclaration = addExactToStringMethod(classDeclaration, memberVariables);
}

return maybeAutoFormat(cd, classDeclaration, ctx);
}
}

private static Stream<J.VariableDeclarations> findAllClassFields(J.ClassDeclaration cd) {
return cd.getBody().getStatements()
.stream()
.filter(J.VariableDeclarations.class::isInstance)
.map(J.VariableDeclarations.class::cast);
}

private static Set<String> getMemberVariableNames(List<J.VariableDeclarations> memberVariables) {
return memberVariables
.stream()
.map(J.VariableDeclarations::getVariables)
.flatMap(List::stream)
.map(J.VariableDeclarations.NamedVariable::getSimpleName)
.collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll);
}
}

3 changes: 2 additions & 1 deletion src/main/resources/META-INF/rewrite/lombok.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ recipeList:
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: lombok.experimental.val
newFullyQualifiedTypeName: lombok.val
- org.openrewrite.java.migrate.lombok.LombokValToFinalVar
- org.openrewrite.java.migrate.lombok.LombokValToFinalVar
- org.openrewrite.java.migrate.lombok.LombokValueToRecord
Loading
Loading