Add CascadingCallWrapping style rule (#4979)

Add a new rule CascadingCallWrapping which requires that if a chained call is placed on a newline then all subsequent calls must be as well, improving readability of long chains.
This commit is contained in:
Dominic Zirbel
2022-06-22 09:18:52 -07:00
committed by GitHub
parent 0e67d142e5
commit 89f6ec1586
16 changed files with 324 additions and 15 deletions

View File

@@ -155,6 +155,8 @@ potential-bugs:
style: style:
CanBeNonNullable: CanBeNonNullable:
active: true active: true
CascadingCallWrapping:
active: true
ClassOrdering: ClassOrdering:
active: true active: true
CollapsibleIfStatements: CollapsibleIfStatements:

View File

@@ -17,7 +17,8 @@ open class SplitPattern(
.mapIf(removeTrailingAsterisks) { seq -> .mapIf(removeTrailingAsterisks) { seq ->
seq.map { it.removePrefix("*") } seq.map { it.removePrefix("*") }
.map { it.removeSuffix("*") } .map { it.removeSuffix("*") }
}.toList() }
.toList()
private fun <T> Sequence<T>.mapIf( private fun <T> Sequence<T>.mapIf(
condition: Boolean, condition: Boolean,

View File

@@ -490,6 +490,9 @@ style:
active: true active: true
CanBeNonNullable: CanBeNonNullable:
active: false active: false
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering: ClassOrdering:
active: false active: false
CollapsibleIfStatements: CollapsibleIfStatements:

View File

@@ -171,7 +171,9 @@ class ConfigurationCollector {
private fun KtValueArgument.getReferenceIdentifierOrNull(): String? = private fun KtValueArgument.getReferenceIdentifierOrNull(): String? =
(getArgumentExpression() as? KtCallableReferenceExpression) (getArgumentExpression() as? KtCallableReferenceExpression)
?.callableReference?.getIdentifier()?.text ?.callableReference
?.getIdentifier()
?.text
} }
private object ConfigWithAndroidVariantsSupport { private object ConfigWithAndroidVariantsSupport {

View File

@@ -64,7 +64,8 @@ class MultiRuleVisitor : DetektVisitor() {
override fun visitSuperTypeList(list: KtSuperTypeList) { override fun visitSuperTypeList(list: KtSuperTypeList) {
val isMultiRule = list.entries val isMultiRule = list.entries
?.mapNotNull { it.typeAsUserType?.referencedName } ?.mapNotNull { it.typeAsUserType?.referencedName }
?.any { it == multiRule } ?: false ?.any { it == multiRule }
?: false
val containingClass = list.containingClass() val containingClass = list.containingClass()
val className = containingClass?.name val className = containingClass?.name

View File

@@ -63,7 +63,8 @@ internal class RuleVisitor : DetektVisitor() {
val isRule = list.entries val isRule = list.entries
?.asSequence() ?.asSequence()
?.map { it.typeAsUserType?.referencedName } ?.map { it.typeAsUserType?.referencedName }
?.any { ruleClasses.contains(it) } ?: false ?.any { ruleClasses.contains(it) }
?: false
val containingClass = list.containingClass() val containingClass = list.containingClass()
val className = containingClass?.name val className = containingClass?.name
@@ -138,7 +139,8 @@ internal class RuleVisitor : DetektVisitor() {
.singleOrNull { it.name == "issue" } .singleOrNull { it.name == "issue" }
?.initializer as? KtCallExpression ?.initializer as? KtCallExpression
) )
?.valueArguments.orEmpty() ?.valueArguments
.orEmpty()
if (arguments.size >= ISSUE_ARGUMENT_SIZE) { if (arguments.size >= ISSUE_ARGUMENT_SIZE) {
severity = getArgument(arguments[1], "Severity") severity = getArgument(arguments[1], "Severity")

View File

@@ -47,7 +47,8 @@ open class DetektExtension @Inject constructor(objects: ObjectFactory) : CodeQua
var baseline: File? = objects.fileProperty() var baseline: File? = objects.fileProperty()
.fileValue(File("detekt-baseline.xml")) .fileValue(File("detekt-baseline.xml"))
.get().asFile .get()
.asFile
var basePath: String? = null var basePath: String? = null

View File

@@ -156,7 +156,8 @@ class TooManyFunctions(config: Config = Config.empty) : Rule(config) {
declarations declarations
.filterIsInstance<KtNamedFunction>() .filterIsInstance<KtNamedFunction>()
.count { !isIgnoredFunction(it) } .count { !isIgnoredFunction(it) }
} ?: 0 }
?: 0
private fun isIgnoredFunction(function: KtNamedFunction): Boolean = when { private fun isIgnoredFunction(function: KtNamedFunction): Boolean = when {
ignoreDeprecated && function.hasAnnotation(DEPRECATED) -> true ignoreDeprecated && function.hasAnnotation(DEPRECATED) -> true

View File

@@ -67,7 +67,10 @@ class SuspendFunWithCoroutineScopeReceiver(config: Config) : Rule(config) {
private fun checkReceiver(function: KtNamedFunction) { private fun checkReceiver(function: KtNamedFunction) {
val suspendModifier = function.modifierList?.getModifier(KtTokens.SUSPEND_KEYWORD) ?: return val suspendModifier = function.modifierList?.getModifier(KtTokens.SUSPEND_KEYWORD) ?: return
val receiver = bindingContext[BindingContext.FUNCTION, function] val receiver = bindingContext[BindingContext.FUNCTION, function]
?.extensionReceiverParameter?.value?.type ?: return ?.extensionReceiverParameter
?.value
?.type
?: return
if (receiver.isCoroutineScope()) { if (receiver.isCoroutineScope()) {
report( report(
CodeSmell( CodeSmell(

View File

@@ -14,7 +14,6 @@ import io.gitlab.arturbosch.detekt.rules.isNonNullCheck
import io.gitlab.arturbosch.detekt.rules.isNullCheck import io.gitlab.arturbosch.detekt.rules.isNullCheck
import io.gitlab.arturbosch.detekt.rules.isOpen import io.gitlab.arturbosch.detekt.rules.isOpen
import io.gitlab.arturbosch.detekt.rules.isOverride import io.gitlab.arturbosch.detekt.rules.isOverride
import org.jetbrains.kotlin.com.intellij.codeInsight.NullableNotNullManager.isNullable
import org.jetbrains.kotlin.descriptors.CallableDescriptor import org.jetbrains.kotlin.descriptors.CallableDescriptor
import org.jetbrains.kotlin.descriptors.DeclarationDescriptor import org.jetbrains.kotlin.descriptors.DeclarationDescriptor
import org.jetbrains.kotlin.descriptors.PropertyDescriptor import org.jetbrains.kotlin.descriptors.PropertyDescriptor
@@ -137,11 +136,13 @@ class CanBeNonNullable(config: Config = Config.empty) : Rule(config) {
function.valueParameters.asSequence() function.valueParameters.asSequence()
.filter { .filter {
it.typeReference?.typeElement is KtNullableType it.typeReference?.typeElement is KtNullableType
}.mapNotNull { parameter -> }
.mapNotNull { parameter ->
bindingContext[BindingContext.DECLARATION_TO_DESCRIPTOR, parameter]?.let { bindingContext[BindingContext.DECLARATION_TO_DESCRIPTOR, parameter]?.let {
it to parameter it to parameter
} }
}.forEach { (descriptor, param) -> }
.forEach { (descriptor, param) ->
candidateDescriptors.add(descriptor) candidateDescriptors.add(descriptor)
nullableParams[descriptor] = NullableParam(param) nullableParams[descriptor] = NullableParam(param)
} }
@@ -175,7 +176,8 @@ class CanBeNonNullable(config: Config = Config.empty) : Rule(config) {
.filter { .filter {
val onlyNonNullCheck = validSingleChildExpression && it.isNonNullChecked && !it.isNullChecked val onlyNonNullCheck = validSingleChildExpression && it.isNonNullChecked && !it.isNullChecked
it.isNonNullForced || onlyNonNullCheck it.isNonNullForced || onlyNonNullCheck
}.forEach { nullableParam -> }
.forEach { nullableParam ->
report( report(
CodeSmell( CodeSmell(
issue, issue,

View File

@@ -0,0 +1,109 @@
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.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtBinaryExpression
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtQualifiedExpression
import org.jetbrains.kotlin.psi.KtUnaryExpression
/**
* Requires that all chained calls are placed on a new line if a preceding one is.
*
* <noncompliant>
* foo()
* .bar().baz()
* </noncompliant>
*
* <compliant>
* foo().bar().baz()
*
* foo()
* .bar()
* .baz()
* </compliant>
*/
class CascadingCallWrapping(config: Config = Config.empty) : Rule(config) {
override val issue = Issue(
id = javaClass.simpleName,
severity = Severity.Style,
description = "If a chained call is wrapped to a new line, subsequent chained calls should be as well.",
debt = Debt.FIVE_MINS,
)
@Configuration("require trailing elvis expressions to be wrapped on a new line")
private val includeElvis: Boolean by config(true)
override fun visitQualifiedExpression(expression: KtQualifiedExpression) {
super.visitQualifiedExpression(expression)
checkExpression(expression, callExpression = expression.selectorExpression)
}
override fun visitBinaryExpression(expression: KtBinaryExpression) {
super.visitBinaryExpression(expression)
if (includeElvis && expression.operationToken == KtTokens.ELVIS) {
checkExpression(expression, callExpression = expression.right)
}
}
private fun checkExpression(expression: KtExpression, callExpression: KtExpression?) {
if (!expression.containsNewline() && expression.receiverContainsNewline()) {
val callTextOrEmpty = callExpression?.text?.let { " `$it`" }.orEmpty()
report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Chained call$callTextOrEmpty should be wrapped to a new line since preceding calls were."
)
)
}
}
@Suppress("ReturnCount")
private fun KtExpression.containsNewline(): Boolean {
val lhs: KtExpression
val rhs: KtExpression
when (this) {
is KtQualifiedExpression -> {
lhs = receiverExpression
rhs = selectorExpression ?: return false
}
is KtBinaryExpression -> {
if (operationToken != KtTokens.ELVIS) return false
lhs = left ?: return false
rhs = right ?: return false
}
else -> return false
}
val receiverEnd = lhs.startOffsetInParent + lhs.textLength
val selectorStart = rhs.startOffsetInParent
return (receiverEnd until selectorStart).any { text[it] == '\n' }
}
private fun KtExpression.receiverContainsNewline(): Boolean {
val lhs = when (this) {
is KtQualifiedExpression -> receiverExpression
is KtBinaryExpression -> left ?: return false
else -> return false
}
return when (lhs) {
is KtQualifiedExpression -> lhs.containsNewline()
is KtUnaryExpression -> (lhs.baseExpression as? KtQualifiedExpression)?.containsNewline() == true
else -> false
}
}
}

View File

@@ -80,7 +80,8 @@ class ForbiddenVoid(config: Config = Config.empty) : Rule(config) {
private fun KtTypeReference.isPartOfReturnTypeOfFunction() = private fun KtTypeReference.isPartOfReturnTypeOfFunction() =
getStrictParentOfType<KtNamedFunction>() getStrictParentOfType<KtNamedFunction>()
?.typeReference ?.typeReference
?.anyDescendantOfType<KtTypeReference> { it == this } ?: false ?.anyDescendantOfType<KtTypeReference> { it == this }
?: false
private fun KtTypeReference.isParameterTypeOfFunction() = private fun KtTypeReference.isParameterTypeOfFunction() =
getStrictParentOfType<KtParameter>() != null getStrictParentOfType<KtParameter>() != null

View File

@@ -102,7 +102,8 @@ class ObjectLiteralToLambda(config: Config = Config.empty) : Rule(config) {
if ( if (
declaration.name == null && declaration.name == null &&
bindingContext.getType(expression) bindingContext.getType(expression)
?.singleSuperTypeOrNull()?.couldBeSamInterface == true && ?.singleSuperTypeOrNull()
?.couldBeSamInterface == true &&
declaration.hasConvertibleMethod() declaration.hasConvertibleMethod()
) { ) {
report(CodeSmell(issue, Entity.from(expression), issue.description)) report(CodeSmell(issue, Entity.from(expression), issue.description))

View File

@@ -100,7 +100,8 @@ class SerialVersionUIDInSerializableClass(config: Config = Config.empty) : Rule(
private fun hasLongAssignment(property: KtProperty): Boolean { private fun hasLongAssignment(property: KtProperty): Boolean {
val assignmentText = property.children val assignmentText = property.children
.singleOrNull { it is KtConstantExpression || it is KtPrefixExpression }?.text .singleOrNull { it is KtConstantExpression || it is KtPrefixExpression }
?.text
return assignmentText != null && assignmentText.last() == 'L' && return assignmentText != null && assignmentText.last() == 'L' &&
assignmentText.substring(0, assignmentText.length - 1).toLongOrNull() != null assignmentText.substring(0, assignmentText.length - 1).toLongOrNull() != null
} }

View File

@@ -24,6 +24,7 @@ class StyleGuideProvider : DefaultRuleSetProvider {
ruleSetId, ruleSetId,
listOf( listOf(
CanBeNonNullable(config), CanBeNonNullable(config),
CascadingCallWrapping(config),
ClassOrdering(config), ClassOrdering(config),
CollapsibleIfStatements(config), CollapsibleIfStatements(config),
DestructuringDeclarationWithTooManyEntries(config), DestructuringDeclarationWithTooManyEntries(config),

View File

@@ -0,0 +1,178 @@
package io.gitlab.arturbosch.detekt.rules.style
import io.gitlab.arturbosch.detekt.api.Config
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.Nested
import org.junit.jupiter.api.Test
class CascadingCallWrappingSpec {
private val subject = CascadingCallWrapping(Config.empty)
@Test
fun `reports missing wrapping`() {
val code = """
val a = 0
.plus(0).plus(0).plus(0)
"""
assertThat(subject.compileAndLint(code))
.hasSize(1)
.hasTextLocations(8 to 30)
.first()
.hasMessage("Chained call `plus(0)` should be wrapped to a new line since preceding calls were.")
}
@Test
fun `does not report when chained calls are on a single line`() {
val code = """
val a = 0.plus(0).plus(0)
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
@Test
fun `does not report wrapped calls`() {
val code = """
val a = 0
.plus(0)
.plus(0)
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
@Test
fun `does not report unwrapped initial calls`() {
val code = """
val a = 0.plus(0).plus(0)
.plus(0)
.plus(0)
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
@Test
fun `reports missing wrapping for safe qualified calls`() {
val code = """
val a = 0
?.plus(0)?.plus(0)
"""
assertThat(subject.compileAndLint(code)).hasSize(1)
}
@Test
fun `reports missing wrapping for calls with non-null assertions`() {
val code = """
val a = 0!!
.plus(0)!!.plus(0)
"""
assertThat(subject.compileAndLint(code)).hasSize(1)
}
@Test
fun `reports missing wrapping for properties`() {
val code = """
val a = ""
.plus("").length
val b = ""
.length.plus(0)
"""
assertThat(subject.compileAndLint(code)).hasSize(2)
}
@Nested
inner class `with multiline calls` {
@Test
fun `does not report with wrapping`() {
val code = """
val a = 0
.plus(
0
)
.let {
0
}
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
@Test
fun `reports missing wrapping`() {
val code = """
val a = 0
.plus(
0
)
.let {
0
}.plus(
0
)
"""
assertThat(subject.compileAndLint(code)).hasSize(1)
}
@Test
fun `does not report when calls are multiline but never wrapped`() {
val code = """
val a = 0.plus(
0
).let {
0
}
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
@Test
fun `does not report for single multiline call`() {
val code = """
val a = 0.plus(
0
)
"""
assertThat(subject.compileAndLint(code)).isEmpty()
}
}
@Nested
inner class `with elvis operators` {
private val subjectIncludingElvis = CascadingCallWrapping(TestConfig(mapOf("includeElvis" to true)))
private val subjectExcludingElvis = CascadingCallWrapping(TestConfig(mapOf("includeElvis" to false)))
@Test
fun `does not report with wrapping`() {
val code = """
val a = 0
.plus(0)
?: 0
"""
assertThat(subjectIncludingElvis.compileAndLint(code)).isEmpty()
assertThat(subjectExcludingElvis.compileAndLint(code)).isEmpty()
}
@Test
fun `reports missing wrapping`() {
val code = """
val a = 0
.plus(0) ?: 0
"""
assertThat(subjectIncludingElvis.compileAndLint(code)).hasSize(1)
assertThat(subjectExcludingElvis.compileAndLint(code)).isEmpty()
}
}
}