diff --git a/.github/workflows/mvn.yml b/.github/workflows/mvn.yml index c224b0e89..cacbeb123 100644 --- a/.github/workflows/mvn.yml +++ b/.github/workflows/mvn.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [windows-2022, ubuntu-24.04, macos-15] - java: [11, 21] + java: [11, 17, 21] steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 diff --git a/qulice-checkstyle/src/main/java/com/qulice/checkstyle/RecordValidationCheck.java b/qulice-checkstyle/src/main/java/com/qulice/checkstyle/RecordValidationCheck.java new file mode 100644 index 000000000..31f797fa5 --- /dev/null +++ b/qulice-checkstyle/src/main/java/com/qulice/checkstyle/RecordValidationCheck.java @@ -0,0 +1,120 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2011-2025 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ +package com.qulice.checkstyle; + +import com.puppycrawl.tools.checkstyle.api.AbstractCheck; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +/** + * Checks for proper record declarations. + * Validates that: + * 1. Records are properly declared with components + * 2. Record components are properly formatted + * 3. Records do not extend other classes + * 4. Records are final + * @since 0.24 + */ +public final class RecordValidationCheck extends AbstractCheck { + /** + * A key is pointing to the warning message text in "messages.properties" + * file. + */ + public static final String MSG_KEY = "record.validation"; + + @Override + public int[] getDefaultTokens() { + return new int[] { + TokenTypes.RECORD_DEF, + TokenTypes.RECORD_COMPONENT_DEF, + }; + } + + @Override + public int[] getAcceptableTokens() { + return this.getDefaultTokens(); + } + + @Override + public int[] getRequiredTokens() { + return this.getDefaultTokens(); + } + + @Override + public void visitToken(final DetailAST ast) { + switch (ast.getType()) { + case TokenTypes.RECORD_DEF: + this.checkRecordDeclaration(ast); + break; + case TokenTypes.RECORD_COMPONENT_DEF: + this.checkRecordComponent(ast); + break; + default: + } + } + + /** + * Check if record extends another class. + * Check if record is final. + * Check if record has components. + * @param ast EXPR RECORD_DEF node that needs to be checked + */ + private void checkRecordDeclaration(final DetailAST ast) { + if (ast.findFirstToken(TokenTypes.EXTENDS_CLAUSE) != null) { + this.log(ast.getLineNo(), ast.getColumnNo(), "Records cannot extend other classes"); + } + final DetailAST modifiers = ast.findFirstToken(TokenTypes.MODIFIERS); + if (modifiers != null && modifiers.findFirstToken(TokenTypes.FINAL) == null) { + this.log(modifiers.getLineNo(), modifiers.getColumnNo(), "Records must be final"); + } + final DetailAST components = ast.findFirstToken(TokenTypes.RECORD_COMPONENTS); + if (components != null + && components.findFirstToken(TokenTypes.RECORD_COMPONENT_DEF) == null) { + this.log( + components.getLineNo(), + components.getColumnNo(), + "Records must declare at least one component" + ); + } + this.checkRecordInstanceFields(ast); + } + + /** + * Check record does not contain instance field. + * @param ast EXPR RECORD_DEF node that needs to be checked + */ + private void checkRecordInstanceFields(final DetailAST ast) { + final DetailAST block = ast.findFirstToken(TokenTypes.OBJBLOCK); + DetailAST child = block.getFirstChild(); + while (child != null) { + if (child.getType() == TokenTypes.VARIABLE_DEF) { + final DetailAST modifiers = child.findFirstToken(TokenTypes.MODIFIERS); + final DetailAST statics = modifiers.findFirstToken(TokenTypes.LITERAL_STATIC); + if (statics == null) { + this.log( + child.getLineNo(), + child.getColumnNo(), + "Records cannot have instance fields" + ); + } + } + child = child.getNextSibling(); + } + } + + /** + * Check if component has a type. + * Check if component has a name. + * @param ast EXPR RECORD_COMPONENT_DEF node that needs to be checked + */ + private void checkRecordComponent(final DetailAST ast) { + if (ast.findFirstToken(TokenTypes.TYPE) == null) { + this.log(ast.getLineNo(), ast.getColumnNo(), "Record component must have a type"); + } + if (ast.findFirstToken(TokenTypes.IDENT) == null) { + this.log(ast.getLineNo(), ast.getColumnNo(), "Record component must have a name"); + } + } +} diff --git a/qulice-checkstyle/src/test/java/com/qulice/checkstyle/ChecksTest.java b/qulice-checkstyle/src/test/java/com/qulice/checkstyle/ChecksTest.java index 150c01dcc..f97c92440 100644 --- a/qulice-checkstyle/src/test/java/com/qulice/checkstyle/ChecksTest.java +++ b/qulice-checkstyle/src/test/java/com/qulice/checkstyle/ChecksTest.java @@ -151,6 +151,7 @@ private void check( @SuppressWarnings("PMD.UnusedPrivateMethod") private static Stream checks() { return Stream.of( + "RecordValidationCheck", "MethodsOrderCheck", "MultilineJavadocTagsCheck", "StringLiteralsConcatenationCheck", diff --git a/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Invalid.java b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Invalid.java new file mode 100644 index 000000000..6d6a6b449 --- /dev/null +++ b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Invalid.java @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2011-2025 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ +package com.qulice.modern; + +/** + * Invalid record example. + */ +// Records must be final +public record InvalidRecord() { // Records must declare at least one component + private String extraField; // Records cannot have instance fields +} \ No newline at end of file diff --git a/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Valid.java b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Valid.java new file mode 100644 index 000000000..6c02ae2ea --- /dev/null +++ b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/Valid.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2011-2025 Yegor Bugayenko + * SPDX-License-Identifier: MIT + */ +package com.qulice.modern; + +/** + * Valid record example. + * + * @since 1.0 + */ +public final record ValidRecord(String name, int age) { + /** + * Constructor. + * @param name Name + * @param age Age + */ + public ValidRecord { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("Name cannot be empty"); + } + if (age < 0) { + throw new IllegalArgumentException("Age cannot be negative"); + } + } +} \ No newline at end of file diff --git a/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/config.xml b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/config.xml new file mode 100644 index 000000000..d0cbe89c1 --- /dev/null +++ b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/config.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/violations.txt b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/violations.txt new file mode 100644 index 000000000..16ea61f46 --- /dev/null +++ b/qulice-checkstyle/src/test/resources/com/qulice/checkstyle/ChecksTest/RecordValidationCheck/violations.txt @@ -0,0 +1,3 @@ +11:Records must be final +11:Records must declare at least one component +12:Records cannot have instance fields \ No newline at end of file