Repository: detekt/detekt (kotlin)

Issue:
Allow mixed configurations for rules that use `valuesWithReason`
## Expected Behavior
I can use config like:

```yaml
  ForbiddenMethodCall:
    active: true
    methods:
      - 'kotlin.io.print'
      - value: 'org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule'
        reason: 'Not performant due to KT-71706. Use buildKspLibraryModule instead.'
```

## Current Behavior
```
> Analyzing C:\Users\user\IdeaProjects\detekt\detekt-test-utils\src\main\kotlin\io\github\detekt\test\utils\CompileExtensions.kt led to an exception.
  Location: io.gitlab.arturbosch.detekt.api.ConfigPropertyKt.getValuesWithReasonOrDefault(ConfigProperty.kt:160)
  The original exception message was: Only lists of strings or maps with keys 'value' and 'reason' are supported. 'methods' is invalid.
```

To avoid it, I have to do this:
```yaml
  ForbiddenMethodCall:
    active: true
    methods:
      - value: 'kotlin.io.print'
      - value: 'org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule'
        reason: 'Not performant due to KT-71706. Use buildKspLibraryModule instead.'
```

But requiring `value: ` for all listed methods should not be necessary if I only want to set the reason for a subset of methods.

I can also just leave out the reason:
```yaml
  ForbiddenMethodCall:
    active: true
    methods:
      - 'kotlin.io.print'
      - 'org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule'
```

But this isn't a good workaround because the reason is lost.

## Context
Configure value and reason only for methods that really need it.

Pull request:
Fixes: https://github.com/detekt/detekt/issues/8114

Patch:
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 38c5a1bff12d..31842efcad02 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -213,12 +213,12 @@ style:
   ForbiddenMethodCall:
     active: true
     methods:
-      - value: 'kotlin.io.print'
-      - value: 'kotlin.io.println'
-      - value: 'java.net.URL.openStream'
-      - value: 'java.lang.Class.getResourceAsStream'
-      - value: 'java.lang.ClassLoader.getResourceAsStream'
-      - value: 'kotlin.system.measureTimeMillis'
+      - 'kotlin.io.print'
+      - 'kotlin.io.println'
+      - 'java.net.URL.openStream'
+      - 'java.lang.Class.getResourceAsStream'
+      - 'java.lang.ClassLoader.getResourceAsStream'
+      - 'kotlin.system.measureTimeMillis'
       - value: 'org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule'
         reason: 'Not performant due to KT-71706. Use buildKspLibraryModule instead.'
       - value: 'org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSdkModule'
diff --git a/detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/ConfigProperty.kt b/detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/ConfigProperty.kt
index b34cf6a6c8ef..a9d9001ff0d2 100644
--- a/detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/ConfigProperty.kt
+++ b/detekt-api/src/main/kotlin/io/gitlab/arturbosch/detekt/api/ConfigProperty.kt
@@ -136,28 +136,31 @@ private fun Config.getValuesWithReasonOrDefault(
     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)
-                    }
-                }
+    if (!valuesAsList.all { it is String || it is Map<*, *> }) {
+        error(
+            "Only lists of strings and/or maps with keys 'value' and 'reason' are supported. " +
+                "'$propertyName' is invalid."
         )
     }
-    error("Only lists of strings or maps with keys 'value' and 'reason' are supported. '$propertyName' is invalid.")
+    return ValuesWithReason(
+        valuesAsList.map {
+            if (it is String) {
+                ValueWithReason(it)
+            } else {
+                val mapValue = it as Map<*, *>
+                try {
+                    ValueWithReason(
+                        value = mapValue["value"] as String,
+                        reason = mapValue["reason"] as String?
+                    )
+                } catch (e: ClassCastException) {
+                    throw Config.InvalidConfigurationError(e)
+                } catch (@Suppress("TooGenericExceptionCaught") e: NullPointerException) {
+                    throw Config.InvalidConfigurationError(e)
+                }
+            }
+        }
+    )
 }
 
 private abstract class MemoizedConfigProperty<U : Any> : ReadOnlyProperty<Rule, U> {


Test patch:
diff --git a/detekt-api/src/test/kotlin/io/gitlab/arturbosch/detekt/api/ConfigPropertySpec.kt b/detekt-api/src/test/kotlin/io/gitlab/arturbosch/detekt/api/ConfigPropertySpec.kt
index fc2327bd4419..7edec76d4ca2 100644
--- a/detekt-api/src/test/kotlin/io/gitlab/arturbosch/detekt/api/ConfigPropertySpec.kt
+++ b/detekt-api/src/test/kotlin/io/gitlab/arturbosch/detekt/api/ConfigPropertySpec.kt
@@ -121,6 +121,33 @@ class ConfigPropertySpec {
                     }
                 }
 
+                @Nested
+                inner class `value defined as list of string and map` {
+                    private val subject = object : TestRule(
+                        "present" to listOf(
+                            "a",
+                            mapOf("value" to "b", "reason" to "reasonB"),
+                            mapOf("value" to "c", "reason" to null),
+                            mapOf("value" to "d"),
+                        )
+                    ) {
+                        val present: ValuesWithReason by config(defaultValue)
+                    }
+
+                    @Test
+                    fun `uses the value provided in config if present`() {
+                        assertThat(subject.present)
+                            .hasSize(4)
+                            .extracting(ValueWithReason::value, ValueWithReason::reason)
+                            .containsExactly(
+                                tuple("a", null),
+                                tuple("b", "reasonB"),
+                                tuple("c", null),
+                                tuple("d", null),
+                            )
+                    }
+                }
+
                 @Nested
                 inner class `value defined as list of maps with invalid data` {
 
diff --git a/detekt-core/src/test/kotlin/io/gitlab/arturbosch/detekt/core/config/YamlConfigSpec.kt b/detekt-core/src/test/kotlin/io/gitlab/arturbosch/detekt/core/config/YamlConfigSpec.kt
index 315e1fdcee8c..f34438698d16 100644
--- a/detekt-core/src/test/kotlin/io/gitlab/arturbosch/detekt/core/config/YamlConfigSpec.kt
+++ b/detekt-core/src/test/kotlin/io/gitlab/arturbosch/detekt/core/config/YamlConfigSpec.kt
@@ -221,6 +221,24 @@ class YamlConfigSpec {
                     .isNotNull
                     .isEmpty()
             }
+
+            @Test
+            fun `supports mixed string and dictionary`() {
+                val actualAsMap: List<Map<*, *>>? = config
+                    .subConfig("style")
+                    .subConfig("MixedWithStringAndMaps")
+                    .valueOrNull("values")
+                assertThat(actualAsMap)
+                    .isNotNull
+                    .isNotEmpty
+                    .hasSize(6)
+                    .elements(0, 1)
+                    .hasOnlyElementsOfType(String::class.java)
+
+                assertThat(actualAsMap)
+                    .elements(2, 3, 4, 5)
+                    .hasOnlyElementsOfType(Map::class.java)
+            }
         }
 
         @Test
diff --git a/detekt-core/src/test/resources/values-with-reason.yml b/detekt-core/src/test/resources/values-with-reason.yml
index ea8fecd04c4f..8e4da703f5ee 100644
--- a/detekt-core/src/test/resources/values-with-reason.yml
+++ b/detekt-core/src/test/resources/values-with-reason.yml
@@ -13,3 +13,13 @@ style:
         reason: reason C
   EmptyListOfMaps:
     values: []
+  MixedWithStringAndMaps:
+    values:
+      - a
+      - b
+      - value: c
+      - value: d
+      - value: e
+        reason: reason E
+      - value: f
+        reason: reason F