diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRule.kt new file mode 100644 index 0000000000..1883779747 --- /dev/null +++ b/ktlint-ruleset-standard/src/main/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRule.kt @@ -0,0 +1,83 @@ +package com.github.shyiko.ktlint.ruleset.standard + +import com.github.shyiko.ktlint.core.Rule +import org.jetbrains.kotlin.KtNodeTypes +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.PsiWhiteSpaceImpl +import org.jetbrains.kotlin.psi.KtAnnotationEntry +import org.jetbrains.kotlin.psi.psiUtil.children +import org.jetbrains.kotlin.psi.psiUtil.startOffset + +class MemberOrTypeAnnotationRule : Rule("member-or-type-annotation") { + + companion object { + const val multipleAnnotationsOnSameLineAsAnnotatedConstructErrorMessage = + "Multiple annotations should not be placed on the same line as the annotated construct" + const val annotationsWithParametersAreNotOnSeparateLinesErrorMessage = + "Annotations with parameters should all be placed on separate lines prior to the annotated construct" + } + + override fun visit( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit + ) { + val root = node.children().firstOrNull { it.elementType == KtNodeTypes.MODIFIER_LIST } + ?: return + + val annotations = root.children() + .mapNotNull { it.psi as? KtAnnotationEntry } + .toList() + check(!annotations.isEmpty()) { "Annotations list should not be empty" } + + // Join the nodes that immediately follow the annotations (whitespace), then add the final whitespace + // if it's not a child of root. This happens when a new line separates the annotations from the annotated + // construct. In the following example, there are no whitespace children of root, but root's next sibling is the + // new line whitespace. + // + // @JvmField + // val s: Any + // + val whiteSpaces = (annotations.asSequence().map { it.nextSibling } + root.treeNext) + .filterIsInstance() + .take(annotations.size) + .toList() + + val multipleAnnotationsOnSameLineAsAnnotatedConstruct = + annotations.size > 1 && !whiteSpaces.last().textContains('\n') + val annotationsWithParametersAreNotOnSeparateLines = + annotations.any { it.valueArgumentList != null } && + !whiteSpaces.all { it.textContains('\n') } + + if (multipleAnnotationsOnSameLineAsAnnotatedConstruct) { + emit( + annotations.first().startOffset, + multipleAnnotationsOnSameLineAsAnnotatedConstructErrorMessage, + true + ) + } + if (annotationsWithParametersAreNotOnSeparateLines) { + emit( + annotations.first().startOffset, + annotationsWithParametersAreNotOnSeparateLinesErrorMessage, + true + ) + } + + if (autoCorrect) { + val nodeBeforeAnnotations = root.treeParent.treePrev as? PsiWhiteSpace + // If there is no whitespace before the annotation, the annotation is the first + // text in the file + val newLineWithIndent = nodeBeforeAnnotations?.text ?: "\n" + + if (annotationsWithParametersAreNotOnSeparateLines) { + whiteSpaces.forEach { + it.rawReplaceWithText(newLineWithIndent) + } + } else if (multipleAnnotationsOnSameLineAsAnnotatedConstruct) { + whiteSpaces.last().rawReplaceWithText(newLineWithIndent) + } + } + } +} diff --git a/ktlint-ruleset-standard/src/test/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRuleTest.kt b/ktlint-ruleset-standard/src/test/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRuleTest.kt new file mode 100644 index 0000000000..4f1f69e75c --- /dev/null +++ b/ktlint-ruleset-standard/src/test/kotlin/com/github/shyiko/ktlint/ruleset/standard/MemberOrTypeAnnotationRuleTest.kt @@ -0,0 +1,319 @@ +package com.github.shyiko.ktlint.ruleset.standard + +import com.github.shyiko.ktlint.core.LintError +import com.github.shyiko.ktlint.test.format +import com.github.shyiko.ktlint.test.lint +import org.assertj.core.api.Assertions.assertThat +import org.testng.annotations.Test + +class MemberOrTypeAnnotationRuleTest { + + @Test + fun `lint single annotation may be placed on line before annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + @FunctionalInterface class A { + @JvmField + var x: String + } + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun `format single annotation may be placed on line before annotated construct`() { + val code = """ + @FunctionalInterface class A { + @JvmField + var x: String + } + """.trimIndent() + assertThat(MemberOrTypeAnnotationRule().format(code)).isEqualTo(code) + } + + @Test + fun `lint single annotation may be placed on same line as annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + @FunctionalInterface class A { + @JvmField var x: String + + @Test fun myTest() {} + } + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun `format single annotation may be placed on same line as annotated construct`() { + val code = """ + @FunctionalInterface class A { + @JvmField var x: String + + @Test fun myTest() {} + } + """.trimIndent() + assertThat(MemberOrTypeAnnotationRule().format(code)).isEqualTo(code) + } + + @Test + fun `lint multiple annotations should not be placed on same line as annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + class A { + @JvmField @Volatile var x: String + + @JvmField @Volatile + var y: String + } + """.trimIndent() + ) + ).containsExactly( + LintError( + 2, 5, "member-or-type-annotation", + MemberOrTypeAnnotationRule.multipleAnnotationsOnSameLineAsAnnotatedConstructErrorMessage + ) + ) + } + + @Test + fun `format multiple annotations should not be placed on same line as annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().format( + """ + class A { + @JvmField @Volatile var x: String + + @JvmField @Volatile + var y: String + } + """.trimIndent() + ) + ).isEqualTo( + """ + class A { + @JvmField @Volatile + var x: String + + @JvmField @Volatile + var y: String + } + """.trimIndent() + ) + } + + @Test + fun `format multiple annotations should not be placed on same line as annotated construct (with no previous whitespace)`() { + assertThat(MemberOrTypeAnnotationRule().format("@JvmField @Volatile var x: String")) + .isEqualTo( + """ + @JvmField @Volatile + var x: String + """.trimIndent() + ) + } + + @Test + fun `format multiple annotations should not be placed on same line as annotated construct (with no previous indent)`() { + assertThat( + MemberOrTypeAnnotationRule().format( + """ + + @JvmField @Volatile var x: String + """.trimIndent() + ) + ).isEqualTo( + """ + + @JvmField @Volatile + var x: String + """.trimIndent() + ) + } + + @Test + fun `lint annotations with params should not be placed on same line before annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + class A { + @JvmName("xJava") var x: String + + @JvmName("yJava") + var y: String + } + """.trimIndent() + ) + ).containsExactly( + LintError( + 2, 5, "member-or-type-annotation", + MemberOrTypeAnnotationRule.annotationsWithParametersAreNotOnSeparateLinesErrorMessage + ) + ) + } + + @Test + fun `format annotations with params should not be placed on same line before annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().format( + """ + class A { + @JvmName("xJava") var x: String + + @JvmName("yJava") + var y: String + } + """.trimIndent() + ) + ).isEqualTo( + """ + class A { + @JvmName("xJava") + var x: String + + @JvmName("yJava") + var y: String + } + """.trimIndent() + ) + } + + @Test + fun `lint multiple annotations with params should not be placed on same line before annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + @Retention(SOURCE) @Target(FUNCTION, PROPERTY_SETTER, FIELD) annotation class A + + @Retention(SOURCE) + @Target(FUNCTION, PROPERTY_SETTER, FIELD) + annotation class B + """.trimIndent() + ) + ).containsExactly( + LintError( + 1, 1, "member-or-type-annotation", + MemberOrTypeAnnotationRule.multipleAnnotationsOnSameLineAsAnnotatedConstructErrorMessage + ), + LintError( + 1, 1, "member-or-type-annotation", + MemberOrTypeAnnotationRule.annotationsWithParametersAreNotOnSeparateLinesErrorMessage + ) + ) + } + + @Test + fun `format multiple annotations with params should not be placed on same line before annotated construct`() { + assertThat( + MemberOrTypeAnnotationRule().format( + """ + @Retention(SOURCE) @Target(FUNCTION, PROPERTY_SETTER, FIELD) annotation class A + + @Retention(SOURCE) + @Target(FUNCTION, PROPERTY_SETTER, FIELD) + annotation class B + """.trimIndent() + ) + ).isEqualTo( + """ + @Retention(SOURCE) + @Target(FUNCTION, PROPERTY_SETTER, FIELD) + annotation class A + + @Retention(SOURCE) + @Target(FUNCTION, PROPERTY_SETTER, FIELD) + annotation class B + """.trimIndent() + ) + } + + @Test + fun `lint annotation after keyword`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + class A { + private @Test fun myTest() {} + } + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun `format annotation after keyword`() { + val code = """ + class A { + private @Test fun myTest() {} + } + """.trimIndent() + assertThat(MemberOrTypeAnnotationRule().format(code)).isEqualTo(code) + } + + @Test + fun `lint multi-line annotation`() { + assertThat( + MemberOrTypeAnnotationRule().lint( + """ + class A { + @JvmField @Volatile @Annotation( + enabled = true, + groups = [ + "a", + "b", + "c" + ] + ) val a: Any + } + """.trimIndent() + ) + ).containsExactly( + LintError( + 2, 5, "member-or-type-annotation", + MemberOrTypeAnnotationRule.multipleAnnotationsOnSameLineAsAnnotatedConstructErrorMessage + ), + LintError( + 2, 5, "member-or-type-annotation", + MemberOrTypeAnnotationRule.annotationsWithParametersAreNotOnSeparateLinesErrorMessage + ) + ) + } + + @Test + fun `format multi-line annotation`() { + val code = """ + class A { + @JvmField @Volatile @Annotation( + enabled = true, + groups = [ + "a", + "b", + "c" + ] + ) val a: Any + } + """.trimIndent() + assertThat(MemberOrTypeAnnotationRule().format(code)).isEqualTo( + """ + class A { + @JvmField + @Volatile + @Annotation( + enabled = true, + groups = [ + "a", + "b", + "c" + ] + ) + val a: Any + } + """.trimIndent() + ) + } +}