diff --git a/CHANGELOG.md b/CHANGELOG.md index 51577e604b..df10aa67f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ The callback function provided as parameter to the format function is now called * When a glob is specified then ensure that it matches files in the current directory and not only in subdirectories of the current directory ([#1533](https://github.com/pinterest/ktlint/issue/1533)). * Execute `ktlint` cli on default kotlin extensions only when an (existing) path to a directory is given. ([#917](https://github.com/pinterest/ktlint/issue/917)). * Invoke callback on `format` function for all errors including errors that are autocorrected ([#1491](https://github.com/pinterest/ktlint/issues/1491)) +* Handle trailing comma in enums `trailing-comma` ([#1542](https://github.com/pinterest/ktlint/pull/1542)) ### Changed diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRule.kt index 54f51b5214..3bf276bf62 100644 --- a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRule.kt +++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRule.kt @@ -4,8 +4,22 @@ import com.pinterest.ktlint.core.Rule import com.pinterest.ktlint.core.api.EditorConfigProperties import com.pinterest.ktlint.core.api.UsesEditorConfigProperties import com.pinterest.ktlint.core.ast.ElementType +import com.pinterest.ktlint.core.ast.ElementType.CLASS +import com.pinterest.ktlint.core.ast.ElementType.CLASS_BODY +import com.pinterest.ktlint.core.ast.ElementType.COLLECTION_LITERAL_EXPRESSION +import com.pinterest.ktlint.core.ast.ElementType.DESTRUCTURING_DECLARATION +import com.pinterest.ktlint.core.ast.ElementType.ENUM_ENTRY +import com.pinterest.ktlint.core.ast.ElementType.FUNCTION_LITERAL +import com.pinterest.ktlint.core.ast.ElementType.INDICES +import com.pinterest.ktlint.core.ast.ElementType.SEMICOLON +import com.pinterest.ktlint.core.ast.ElementType.TYPE_ARGUMENT_LIST +import com.pinterest.ktlint.core.ast.ElementType.TYPE_PARAMETER_LIST +import com.pinterest.ktlint.core.ast.ElementType.VALUE_ARGUMENT_LIST +import com.pinterest.ktlint.core.ast.ElementType.VALUE_PARAMETER_LIST +import com.pinterest.ktlint.core.ast.ElementType.WHEN_ENTRY import com.pinterest.ktlint.core.ast.children import com.pinterest.ktlint.core.ast.containsLineBreakInRange +import com.pinterest.ktlint.core.ast.lineNumber import com.pinterest.ktlint.core.ast.prevCodeLeaf import com.pinterest.ktlint.core.ast.prevLeaf import kotlin.properties.Delegates @@ -15,8 +29,10 @@ import org.jetbrains.kotlin.com.intellij.lang.ASTNode import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace import org.jetbrains.kotlin.com.intellij.psi.tree.TokenSet +import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtEnumEntry import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtPsiFactory import org.jetbrains.kotlin.psi.KtValueArgumentList @@ -47,7 +63,7 @@ private enum class TrailingCommaState { /** * The trailing comma isn't needed, but exists */ - REDUNDANT, + REDUNDANT ; } @@ -85,18 +101,16 @@ public class TrailingCommaRule : // Keep processing of element types in sync with Intellij Kotlin formatting settings. // https://github.com/JetBrains/intellij-kotlin/blob/master/formatter/src/org/jetbrains/kotlin/idea/formatter/trailingComma/util.kt when (node.elementType) { - ElementType.DESTRUCTURING_DECLARATION -> visitDestructuringDeclaration(node, emit, autoCorrect) - ElementType.FUNCTION_LITERAL -> visitFunctionLiteral(node, emit, autoCorrect) - ElementType.TYPE_PARAMETER_LIST -> visitTypeList(node, emit, autoCorrect) - ElementType.VALUE_PARAMETER_LIST -> visitValueList(node, emit, autoCorrect) - ElementType.WHEN_ENTRY -> visitWhenEntry(node, emit, autoCorrect) - else -> Unit - } - when (node.elementType) { - ElementType.COLLECTION_LITERAL_EXPRESSION -> visitCollectionLiteralExpression(node, emit, autoCorrect) - ElementType.INDICES -> visitIndices(node, emit, autoCorrect) - ElementType.TYPE_ARGUMENT_LIST -> visitTypeList(node, emit, autoCorrect) - ElementType.VALUE_ARGUMENT_LIST -> visitValueList(node, emit, autoCorrect) + CLASS -> visitClass(node, emit, autoCorrect) + COLLECTION_LITERAL_EXPRESSION -> visitCollectionLiteralExpression(node, emit, autoCorrect) + DESTRUCTURING_DECLARATION -> visitDestructuringDeclaration(node, emit, autoCorrect) + FUNCTION_LITERAL -> visitFunctionLiteral(node, emit, autoCorrect) + INDICES -> visitIndices(node, emit, autoCorrect) + TYPE_ARGUMENT_LIST -> visitTypeList(node, emit, autoCorrect) + TYPE_PARAMETER_LIST -> visitTypeList(node, emit, autoCorrect) + VALUE_ARGUMENT_LIST -> visitValueList(node, emit, autoCorrect) + VALUE_PARAMETER_LIST -> visitValueList(node, emit, autoCorrect) + WHEN_ENTRY -> visitWhenEntry(node, emit, autoCorrect) else -> Unit } } @@ -152,7 +166,7 @@ public class TrailingCommaRule : emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, autoCorrect: Boolean ) { - if (node.treeParent.elementType != ElementType.FUNCTION_LITERAL) { + if (node.treeParent.elementType != FUNCTION_LITERAL) { node .children() .lastOrNull { it.elementType == ElementType.RPAR } @@ -191,6 +205,52 @@ public class TrailingCommaRule : node.reportAndCorrectTrailingCommaNodeBefore(inspectNode, emit, autoCorrect) } + private fun visitClass( + node: ASTNode, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + autoCorrect: Boolean + ) { + val psi = node.psi + require(psi is KtClass) + + node + .takeIf { psi.isEnum() } + ?.findChildByType(CLASS_BODY) + ?.takeUnless { + // Do nothing when last two entries are on same line as no trailing comma should be inserted + it.lastTwoEnumEntriesAreOnSameLine() + }?.let { classBody -> + val nodeAfterTrailingCommaPosition = classBody.findNodeAfterTrailingCommaPosition() + node.reportAndCorrectTrailingCommaNodeBefore(nodeAfterTrailingCommaPosition, emit, autoCorrect) + } + } + + /** + * Determines the [ASTNode] before which the trailing comma is allowed. + * + * If the list of enumeration entries is terminated by a semicolon, that semicolon will be returned. Otherwise, the + * last element of the class. + */ + private fun ASTNode.findNodeAfterTrailingCommaPosition(): ASTNode { + val lastEnumEntry = children().last { it.psi is KtEnumEntry } + + val semicolonAfterLastEnumEntry = lastEnumEntry + .children() + .singleOrNull { it.elementType == SEMICOLON } + + return semicolonAfterLastEnumEntry ?: lastChildNode + } + + private fun ASTNode.lastTwoEnumEntriesAreOnSameLine(): Boolean { + val lastTwoEnumEntries = + children() + .filter { it.psi is KtEnumEntry } + .toList() + .takeLast(2) + + return lastTwoEnumEntries.count() == 2 && lastTwoEnumEntries[0].lineNumber() == lastTwoEnumEntries[1].lineNumber() + } + private fun ASTNode.reportAndCorrectTrailingCommaNodeBefore( inspectNode: ASTNode, emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, @@ -234,6 +294,7 @@ public class TrailingCommaRule : true ) } + if (autoCorrect) { if (addNewLineBeforeArrowInWhenEntry) { val parentIndent = (prevNode.psi.parent.prevLeaf() as? PsiWhiteSpace)?.text ?: "\n" @@ -246,6 +307,20 @@ public class TrailingCommaRule : prevNode.psi.parent.addAfter(newLine, prevNode.psi) } } + + if (inspectNode.treeParent.elementType == ENUM_ENTRY) { + with(KtPsiFactory(prevNode.psi)) { + val parentIndent = (prevNode.psi.parent.prevLeaf() as? PsiWhiteSpace)?.text ?: "\n" + val newline = createWhiteSpace(parentIndent) + val enumEntry = inspectNode.treeParent.psi + enumEntry.apply { + add(newline) + removeChild(inspectNode) + parent.addAfter(createSemicolon(), this) + } + } + } + val comma = KtPsiFactory(prevNode.psi).createComma() prevNode.psi.parent.addAfter(comma, prevNode.psi) } @@ -319,19 +394,20 @@ public class TrailingCommaRule : public companion object { private val TYPES_ON_DECLARATION_SITE = TokenSet.create( - ElementType.DESTRUCTURING_DECLARATION, - ElementType.FUNCTION_LITERAL, + DESTRUCTURING_DECLARATION, + FUNCTION_LITERAL, ElementType.FUNCTION_TYPE, - ElementType.TYPE_PARAMETER_LIST, - ElementType.VALUE_PARAMETER_LIST, - ElementType.WHEN_ENTRY + TYPE_PARAMETER_LIST, + VALUE_PARAMETER_LIST, + WHEN_ENTRY, + CLASS ) private val TYPES_ON_CALL_SITE = TokenSet.create( - ElementType.COLLECTION_LITERAL_EXPRESSION, - ElementType.INDICES, - ElementType.TYPE_ARGUMENT_LIST, - ElementType.VALUE_ARGUMENT_LIST + COLLECTION_LITERAL_EXPRESSION, + INDICES, + TYPE_ARGUMENT_LIST, + VALUE_ARGUMENT_LIST ) internal const val ALLOW_TRAILING_COMMA_NAME = "ij_kotlin_allow_trailing_comma" diff --git a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRuleTest.kt b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRuleTest.kt index 2af3517c9f..f7e69b015d 100644 --- a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRuleTest.kt +++ b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/TrailingCommaRuleTest.kt @@ -6,6 +6,7 @@ import com.pinterest.ktlint.ruleset.experimental.trailingcomma.TrailingCommaRule import com.pinterest.ktlint.ruleset.experimental.trailingcomma.TrailingCommaRule.Companion.allowTrailingCommaProperty import com.pinterest.ktlint.test.KtLintAssertThat.Companion.assertThatRule import com.pinterest.ktlint.test.LintViolation +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test class TrailingCommaRuleTest { @@ -1024,4 +1025,241 @@ class TrailingCommaRuleTest { LintViolation(7, 6, "Missing trailing comma before \")\"") ).isFormattedAs(formattedCode) } + + @Test + fun `Given that a trailing comma is is not allowed then remove comma after last enum member`() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE, + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to false) + .hasLintViolation(3, 13, "Unnecessary trailing comma before \"}\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given that a trailing comma is not allowed then it is removed for enums terminated with semicolon`() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE, + ; + + fun print() = name() + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE + ; + + fun print() = name() + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to false) + .hasLintViolation(3, 13, "Unnecessary trailing comma before \";\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given that a trailing comma is not allowed then it is not removed for enums where last two entries are on same line`() { + val code = + """ + enum class Shape { + SQUARE, TRIANGLE, + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to false) + .hasNoLintViolations() + } + + @Nested + inner class MissingRequiredTrailingComma { + @Test + fun `Given that last two enumeration entries are on same line, do not add a trailing comma`() { + val code = + """ + enum class Shape { + SQUARE, TRIANGLE + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasNoLintViolations() + } + + @Test + fun `Given an enum is terminated by a semicolon and EOL comment without a trailing comma, then it is added `() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE; // EOL Comment should be kept + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE, // EOL Comment should be kept + ; + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(3, 13, "Missing trailing comma before \";\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given an enum is terminated by a semicolon and block comment, then it is added `() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE; /* block comment should be kept */ + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE, /* block comment should be kept */ + ; + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(3, 13, "Missing trailing comma before \";\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given an enum terminated by semicolon without a trailing comma then it is added`() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE; + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE, + ; + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(3, 13, "Missing trailing comma before \";\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given an enum without trailing-comma with other declarations following the enum entries then it is added`() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE; + + fun print() = name() + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE, + ; + + fun print() = name() + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(3, 13, "Missing trailing comma before \";\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given that a trailing comma is required then it is added for complicated enums`() { + val code = + """ + interface Printable { + fun print(): String + } + + enum class Shape : Printable { + Square { + override fun print() = "■" + }, + + Triangle { + override fun print() = "▲" + } + } + """.trimIndent() + val formattedCode = + """ + interface Printable { + fun print(): String + } + + enum class Shape : Printable { + Square { + override fun print() = "■" + }, + + Triangle { + override fun print() = "▲" + }, + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(12, 6, "Missing trailing comma before \"}\"") + .isFormattedAs(formattedCode) + } + + @Test + fun `Given that a trailing comma is required then add trailing comma after last enum member`() { + val code = + """ + enum class Shape { + SQUARE, + TRIANGLE + } + """.trimIndent() + val formattedCode = + """ + enum class Shape { + SQUARE, + TRIANGLE, + } + """.trimIndent() + trailingCommaRuleAssertThat(code) + .withEditorConfigOverride(allowTrailingCommaProperty to true) + .hasLintViolation(3, 13, "Missing trailing comma before \"}\"") + .isFormattedAs(formattedCode) + } + } }