Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NestedScopeFunctions - Add rule for nested scope functions #4788

Merged
merged 7 commits into from Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Expand Up @@ -10,7 +10,7 @@
- We use [JUnit 5](https://junit.org/junit5/docs/current/user-guide/) for testing. Please use the `Spec.kt` suffix on
new test classes. If your new rule requires type resolution (i.e. it utilises `BindingContext`) then annotate your
test class with `@KotlinCoreEnvironmentTest` and have the test class accept `KotlinCoreEnvironment` as a parameter.
See "Testing a rule that uses type resolution" section of the [Using Type Resolution](../docs/pages/gettingstarted/type-resolution.md)
See "Testing a rule that uses type resolution" section of the [Using Type Resolution](../website/docs/gettingstarted/type-resolution.md)
guide for details.
- Feel free to add your name to the contributors list at the end of the readme file when opening a pull request.
- The code in `detekt-api` and any rule in `detekt-rules` must be documented. We generate documentation for our website based on these modules.
Expand Down
9 changes: 9 additions & 0 deletions detekt-core/src/main/resources/default-detekt-config.yml
Expand Up @@ -133,6 +133,15 @@ complexity:
NestedBlockDepth:
active: true
threshold: 4
NestedScopeFunctions:
active: false
threshold: 1
functions:
- 'kotlin.apply'
- 'kotlin.run'
- 'kotlin.with'
- 'kotlin.let'
- 'kotlin.also'
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
Expand Down
1 change: 1 addition & 0 deletions detekt-rules-complexity/build.gradle.kts
Expand Up @@ -5,6 +5,7 @@ plugins {
dependencies {
compileOnly(projects.detektApi)
compileOnly(projects.detektMetrics)
compileOnly(projects.detektTooling)
testImplementation(projects.detektMetrics)
testImplementation(projects.detektTest)
testImplementation(libs.assertj)
Expand Down
Expand Up @@ -24,6 +24,7 @@ class ComplexityProvider : DefaultRuleSetProvider {
StringLiteralDuplication(config),
MethodOverloading(config),
NestedBlockDepth(config),
NestedScopeFunctions(config),
TooManyFunctions(config),
ComplexCondition(config),
LabeledExpression(config),
Expand Down
@@ -0,0 +1,135 @@
package io.gitlab.arturbosch.detekt.rules.complexity

import io.github.detekt.tooling.api.FunctionMatcher
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.DetektVisitor
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Metric
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import io.gitlab.arturbosch.detekt.api.ThresholdedCodeSmell
import io.gitlab.arturbosch.detekt.api.config
import io.gitlab.arturbosch.detekt.api.internal.Configuration
import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution
import org.jetbrains.kotlin.descriptors.CallableDescriptor
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall

/**
* Although the scope functions are a way of making the code more concise, avoid overusing them: it can decrease
* your code readability and lead to errors. Avoid nesting scope functions and be careful when chaining them:
* it's easy to get confused about the current context object and the value of this or it.
*
* [Reference](https://kotlinlang.org/docs/scope-functions.html)
WildOrangutan marked this conversation as resolved.
Show resolved Hide resolved
*
* <noncompliant>
* // Try to figure out, what changed, without knowing the details
* first.apply {
* second.apply {
* b = a
* c = b
* }
* }
* </noncompliant>
*
* <compliant>
* // 'a' is a property of current class
* // 'b' is a property of class 'first'
* // 'c' is a property of class 'second'
* first.b = this.a
* second.c = first.b
* </compliant>
*/
@RequiresTypeResolution
class NestedScopeFunctions(config: Config = Config.empty) : Rule(config) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'm not a super big fan of this rule explicitly mentioning ScopeFunctions while in reality you can customize the functions. So theoretically you could use this rule to check the nesting of your own non-scope functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, dunno. Judging by this, scope is general term and not explicitly tied to "scope functions" like apply.

I'm not a Kotlin expert tough, so let me know, if you have better naming in mind. Not sure how else to name it tough. One alternative I see is NestedFunctions, but that can be confusing (function definition in a function).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to hear what @BraisGabin and others are thinking about this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a better name in mind right now. I'm fine with the current one.


override val issue = Issue(
javaClass.simpleName,
Severity.Maintainability,
"Over-using scope functions makes code confusing, hard to read and bug prone.",
Debt.FIVE_MINS
)

@Configuration("Number of nested scope functions allowed.")
private val threshold: Int by config(defaultValue = 1)

@Configuration(
"Set of scope function names which add complexity. " +
"Function names have to be fully qualified. For example 'kotlin.apply'."
)
private val functions: List<FunctionMatcher> by config(DEFAULT_FUNCTIONS) {
it.toSet().map(FunctionMatcher::fromFunctionSignature)
}

override fun visitNamedFunction(function: KtNamedFunction) {
function.accept(FunctionDepthVisitor())
WildOrangutan marked this conversation as resolved.
Show resolved Hide resolved
}

private fun report(element: KtCallExpression, depth: Int) {
val finding = ThresholdedCodeSmell(
issue,
Entity.from(element),
Metric("SIZE", depth, threshold),
"The scope function '${element.calleeExpression?.text}' is nested too deeply ('$depth'). " +
"Complexity threshold is set to '$threshold'."
)
report(finding)
}

private companion object {
val DEFAULT_FUNCTIONS = listOf(
"kotlin.apply",
"kotlin.run",
"kotlin.with",
"kotlin.let",
"kotlin.also",
)
}

private inner class FunctionDepthVisitor : DetektVisitor() {
private var depth = 0

override fun visitCallExpression(expression: KtCallExpression) {
fun callSuper(): Unit = super.visitCallExpression(expression)

if (expression.isScopeFunction()) {
doWithIncrementedDepth {
reportIfOverThreshold(expression)
callSuper()
}
} else {
callSuper()
}
}

private fun doWithIncrementedDepth(block: () -> Unit) {
depth++
block()
depth--
}

private fun reportIfOverThreshold(expression: KtCallExpression) {
if (depth > threshold) {
report(expression, depth)
}
}

private fun KtCallExpression.isScopeFunction(): Boolean {
val descriptors = resolveDescriptors()
val descriptor = descriptors.find { it.matchesScopeFunction() }
return descriptor != null
}

private fun KtCallExpression.resolveDescriptors(): List<CallableDescriptor> =
getResolvedCall(bindingContext)?.resultingDescriptor
?.let { listOf(it) + it.overriddenDescriptors }
?: emptyList()

private fun CallableDescriptor.matchesScopeFunction(): Boolean {
return functions.find { it.match(this) } != null
}
}
}
@@ -0,0 +1,113 @@
package io.gitlab.arturbosch.detekt.rules.complexity

import io.gitlab.arturbosch.detekt.api.Finding
import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
import io.gitlab.arturbosch.detekt.test.TestConfig
import io.gitlab.arturbosch.detekt.test.assertThat
import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.junit.jupiter.api.Test

@KotlinCoreEnvironmentTest
class NestedScopeFunctionsSpec(private val env: KotlinCoreEnvironment) {

private var defaultConfig = TestConfig(
mapOf(
"threshold" to 1,
"functions" to listOf("kotlin.run", "kotlin.with")
)
)
private val subject = NestedScopeFunctions(defaultConfig)

private lateinit var givenCode: String
private lateinit var actual: List<Finding>

@Test
fun `should report nesting`() {
givenCode = """
fun f() {
1.run {
1.run { }
}
}
"""
whenLintRuns()
expectSourceLocation(3 to 11)
expectFunctionInMsg("run")
}

@Test
fun `should report mixed nesting`() {
givenCode = """
fun f() {
1.run {
with(1) { }
}
}
"""
whenLintRuns()
expectSourceLocation(3 to 9)
expectFunctionInMsg("with")
}

@Test
fun `should report when valid scope in between`() {
givenCode = """
fun f() {
1.run {
"valid".apply {
with(1) { }
}
}
}
"""
whenLintRuns()
expectSourceLocation(4 to 13)
}

@Test
fun `should not report in nested function`() {
givenCode = """
fun f() {
1.run { }
fun f2() {
with(1) { }
}
}
"""
whenLintRuns()
expectNoFindings()
}

@Test
fun `should not report in neighboring scope functions`() {
givenCode = """
fun f() {
1.run { }
1.run { }
with(1) {}
with(1) {}
}
"""
whenLintRuns()
expectNoFindings()
}

private fun whenLintRuns() {
actual = subject.compileAndLintWithContext(env, givenCode)
}

private fun expectSourceLocation(location: Pair<Int, Int>) {
assertThat(actual).hasSourceLocation(location.first, location.second)
}

private fun expectFunctionInMsg(scopeFunction: String) {
val expected = "The scope function '$scopeFunction' is nested too deeply ('2'). " +
"Complexity threshold is set to '1'."
assertThat(actual[0]).hasMessage(expected)
}

private fun expectNoFindings() {
assertThat(actual).describedAs("findings size").isEmpty()
}
}