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

ForbiddenAnnotation rule #2719 #5515

Merged
merged 20 commits into from
Nov 29, 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
17 changes: 17 additions & 0 deletions detekt-core/src/main/resources/default-detekt-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,23 @@ style:
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenAnnotation:
active: false
annotations:
- reason: 'it is a java annotation. Use `Suppress` instead.'
value: 'java.lang.SuppressWarnings'
TWiStErRob marked this conversation as resolved.
Show resolved Hide resolved
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
value: 'java.lang.Deprecated'
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
value: 'java.lang.annotation.Documented'
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
value: 'java.lang.annotation.Target'
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
value: 'java.lang.annotation.Retention'
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
value: 'java.lang.annotation.Repeatable'
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
value: 'java.lang.annotation.Inherited'
ForbiddenComment:
active: true
values:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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.Location
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import io.gitlab.arturbosch.detekt.api.ValueWithReason
import io.gitlab.arturbosch.detekt.api.config
import io.gitlab.arturbosch.detekt.api.internal.Configuration
import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution
import io.gitlab.arturbosch.detekt.api.valuesWithReason
import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtAnnotationEntry
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtTypeReference
import org.jetbrains.kotlin.psi.psiUtil.endOffset
import org.jetbrains.kotlin.resolve.BindingContext
import org.jetbrains.kotlin.types.KotlinType

/**
* This rule allows to set a list of forbidden annotations. This can be used to discourage the use
* of language annotations which do not require explicit import.
*
* <noncompliant>
* @@SuppressWarnings("unused")
* class SomeClass()
* </noncompliant>
Comment on lines +30 to +33
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 keep both a compliant and a noncompliant block here.
Maybe show the @Deprecated one or others

*
* <compliant>
* @@Suppress("unused")
* class SomeClass()
* </compliant>
*/
@RequiresTypeResolution
class ForbiddenAnnotation(config: Config = Config.empty) : Rule(config) {

override val issue = Issue(
javaClass.simpleName,
Severity.Style,
"Avoid using this annotation.",
Debt.FIVE_MINS
)

@Configuration(
"List of fully qualified annotation classes which are forbidden."
)
private val annotations: Map<String, ValueWithReason> by config(
valuesWithReason(
"java.lang.SuppressWarnings" to "it is a java annotation. Use `Suppress` instead.",
"java.lang.Deprecated" to "it is a java annotation. Use `kotlin.Deprecated` instead.",
"java.lang.annotation.Documented" to "it is a java annotation. " +
"Use `kotlin.annotation.MustBeDocumented` instead.",
"java.lang.annotation.Target" to "it is a java annotation. Use `kotlin.annotation.Target` instead.",
"java.lang.annotation.Retention" to "it is a java annotation. Use `kotlin.annotation.Retention` instead.",
"java.lang.annotation.Repeatable" to "it is a java annotation. Use `kotlin.annotation.Repeatable` instead.",
"java.lang.annotation.Inherited" to "Kotlin does not support @Inherited annotation, " +
"see https://youtrack.jetbrains.com/issue/KT-22265",
)
) { list ->
list.associateBy { it.value }
}

override fun visitAnnotationEntry(annotation: KtAnnotationEntry) {
super.visitAnnotationEntry(annotation)

annotation.typeReference?.fqNameOrNull()?.let {
check(annotation, it)
}
}

override fun visitExpression(expression: KtExpression) {
super.visitExpression(expression)

expression.expressionTypeOrNull()?.fqNameOrNull()?.let {
check(expression, it)
}
}

private fun check(element: KtElement, fqName: FqName) {
val forbidden = annotations[fqName.asString()]

if (forbidden != null) {
val message = if (forbidden.reason != null) {
"The annotation `${forbidden.value}` has been forbidden: ${forbidden.reason}"
} else {
"The annotation `${forbidden.value}` has been forbidden in the detekt config."
}
val location = Location.from(element).let { location ->
location.copy(
text = location.text.copy(
end = element.children.firstOrNull()?.endOffset ?: location.text.end
)
)
}
report(CodeSmell(issue, Entity.from(element, location), message))
}
}

private fun KtTypeReference.fqNameOrNull(): FqName? =
bindingContext[BindingContext.TYPE, this]?.fqNameOrNull()

private fun KtExpression.expressionTypeOrNull(): KotlinType? =
bindingContext[BindingContext.EXPRESSION_TYPE_INFO, this]?.type
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class StyleGuideProvider : DefaultRuleSetProvider {
NoTabs(config),
EqualsOnSignatureLine(config),
EqualsNullCall(config),
ForbiddenAnnotation(config),
ForbiddenComment(config),
ForbiddenImport(config),
ForbiddenMethodCall(config),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package io.gitlab.arturbosch.detekt.rules.style

import io.gitlab.arturbosch.detekt.api.SourceLocation
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

private const val ANNOTATIONS = "annotations"

@KotlinCoreEnvironmentTest
class ForbiddenAnnotationSpec(val env: KotlinCoreEnvironment) {
TWiStErRob marked this conversation as resolved.
Show resolved Hide resolved
TWiStErRob marked this conversation as resolved.
Show resolved Hide resolved
TWiStErRob marked this conversation as resolved.
Show resolved Hide resolved
TWiStErRob marked this conversation as resolved.
Show resolved Hide resolved

@Test
fun `should report SuppressWarnings usages by default`() {
val code = """
@SuppressWarnings("unused")
fun main() {}
""".trimIndent()
val findings = ForbiddenAnnotation(TestConfig()).compileAndLintWithContext(env, code)

assertThat(findings)
.hasSize(1)
.hasStartSourceLocations(
SourceLocation(1, 1)
)
.hasTextLocations("@SuppressWarnings")
.extracting("message")
.containsExactly(
"The annotation `java.lang.SuppressWarnings` has been forbidden: it is a java annotation. Use `Suppress` instead.",
)
}

@Test
fun `should report annotations from java lang annotation package by default`() {
val code = """
import java.lang.annotation.Retention
import java.lang.annotation.Documented
import java.lang.annotation.Target
import java.lang.annotation.Repeatable
import java.lang.annotation.Inherited
import java.lang.annotation.RetentionPolicy
import java.lang.annotation.ElementType
import java.lang.Deprecated
@Deprecated
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Repeatable(value = SomeClass::class)
@Inherited
annotation class SomeClass(val value: Array<SomeClass>)
""".trimIndent()
val findings = ForbiddenAnnotation(TestConfig()).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(6)
.hasTextLocations(
"@Deprecated",
"@Documented",
"@Retention",
"@Target",
"@Repeatable",
"@Inherited"
)
}
BraisGabin marked this conversation as resolved.
Show resolved Hide resolved

@Test
fun `should report nothing when annotations do not match`() {
val code = """
@SuppressWarnings("unused")
fun main() {}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("kotlin.jvm.Transient")))
).compileAndLintWithContext(env, code)
assertThat(findings).isEmpty()
}

@Test
fun `should report annotation call when the fully qualified name is used`() {
val code = """
@java.lang.SuppressWarnings("unused")
fun main() {}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@java.lang.SuppressWarnings")
}

@Test
fun `should report multiple different annotations`() {
val code = """
@SuppressWarnings("unused")
data class SomeClass(
@Transient
@Volatile
var transient: String? = null
)
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(
mapOf(
ANNOTATIONS to listOf(
"java.lang.SuppressWarnings",
"kotlin.jvm.Transient",
"kotlin.jvm.Volatile"
)
)
)
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(3)
.hasTextLocations("@SuppressWarnings", "@Transient", "@Volatile")
}

@Test
fun `should report annotation on class`() {
val code = """
@SuppressWarnings("unused")
class SomeClass
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report annotation on method`() {
val code = """
class SomeClass {
@SuppressWarnings("unused")
fun someMethod(){}
}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report annotation on field`() {
val code = """
class SomeClass {
@SuppressWarnings("unused")
val someField: String = "lalala"
}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report annotation on function parameter`() {
val code = """
fun main(@SuppressWarnings("unused") args: Array<String>){}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report annotation on constructor`() {
val code = """
class SomeClass {
@SuppressWarnings("unused")
constructor(s: String)
constructor(t: Int)
}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report annotation on local variable`() {
val code = """
fun some(): Int {
@SuppressWarnings("unused")
val q = "1234"
return 10
}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("java.lang.SuppressWarnings")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@SuppressWarnings")
}

@Test
fun `should report nested annotations`() {
val code = """
import kotlin.Deprecated
@Deprecated(message = "unused", replaceWith = ReplaceWith("bar"))
fun foo() = "1234"
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("kotlin.ReplaceWith")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("ReplaceWith")
}

@Test
fun `should report aliased annotations`() {
val code = """
typealias Dep = java.lang.Deprecated
@Dep
fun f() = Unit
""".trimIndent()
val findings = ForbiddenAnnotation(TestConfig()).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@Dep")
}

@Test
fun `should report annotations for expressions`() {
val code = """
val x = 0 + @Suppress("UnnecessaryParentheses") (((1)+(2))) + 3
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("kotlin.Suppress")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@Suppress")
}

@Test
fun `should report annotations for blocks`() {
val code = """
fun f(list: List<String>) {
list.map @Suppress("x") { it.length }
}
""".trimIndent()
val findings = ForbiddenAnnotation(
TestConfig(mapOf(ANNOTATIONS to listOf("kotlin.Suppress")))
).compileAndLintWithContext(env, code)
assertThat(findings).hasSize(1)
.hasTextLocations("@Suppress")
}
}