diff --git a/detekt-core/src/main/resources/default-detekt-config.yml b/detekt-core/src/main/resources/default-detekt-config.yml index f7da171eeb3..be0181d5bb8 100644 --- a/detekt-core/src/main/resources/default-detekt-config.yml +++ b/detekt-core/src/main/resources/default-detekt-config.yml @@ -579,6 +579,9 @@ style: active: false MandatoryBracesLoops: active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 MaxLineLength: active: true maxLineLength: 120 diff --git a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLine.kt b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLine.kt new file mode 100644 index 00000000000..f9680a0fc0a --- /dev/null +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLine.kt @@ -0,0 +1,79 @@ +package io.gitlab.arturbosch.detekt.rules.style + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import io.gitlab.arturbosch.detekt.api.config +import io.gitlab.arturbosch.detekt.api.internal.Configuration +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtUnaryExpression + +/** + * Limits the number of chained calls which can be placed on a single line. + * + * + * a().b().c().d().e().f() + * + * + * + * a().b().c() + * .d().e().f() + * + */ +class MaxChainedCallsOnSameLine(config: Config = Config.empty) : Rule(config) { + override val issue = Issue( + id = javaClass.simpleName, + severity = Severity.Style, + description = "Chained calls beyond the maximum should be wrapped to a new line.", + debt = Debt.FIVE_MINS, + ) + + @Configuration("maximum chained calls allowed on a single line") + private val maxChainedCalls: Int by config(defaultValue = 5) + + override fun visitQualifiedExpression(expression: KtQualifiedExpression) { + super.visitQualifiedExpression(expression) + + // skip if the parent is also a call on the same line to avoid duplicated warnings + val parent = expression.parent + if (parent is KtQualifiedExpression && !parent.callOnNewLine()) return + + val chainedCalls = expression.countChainedCalls() + 1 + if (chainedCalls > maxChainedCalls) { + report( + CodeSmell( + issue = issue, + entity = Entity.from(expression), + message = "$chainedCalls chained calls on a single line; more than $maxChainedCalls calls should " + + "be wrapped to a new line." + ) + ) + } + } + + private fun KtExpression.countChainedCalls(): Int { + return when (this) { + is KtQualifiedExpression -> + if (callOnNewLine()) 0 else receiverExpression.countChainedCalls() + 1 + is KtUnaryExpression -> baseExpression?.countChainedCalls() ?: 0 + else -> 0 + } + } + + private fun KtQualifiedExpression.callOnNewLine(): Boolean { + val receiver = receiverExpression + val selector = selectorExpression ?: return false + + val receiverEnd = receiver.startOffsetInParent + receiver.textLength + val selectorStart = selector.startOffsetInParent + + return text + .subSequence(startIndex = receiverEnd, endIndex = selectorStart) + .contains('\n') + } +} diff --git a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt index 7d6cbc250cc..7fbcf1d4f0e 100644 --- a/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt +++ b/detekt-rules-style/src/main/kotlin/io/gitlab/arturbosch/detekt/rules/style/StyleGuideProvider.kt @@ -98,6 +98,7 @@ class StyleGuideProvider : DefaultRuleSetProvider { UseOrEmpty(config), UseAnyOrNoneInsteadOfFind(config), UnnecessaryBackticks(config), + MaxChainedCallsOnSameLine(config), ) ) } diff --git a/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLineSpec.kt b/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLineSpec.kt new file mode 100644 index 00000000000..9d135a648d2 --- /dev/null +++ b/detekt-rules-style/src/test/kotlin/io/gitlab/arturbosch/detekt/rules/style/MaxChainedCallsOnSameLineSpec.kt @@ -0,0 +1,107 @@ +package io.gitlab.arturbosch.detekt.rules.style + +import io.gitlab.arturbosch.detekt.test.TestConfig +import io.gitlab.arturbosch.detekt.test.assertThat +import io.gitlab.arturbosch.detekt.test.compileAndLint +import org.junit.jupiter.api.Test + +class MaxChainedCallsOnSameLineSpec { + @Test + fun `does not report 2 calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0.plus(0)" + + assertThat(rule.compileAndLint(code)).isEmpty() + } + + @Test + fun `does not report 3 calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0.plus(0).plus(0)" + + assertThat(rule.compileAndLint(code)).isEmpty() + } + + @Test + fun `reports 4 calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0.plus(0).plus(0).plus(0)" + + assertThat(rule.compileAndLint(code)).hasSize(1) + } + + @Test + fun `reports 4 safe qualified calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0?.plus(0)?.plus(0)?.plus(0)" + + assertThat(rule.compileAndLint(code)).hasSize(1) + } + + @Test + fun `reports 4 non-null asserted calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0!!.plus(0)!!.plus(0)!!.plus(0)" + + assertThat(rule.compileAndLint(code)).hasSize(1) + } + + @Test + fun `reports once for 7 calls on a single line with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = "val a = 0.plus(0).plus(0).plus(0).plus(0).plus(0).plus(0)" + + assertThat(rule.compileAndLint(code)).hasSize(1) + } + + @Test + fun `does not report 5 calls on separate lines with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = """ + val a = 0 + .plus(0) + .plus(0) + .plus(0) + .plus(0) + """ + + assertThat(rule.compileAndLint(code)).isEmpty() + } + + @Test + fun `does not report 3 calls on same line with wrapped calls with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = """ + val a = 0.plus(0).plus(0) + .plus(0).plus(0).plus(0) + .plus(0).plus(0).plus(0) + """ + + assertThat(rule.compileAndLint(code)).isEmpty() + } + + @Test + fun `reports 4 calls on same line with wrapped calls with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = """ + val a = 0.plus(0).plus(0).plus(0) + .plus(0) + .plus(0) + """ + + assertThat(rule.compileAndLint(code)).hasSize(1) + } + + @Test + fun `reports 4 calls on wrapped line with with a max of 3`() { + val rule = MaxChainedCallsOnSameLine(TestConfig(mapOf("maxChainedCalls" to 3))) + val code = """ + val a = 0 + .plus(0) + .plus(0).plus(0).plus(0).plus(0) + .plus(0) + """ + + assertThat(rule.compileAndLint(code)).hasSize(1) + } +}