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

Add NullableBooleanCheck rule #4872

Merged
merged 1 commit into from May 30, 2022
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions detekt-core/src/main/resources/default-detekt-config.yml
Expand Up @@ -581,6 +581,8 @@ style:
active: true
NoTabs:
active: false
NullableBooleanCheck:
active: false
ObjectLiteralToLambda:
active: false
OptionalAbstractKeyword:
Expand Down
@@ -0,0 +1,72 @@
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.internal.RequiresTypeResolution
import org.jetbrains.kotlin.KtNodeTypes
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtBinaryExpression
import org.jetbrains.kotlin.resolve.calls.util.getType
import org.jetbrains.kotlin.types.typeUtil.isBooleanOrNullableBoolean

/**
* Detects nullable boolean checks which use an elvis expression `?:` rather than equals `==`.
*
* Per the [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html#nullable-boolean-values-in-conditions)
* converting a nullable boolean property to non-null should be done via `!= false` or `== true`
* rather than `?: true` or `?: false` (respectively).
*
* <noncompliant>
* value ?: true
* value ?: false
* </noncompliant>
*
* <compliant>
* value != false
* value == true
* </compliant>
*/
@RequiresTypeResolution
class NullableBooleanCheck(config: Config = Config.empty) : Rule(config) {
override val issue = Issue(
javaClass.simpleName,
Severity.Style,
"Nullable boolean check should use `==` rather than `?:`",
Debt.FIVE_MINS,
)

override fun visitBinaryExpression(expression: KtBinaryExpression) {
if (expression.operationToken == KtTokens.ELVIS &&
expression.right?.isBooleanConstant() == true &&
expression.left?.getType(bindingContext)?.isBooleanOrNullableBoolean() == true
) {
if (expression.right?.text == "true") {
report(
CodeSmell(
issue,
Entity.from(expression),
"The nullable boolean check `${expression.text}` should use `!= false` rather than `?: true`",
)
)
} else {
report(
CodeSmell(
issue,
Entity.from(expression),
"The nullable boolean check `${expression.text}` should use `== true` rather than `?: false`",
)
)
}
}

super.visitBinaryExpression(expression)
}

private fun PsiElement.isBooleanConstant() = node.elementType == KtNodeTypes.BOOLEAN_CONSTANT
}
Expand Up @@ -73,6 +73,7 @@ class StyleGuideProvider : DefaultRuleSetProvider {
PreferToOverPairSyntax(config),
MandatoryBracesIfStatements(config),
MandatoryBracesLoops(config),
NullableBooleanCheck(config),
VarCouldBeVal(config),
ForbiddenVoid(config),
ExplicitItLambdaParameter(config),
Expand Down
@@ -0,0 +1,140 @@
package io.gitlab.arturbosch.detekt.rules.style

import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
import io.gitlab.arturbosch.detekt.test.compileAndLint
import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource

@KotlinCoreEnvironmentTest
class NullableBooleanCheckSpec(val env: KotlinCoreEnvironment) {
val subject = NullableBooleanCheck(Config.empty)

/**
* The recommended replacement string for `?: [fallback]`.
*/
private fun replacementForElvis(fallback: Boolean): String {
return if (fallback) "!= false" else "== true"
}

@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `does not report when there is no context`(bool: Boolean) {
val code = """
import kotlin.random.Random

fun nullableBoolean(): Boolean? = true.takeIf { Random.nextBoolean() }

fun foo(): Boolean {
return nullableBoolean() ?: $bool
}
"""

assertThat(subject.compileAndLint(code)).isEmpty()
}

@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `reports elvis in statement`(bool: Boolean) {
val code = """
import kotlin.random.Random

fun nullableBoolean(): Boolean? = true.takeIf { Random.nextBoolean() }

fun foo(): Boolean {
return nullableBoolean() ?: $bool
}
"""

val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
assertThat(findings).first().extracting { it.message }.isEqualTo(
"The nullable boolean check `nullableBoolean() ?: $bool` should use " +
"`${replacementForElvis(bool)}` rather than `?: $bool`"
)
}

@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `reports elvis in if condition`(bool: Boolean) {
val code = """
import kotlin.random.Random

fun nullableBoolean(): Boolean? = true.takeIf { Random.nextBoolean() }

fun foo() {
if (nullableBoolean() ?: $bool) println("foo")
}
"""

val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
assertThat(findings).first().extracting { it.message }.isEqualTo(
"The nullable boolean check `nullableBoolean() ?: $bool` should use " +
"`${replacementForElvis(bool)}` rather than `?: $bool`"
)
}

@Test
fun `does not report for non-constant fallback`() {
val code = """
import kotlin.random.Random

fun nullableBoolean(): Boolean? = true.takeIf { Random.nextBoolean() }

fun foo(): Boolean {
return nullableBoolean() ?: Random.nextBoolean()
}
"""

assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
}

@ParameterizedTest
@ValueSource(booleans = [true, false])
fun `does not report elvis for non-boolean statement with boolean default`(bool: Boolean) {
val code = """
import kotlin.random.Random

fun nullableAny(): Any? = Unit.takeIf { Random.nextBoolean() }

fun foo(): Any {
return nullableAny() ?: $bool
}
"""

assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
}

@Test
fun `does not report non-boolean elvis`() {
val code = """
import kotlin.random.Random

fun nullableInt(): Int? = 42.takeIf { Random.nextBoolean() }

fun foo(): Int {
return nullableInt() ?: 0
}
"""

assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
}

@Test
fun `does not report non-elvis binary expression`() {
val code = """
import kotlin.random.Random

fun foo(): Boolean {
return Random.nextBoolean() || false
}
"""

assertThat(subject.compileAndLintWithContext(env, code)).isEmpty()
}
}