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 CastNullableToNonNullableType rule #5653

Merged
merged 8 commits into from
Jan 26, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,8 @@ potential-bugs:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: false
CastToNullableType:
active: false
Deprecation:
Expand Down
1 change: 1 addition & 0 deletions detekt-psi-utils/api/detekt-psi-utils.api
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,6 @@ public final class io/gitlab/arturbosch/detekt/rules/TypeUtilsKt {
public static final fun fqNameOrNull (Lorg/jetbrains/kotlin/types/KotlinType;)Lorg/jetbrains/kotlin/name/FqName;
public static final fun getDataFlowAwareTypes (Lorg/jetbrains/kotlin/psi/KtExpression;Lorg/jetbrains/kotlin/resolve/BindingContext;Lorg/jetbrains/kotlin/config/LanguageVersionSettings;Lorg/jetbrains/kotlin/resolve/calls/smartcasts/DataFlowValueFactory;Lorg/jetbrains/kotlin/types/KotlinType;)Ljava/util/Set;
public static synthetic fun getDataFlowAwareTypes$default (Lorg/jetbrains/kotlin/psi/KtExpression;Lorg/jetbrains/kotlin/resolve/BindingContext;Lorg/jetbrains/kotlin/config/LanguageVersionSettings;Lorg/jetbrains/kotlin/resolve/calls/smartcasts/DataFlowValueFactory;Lorg/jetbrains/kotlin/types/KotlinType;ILjava/lang/Object;)Ljava/util/Set;
public static final fun isNullable (Lorg/jetbrains/kotlin/psi/KtExpression;Lorg/jetbrains/kotlin/resolve/BindingContext;Lorg/jetbrains/kotlin/config/LanguageVersionSettings;Lorg/jetbrains/kotlin/resolve/calls/smartcasts/DataFlowValueFactory;Z)Z
}

Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package io.gitlab.arturbosch.detekt.rules

import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import org.jetbrains.kotlin.config.LanguageVersionSettings
import org.jetbrains.kotlin.descriptors.CallableDescriptor
import org.jetbrains.kotlin.diagnostics.Errors
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression
import org.jetbrains.kotlin.resolve.BindingContext
import org.jetbrains.kotlin.resolve.calls.smartcasts.DataFlowValueFactory
import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall
import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameOrNull
import org.jetbrains.kotlin.types.KotlinType
import org.jetbrains.kotlin.types.TypeUtils
import org.jetbrains.kotlin.types.isFlexible
import org.jetbrains.kotlin.types.isNullable
import org.jetbrains.kotlin.util.containingNonLocalDeclaration

fun KotlinType.fqNameOrNull(): FqName? {
Expand Down Expand Up @@ -52,3 +59,44 @@ fun KtExpression.getDataFlowAwareTypes(
return dataFlowInfo.getStableTypes(dataFlowValue, languageVersionSettings)
.ifEmpty { setOf(originalType) }
}

/**
* Return if overall expression is nullable or not nullable
*
* ```kotlin
* var a: Int? = null
* val b = a // RHS expression will be nullable
* val c = a!! // RHS expression will be not nullable
* val d = (a ?: error("null assertion message")) // RHS expression will be not nullable
* val c = 1?.and(2) // RHS expression will be not nullable
* ```
* [shouldConsiderPlatformTypeAsNullable] determines the behaviour of platform type. Passing true considers
* the platform type as nullable otherwise it is not considered nullable in case of false
*/
@Suppress("ReturnCount")
fun KtExpression.isNullable(
bindingContext: BindingContext,
languageVersionSettings: LanguageVersionSettings,
dataFlowValueFactory: DataFlowValueFactory,
shouldConsiderPlatformTypeAsNullable: Boolean,
): Boolean {
val safeAccessOperation = safeAs<KtSafeQualifiedExpression>()?.operationTokenNode?.safeAs<PsiElement>()
if (safeAccessOperation != null) {
return bindingContext.diagnostics.forElement(safeAccessOperation).none {
it.factory == Errors.UNNECESSARY_SAFE_CALL
}
}
val originalType = descriptor(bindingContext)?.returnType?.takeIf {
it.isNullable() && (shouldConsiderPlatformTypeAsNullable || !it.isFlexible())
} ?: return false
val dataFlowTypes = getDataFlowAwareTypes(
bindingContext,
languageVersionSettings,
dataFlowValueFactory,
originalType
)
return dataFlowTypes.all { it.isNullable() }
}

private fun KtExpression.descriptor(bindingContext: BindingContext): CallableDescriptor? =
getResolvedCall(bindingContext)?.resultingDescriptor
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.gitlab.arturbosch.detekt.rules.bugs

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 io.gitlab.arturbosch.detekt.rules.isNullable
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtBinaryExpressionWithTypeRHS
import org.jetbrains.kotlin.psi.KtNullableType
import org.jetbrains.kotlin.utils.addToStdlib.ifFalse
import org.jetbrains.kotlin.utils.addToStdlib.ifTrue

/**
* Reports cast of nullable variable to non-null type. Cast like this can hide `null`
* problems in your code. The compliant code would be that which will correctly check
* for two things (nullability and type) and not just one (cast).
Comment on lines +20 to +21
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* problems in your code. The compliant code would be that which will correctly check
* for two things (nullability and type) and not just one (cast).
* problems in your code. The compliant code would either us smart-casting or would check the type before performing the cast.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi, @cortinico changing the message to * problems in your code. The compliant code would either us smart-casting or would check the type before performing the cast. doesn't actually reflect the intention of the rule.
Original code bar as String asserts two things(nullability as well as type assertions) at once. And suggested fix is to assert both the cases differently or handle both the cases

Copy link
Member

Choose a reason for hiding this comment

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

I'm unsure I get the point of this rule at this point.

The problem of bar as String is that it will fail at runtime if the two types are incompatible. Using a if (bar is String) ... else ... would not instead.

Your rule is instead suggesting to fail for two different scenarios (null or non compatible types) with different error messages, right?.

Copy link
Member

Choose a reason for hiding this comment

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

The point of this rule is that a cast can hide a !! behind it. That's a potential error and what this rule does is to point it out to ensure that this is not an error but a decission by the devolver. If you really want to cast directly from Any? to String you should be fine with foo!! as String. If you didn't want to do that, well, then you need to rewrite your code in any other way that doesn't produces a null pointer exception.

To me this rule should not be too opinated. Just point to the potential error and its up to the dev to decide how to fix it. If the problem here are the compilant part we can add 3 different ways to make the code compilant because it depends a lot to what the user wants on each case.

If you are casting your code is smelly. This rule at least ensure that the cast doesn't hide any extra surprise.

*
* <noncompliant>
* fun foo(bar: Any?) {
* val x = bar as String
* }
* </noncompliant>
*
* <compliant>
* fun foo(bar: Any?) {
* val x = checkNotNull(bar) as String
* }
*
* // Alternative
* fun foo(bar: Any?) {
* val x = (bar ?: error("null assertion message")) as String
Copy link
Member

Choose a reason for hiding this comment

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

If would suggest something like:

val x = if (bar is String) ... else ...

Copy link
Contributor Author

@atulgpt atulgpt Jan 2, 2023

Choose a reason for hiding this comment

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

Hi @cortinico wouldn't val x = if (bar is String) ... else ... defeat the purpose of the rule? As if type of bar is Any? then the check bar is String can be false due to two reasons

  1. bar being null
  2. bar is not the type of String

hence there will be no clear and separate handling of null and wrong type. The initial idea was The idea is to write code that shows that it could fail for two things (nullability and type) and not just one (cast). at the issue description

@BraisGabin let me know your thoughts as well

Copy link
Member

Choose a reason for hiding this comment

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

I would vote to add two alternatives:

val x = bar!! as String
val x = bar as String?

Or if !! is so bad (I really like this operator):

val x = checkNotNull(bar) as String
val x = bar as String?

The idea of this rule is to make clear that there is a cast from nullable to non-nullable. The idea is to avoid it use as String? or make it completely clear. For that second part I think that !! does its job.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi, @BraisGabin I personally use (bar ?: error("null assertion message")) as String as I always prefer some custom error message. But I am also okay with val x = checkNotNull(bar) as String

I don't like !! much also this also gets flagged by UnsafeCallOnNullableType

Copy link
Member

Choose a reason for hiding this comment

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

hence there will be no clear and separate handling of null and wrong type

Which might very much be outside the scope of the rule.
The user receives a bar: Any? and wants to know if it can be used as a String.

Your code suggestion is too opinionated in failing on null state, which the user might not want to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @BraisGabin / @cortinico added below compliant block

* <compliant>
 * fun foo(bar: Any?) {
 *     val x = checkNotNull(bar) as String
 * }
 *
 * // Alternative
 * fun foo(bar: Any?) {
 *     val x = (bar ?: error("null assertion message")) as String
 * }
 * </compliant>

Copy link
Member

Choose a reason for hiding this comment

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

That works for me

* }
* </compliant>
*/
@RequiresTypeResolution
class CastNullableToNonNullableType(config: Config = Config.empty) : Rule(config) {
override val issue: Issue = Issue(
javaClass.simpleName,
Severity.Defect,
"Nullable type to non-null type cast is found. Consider using two assertions, " +
"`null` assertions and type cast",
Debt.FIVE_MINS
)

@Suppress("ReturnCount")
override fun visitBinaryWithTypeRHSExpression(expression: KtBinaryExpressionWithTypeRHS) {
atulgpt marked this conversation as resolved.
Show resolved Hide resolved
super.visitBinaryWithTypeRHSExpression(expression)

val operationReference = expression.operationReference
if (operationReference.getReferencedNameElementType() != KtTokens.AS_KEYWORD) return
if (expression.left.text == KtTokens.NULL_KEYWORD.value) return
val typeElement = expression.right?.typeElement ?: return
(typeElement is KtNullableType).ifTrue { return }
val compilerResourcesNonNull = compilerResources ?: return
expression.left.isNullable(
bindingContext,
compilerResourcesNonNull.languageVersionSettings,
compilerResourcesNonNull.dataFlowValueFactory,
shouldConsiderPlatformTypeAsNullable = true,
).ifFalse { return }

val message =
"Use separate `null` assertion and type cast like ('(${expression.left.text} ?: " +
"error(\"null assertion message\")) as ${typeElement.text}') instead of '${expression.text}'."
report(CodeSmell(issue, Entity.from(operationReference), message))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,19 @@ 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 io.gitlab.arturbosch.detekt.rules.getDataFlowAwareTypes
import io.gitlab.arturbosch.detekt.rules.isNullable
import io.gitlab.arturbosch.detekt.rules.safeAs
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import org.jetbrains.kotlin.descriptors.CallableDescriptor
import org.jetbrains.kotlin.diagnostics.Errors
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtQualifiedExpression
import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression
import org.jetbrains.kotlin.psi.KtSimpleNameExpression
import org.jetbrains.kotlin.psi.KtStringTemplateEntry
import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType
import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall
import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameOrNull
import org.jetbrains.kotlin.types.isFlexible
import org.jetbrains.kotlin.types.isNullable

/**
* Reports `toString()` calls with a nullable receiver that may return the string "null".
Expand Down Expand Up @@ -69,8 +65,18 @@ class NullableToStringCall(config: Config = Config.empty) : Rule(config) {
simpleOrCallExpression.descriptor()?.fqNameOrNull() == toString
) {
report(targetExpression)
} else if (targetExpression.parent is KtStringTemplateEntry && targetExpression.isNullable()) {
report(targetExpression.parent)
} else if (targetExpression.parent is KtStringTemplateEntry) {
compilerResources?.let { compilerResources ->
if (targetExpression.isNullable(
bindingContext,
compilerResources.languageVersionSettings,
compilerResources.dataFlowValueFactory,
shouldConsiderPlatformTypeAsNullable = false,
)
) {
report(targetExpression.parent)
}
}
}
}

Expand All @@ -85,25 +91,6 @@ class NullableToStringCall(config: Config = Config.empty) : Rule(config) {
return targetExpression
}

private fun KtExpression.isNullable(): Boolean {
val compilerResources = compilerResources ?: return false

val safeAccessOperation = safeAs<KtSafeQualifiedExpression>()?.operationTokenNode?.safeAs<PsiElement>()
if (safeAccessOperation != null) {
return bindingContext.diagnostics.forElement(safeAccessOperation).none {
it.factory == Errors.UNNECESSARY_SAFE_CALL
}
}
val originalType = descriptor()?.returnType?.takeIf { it.isNullable() && !it.isFlexible() } ?: return false
val dataFlowTypes = getDataFlowAwareTypes(
bindingContext,
compilerResources.languageVersionSettings,
compilerResources.dataFlowValueFactory,
originalType
)
return dataFlowTypes.all { it.isNullable() }
}

private fun report(element: PsiElement) {
val codeSmell = CodeSmell(
issue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class PotentialBugProvider : DefaultRuleSetProvider {
NullableToStringCall(config),
UnreachableCatchBlock(config),
CastToNullableType(config),
CastNullableToNonNullableType(config),
UnusedUnaryOperator(config)
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package io.gitlab.arturbosch.detekt.rules.bugs

import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
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 CastNullableToNonNullableTypeSpec(private val env: KotlinCoreEnvironment) {
private val subject = CastNullableToNonNullableType()

@Test
fun `reports casting Nullable type to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = bar as String
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
assertThat(findings).hasStartSourceLocation(2, 17)
assertThat(findings[0]).hasMessage(
"Use separate `null` assertion and type cast like " +
"('(bar ?: error(\"null assertion message\")) as String') instead of 'bar as String'."
)
}

@Test
fun `reports casting Nullable value returned from a function call to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
bar() as Int
}

fun bar(): Int? {
return null
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
assertThat(findings).hasStartSourceLocation(2, 11)
assertThat(findings[0]).hasMessage(
"Use separate `null` assertion and type cast like " +
"('(bar() ?: error(\"null assertion message\")) as Int') instead of 'bar() as Int'."
)
}

@Test
fun `reports casting of platform type to NonNullable type`() {
val code = """
class Foo {
fun test() {
// getSimpleName() is not annotated with nullability information in the JDK, so compiler treats
// it as a platform type with unknown nullability.
val y = javaClass.simpleName as String
}
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
assertThat(findings).hasStartSourceLocation(5, 38)
assertThat(findings[0]).hasMessage(
"Use separate `null` assertion and type cast like " +
"('(javaClass.simpleName ?: error(\"null assertion message\")) as String') instead of " +
"'javaClass.simpleName as String'."
)
}

@Test
fun `does not report unnecessary safe check chain to NonNullable type`() {
val code = """
class Foo {
fun test() {
val z = 1?.and(2) as Int
}
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report casting of Nullable type to NonNullable expression with assertion to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = (bar ?: error("null assertion message")) as String
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report casting of Nullable type to NonNullable expression with !! assertion to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = bar!! as String
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report casting of Nullable type to NonNullable smart casted variable to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = bar?.let { bar as String }
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report casting of NonNullable type to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = bar as String?
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report safe casting of Nullable type to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = bar as? String
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `does not report as compile error will happen when null to NonNullable type`() {
val code = """
fun foo(bar: Any?) {
val x = null as String
}
""".trimIndent()
val findings = subject.compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}
}