[java-inspections] IDEA-374865 ClassCanBeRecord inspection: support JEP 513 "Flexible Constructor Bodies"

GitOrigin-RevId: a2c2b1a371904fb6d2c9394853b64defcd48156b
This commit is contained in:
Bartek Pacia
2025-07-02 17:06:05 +00:00
committed by intellij-monorepo-bot
parent 5fddd2c746
commit a4c688156c
16 changed files with 281 additions and 11 deletions
@@ -9,6 +9,7 @@ import com.intellij.psi.util.PsiUtil;
import com.intellij.psi.util.TypeConversionUtil;
import com.intellij.util.JavaPsiConstructorUtil;
import com.intellij.util.containers.MultiMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
@@ -19,6 +20,7 @@ import static com.intellij.psi.PsiModifier.STATIC;
@NotNullByDefault
final class ConstructorBodyProcessor {
private final PsiClass containingClass;
private final PsiMethod constructor;
private final Map<PsiParameter, @Nullable PsiField> paramsToFields = new HashMap<>();
// TODO(bartekpacia): change type to SequencedMap once we move to Java 21
@@ -29,16 +31,23 @@ final class ConstructorBodyProcessor {
private boolean delegating = false;
private boolean hasUnresolvedRefs = false;
private boolean tooComplex = false;
/// Even if false, it doesn't necessarily mean that conversion to record isn't possible.
///
/// It is OK to have statements before all fields are assigned if:
/// - this constructor is a canonical constructor, OR
/// - (JDK 25+) this statement is in constructor prologue (see JEP 513)
private boolean statementsBeforeAllFieldsAssigned = false;
private final List<PsiStatement> otherStatements = new ArrayList<>();
private final MultiMap<PsiField, PsiParameter> fieldsToParams = new MultiMap<>();
ConstructorBodyProcessor(PsiMethod constructor,
List<PsiField> instanceFields) {
this.containingClass = Objects.requireNonNull(constructor.getContainingClass(), "constructor must have containing class");
this.constructor = constructor;
this.instanceFields = instanceFields;
assert constructor.getBody() != null; // The caller asserts this
for (PsiStatement statement : constructor.getBody().getStatements()) {
final PsiCodeBlock body = Objects.requireNonNull(constructor.getBody(), "constructor must have body");
for (PsiStatement statement : body.getStatements()) {
execute(statement);
}
@@ -52,22 +61,35 @@ final class ConstructorBodyProcessor {
}
final PsiExpression expression = expressionStatement.getExpression();
if (expression instanceof PsiMethodCallExpression methodCallExpr) {
if (JavaPsiConstructorUtil.isChainedConstructorCall(methodCallExpr)) {
delegating = true;
return;
if (expression instanceof PsiMethodCallExpression methodCallExpr && JavaPsiConstructorUtil.isChainedConstructorCall(methodCallExpr)) {
delegating = true;
for (PsiExpression arg : methodCallExpr.getArgumentList().getExpressions()) {
if (hasReferenceToContainingClass(containingClass, arg)) {
statementsBeforeAllFieldsAssigned = true;
}
}
return;
}
// Is it an assignment expression to an instance field?
// If not, then all instance variables must already be assigned.
if (!expressionIsAssignmentToInstanceField(expression) && !delegating) {
// It is NOT an assignment expression to an instance field.
// This means that:
// - all instance variables must be already initialized, OR
// - (JDK 25+) this statement is inside early construction context, more specifically: in constructor prologue (see JEP 513).
// This means that it must NOT use use 'this', either implicitly or explicitly, except for simple assignment statements.
otherStatements.add(statement);
// If not all instance fields are assigned up to this point,
// then this constructor cannot be converted to a non-canonical record constructor.
if (fieldNamesToInitializers.size() < instanceFields.size()) {
if (fieldNamesToInitializers.isEmpty() && PsiUtil.isAvailable(JavaFeature.STATEMENTS_BEFORE_SUPER, statement)) {
PsiExpression exprToConsider = expression instanceof PsiAssignmentExpression assignExpr ? assignExpr.getRExpression() : expression;
if (hasReferenceToContainingClass(containingClass, exprToConsider)) {
statementsBeforeAllFieldsAssigned = true;
}
}
else if (fieldNamesToInitializers.size() < instanceFields.size()) {
// If not all instance fields are assigned up to this point,
// then this constructor cannot be converted to a non-canonical record constructor.
statementsBeforeAllFieldsAssigned = true;
// It is OK to have statements before all fields are assigned if this constructor is a canonical constructor.
}
return;
}
@@ -225,4 +247,25 @@ final class ConstructorBodyProcessor {
PsiElement resolved = referenceExpr.resolve();
return resolved instanceof PsiField psiField && !psiField.hasModifierProperty(STATIC);
}
private static boolean hasReferenceToContainingClass(@NotNull PsiClass containingClass, @Nullable PsiExpression expression) {
if (expression == null) return false;
Ref<Boolean> hasReferenceToClassUnderConstruction = new Ref<>(false);
expression.accept(new JavaRecursiveElementWalkingVisitor() {
@Override
public void visitReferenceExpression(PsiReferenceExpression expression) {
super.visitReferenceExpression(expression);
PsiElement resolved = expression.resolve();
if (resolved instanceof PsiField field && !field.hasModifierProperty(STATIC) && field.getContainingClass() == containingClass) {
hasReferenceToClassUnderConstruction.set(true);
}
else if (resolved instanceof PsiMethod method &&
!method.hasModifierProperty(STATIC) &&
method.getContainingClass() == containingClass) {
hasReferenceToClassUnderConstruction.set(true);
}
}
});
return hasReferenceToClassUnderConstruction.get();
}
}
@@ -0,0 +1,14 @@
// "Convert to record class" "false"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
this(name, age); // javac error
}
}
@@ -0,0 +1,9 @@
// "Convert to record class" "true-preview"
record Point(double x, double y) {
Point(double x, double y) {
System.out.println("Hello I am going to be created");
this.x = x;
this.y = y;
System.out.println("Hello I was just created");
}
}
@@ -0,0 +1,8 @@
// "Convert to record class" "true-preview"
record Person(String name, int age) {
Person(String name) {
System.out.println("age not passed");
this(name, 0);
}
}
@@ -0,0 +1,8 @@
// "Convert to record class" "true-preview"
record Person(String name, int age) {
Person(String name) {
System.out.println("age not passed" + name);
this(name, 0);
}
}
@@ -0,0 +1,13 @@
// "Convert to record class" "true-preview"
record Person(String name, int age) {
static int staticVar = 42;
static void staticMethod() {
}
Person(String name) {
System.out.println("age not passed" + staticVar);
staticMethod();
this(name, 0);
}
}
@@ -0,0 +1,8 @@
// "Convert to record class" "true-preview"
record Person(String name, int age) {
Person(String name) {
System.out.println("age not passed");
this(name, 0);
}
}
@@ -0,0 +1,12 @@
// "Convert to record class" "true-preview"
class Poi<caret>nt {
final double x;
final double y;
Point(double x, double y) {
System.out.println("Hello I am going to be created");
this.x = x;
this.y = y;
System.out.println("Hello I was just created");
}
}
@@ -0,0 +1,15 @@
// "Convert to record class" "true-preview"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
System.out.println("age not passed");
this(name, 0);
}
}
@@ -0,0 +1,15 @@
// "Convert to record class" "true-preview"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
System.out.println("age not passed" + name);
this(name, 0);
}
}
@@ -0,0 +1,15 @@
// "Convert to record class" "false"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
System.out.println("age not passed" + this.name); // javac error: "cannot reference 'this' before superclass constructor is called"
this(name, 0);
}
}
@@ -0,0 +1,20 @@
// "Convert to record class" "true-preview"
class Person<caret> {
final String name;
final int age;
static int staticVar = 42;
static void staticMethod() {
}
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
System.out.println("age not passed" + staticVar);
staticMethod();
this(name, 0);
}
}
@@ -0,0 +1,14 @@
// "Convert to record class" "false"
class Person<caret> {
final String name;
int age = 0;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
this(name, 0 + age); // javac error: "cannot reference age before supertype constructor has been called"
}
}
@@ -0,0 +1,16 @@
// "Convert to record class" "true-preview"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
System.out.println("age not passed");
this.name = name;
this.age = 0;
}
}
@@ -0,0 +1,16 @@
// "Convert to record class" "false"
class Person<caret> {
final String name;
final int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
Person(String name) {
this.name = name;
System.out.println("age not passed"); // cannot convert to delegating constructor call while preserving semantics
this.age = 0;
}
}
@@ -0,0 +1,44 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.java.codeInspection.classCanBeRecord;
import com.intellij.codeInsight.daemon.quickFix.LightQuickFixParameterizedTestCase;
import com.intellij.codeInspection.LocalInspectionTool;
import com.intellij.codeInspection.classCanBeRecord.ClassCanBeRecordInspection;
import com.intellij.refactoring.BaseRefactoringProcessor;
import com.intellij.testFramework.LightProjectDescriptor;
import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase;
import org.jetbrains.annotations.NotNull;
import static com.intellij.codeInspection.classCanBeRecord.ClassCanBeRecordInspection.ConversionStrategy;
public class ClassCanBeRecordFlexibleConstructorBodiesTest extends LightQuickFixParameterizedTestCase {
@Override
protected LocalInspectionTool @NotNull [] configureLocalInspectionTools() {
ClassCanBeRecordInspection inspection = new ClassCanBeRecordInspection(ConversionStrategy.DO_NOT_SUGGEST, true);
return new LocalInspectionTool[]{inspection};
}
@Override
protected @NotNull LightProjectDescriptor getProjectDescriptor() {
return LightJavaCodeInsightFixtureTestCase.JAVA_LATEST;
}
@Override
protected String getBasePath() {
return "/inspection/classCanBeRecord/flexibleConstructorBodies";
}
@Override
public void runSingle() throws Throwable {
try {
super.runSingle();
}
catch (BaseRefactoringProcessor.ConflictsInTestsException e) {
// Verify that no content was changed. See IDEA-371645.
checkResultByFile(getTestName(false) + ".java", getBasePath() + "/before" + getTestName(false), false);
}
super.runSingle();
}
}