diff --git a/java/java.hints/src/org/netbeans/modules/java/hints/Feature.java b/java/java.hints/src/org/netbeans/modules/java/hints/Feature.java index 4176236793e5..83f47dc2f423 100644 --- a/java/java.hints/src/org/netbeans/modules/java/hints/Feature.java +++ b/java/java.hints/src/org/netbeans/modules/java/hints/Feature.java @@ -38,6 +38,9 @@ public enum Feature { /// https://openjdk.org/jeps/394 INSTANCEOF_PATTERN(14, 16), + /// https://openjdk.org/jeps/395 + RECORD(14, 16), + /// https://openjdk.org/jeps/441 SWITCH_PATTERN(17, 21), diff --git a/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/Bundle.properties b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/Bundle.properties index 9ae23b594e4c..491608fe5918 100644 --- a/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/Bundle.properties +++ b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/Bundle.properties @@ -111,4 +111,7 @@ DN_ConvertVarLambdaParameters=Convert Lambda Parameter Type to 'var' Type DESC_ConvertVarLambdaParameters=Convert lambda explicit or implicit parameter type to 'var' type. \ The hint will be disable for mixed or error parameter type. FIX_ConvertVarLambdaParameters=Use var parameter types -ERR_ConvertVarLambdaParameters=Use var parameter types \ No newline at end of file +ERR_ConvertVarLambdaParameters=Use var parameter types + +OPT_ClassToRecord_RemoveObjectMethods=Also remove equals(), hashCode() and toString() methods +DESC_ClassToRecord_RemoveObjectMethods=Drops the methods from the record body, even when their implementation differs from the one provided by java.lang.Record diff --git a/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/ClassToRecord.java b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/ClassToRecord.java new file mode 100644 index 000000000000..851e85b07291 --- /dev/null +++ b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/ClassToRecord.java @@ -0,0 +1,472 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.modules.java.hints.suggestions; + +import com.sun.source.tree.AnnotationTree; +import com.sun.source.tree.AssignmentTree; +import com.sun.source.tree.BlockTree; +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.ExpressionStatementTree; +import com.sun.source.tree.IdentifierTree; +import com.sun.source.tree.MemberSelectTree; +import com.sun.source.tree.MethodInvocationTree; +import com.sun.source.tree.MethodTree; +import com.sun.source.tree.ModifiersTree; +import com.sun.source.tree.ReturnTree; +import com.sun.source.tree.StatementTree; +import com.sun.source.tree.Tree; +import com.sun.source.tree.Tree.Kind; +import com.sun.source.tree.VariableTree; +import com.sun.source.util.TreePath; +import com.sun.source.util.Trees; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.prefs.Preferences; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import org.netbeans.api.java.source.CompilationInfo; +import org.netbeans.api.java.source.GeneratorUtilities; +import org.netbeans.api.java.source.TreeMaker; +import org.netbeans.api.java.source.TreePathHandle; +import org.netbeans.api.java.source.TreeUtilities; +import org.netbeans.api.java.source.WorkingCopy; +import org.netbeans.modules.java.hints.Feature; +import org.netbeans.spi.editor.hints.ErrorDescription; +import org.netbeans.spi.editor.hints.Fix; +import org.netbeans.spi.java.hints.ErrorDescriptionFactory; +import org.netbeans.spi.java.hints.BooleanOption; +import org.netbeans.spi.java.hints.Hint; +import org.netbeans.spi.java.hints.HintContext; +import org.netbeans.spi.java.hints.JavaFix; +import org.netbeans.spi.java.hints.TriggerTreeKind; +import org.netbeans.spi.java.hints.UseOptions; +import org.openide.util.NbBundle; +import org.openide.util.NbBundle.Messages; + +/** + * Suggests converting a class declaration into a record. + *

+ * The conversion is only offered when it does not change the program's + * behavior: a canonical constructor and accessor methods that are redundant + * with the record's implicit ones are dropped, everything else remains in + * the body. + *

+ * The {@link #OPTION_REMOVE_OBJECT_METHODS} option trades that guarantee for + * a slimmer record body: the class' {@code equals()}, {@code hashCode()} and + * {@code toString()} methods are dropped in favor of the ones inherited from + * {@code java.lang.Record}, even when their implementation differs. + * + * @author mbien + */ +public class ClassToRecord { + + @BooleanOption( + displayName = "#OPT_ClassToRecord_RemoveObjectMethods", + tooltip = "#DESC_ClassToRecord_RemoveObjectMethods", + defaultValue = false + ) + public static final String OPTION_REMOVE_OBJECT_METHODS = "classtorecord.remove.object.methods"; //NOI18N + + @Hint( + displayName = "#DN_convertClassToRecord", + description = "#DESC_convertClassToRecord", + category = "class_structure", + hintKind = Hint.Kind.INSPECTION, + minSourceVersion = "14", + suppressWarnings = "ClassToRecord" + ) + @Messages({ + "DN_convertClassToRecord=Convert Class to Record", + "DESC_convertClassToRecord=Reports classes which can be converted to records. The non-static fields become record components, a redundant canonical constructor and record-style accessors are dropped and everything else remains in the body.", + "MSG_ClassToRecord=Class {0} may be converted to a record", + "FIX_ConvertClassToRecord=Convert class {0} to a record" + }) + @TriggerTreeKind(Kind.CLASS) + @UseOptions(OPTION_REMOVE_OBJECT_METHODS) + public static ErrorDescription convertClassToRecord(HintContext context) { + CompilationInfo info = context.getInfo(); + if (!Feature.RECORD.isEnabled(info)) { + return null; + } + if (testClassMayBeRecord(info, context.getPath())) { + ClassTree cls = (ClassTree) context.getPath().getLeaf(); + Fix fix = new ConvertClassToRecordFixImpl( + TreePathHandle.create(context.getPath(), info), + NbBundle.getMessage(ClassToRecord.class, "FIX_ConvertClassToRecord", cls.getSimpleName()), + context.getPreferences() + ).toEditorFix(); + return ErrorDescriptionFactory.forName(context, cls, NbBundle.getMessage(ClassToRecord.class, "MSG_ClassToRecord", cls.getSimpleName()), fix); //NOI18N + } + return null; + } + + private static boolean testClassMayBeRecord(CompilationInfo info, TreePath path) { + // a record may be top level or a member of another class, + // but not a local or anonymous class + if (path.getParentPath() == null) { + return false; + } + Tree parent = path.getParentPath().getLeaf(); + if (parent.getKind() != Kind.COMPILATION_UNIT && !TreeUtilities.CLASS_TREE_KINDS.contains(parent.getKind())) { + return false; + } + Trees trees = info.getTrees(); + Element element = trees.getElement(path); + if (element == null || element.getKind() != ElementKind.CLASS) { + return false; + } + Set classModifiers = element.getModifiers(); + if (classModifiers.contains(Modifier.SEALED) || classModifiers.contains(Modifier.NON_SEALED)) { + return false; + } + if (classModifiers.contains(Modifier.ABSTRACT) || classModifiers.contains(Modifier.STRICTFP)) { + return false; + } + ClassTree cls = (ClassTree) path.getLeaf(); + if (cls.getExtendsClause() != null) { + return false; + } + // record components are the plain non-static fields + List componentNames = new ArrayList<>(); + Map components = new HashMap<>(); + for (Tree member : cls.getMembers()) { + if (member.getKind() == Kind.VARIABLE && !((VariableTree) member).getModifiers().getFlags().contains(Modifier.STATIC)) { + VariableTree field = (VariableTree) member; + // a record component cannot have an initializer or annotations + if (field.getInitializer() != null || !field.getModifiers().getAnnotations().isEmpty()) { + return false; + } + componentNames.add(field.getName().toString()); + components.put(field.getName().toString(), field); + } + } + TreeUtilities treeUtilities = info.getTreeUtilities(); + Types types = info.getTypes(); + for (Tree member : cls.getMembers()) { + if (treeUtilities.isSynthetic(new TreePath(path, member))) { + continue; + } + switch (member.getKind()) { + case METHOD -> { + MethodTree mth = (MethodTree) member; + if (mth.getModifiers().getFlags().contains(Modifier.ABSTRACT)) { + // records cannot be abstract + return false; + } + if (mth.getReturnType() == null) { + // a constructor is kept as the record's explicitly + // declared canonical constructor, unless it is + // redundant with the implicit one + if (!isRedundantCanonicalConstructor(mth, componentNames, components, trees, types, treeUtilities, path) + && hasSuperCall(mth, treeUtilities, path)) { + // records extend java.lang.Record, an explicit + // super(...) call would not be safe + return false; + } + } else if (mth.getParameters().isEmpty() && components.containsKey(mth.getName().toString())) { + // a no-arg method named after a component would + // collide with the implicitly declared accessor + if (!isRedundantAccessor(mth, components.get(mth.getName().toString()), trees, types, path)) { + return false; + } + } + } + case VARIABLE -> { + // fields were already validated above: static fields and + // record components both stay valid in the record body + break; + } + case CLASS, ENUM, INTERFACE, ANNOTATION_TYPE -> { + // nested types stay in the record body + break; + } + default -> { + return false; + } + } + } + return true; + } + + /** + * Checks whether the given constructor is redundant with the implicit + * canonical constructor of the record: it has one parameter per record + * component in declaration order (same name and type) and its body merely + * assigns the parameters to the components. + */ + private static boolean isRedundantCanonicalConstructor(MethodTree ctor, + List componentNames, Map components, + Trees trees, Types types, TreeUtilities treeUtilities, TreePath classPath) { + if (!ctor.getModifiers().getAnnotations().isEmpty()) { + // an annotated constructor is kept, it becomes the record's + // explicitly declared canonical constructor + return false; + } + List params = ctor.getParameters(); + if (params.size() != componentNames.size()) { + return false; + } + TreePath ctorPath = new TreePath(classPath, ctor); + for (int i = 0; i < params.size(); i++) { + VariableTree param = params.get(i); + if (!componentNames.get(i).equals(param.getName().toString()) + || !param.getModifiers().getAnnotations().isEmpty() + || !types.isSameType(trees.getElement(new TreePath(ctorPath, param)).asType(), + trees.getElement(new TreePath(classPath, components.get(componentNames.get(i)))).asType())) { + return false; + } + } + if (!(ctor.getBody() instanceof BlockTree block)) { + return false; + } + TreePath bodyPath = new TreePath(ctorPath, block); + List statements = new ArrayList<>(); + for (StatementTree statement : block.getStatements()) { + if (!treeUtilities.isSynthetic(new TreePath(bodyPath, statement))) { + // javac injects the implicit super(...) call into + // resolved constructor bodies, it is not part of the source + statements.add(statement); + } + } + if (statements.size() != params.size()) { + return false; + } + for (int i = 0; i < statements.size(); i++) { + if (!isThisAssignment(statements.get(i), componentNames.get(i))) { + return false; + } + } + return true; + } + + /** + * Checks whether the given method is a redundant record-style accessor: + * a public, non-annotated, no-argument method named after a record + * component whose body merely returns the component field. + */ + private static boolean isRedundantAccessor(MethodTree mth, VariableTree component, + Trees trees, Types types, TreePath classPath) { + if (!mth.getModifiers().getFlags().equals(EnumSet.of(Modifier.PUBLIC)) + || !mth.getModifiers().getAnnotations().isEmpty()) { + return false; + } + if (!types.isSameType(((ExecutableElement) trees.getElement(new TreePath(classPath, mth))).getReturnType(), + trees.getElement(new TreePath(classPath, component)).asType())) { + return false; + } + if (!(mth.getBody() instanceof BlockTree block) || block.getStatements().size() != 1) { + return false; + } + String name = mth.getName().toString(); + if (!(block.getStatements().getFirst() instanceof ReturnTree ret) || ret.getExpression() == null) { + return false; + } + Tree expression = ret.getExpression(); + if (expression.getKind() == Kind.IDENTIFIER) { + return name.equals(((IdentifierTree) expression).getName().toString()); + } + if (expression.getKind() == Kind.MEMBER_SELECT) { + MemberSelectTree select = (MemberSelectTree) expression; + return "this".equals(identifierName(select.getExpression())) //NOI18N + && name.equals(select.getIdentifier().toString()); + } + return false; + } + + /** + * Checks whether the given method is one of the three {@code Object} methods + * provided by {@code java.lang.Record}: {@code public [final] boolean + * equals(Object)}, {@code public [final] int hashCode()} or {@code public + * [final] String toString()}. An {@code @Override} annotation is tolerated, + * any other annotation keeps the method in the record body. + */ + private static boolean isRecordObjectMethod(MethodTree mth, Trees trees, Types types, Elements elements, TreePath classPath) { + Set flags = mth.getModifiers().getFlags(); + if (!flags.equals(EnumSet.of(Modifier.PUBLIC)) && !flags.equals(EnumSet.of(Modifier.PUBLIC, Modifier.FINAL))) { + return false; + } + List annotations = mth.getModifiers().getAnnotations(); + if (annotations.size() > 1 + || (!annotations.isEmpty() && !isOverrideAnnotation(annotations.getFirst(), trees, types, elements, classPath, mth))) { + return false; + } + List params = mth.getParameters(); + String name = mth.getName().toString(); + if ("equals".equals(name)) { //NOI18N + if (params.size() != 1) { + return false; + } + // the return type is forced to boolean by the compiler, the + // parameter type must be Object for this to be an override + return types.isSameType(trees.getElement(new TreePath(new TreePath(classPath, mth), params.getFirst())).asType(), + elements.getTypeElement("java.lang.Object").asType()); //NOI18N + } + // the return types are forced to int and String by the compiler + return params.isEmpty() && ("hashCode".equals(name) || "toString".equals(name)); //NOI18N + } + + private static boolean isOverrideAnnotation(AnnotationTree annotation, Trees trees, Types types, Elements elements, TreePath classPath, MethodTree mth) { + TypeElement override = elements.getTypeElement("java.lang.Override"); //NOI18N + if (override == null) { + return false; + } + Element element = trees.getElement(new TreePath(new TreePath(classPath, mth), annotation.getAnnotationType())); + return element != null && types.isSameType(element.asType(), override.asType()); + } + + private static boolean isThisAssignment(StatementTree statement, String name) { + if (!(statement instanceof ExpressionStatementTree es) + || !(es.getExpression() instanceof AssignmentTree assignment)) { + return false; + } + if (assignment.getVariable().getKind() != Kind.MEMBER_SELECT) { + return false; + } + MemberSelectTree select = (MemberSelectTree) assignment.getVariable(); + return "this".equals(identifierName(select.getExpression())) //NOI18N + && name.equals(select.getIdentifier().toString()) + && assignment.getExpression().getKind() == Kind.IDENTIFIER + && name.equals(((IdentifierTree) assignment.getExpression()).getName().toString()); + } + + private static boolean hasSuperCall(MethodTree ctor, TreeUtilities treeUtilities, TreePath classPath) { + if (!(ctor.getBody() instanceof BlockTree block)) { + // be conservative with unusual constructor bodies + return true; + } + TreePath bodyPath = new TreePath(new TreePath(classPath, ctor), block); + for (StatementTree statement : block.getStatements()) { + if (treeUtilities.isSynthetic(new TreePath(bodyPath, statement))) { + // the implicit super(...) call javac injects into + // resolved constructor bodies + continue; + } + if (statement instanceof ExpressionStatementTree es + && es.getExpression().getKind() == Kind.METHOD_INVOCATION + && "super".equals(identifierName(((MethodInvocationTree) es.getExpression()).getMethodSelect()))) { //NOI18N + return true; + } + } + return false; + } + + private static String identifierName(Tree tree) { + if (tree.getKind() == Kind.IDENTIFIER) { + return ((IdentifierTree) tree).getName().toString(); + } + return null; + } + + private static final class ConvertClassToRecordFixImpl extends JavaFix { + + private final String text; + private final Preferences prefs; + + public ConvertClassToRecordFixImpl(TreePathHandle clsHandle, String text, Preferences prefs) { + super(clsHandle); + this.text = text; + this.prefs = prefs; + } + + @Override + public String getText() { + return text; + } + + @Override + protected void performRewrite(TransformationContext ctx) { + WorkingCopy wc = ctx.getWorkingCopy(); + GeneratorUtilities gu = GeneratorUtilities.get(wc); + TreePath path = ctx.getPath(); + ClassTree cls = (ClassTree) path.getLeaf(); + gu.importComments(cls, wc.getCompilationUnit()); + TreeMaker make = wc.getTreeMaker(); + // records are implicitly final + Set modifiers = EnumSet.noneOf(Modifier.class); + modifiers.addAll(cls.getModifiers().getFlags()); + modifiers.remove(Modifier.FINAL); + ModifiersTree mods = make.Modifiers(modifiers, cls.getModifiers().getAnnotations()); + gu.copyComments(cls.getModifiers(), mods, true); + gu.copyComments(cls.getModifiers(), mods, false); + // record components are the plain non-static fields + List componentNames = new ArrayList<>(); + Map components = new HashMap<>(); + for (Tree member : cls.getMembers()) { + if (member.getKind() == Kind.VARIABLE && !((VariableTree) member).getModifiers().getFlags().contains(Modifier.STATIC)) { + VariableTree field = (VariableTree) member; + componentNames.add(field.getName().toString()); + components.put(field.getName().toString(), field); + } + } + Trees trees = wc.getTrees(); + Types types = wc.getTypes(); + Elements elements = wc.getElements(); + TreeUtilities treeUtilities = wc.getTreeUtilities(); + List members = new ArrayList<>(); + boolean removeObjectMethods = prefs.getBoolean(OPTION_REMOVE_OBJECT_METHODS, false); + for (Tree member : cls.getMembers()) { + if (member.getKind() == Kind.METHOD) { + MethodTree mth = (MethodTree) member; + if (removeObjectMethods && isRecordObjectMethod(mth, trees, types, elements, path)) { + // replaced by the methods inherited from java.lang.Record + continue; + } + if (mth.getReturnType() == null) { + if (isRedundantCanonicalConstructor(mth, componentNames, components, trees, types, treeUtilities, path)) { + // the implicit canonical constructor replaces the redundant one + continue; + } + } else if (mth.getParameters().isEmpty() && components.containsKey(mth.getName().toString())) { + if (isRedundantAccessor(mth, components.get(mth.getName().toString()), trees, types, path)) { + // the implicit component accessor replaces the redundant one + continue; + } + } + } + if (member.getKind() == Kind.VARIABLE && !((VariableTree) member).getModifiers().getFlags().contains(Modifier.STATIC)) { + VariableTree field = (VariableTree) member; + VariableTree component = make.RecordComponent( + make.Modifiers(EnumSet.noneOf(Modifier.class)), + field.getName(), + field.getType() + ); + gu.copyComments(field, component, true); + gu.copyComments(field, component, false); + members.add(component); + } else { + members.add(member); + } + } + ClassTree nue = make.Record(mods, cls.getSimpleName(), cls.getTypeParameters(), cls.getImplementsClause(), members); + gu.copyComments(cls, nue, true); + gu.copyComments(cls, nue, false); + wc.rewrite(path.getLeaf(), nue); + } + } +} + diff --git a/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/RecordToClass.java b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/RecordToClass.java new file mode 100644 index 000000000000..0696762b591d --- /dev/null +++ b/java/java.hints/src/org/netbeans/modules/java/hints/suggestions/RecordToClass.java @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.modules.java.hints.suggestions; + +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.MethodTree; +import com.sun.source.tree.ModifiersTree; +import com.sun.source.tree.StatementTree; +import com.sun.source.tree.Tree; +import com.sun.source.tree.Tree.Kind; +import com.sun.source.tree.VariableTree; +import com.sun.source.util.TreePath; +import com.sun.source.util.Trees; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.Modifier; +import org.netbeans.api.java.source.GeneratorUtilities; +import org.netbeans.api.java.source.TreeMaker; +import org.netbeans.api.java.source.TreePathHandle; +import org.netbeans.api.java.source.TreeUtilities; +import org.netbeans.api.java.source.WorkingCopy; +import org.netbeans.spi.editor.hints.ErrorDescription; +import org.netbeans.spi.editor.hints.Severity; +import org.netbeans.spi.java.hints.ErrorDescriptionFactory; +import org.netbeans.spi.java.hints.Hint; +import org.netbeans.spi.java.hints.HintContext; +import org.netbeans.spi.java.hints.JavaFix; +import org.netbeans.spi.java.hints.TriggerTreeKind; +import org.openide.util.NbBundle; +import org.openide.util.NbBundle.Messages; + +/** + * Suggests converting a record declaration into a class. + *

+ * The record components become {@code private final} fields, the record's + * implicit finality is preserved by a {@code final} class modifier, and an + * explicit canonical constructor is generated when the record does not + * declare one. + * + * @author mbien + */ +public class RecordToClass { + + @Hint( + displayName = "#DN_convertRecordToClass", + description = "#DESC_convertRecordToClass", + category = "class_structure", + hintKind = Hint.Kind.INSPECTION, + severity = Severity.HINT, + minSourceVersion = "14", + suppressWarnings = "RecordToClass" + ) + @Messages({ + "DN_convertRecordToClass=Convert Record to Class", + "DESC_convertRecordToClass=Reports records which can be converted to classes. The record components become private final fields, the canonical constructor is made explicit and the remaining members stay in the body.", + "MSG_RecordToClass=Record {0} can be converted to a class", + "FIX_ConvertRecordToClass=Convert record {0} to a class" + }) + @TriggerTreeKind(Kind.RECORD) + public static ErrorDescription convertRecordToClass(HintContext context) { + if (testRecordMayBeClass(context.getInfo().getTrees(), context.getPath())) { + ClassTree rec = (ClassTree) context.getPath().getLeaf(); + return ErrorDescriptionFactory.forName(context, rec, NbBundle.getMessage(RecordToClass.class, "MSG_RecordToClass", rec.getSimpleName()), //NOI18N + new ConvertRecordToClassFixImpl(TreePathHandle.create(context.getPath(), context.getInfo()), + NbBundle.getMessage(RecordToClass.class, "FIX_ConvertRecordToClass", rec.getSimpleName())).toEditorFix()); //NOI18N + } + return null; + } + + private static boolean testRecordMayBeClass(Trees trees, TreePath path) { + // a class may be top level or a member of another class, + // but not a local or anonymous class + if (path.getParentPath() == null) { + return false; + } + Tree parent = path.getParentPath().getLeaf(); + if (parent.getKind() != Kind.COMPILATION_UNIT && !TreeUtilities.CLASS_TREE_KINDS.contains(parent.getKind())) { + return false; + } + Element element = trees.getElement(path); + return element != null && element.getKind() == ElementKind.RECORD; + } + + private static final class ConvertRecordToClassFixImpl extends JavaFix { + + private final String text; + + public ConvertRecordToClassFixImpl(TreePathHandle recHandle, String text) { + super(recHandle); + this.text = text; + } + + @Override + public String getText() { + return text; + } + + @Override + protected void performRewrite(TransformationContext ctx) { + WorkingCopy wc = ctx.getWorkingCopy(); + GeneratorUtilities gu = GeneratorUtilities.get(wc); + TreePath path = ctx.getPath(); + ClassTree rec = (ClassTree) path.getLeaf(); + gu.importComments(rec, wc.getCompilationUnit()); + TreeMaker make = wc.getTreeMaker(); + TreeUtilities tu = wc.getTreeUtilities(); + List components = new ArrayList<>(); + List otherMembers = new ArrayList<>(); + for (Tree member : rec.getMembers()) { + if (tu.isSynthetic(new TreePath(path, member))) { + // skip the implicit canonical constructor the compiler + // adds to the tree - it has no source text of its own + continue; + } + if (member.getKind() == Kind.VARIABLE && tu.isRecordComponent((VariableTree) member)) { + components.add((VariableTree) member); + } else { + otherMembers.add(member); + } + } + // a record is implicitly final: keep the finality in the class + Set modifiers = EnumSet.noneOf(Modifier.class); + for (Modifier m : rec.getModifiers().getFlags()) { + if (m == Modifier.PUBLIC || m == Modifier.PROTECTED || m == Modifier.PRIVATE) { + modifiers.add(m); + } + } + modifiers.add(Modifier.FINAL); + ModifiersTree mods = make.Modifiers(modifiers, rec.getModifiers().getAnnotations()); + gu.copyComments(rec.getModifiers(), mods, true); + gu.copyComments(rec.getModifiers(), mods, false); + boolean hasCanonicalConstructor = false; + for (Tree member : otherMembers) { + if (member.getKind() == Kind.METHOD) { + MethodTree mth = (MethodTree) member; + if (mth.getReturnType() == null && isCanonicalConstructor(mth, components)) { + hasCanonicalConstructor = true; + } + } + } + List members = new ArrayList<>(); + for (VariableTree component : components) { + VariableTree field = make.Variable(make.Modifiers(EnumSet.of(Modifier.PRIVATE, Modifier.FINAL)), + component.getName(), + component.getType(), + null); + gu.copyComments(component, field, true); + gu.copyComments(component, field, false); + members.add(field); + } + if (!hasCanonicalConstructor) { + members.add(makeCanonicalConstructor(make, rec, components)); + } + members.addAll(otherMembers); + ClassTree nue = make.Class(mods, rec.getSimpleName(), rec.getTypeParameters(), null, rec.getImplementsClause(), List.of(), members); + gu.copyComments(rec, nue, true); + gu.copyComments(rec, nue, false); + wc.rewrite(path.getLeaf(), nue); + } + + // the compiler enforces that the canonical constructor's parameters + // are named after the record components, in order + private static boolean isCanonicalConstructor(MethodTree mth, List components) { + List params = mth.getParameters(); + if (params.size() != components.size()) { + return false; + } + for (int i = 0; i < params.size(); i++) { + if (!params.get(i).getName().contentEquals(components.get(i).getName())) { + return false; + } + } + return true; + } + + private static MethodTree makeCanonicalConstructor(TreeMaker make, ClassTree rec, List components) { + // the implicit canonical constructor has the record's access + Set flags = rec.getModifiers().getFlags().contains(Modifier.PUBLIC) ? + EnumSet.of(Modifier.PUBLIC) : + EnumSet.noneOf(Modifier.class); + List params = new ArrayList<>(); + List body = new ArrayList<>(); + for (VariableTree component : components) { + params.add(make.Variable(make.Modifiers(EnumSet.noneOf(Modifier.class)), + component.getName(), + component.getType(), + null)); + body.add(make.ExpressionStatement(make.Assignment( + make.MemberSelect(make.Identifier("this"), component.getName()), //NOI18N + make.Identifier(component.getName())))); + } + return make.Method(make.Modifiers(flags), + "", //NOI18N + null, + List.of(), + params, + List.of(), + make.Block(body, false), + null); + } + } +} + diff --git a/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/ClassToRecordTest.java b/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/ClassToRecordTest.java new file mode 100644 index 000000000000..40720048cbf0 --- /dev/null +++ b/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/ClassToRecordTest.java @@ -0,0 +1,591 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.modules.java.hints.suggestions; + +import org.junit.Test; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.java.hints.test.api.HintTest; + +/** + * @author mbien + */ +public class ClassToRecordTest extends NbTestCase { + + public ClassToRecordTest(String name) { + super(name); + } + + @Test + public void testConvertClassToRecord() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + private String b; + public static final int K = 1; + public int getA() { + return a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a, String b) { + public static final int K = 1; + public int getA() { + return a; + } + } + """); + } + + @Test + public void testConvertClassToRecordNoBody() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) {} + """); + } + + @Test + public void testConvertFinalClassToRecord() throws Exception { + HintTest.create() + .input( """ + package test; + public final class Test { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:19-1:23:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) {} + """); + } + + @Test + public void testConvertClassWithImplements() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test implements Cloneable, java.io.Serializable { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) implements Cloneable, java.io.Serializable {} + """); + } + + @Test + public void testConvertGenericClassToRecord() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private T value; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(T value) {} + """); + } + + @Test + public void testPackagePrivateClass() throws Exception { + HintTest.create() + .input( """ + package test; + class Test { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:6-1:10:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + record Test(int a) {} + """); + } + + @Test + public void testClassWithConstructor() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + public Test(int a) { + this.a = a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) {} + """); + } + + @Test + public void testRecordLikeClass() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private final int i; + public Test(int i) { + this.i = i; + } + public int i() { + return i; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int i) {} + """); + } + + @Test + public void testRecordLikeClassWithObjectMembers() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private final int i; + public Test(int i) { + this.i = i; + } + public int i() { + return i; + } + @Override + public boolean equals(Object o) { + if (o == this) + return true; + if (!(o instanceof Test other)) + return false; + return i == other.i; + } + @Override + public int hashCode() { + return java.util.Objects.hash(i); + } + @Override + public String toString() { + return "Test[i=" + i + "]"; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int i) { + @Override + public boolean equals(Object o) { + if (o == this) + return true; + if (!(o instanceof Test other)) + return false; + return i == other.i; + } + @Override + public int hashCode() { + return java.util.Objects.hash(i); + } + @Override + public String toString() { + return "Test[i=" + i + "]"; + } + } + """); + } + + @Test + public void testCtorWithLogic() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + public Test(int a) { + if (a < 0) + throw new IllegalArgumentException(); + this.a = a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) { + public Test(int a) { + if (a < 0) + throw new IllegalArgumentException(); + this.a = a; + } + } + """); + } + + @Test + public void testAnnotatedCtorIsKept() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + @Deprecated + public Test(int a) { + this.a = a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) { + @Deprecated + public Test(int a) { + this.a = a; + } + } + """); + } + + @Test + public void testCtorWithSuperCall() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + public Test(int a) { + super(); + this.a = a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testAnnotatedAccessor() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + @Deprecated + public int a() { + return a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testClassWithExtends() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test extends Object { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testAbstractClass() throws Exception { + HintTest.create() + .input( """ + package test; + public abstract class Test { + private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testFieldWithInitializer() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a = 1; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testFieldWithAnnotation() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + @Deprecated private int a; + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testRecordStyleAccessor() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + public int a() { + return a; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int a) {} + """); + } + + @Test + public void testNonRedundantAccessorNameCollision() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + public int a() { + return a + 1; + } + } + """) + .sourceLevel("16") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testSealedClass() throws Exception { + HintTest.create() + .input( """ + package test; + public sealed class Test permits Sub { + private int a; + } + final class Sub extends Test { + private final int b = 1; + } + """) + .sourceLevel("17") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testOldSourceLevel() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int a; + } + """) + .sourceLevel("13") + .run(ClassToRecord.class) + .assertNotContainsWarnings("Class Test may be converted to a record"); + } + + @Test + public void testObjectMethodsRemoved() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private final int i; + public Test(int i) { + this.i = i; + } + @Override + public final boolean equals(Object o) { + if (o == this) + return true; + if (!(o instanceof Test other)) + return false; + return i == other.i; + } + @Override + public int hashCode() { + return java.util.Objects.hash(i); + } + @Override + public String toString() { + return "Test[i=" + i + "]"; + } + } + """) + .sourceLevel("16") + .preference(ClassToRecord.OPTION_REMOVE_OBJECT_METHODS, true) + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int i) {} + """); + } + + @Test + public void testObjectMethodsRemovedKeepsNonOverriding() throws Exception { + HintTest.create() + .input( """ + package test; + public class Test { + private int i; + @Override + public boolean equals(Object o) { + return o instanceof Test other && i == other.i; + } + public boolean equals(int other) { + return i == other; + } + @Deprecated + @Override + public String toString() { + return "Test[i=" + i + "]"; + } + } + """) + .sourceLevel("16") + .preference(ClassToRecord.OPTION_REMOVE_OBJECT_METHODS, true) + .run(ClassToRecord.class) + .findWarning("1:13-1:17:verifier:Class Test may be converted to a record") + .applyFix("Convert class Test to a record") + .assertCompilable() + .assertOutput( + """ + package test; + public record Test(int i) { + public boolean equals(int other) { + return i == other; + } + @Deprecated + @Override + public String toString() { + return "Test[i=" + i + "]"; + } + } + """); + } +} + diff --git a/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/RecordToClassTest.java b/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/RecordToClassTest.java new file mode 100644 index 000000000000..f420eac2dd76 --- /dev/null +++ b/java/java.hints/test/unit/src/org/netbeans/modules/java/hints/suggestions/RecordToClassTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.modules.java.hints.suggestions; + +import org.junit.Test; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.java.hints.test.api.HintTest; + +/** + * @author mbien + */ +public class RecordToClassTest extends NbTestCase { + + public RecordToClassTest(String name) { + super(name); + } + + @Test + public void testConvertRecordToClass() throws Exception { + HintTest.create() + .input( """ + package test; + public record Test(int a, String b) { + public static final int K = 1; + public int getA() { + return a; + } + } + """) + .sourceLevel("16") + .run(RecordToClass.class) + .findWarning("1:14-1:18:hint:Record Test can be converted to a class") + .applyFix("Convert record Test to a class") + .assertCompilable() + .assertOutput( + """ + package test; + public final class Test { + + private final int a; + private final String b; + + public Test(int a, String b) { + this.a = a; + this.b = b; + } + public static final int K = 1; + public int getA() { + return a; + } + } + """); + } + + @Test + public void testConvertRecordToClassWithConstructor() throws Exception { + HintTest.create() + .input( """ + package test; + public record Test(int a, String b) { + public Test(int a, String b) { + this.a = a; + this.b = b; + } + } + """) + .sourceLevel("16") + .run(RecordToClass.class) + .findWarning("1:14-1:18:hint:Record Test can be converted to a class") + .applyFix("Convert record Test to a class") + .assertCompilable() + .assertOutput( + """ + package test; + public final class Test { + + private final int a; + private final String b; + public Test(int a, String b) { + this.a = a; + this.b = b; + } + } + """); + } + + @Test + public void testConvertRecordToClassNoBody() throws Exception { + HintTest.create() + .input( """ + package test; + public record Test(int a) {} + """) + .sourceLevel("16") + .run(RecordToClass.class) + .findWarning("1:14-1:18:hint:Record Test can be converted to a class") + .applyFix("Convert record Test to a class") + .assertCompilable() + .assertOutput( + """ + package test; + public final class Test { + + private final int a; + public Test(int a) { + this.a = a; + } + } + """); + } + + @Test + public void testNoHintForClass() throws Exception { + HintTest.create() + .input( """ + package test; + public final class Test { + private int a; + } + """) + .sourceLevel("16") + .run(RecordToClass.class) + .assertWarnings(); + } +} + diff --git a/java/java.source.base/apichanges.xml b/java/java.source.base/apichanges.xml index 98894a974bab..d17a5a703083 100644 --- a/java/java.source.base/apichanges.xml +++ b/java/java.source.base/apichanges.xml @@ -25,6 +25,18 @@ Java Source API + + +

Added TreeMaker.Record + + + + + + Adding TreeMaker.Record to create a ClassTree representing a record. + + + Added TreeMaker.Yield diff --git a/java/java.source.base/nbproject/project.properties b/java/java.source.base/nbproject/project.properties index 60d4aafee63e..b0e9d0cb3753 100644 --- a/java/java.source.base/nbproject/project.properties +++ b/java/java.source.base/nbproject/project.properties @@ -23,7 +23,7 @@ javadoc.name=Java Source Base javadoc.title=Java Source Base javadoc.arch=${basedir}/arch.xml javadoc.apichanges=${basedir}/apichanges.xml -spec.version.base=2.87.0 +spec.version.base=2.88.0 test.qa-functional.cp.extra=${refactoring.java.dir}/modules/ext/nb-javac-api.jar test.unit.run.cp.extra=${o.n.core.dir}/core/core.jar:\ ${o.n.core.dir}/lib/boot.jar:\ diff --git a/java/java.source.base/src/org/netbeans/api/java/source/TreeMaker.java b/java/java.source.base/src/org/netbeans/api/java/source/TreeMaker.java index fa8a63cbb466..1569d23874f4 100644 --- a/java/java.source.base/src/org/netbeans/api/java/source/TreeMaker.java +++ b/java/java.source.base/src/org/netbeans/api/java/source/TreeMaker.java @@ -471,13 +471,38 @@ public ClassTree AnnotationType(ModifiersTree modifiers, * empty list. * @see com.sun.source.tree.ClassTree */ - public ClassTree Enum(ModifiersTree modifiers, + public ClassTree Enum(ModifiersTree modifiers, CharSequence simpleName, List implementsClauses, List memberDecls) { return delegate.Enum(modifiers, simpleName, implementsClauses, memberDecls); } - + + /** + * Creates a new ClassTree representing a record. + *

+ * The record components must be included in {@code memberDecls} as + * {@link VariableTree}s created with {@link #RecordComponent}. + * + * @param modifiers the modifiers declaration + * @param simpleName the name of the record without its package, such + * as "Point" for the record "java.awt.Point". + * @param typeParameters the list of type parameters, or an empty list. + * @param implementsClauses the list of the interfaces this record + * implements, or an empty list. + * @param memberDecls the list of fields, methods and record + * components of this record, or an empty list. + * @see com.sun.source.tree.ClassTree + * @since 2.88 + */ + public ClassTree Record(ModifiersTree modifiers, + CharSequence simpleName, + List typeParameters, + List implementsClauses, + List memberDecls) { + return delegate.Record(modifiers, simpleName, typeParameters, implementsClauses, memberDecls); + } + /** * Creates a new CompilationUnitTree. * diff --git a/java/java.source.base/src/org/netbeans/modules/java/source/builder/TreeFactory.java b/java/java.source.base/src/org/netbeans/modules/java/source/builder/TreeFactory.java index 991512dc9d15..de2be9a32694 100644 --- a/java/java.source.base/src/org/netbeans/modules/java/source/builder/TreeFactory.java +++ b/java/java.source.base/src/org/netbeans/modules/java/source/builder/TreeFactory.java @@ -326,13 +326,22 @@ public ClassTree AnnotationType(ModifiersTree modifiers, return Class(flags, (com.sun.tools.javac.util.List) modifiers.getAnnotations(), simpleName, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), memberDecls); } - public ClassTree Enum(ModifiersTree modifiers, + public ClassTree Enum(ModifiersTree modifiers, CharSequence simpleName, List implementsClauses, List memberDecls) { long flags = getBitFlags(modifiers.getFlags()) | Flags.ENUM; return Class(flags, (com.sun.tools.javac.util.List) modifiers.getAnnotations(), simpleName, Collections.emptyList(), null, implementsClauses, Collections.emptyList(), memberDecls); } + + public ClassTree Record(ModifiersTree modifiers, + CharSequence simpleName, + List typeParameters, + List implementsClauses, + List memberDecls) { + long flags = getBitFlags(modifiers.getFlags()) | Flags.RECORD; + return Class(flags, (com.sun.tools.javac.util.List) modifiers.getAnnotations(), simpleName, typeParameters, null, implementsClauses, Collections.emptyList(), memberDecls); + } public CompilationUnitTree CompilationUnit(PackageTree packageDecl, List importDecls, diff --git a/java/java.source.base/src/org/netbeans/modules/java/source/pretty/VeryPretty.java b/java/java.source.base/src/org/netbeans/modules/java/source/pretty/VeryPretty.java index f526c91e3a41..bbf58c91c207 100644 --- a/java/java.source.base/src/org/netbeans/modules/java/source/pretty/VeryPretty.java +++ b/java/java.source.base/src/org/netbeans/modules/java/source/pretty/VeryPretty.java @@ -873,6 +873,41 @@ public void visitClassDef(JCClassDecl tree) { wrapTrees(tree.implementing, cs.wrapExtendsImplementsList(), cs.alignMultilineImplements() ? out.col : out.leftMargin + cs.getContinuationIndentSize()); } + } else if ((flags & RECORD) != 0) { + print("record "); + print(tree.name); + printTypeParameters(tree.typarams); + List components = recordComponents(tree); + if (components.nonEmpty()) { + print(cs.spaceBeforeMethodDeclParen() ? " (" : "("); + if (cs.spaceWithinMethodDeclParens()) { + print(' '); + } + int oldPrec = setPrec(TreeInfo.noPrec); + wrapTrees( + components, + cs.wrapMethodParams(), + cs.alignMultilineMethodParams() + ? out.col + : out.leftMargin + cs.getContinuationIndentSize(), + true + ); + setPrec(oldPrec); + if (cs.spaceWithinMethodDeclParens()) { + needSpace(); + } + print(')'); + } + if (tree.implementing.nonEmpty()) { + wrap("implements ", cs.wrapExtendsImplementsKeyword()); + wrapTrees( + tree.implementing, + cs.wrapExtendsImplementsList(), + cs.alignMultilineImplements() + ? out.col + : out.leftMargin + cs.getContinuationIndentSize() + ); + } } else { if ((flags & ENUM) != 0) print("enum "); @@ -916,6 +951,10 @@ public void visitClassDef(JCClassDecl tree) { print('{'); printInnerCommentsAsTrailing(tree, true); java.util.List members = CasualDiff.filterHidden(diffContext, tree.defs); + if ((flags & RECORD) != 0) { + // the record components are printed in the class header: + members.removeIf(t -> t instanceof JCVariableDecl var && (var.mods.flags & RECORD) != 0); + } if (!members.isEmpty()) { blankLines(enclClass.name.isEmpty() ? cs.getBlankLinesAfterAnonymousClassHeader() : (flags & ENUM) != 0 ? cs.getBlankLinesAfterEnumHeader() : cs.getBlankLinesAfterClassHeader()); boolean firstMember = true; @@ -933,6 +972,22 @@ public void visitClassDef(JCClassDecl tree) { enclClass = enclClassPrev; } + /** + * Returns the record components of the given record class declaration. + * + * @param tree the record class declaration + * @return the list of the record components (variables with the {@code RECORD} flag) + */ + private static List recordComponents(JCClassDecl tree) { + ListBuffer components = new ListBuffer<>(); + for (JCTree t : tree.defs) { + if (t instanceof JCVariableDecl var && (var.mods.flags & RECORD) != 0) { + components.add(t); + } + } + return components.toList(); + } + private void printEnumConstants(java.util.List defs, boolean forceSemicolon, boolean printComments) { boolean first = true; boolean hasNonEnumerator = false; diff --git a/java/java.source.base/src/org/netbeans/modules/java/source/save/CasualDiff.java b/java/java.source.base/src/org/netbeans/modules/java/source/save/CasualDiff.java index 7279a43f6ffb..61033da1d7ed 100644 --- a/java/java.source.base/src/org/netbeans/modules/java/source/save/CasualDiff.java +++ b/java/java.source.base/src/org/netbeans/modules/java/source/save/CasualDiff.java @@ -993,6 +993,9 @@ protected int diffClassDef(JCClassDecl oldT, JCClassDecl newT, int[] bounds) { } else if ((newT.mods.flags & Flags.INTERFACE) != 0) { copyTo(localPointer, pos); printer.print("interface"); //NOI18N + } else if ((newT.mods.flags & Flags.RECORD) != 0) { + copyTo(localPointer, pos); + printer.print("record"); //NOI18N } else { copyTo(localPointer, pos); printer.print("class"); //NOI18N @@ -1031,7 +1034,6 @@ protected int diffClassDef(JCClassDecl oldT, JCClassDecl newT, int[] bounds) { localPointer = insertHint; } } - //TODO: class to record and vice versa! if (oldT.getKind() == Kind.RECORD && newT.getKind() == Kind.RECORD) { ComponentsAndOtherMembers oldParts = splitOutRecordComponents(filteredOldTDefs); ComponentsAndOtherMembers newParts = splitOutRecordComponents(filteredNewTDefs); @@ -1071,6 +1073,41 @@ protected int diffClassDef(JCClassDecl oldT, JCClassDecl newT, int[] bounds) { moveToSrcRelevant(tokenSequence, Direction.FORWARD); // it can be > (GT) or >> (SHIFT) insertHint = tokenSequence.offset() + tokenSequence.token().length(); + } else if (newT.getKind() == Kind.RECORD) { + // class (or other non-record) -> record: print the new record + // components in the header. The old body members are diffed below; + // the ones that became components are simply deleted there. + ComponentsAndOtherMembers newParts = splitOutRecordComponents(filteredNewTDefs); + if (!newParts.components().isEmpty()) { + int posHint = insertHint; + copyTo(localPointer, posHint); + int old = printer.setPrec(TreeInfo.noPrec); + parameterPrint = true; + JCClassDecl oldEnclClass = printer.enclClass; + printer.enclClass = null; + localPointer = diffParameterList( + Collections.emptyList(), newParts.components(), new JavaTokenId[] { JavaTokenId.LPAREN, JavaTokenId.RPAREN }, posHint, Measure.MEMBER + ); + printer.enclClass = oldEnclClass; + parameterPrint = false; + printer.setPrec(old); + } + filteredNewTDefs = newParts.defs(); + } else if (oldT.getKind() == Kind.RECORD && newT.getKind() == Kind.CLASS) { + // record to class: the components in the old header become field + // declarations in the new body, where they are diffed as inserted + // members below. The old component parameter list is dropped. + ComponentsAndOtherMembers oldParts = splitOutRecordComponents(filteredOldTDefs); + filteredOldTDefs = oldParts.defs; + if (!oldParts.components().isEmpty()) { + // skip the old component parameter list '(...)': + moveFwdToToken(tokenSequence, endPos(oldParts.components.getLast()), JavaTokenId.RPAREN); + tokenSequence.moveNext(); + localPointer = tokenSequence.offset(); + } + tokenSequence.move(localPointer); + moveToSrcRelevant(tokenSequence, Direction.FORWARD); + insertHint = tokenSequence.offset() + tokenSequence.token().length(); } switch (getChangeKind(oldT.extending, newT.extending)) { case NOCHANGE: @@ -1202,7 +1239,26 @@ protected int diffClassDef(JCClassDecl oldT, JCClassDecl newT, int[] bounds) { } } } - localPointer = diffList(filteredOldTDefs, filteredNewTDefs, insertHint, est, Measure.REAL_MEMBER, printer); + if (newT.getKind() == Kind.RECORD && oldT.getKind() != Kind.RECORD && filteredNewTDefs.isEmpty() && !filteredOldTDefs.isEmpty()) { + // class -> record conversion in which every body member became + // a record component: the body is now empty, emit '{}'. + // (Skipping the member diff would otherwise leave a stray + // line break before the closing brace.) + tokenSequence.move(localPointer); + tokenSequence.moveNext(); // right after the opening '{' + int braceDepth = 0; + while (tokenSequence.moveNext()) { + JavaTokenId id = tokenSequence.token().id(); + if (id == JavaTokenId.LBRACE) { + braceDepth++; + } else if (id == JavaTokenId.RBRACE && --braceDepth < 0) { + break; + } + } + localPointer = tokenSequence.offset(); + } else { + localPointer = diffList(filteredOldTDefs, filteredNewTDefs, insertHint, est, Measure.REAL_MEMBER, printer); + } printer.enclClass = origClass; origClassName = origOuterClassName; newClassName = newOuterClassName; @@ -3498,8 +3554,8 @@ public boolean treesMatch(JCTree t1, JCTree t2, boolean deepMatch) { } private boolean kindChanged(long oldFlags, long newFlags) { - return (oldFlags & (Flags.INTERFACE | Flags.ENUM | Flags.ANNOTATION)) - != (newFlags & (Flags.INTERFACE | Flags.ENUM | Flags.ANNOTATION)); + return (oldFlags & (Flags.INTERFACE | Flags.ENUM | Flags.ANNOTATION | Flags.RECORD)) + != (newFlags & (Flags.INTERFACE | Flags.ENUM | Flags.ANNOTATION | Flags.RECORD)); } protected boolean nameChanged(Name oldName, Name newName) { diff --git a/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/ClassToRecordDiffTest.java b/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/ClassToRecordDiffTest.java new file mode 100644 index 000000000000..a76a820ae15e --- /dev/null +++ b/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/ClassToRecordDiffTest.java @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.api.java.source.gen; + +import java.io.File; +import java.io.IOException; +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.Tree; +import com.sun.source.tree.Tree.Kind; +import com.sun.source.tree.VariableTree; +import java.util.EnumSet; +import java.util.List; +import javax.lang.model.element.Modifier; +import org.netbeans.api.java.source.*; +import static org.netbeans.api.java.source.JavaSource.*; +import org.netbeans.junit.NbTestSuite; + +public class ClassToRecordDiffTest extends GeneratorTestMDRCompat { + + private String sourceLevel; + + public ClassToRecordDiffTest(String testName) { + super(testName); + } + + public static NbTestSuite suite() { + NbTestSuite suite = new NbTestSuite(); + suite.addTestSuite(ClassToRecordDiffTest.class); + return suite; + } + + public void testClassToRecord() throws Exception { + testFile = new File(getWorkDir(), "Test.java"); + TestUtilities.copyStringToFile(testFile, + """ + package hierbas.del.litoral; + public class R { + private int a; + private String b; + public static final int K = 1; + public int getA() { return a; } + } + """); + String golden = + """ + package hierbas.del.litoral; + public record R(int a, String b) { + public static final int K = 1; + public int getA() { return a; } + } + """; + + JavaSource src = getJavaSource(testFile); + Task task = new Task() { + + public void run(WorkingCopy workingCopy) throws IOException { + workingCopy.toPhase(Phase.RESOLVED); + CompilationUnitTree cut = workingCopy.getCompilationUnit(); + TreeMaker make = workingCopy.getTreeMaker(); + + Tree classDecl = cut.getTypeDecls().get(0); + assertEquals(Kind.CLASS, classDecl.getKind()); + ClassTree classTree = (ClassTree) classDecl; + List members = classTree.getMembers(); + Tree k = null; + Tree getA = null; + for (Tree m : members) { + if (m.getKind() == Kind.VARIABLE && "K".contentEquals(((VariableTree) m).getName())) k = m; + if (m.getKind() == Kind.METHOD && "getA".contentEquals(((com.sun.source.tree.MethodTree) m).getName())) getA = m; + } + assertNotNull(k); + assertNotNull(getA); + VariableTree a = make.RecordComponent(make.Modifiers(EnumSet.noneOf(Modifier.class)), + "a", + make.Type("int")); + VariableTree b = make.RecordComponent(make.Modifiers(EnumSet.noneOf(Modifier.class)), + "b", + make.Type("java.lang.String")); + ClassTree newTree = make.Record(make.Modifiers(EnumSet.of(Modifier.PUBLIC)), + "R", + List.of(), + List.of(), + List.of(a, b, k, getA)); + workingCopy.rewrite(classTree, newTree); + } + + }; + src.runModificationTask(task).commit(); + String res = TestUtilities.copyFileToString(testFile); + //System.err.println(res); + assertEquals(golden, res); + } + + public void testClassToRecordNoBody() throws Exception { + testFile = new File(getWorkDir(), "Test.java"); + TestUtilities.copyStringToFile(testFile, + """ + package hierbas.del.litoral; + public class R { + private int a; + } + """); + String golden = + """ + package hierbas.del.litoral; + public record R(int a) {} + """; + + JavaSource src = getJavaSource(testFile); + Task task = new Task() { + + public void run(WorkingCopy workingCopy) throws IOException { + workingCopy.toPhase(Phase.RESOLVED); + CompilationUnitTree cut = workingCopy.getCompilationUnit(); + TreeMaker make = workingCopy.getTreeMaker(); + + Tree classDecl = cut.getTypeDecls().get(0); + assertEquals(Kind.CLASS, classDecl.getKind()); + ClassTree classTree = (ClassTree) classDecl; + VariableTree a = make.RecordComponent(make.Modifiers(EnumSet.noneOf(Modifier.class)), + "a", + make.Type("int")); + ClassTree newTree = make.Record(make.Modifiers(EnumSet.of(Modifier.PUBLIC)), + "R", + List.of(), + List.of(), + List.of(a)); + workingCopy.rewrite(classTree, newTree); + } + + }; + src.runModificationTask(task).commit(); + String res = TestUtilities.copyFileToString(testFile); + //System.err.println(res); + assertEquals(golden, res); + } + + String getGoldenPckg() { + return ""; + } + + String getSourcePckg() { + return ""; + } + + @Override + String getSourceLevel() { + return sourceLevel; + } +} + diff --git a/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/RecordToClassDiffTest.java b/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/RecordToClassDiffTest.java new file mode 100644 index 000000000000..ff381ca51295 --- /dev/null +++ b/java/java.source.base/test/unit/src/org/netbeans/api/java/source/gen/RecordToClassDiffTest.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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.netbeans.api.java.source.gen; + +import java.io.File; +import java.io.IOException; +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.Tree; +import com.sun.source.tree.Tree.Kind; +import com.sun.source.tree.VariableTree; +import java.util.EnumSet; +import java.util.List; +import javax.lang.model.element.Modifier; +import org.netbeans.api.java.source.*; +import static org.netbeans.api.java.source.JavaSource.*; +import org.netbeans.junit.NbTestSuite; + +public class RecordToClassDiffTest extends GeneratorTestMDRCompat { + + private String sourceLevel; + + public RecordToClassDiffTest(String testName) { + super(testName); + } + + public static NbTestSuite suite() { + NbTestSuite suite = new NbTestSuite(); + suite.addTestSuite(RecordToClassDiffTest.class); + return suite; + } + + public void testRecordToClass() throws Exception { + testFile = new File(getWorkDir(), "Test.java"); + TestUtilities.copyStringToFile(testFile, + """ + package hierbas.del.litoral; + public record R(int a, String b) { + public static final int K = 1; + public int getA() { return a; } + } + """); + String golden = + """ + package hierbas.del.litoral; + public final class R { + + private final int a; + private final String b; + + public R(int a, String b) { + this.a = a; + this.b = b; + } + public static final int K = 1; + public int getA() { return a; } + } + """; + + JavaSource src = getJavaSource(testFile); + Task task = new Task() { + + public void run(WorkingCopy workingCopy) throws IOException { + workingCopy.toPhase(Phase.RESOLVED); + CompilationUnitTree cut = workingCopy.getCompilationUnit(); + TreeMaker make = workingCopy.getTreeMaker(); + + Tree classDecl = cut.getTypeDecls().get(0); + assertEquals(Kind.RECORD, classDecl.getKind()); + ClassTree classTree = (ClassTree) classDecl; + List members = classTree.getMembers(); + Tree k = null; + Tree getA = null; + VariableTree compA = null, compB = null; + for (Tree m : members) { + if (m.getKind() == Kind.VARIABLE) { + if ("a".contentEquals(((VariableTree) m).getName())) compA = (VariableTree) m; + if ("b".contentEquals(((VariableTree) m).getName())) compB = (VariableTree) m; + if ("K".contentEquals(((VariableTree) m).getName())) k = m; + } + if (m.getKind() == Kind.METHOD && "getA".contentEquals(((com.sun.source.tree.MethodTree) m).getName())) getA = m; + } + assertNotNull(k); + assertNotNull(getA); + assertNotNull(compA); + assertNotNull(compB); + VariableTree a = make.Variable(make.Modifiers(EnumSet.of(Modifier.PRIVATE, Modifier.FINAL)), + compA.getName(), + compA.getType(), + null); + VariableTree b = make.Variable(make.Modifiers(EnumSet.of(Modifier.PRIVATE, Modifier.FINAL)), + compB.getName(), + compB.getType(), + null); + com.sun.source.tree.MethodTree ctor = make.Method(make.Modifiers(EnumSet.of(Modifier.PUBLIC)), + "", + null, + List.of(), + List.of( + make.Variable(make.Modifiers(EnumSet.noneOf(Modifier.class)), compA.getName(), compA.getType(), null), + make.Variable(make.Modifiers(EnumSet.noneOf(Modifier.class)), compB.getName(), compB.getType(), null)), + List.of(), + make.Block(List.of( + make.ExpressionStatement(make.Assignment(make.MemberSelect(make.Identifier("this"), compA.getName()), make.Identifier(compA.getName()))), + make.ExpressionStatement(make.Assignment(make.MemberSelect(make.Identifier("this"), compB.getName()), make.Identifier(compB.getName())))), + false), + null); + ClassTree newTree = make.Class(make.Modifiers(EnumSet.of(Modifier.PUBLIC, Modifier.FINAL)), + "R", + List.of(), + null, + List.of(), + List.of(), + List.of(a, b, ctor, k, getA)); + workingCopy.rewrite(classTree, newTree); + } + + }; + src.runModificationTask(task).commit(); + String res = TestUtilities.copyFileToString(testFile); + assertEquals(golden, res); + } + + public void testRecordToClassExistingConstructor() throws Exception { + testFile = new File(getWorkDir(), "Test.java"); + TestUtilities.copyStringToFile(testFile, + """ + package hierbas.del.litoral; + public record R(int a) { + public R(int a) { this.a = a; } + } + """); + String golden = + """ + package hierbas.del.litoral; + public final class R { + + private final int a; + public R(int a) { this.a = a; } + } + """; + + JavaSource src = getJavaSource(testFile); + Task task = new Task() { + + public void run(WorkingCopy workingCopy) throws IOException { + workingCopy.toPhase(Phase.RESOLVED); + CompilationUnitTree cut = workingCopy.getCompilationUnit(); + TreeMaker make = workingCopy.getTreeMaker(); + + Tree classDecl = cut.getTypeDecls().get(0); + assertEquals(Kind.RECORD, classDecl.getKind()); + ClassTree classTree = (ClassTree) classDecl; + Tree ctor = null; + for (Tree m : classTree.getMembers()) { + if (m.getKind() == Kind.METHOD) ctor = m; + } + assertNotNull(ctor); + VariableTree a = make.Variable(make.Modifiers(EnumSet.of(Modifier.PRIVATE, Modifier.FINAL)), + "a", + make.Type("int"), + null); + ClassTree newTree = make.Class(make.Modifiers(EnumSet.of(Modifier.PUBLIC, Modifier.FINAL)), + "R", + List.of(), + null, + List.of(), + List.of(), + List.of(a, ctor)); + workingCopy.rewrite(classTree, newTree); + } + + }; + src.runModificationTask(task).commit(); + String res = TestUtilities.copyFileToString(testFile); + assertEquals(golden, res); + } + + String getGoldenPckg() { + return ""; + } + + String getSourcePckg() { + return ""; + } + + @Override + String getSourceLevel() { + return sourceLevel; + } +}