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

Allow users and rule authors to specify a reason per configured value #4611

Merged
merged 28 commits into from Jun 1, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
60023f3
implement first idea on values with reasons
marschwar Jan 20, 2022
83d9398
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Jan 22, 2022
a736879
have ExplainedValues implement List by delegation
Jan 23, 2022
ea85090
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Jan 23, 2022
fc68d64
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Feb 1, 2022
3f14934
Parse explained value defaults
Feb 9, 2022
6cbb99f
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Feb 9, 2022
1de89d6
fix error message
Feb 9, 2022
304e23a
Merge remote-tracking branch 'upstream/main' into feature/values-with…
marschwar Feb 18, 2022
0bbcdc6
[WIP] support ExplainedValues in yaml
marschwar Feb 18, 2022
0991416
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Mar 5, 2022
f19b593
print markdown value in DefaultValue directly
Mar 5, 2022
5544c0d
quote values in yaml maps
Mar 6, 2022
87f1ea5
Update detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/Con…
marschwar Mar 7, 2022
d8010b6
Update detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/Con…
marschwar Mar 7, 2022
8b24d41
add test case for missing reason
Mar 7, 2022
bd0261e
fail fast in case of invalid configuration
Mar 7, 2022
0156a4c
Merge branch 'main' into feature/values-with-reasons
Mar 21, 2022
204aaec
rename explainedValues to valuesWithReason
Mar 21, 2022
c2eec92
add api documentation
Mar 21, 2022
edcf8c3
resolve and suppress code smells
Mar 21, 2022
ecbce9f
suppress UNCHECKED_CAST
Mar 21, 2022
c0b1038
Merge remote-tracking branch 'upstream/main' into feature/values-with…
Apr 21, 2022
fe99851
Merge remote-tracking branch 'upstream/main' into feature/values-with…
May 22, 2022
8b4a23c
merge RuleCollectorSpec
May 24, 2022
23efa5a
reformat spec
May 24, 2022
4d2d279
fix indentation of test code
May 29, 2022
b27a563
Merge remote-tracking branch 'upstream/main' into feature/values-with…
May 29, 2022
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
28 changes: 28 additions & 0 deletions detekt-api/api/detekt-api.api
Expand Up @@ -616,3 +616,31 @@ public abstract interface annotation class io/gitlab/arturbosch/detekt/api/Unsta
public abstract fun reason ()Ljava/lang/String;
}

public final class io/gitlab/arturbosch/detekt/api/ValueWithReason {
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/gitlab/arturbosch/detekt/api/ValueWithReason;
public static synthetic fun copy$default (Lio/gitlab/arturbosch/detekt/api/ValueWithReason;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/gitlab/arturbosch/detekt/api/ValueWithReason;
public fun equals (Ljava/lang/Object;)Z
public final fun getReason ()Ljava/lang/String;
public final fun getValue ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/gitlab/arturbosch/detekt/api/ValuesWithReason : java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker {
public final fun copy (Ljava/util/List;)Lio/gitlab/arturbosch/detekt/api/ValuesWithReason;
public static synthetic fun copy$default (Lio/gitlab/arturbosch/detekt/api/ValuesWithReason;Ljava/util/List;ILjava/lang/Object;)Lio/gitlab/arturbosch/detekt/api/ValuesWithReason;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public fun iterator ()Ljava/util/Iterator;
public fun toString ()Ljava/lang/String;
}

public final class io/gitlab/arturbosch/detekt/api/ValuesWithReasonKt {
public static final fun valuesWithReason (Ljava/util/List;)Lio/gitlab/arturbosch/detekt/api/ValuesWithReason;
public static final fun valuesWithReason ([Lkotlin/Pair;)Lio/gitlab/arturbosch/detekt/api/ValuesWithReason;
}

Expand Up @@ -111,14 +111,8 @@ fun <T : Any, U : Any> configWithAndroidVariants(
private fun <T : Any> getValueOrDefault(configAware: ConfigAware, propertyName: String, defaultValue: T): T {
@Suppress("UNCHECKED_CAST")
return when (defaultValue) {
is List<*> -> {
if (defaultValue.all { it is String }) {
val defaultValueAsListOfStrings = defaultValue as List<String>
configAware.valueOrDefaultCommaSeparated(propertyName, defaultValueAsListOfStrings) as T
} else {
error("Only lists of strings are supported. '$propertyName' is invalid. ")
}
}
is ValuesWithReason -> configAware.getValuesWithReasonOrDefault(propertyName, defaultValue) as T
is List<*> -> configAware.getListOrDefault(propertyName, defaultValue) as T
is String,
is Boolean,
is Int -> configAware.valueOrDefault(propertyName, defaultValue)
Expand All @@ -129,6 +123,45 @@ private fun <T : Any> getValueOrDefault(configAware: ConfigAware, propertyName:
}
}

private fun ConfigAware.getListOrDefault(propertyName: String, defaultValue: List<*>): List<String> {
return if (defaultValue.all { it is String }) {
@Suppress("UNCHECKED_CAST")
val defaultValueAsListOfStrings = defaultValue as List<String>
valueOrDefaultCommaSeparated(propertyName, defaultValueAsListOfStrings)
} else {
error("Only lists of strings are supported. '$propertyName' is invalid. ")
}
}

private fun ConfigAware.getValuesWithReasonOrDefault(
propertyName: String,
defaultValue: ValuesWithReason
): ValuesWithReason {
val valuesAsList: List<*> = valueOrNull(propertyName) ?: return defaultValue
if (valuesAsList.all { it is String }) {
return ValuesWithReason(values = valuesAsList.map { ValueWithReason(it as String) })
}
if (valuesAsList.all { it is Map<*, *> }) {
return ValuesWithReason(
valuesAsList
.map { it as Map<*, *> }
.map { dict ->
try {
ValueWithReason(
value = dict["value"] as String,
reason = dict["reason"] as String?
)
} catch (e: ClassCastException) {
throw Config.InvalidConfigurationError(e)
} catch (@Suppress("TooGenericExceptionCaught") e: NullPointerException) {
throw Config.InvalidConfigurationError(e)
}
}
)
}
error("Only lists of strings or maps with keys 'value' and 'reason' are supported. '$propertyName' is invalid.")
}

private abstract class MemoizedConfigProperty<U : Any> : ReadOnlyProperty<ConfigAware, U> {
private var value: U? = null

Expand Down
@@ -0,0 +1,42 @@
package io.gitlab.arturbosch.detekt.api

/**
* This factory method can be used by rule authors to specify one or many configuration values along with an
* explanation for each value. For example:
*
* ```kotlin
* @Configuration("imports which should not be used")
* private val imports: ValuesWithReason by config(
* valuesWithReason("org.junit.Test" to "Do not use Junit4. Use org.junit.jupiter.api.Test instead.")
* )
* ```
*
* Note that the [config] property delegate only supports the factory methods when defining [ValuesWithReason].
*/
fun valuesWithReason(vararg values: Pair<String, String?>): ValuesWithReason {
return valuesWithReason(values.map { ValueWithReason(it.first, it.second) })
}

/**
* This factory method can be used by rule authors to specify one or many configuration values along with an
* explanation for each value.
*
* Note that the [config] property delegate only supports the factory methods when defining [ValuesWithReason].
*/
fun valuesWithReason(values: List<ValueWithReason>): ValuesWithReason {
return ValuesWithReason(values)
}

/**
* [ValuesWithReason] is essentially the same as [List] of [ValueWithReason]. Due to type erasure we cannot use the
* list directly. Instances of this type should always created using the [valuesWithReason] factory method.
*/
data class ValuesWithReason internal constructor(private val values: List<ValueWithReason>) :
Iterable<ValueWithReason> by values

/**
* A ValueWithReason represents a single configuration value that may have an explanation as to why it is used.
* @property value the actual value that is configured
* @property reason an optional explanation for the configured value
*/
data class ValueWithReason(val value: String, val reason: String? = null)
Expand Up @@ -3,6 +3,7 @@ package io.gitlab.arturbosch.detekt.api
import io.gitlab.arturbosch.detekt.test.TestConfig
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.assertj.core.api.Assertions.tuple
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
Expand Down Expand Up @@ -145,6 +146,104 @@ class ConfigPropertySpec {
}
}
}

@Nested
inner class `ValuesWithReason property` {
private val defaultValue = valuesWithReason("aValue" to "aReason")

@Nested
inner class `value defined as list` {
private val subject = object : TestConfigAware("present" to listOf("a", "b", "c")) {
val present: ValuesWithReason by config(defaultValue)
val notPresent: ValuesWithReason by config(defaultValue)
}

@Test
fun `uses the value provided in config if present`() {
assertThat(subject.present)
.extracting(ValueWithReason::value, ValueWithReason::reason)
.containsExactly(tuple("a", null), tuple("b", null), tuple("c", null))
}

@Test
fun `uses the default value if not present`() {
assertThat(subject.notPresent).isEqualTo(defaultValue)
}
}

@Nested
inner class `value defined as list of maps` {
private val subject = object : TestConfigAware(
"present" to listOf(
mapOf("value" to "a", "reason" to "reasonA"),
mapOf("value" to "b", "reason" to null),
mapOf("value" to "c"),
)
) {
val present: ValuesWithReason by config(defaultValue)
val notPresent: ValuesWithReason by config(defaultValue)
}

@Test
fun `uses the value provided in config if present`() {
assertThat(subject.present)
.extracting(ValueWithReason::value, ValueWithReason::reason)
.containsExactly(
tuple("a", "reasonA"),
tuple("b", null),
tuple("c", null)
)
}

@Test
fun `uses the default value if not present`() {
assertThat(subject.notPresent).isEqualTo(defaultValue)
}
}

@Nested
inner class `value defined as list of maps with invalid data` {

@Test
fun `value missing`() {
assertThatThrownBy {
object : TestConfigAware(
"present" to listOf(
mapOf("reason" to "reason")
)
) {
val present: ValuesWithReason by config(defaultValue)
}.present
}.isInstanceOf(Config.InvalidConfigurationError::class.java)
}

@Test
fun `value with an invalid type`() {
assertThatThrownBy {
object : TestConfigAware(
"present" to listOf(
mapOf("value" to 42, "reason" to "reason")
)
) {
val present: ValuesWithReason by config(defaultValue)
}.present
}.isInstanceOf(Config.InvalidConfigurationError::class.java)
}

@Test
fun `reason with an invalid type`() {
assertThatThrownBy {
object : TestConfigAware(
"present" to listOf(
mapOf("value" to "a", "reason" to 42)
)
) {
val present: ValuesWithReason by config(defaultValue)
}.present
}.isInstanceOf(Config.InvalidConfigurationError::class.java)
}
}
}
}

@Nested
Expand Down
Expand Up @@ -126,6 +126,46 @@ class YamlConfigSpec {
assertThat(config).isNotNull
}

@Nested
inner class `Values with reason` {
private val config = YamlConfig.load(resourceAsPath("values-with-reason.yml"))

@Test
fun `can be parsed`() {
assertThat(config).isNotNull
}

@Test
fun `supports lists`() {
val actualAsList: List<*>? = config
.subConfig("style")
.subConfig("AsList")
.valueOrNull("values")
assertThat(actualAsList).hasSize(3)
}

@Test
fun `supports dictionaries`() {
val actualAsMap: List<Map<*, *>>? = config
.subConfig("style")
.subConfig("AsListOfMaps")
.valueOrNull("values")
assertThat(actualAsMap)
.hasSize(3)
}

@Test
fun `supports empty dictionaries`() {
val actualAsMap: List<Map<*, *>>? = config
.subConfig("style")
.subConfig("EmptyListOfMaps")
.valueOrNull("values")
assertThat(actualAsMap)
.isNotNull
.isEmpty()
}
}

@Test
fun `throws an exception on an non-existing file`() {
val path = Paths.get("doesNotExist.yml")
Expand Down
15 changes: 15 additions & 0 deletions detekt-core/src/test/resources/values-with-reason.yml
@@ -0,0 +1,15 @@
style:
AsList:
values:
- a
- b
- c
AsListOfMaps:
values:
- value: a
reason: reason A
- value: b
- value: c
reason: reason C
EmptyListOfMaps:
values: []
Expand Up @@ -8,8 +8,4 @@ data class Configuration(
val deprecated: String?
) {
fun isDeprecated() = deprecated != null

fun isDefaultValueNonEmptyList() = defaultValue.isNonEmptyList()

fun getDefaultValueAsList(): List<String> = defaultValue.getAsList()
}