Skip to content

Commit

Permalink
Convert Lombok @Value classes to Java records (#319)
Browse files Browse the repository at this point in the history
* #141 add draft of lombok value to java record conversion

* License headers

* #141 extend implementation with further acceptance criterias

* #141 add useExactToString option to generate an exact version of the lombok toString method

* #141 format files

* #141 minor refactoring

* #141 re-order static member

* #141 review feedback adjustments

* #141 code review feedback, only convert classes without member field annotations

* #141 code review feedback, refactor to ScanningRecipe

* #141 add lombok value to record conversion to lombok recipes

* #141 remove final modifiers in order to align more to already existing code

* #141 adjusts precondition check to usage of use type in favor of MaybeUsesImport

* Use AnnotationMatcher in more places

* Add a test with a static field that breaks

* #141 do not convert classes with static members

* Collapse checks that all statements are record compatible fields

* Extract a separate ScannerVisitor

* Add TODO to add type to record constructor post conversion

* Any member assignment disqualifies, not just literal

* Polish

* Polish

* Polish

* #141 address review feedback

* Adopt Boolean for `useExactToString`

* Make `useExactToString` optional

---------

Co-authored-by: Tim te Beek <tim@moderne.io>
  • Loading branch information
kevin0x90 and timtebeek authored Oct 27, 2023
1 parent ecbd01a commit ffe1b69
Show file tree
Hide file tree
Showing 3 changed files with 593 additions and 1 deletion.
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

0 comments on commit ffe1b69

Please sign in to comment.