Repository: ben-manes/gradle-versions-plugin (kotlin)

Issue:
Question: Setup outputFormatter using Closure in Kotlin
I am having problems setting up the outputFormatter property using a Close in Kotlin scripts.

This is the configuration I am using right now...

```kotlin
tasks.withType<DependencyUpdatesTask> {
    gradleReleaseChannel = "current"
    rejectVersionIf {
        isNonStable(candidate.version) && !isNonStable(currentVersion)
    }
    outputFormatter = { result: com.github.benmanes.gradle.versions.reporter.result.Result ->
        val updatable = result.outdated.dependencies
        if (!updatable.isEmpty()) {
            val writer = StringWriter()
            updatable.forEach { dep ->
                writer.write("> ${dep.group}:${dep.name}: ${dep.version} -> ${dep.available.release ?: dep.available.milestone}")
            }
            println(writer.toString())
        }
    }
}
```

... and the error I'm getting:
```
Cannot handle output formatter (com.github.benmanes.gradle.versions.reporter.result.Result) -> kotlin.Unit, unsupported type
```

I didn't find a complete example using Kotlin, any clue?

Pull request description:
Fixes https://github.com/ben-manes/gradle-versions-plugin/issues/584.

**Support Kotlin trailing lambda syntax for custom output formatters**

* This is done by offering a new API in which an output formatter can be specified as org.gradle.api.Action<Result>. Gradle automatically maps Groovy closures and Kotlin lambdas to arguments of that type (https://docs.gradle.org/current/userguide/kotlin_dsl.html#groovy_closures_from_kotlin).

* Internally, there have been some changes done to support this:
  - The type of the property that is used to set an output formatter is `Any`, and this isn't the nicer because tooling can't really hint users of the API with the correct types, and we also have to deal manually with the types of input.
  - The new type we wanted to support (Action<Result>) would run into type erasure problems if we used this existing property and we would need to have code casting the types in an unsafe way (as we can't know if a provided argument is Action<Result> or Action of something else).
  - To sort this out, we have kept the existing property for compatibility reasons (it can still be used to set String, Reporter or Closure output formatters) but we have added a new function to set Action<Result> output formatters. With this we achieve maintaining public API compatibility whilst adding support for the new Action<Result> output formatters with improved type support (internally, safe use of types, and externally, type hinting in tooling and clear public API signature).
  

**Testing**

*Manual*
Manually tested that we can now use the trailing lamdba syntax in Kotlin, and that everything works as before both in Groovy and Kotlin. Also verified that we get type hints in the IDE when using the new API:

![image](https://user-images.githubusercontent.com/20244357/189531113-96439cc7-f623-4d9e-9f6e-27afecd163a1.png)

*Automated*
Added a couple of tests covering the new changes.

Patch:
diff --git a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdates.kt b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdates.kt
index 946da78c2..d656be000 100644
--- a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdates.kt
+++ b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdates.kt
@@ -21,7 +21,7 @@ class DependencyUpdates @JvmOverloads constructor(
   val project: Project,
   val resolutionStrategy: Action<in ResolutionStrategyWithCurrent>?,
   val revision: String,
-  val outputFormatter: Any?,
+  private val outputFormatterArgument: OutputFormatterArgument,
   val outputDir: String,
   val reportfileName: String?,
   val checkForGradleUpdate: Boolean,
@@ -108,7 +108,7 @@ class DependencyUpdates @JvmOverloads constructor(
     val gradleUpdateChecker = GradleUpdateChecker(checkForGradleUpdate)
 
     return DependencyUpdatesReporter(
-      project, revision, outputFormatter, outputDir,
+      project, revision, outputFormatterArgument, outputDir,
       reportfileName, currentVersions, latestVersions, upToDateVersions, downgradeVersions,
       upgradeVersions, versions.undeclared, unresolved, projectUrls, gradleUpdateChecker,
       gradleReleaseChannel
diff --git a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesReporter.kt b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesReporter.kt
index 62ca39b1a..8e9ae0463 100644
--- a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesReporter.kt
+++ b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesReporter.kt
@@ -16,7 +16,6 @@ import com.github.benmanes.gradle.versions.updates.gradle.GradleReleaseChannel
 import com.github.benmanes.gradle.versions.updates.gradle.GradleUpdateChecker
 import com.github.benmanes.gradle.versions.updates.gradle.GradleUpdateResult
 import com.github.benmanes.gradle.versions.updates.gradle.GradleUpdateResults
-import groovy.lang.Closure
 import org.gradle.api.Project
 import org.gradle.api.artifacts.ModuleVersionSelector
 import org.gradle.api.artifacts.UnresolvedDependency
@@ -31,7 +30,7 @@ import java.util.TreeSet
  *
  * @property project The project evaluated against.
  * @property revision The revision strategy evaluated with.
- * @property outputFormatter The output formatter strategy evaluated with.
+ * @property outputFormatterArgument The output formatter strategy evaluated with.
  * @property outputDir The outputDir for report.
  * @property reportfileName The filename of the report file.
  * @property currentVersions The current versions of each dependency declared in the project(s).
@@ -50,7 +49,7 @@ import java.util.TreeSet
 class DependencyUpdatesReporter(
   val project: Project,
   val revision: String,
-  val outputFormatter: Any?,
+  private val outputFormatterArgument: OutputFormatterArgument,
   val outputDir: String,
   val reportfileName: String?,
   val currentVersions: Map<Map<String, String>, Coordinate>,
@@ -67,34 +66,32 @@ class DependencyUpdatesReporter(
 
   @Synchronized
   fun write() {
-    if (outputFormatter !is Closure<*>) {
+    if (outputFormatterArgument !is OutputFormatterArgument.CustomAction) {
       val plainTextReporter = PlainTextReporter(
         project, revision, gradleReleaseChannel
       )
       plainTextReporter.write(System.out, buildBaseObject())
     }
 
-    if (outputFormatter == null || (outputFormatter is String && outputFormatter.isEmpty())) {
+    if (outputFormatterArgument is OutputFormatterArgument.BuiltIn && outputFormatterArgument.formatterNames.isEmpty()) {
       project.logger.lifecycle("Skip generating report to file (outputFormatter is empty)")
       return
     }
-    when (outputFormatter) {
-      is String -> {
-        for (it in outputFormatter.split(",")) {
+
+    when (outputFormatterArgument) {
+      is OutputFormatterArgument.BuiltIn -> {
+        for (it in outputFormatterArgument.formatterNames.split(",")) {
           generateFileReport(getOutputReporter(it))
         }
       }
-      is Reporter -> {
-        generateFileReport(outputFormatter)
+
+      is OutputFormatterArgument.CustomReporter -> {
+        generateFileReport(outputFormatterArgument.reporter)
       }
-      is Closure<*> -> {
+
+      is OutputFormatterArgument.CustomAction -> {
         val result = buildBaseObject()
-        outputFormatter.call(result)
-      }
-      else -> {
-        throw IllegalArgumentException(
-          "Cannot handle output formatter $outputFormatter, unsupported type"
-        )
+        outputFormatterArgument.action.execute(result)
       }
     }
   }
diff --git a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/OutputFormatterArgument.kt b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/OutputFormatterArgument.kt
new file mode 100644
index 000000000..4035dd4b1
--- /dev/null
+++ b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/OutputFormatterArgument.kt
@@ -0,0 +1,32 @@
+package com.github.benmanes.gradle.versions.updates
+
+import com.github.benmanes.gradle.versions.reporter.Reporter
+import com.github.benmanes.gradle.versions.reporter.result.Result
+import org.gradle.api.Action
+
+/**
+ * Represents all the types of arguments for output formatting supported in [DependencyUpdatesTask].
+ */
+sealed interface OutputFormatterArgument {
+
+  /**
+   * A string representing one of the built-in output formatters (i.e. "json", "text", "html" or
+   * "xml"), or a comma-separated list with a combination of them (e.g. "json,text").
+   */
+  class BuiltIn(val formatterNames: String) : OutputFormatterArgument
+
+  /**
+   * An implementation of the [Reporter] interface to provide a custom output formatting.
+   */
+  class CustomReporter(val reporter: Reporter) : OutputFormatterArgument
+
+  /**
+   * An [Action] to provide a custom output formatting. Enables the use of the trailing closure/lambda
+   * syntax for output formatting definition.
+   */
+  class CustomAction(val action: Action<Result>) : OutputFormatterArgument
+
+  companion object {
+    val DEFAULT = BuiltIn(formatterNames = "text")
+  }
+}
diff --git a/gradle.properties b/gradle.properties
index e67a0d3dc..b44125528 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
 GROUP=com.github.ben-manes
-VERSION_NAME=0.42.0-SNAPSHOT
+VERSION_NAME=0.43.5-SNAPSHOT
 
 POM_INCEPTION_YEAR=2012
 POM_PACKAGING=jar


Test patch:
diff --git a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesTask.kt b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesTask.kt
index 137d92518..90f7ed070 100644
--- a/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesTask.kt
+++ b/gradle-versions-plugin/src/main/kotlin/com/github/benmanes/gradle/versions/updates/DependencyUpdatesTask.kt
@@ -1,5 +1,7 @@
 package com.github.benmanes.gradle.versions.updates
 
+import com.github.benmanes.gradle.versions.reporter.Reporter
+import com.github.benmanes.gradle.versions.reporter.result.Result
 import com.github.benmanes.gradle.versions.updates.gradle.GradleReleaseChannel.RELEASE_CANDIDATE
 import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ComponentFilter
 import com.github.benmanes.gradle.versions.updates.resolutionstrategy.ComponentSelectionWithCurrent
@@ -41,16 +43,45 @@ open class DependencyUpdatesTask : DefaultTask() { // tasks can't be final
   var reportfileName: String = "report"
     get() = (System.getProperties()["reportfileName"] ?: field) as String
 
-  @Internal
-  var outputFormatter: Any = "plain"
+  /**
+   * Sets an output formatting for the task result. It can either be a [String] referencing one of
+   * the existing output formatters (i.e. "text", "xml", "json" or "html"), a [String] containing a
+   * comma-separated list with any combination of the existing output formatters (e.g. "xml,json"),
+   * or a [Reporter]/a [Closure] with a custom output formatting implementation.
+   *
+   * Use the [outputFormatter] function as an alternative to set a custom output formatting using
+   * the trailing closure/lambda syntax.
+   */
+  var outputFormatter: Any?
+    @Internal get() = null
+    set(value) {
+      outputFormatterArgument = when (value) {
+        is String -> OutputFormatterArgument.BuiltIn(value)
+        is Reporter -> OutputFormatterArgument.CustomReporter(value)
+        // Kept for retro-compatibility with "outputFormatter = {}" usages.
+        is Closure<*> -> OutputFormatterArgument.CustomAction { value.call(it) }
+        else -> throw IllegalArgumentException(
+          "Unsupported output formatter provided $value. Please use a String, a Reporter/Closure, " +
+            "or alternatively provide a function using the `outputFormatter(Action<Result>)` API."
+        )
+      }
+    }
+
+  /**
+   * Keeps a reference to the latest [OutputFormatterArgument] provided either via the [outputFormatter]
+   * property or the [outputFormatter] function.
+   */
+  private var outputFormatterArgument: OutputFormatterArgument = OutputFormatterArgument.DEFAULT
 
   @Input
   @Optional
   fun getOutputFormatterName(): String? {
-    return if (outputFormatter is String) {
-      outputFormatter as String
-    } else {
-      null
+    return with(outputFormatterArgument) {
+      if (this is OutputFormatterArgument.BuiltIn) {
+        formatterNames
+      } else {
+        null
+      }
     }
   }
 
@@ -125,8 +156,20 @@ open class DependencyUpdatesTask : DefaultTask() { // tasks can't be final
   }
 
   /** Returns the outputDir format. */
-  private fun outputFormatter(): Any {
-    return (System.getProperties()["outputFormatter"] ?: outputFormatter)
+  private fun outputFormatter(): OutputFormatterArgument {
+    val outputFormatterProperty = System.getProperties()["outputFormatter"] as? String
+
+    return outputFormatterProperty?.let { OutputFormatterArgument.BuiltIn(it) }
+      ?: outputFormatterArgument
+  }
+
+  /**
+   * Sets a custom output formatting for the task result.
+   *
+   * @param action [Action] implementing the desired custom output formatting.
+   */
+  fun outputFormatter(action: Action<Result>) {
+    outputFormatterArgument = OutputFormatterArgument.CustomAction(action)
   }
 
   private fun callIncompatibleWithConfigurationCache() {
diff --git a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/DependencyUpdatesSpec.groovy b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/DependencyUpdatesSpec.groovy
index cbbbe9581..0bb694371 100644
--- a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/DependencyUpdatesSpec.groovy
+++ b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/DependencyUpdatesSpec.groovy
@@ -15,6 +15,9 @@
  */
 package com.github.benmanes.gradle.versions
 
+import com.github.benmanes.gradle.versions.updates.OutputFormatterArgument
+import org.gradle.api.Action
+
 import static com.github.benmanes.gradle.versions.updates.gradle.GradleReleaseChannel.CURRENT
 import static com.github.benmanes.gradle.versions.updates.gradle.GradleReleaseChannel.RELEASE_CANDIDATE
 
@@ -300,7 +303,7 @@ final class DependencyUpdatesSpec extends Specification {
     }
   }
 
-  def 'Single project with a Closure as Reporter'() {
+  def 'Single project with an Action<Result> as Reporter'() {
     given:
     def project = singleProject()
     addRepositoryTo(project)
@@ -311,13 +314,13 @@ final class DependencyUpdatesSpec extends Specification {
     int undeclared = -1
     int unresolved = -1
 
-    def customReporter = { result ->
+    def customReporter = [execute: { result ->
       current = result.current.count
       outdated = result.outdated.count
       exceeded = result.exceeded.count
       undeclared = result.undeclared.count
       unresolved = result.unresolved.count
-    }
+    }] as Action<Result>
 
     when:
     def reporter = evaluate(project, 'release', customReporter)
@@ -620,13 +623,25 @@ final class DependencyUpdatesSpec extends Specification {
     [rootProject, childProject, leafProject]
   }
 
-  private static def evaluate(project, revision = 'milestone', outputFormatter = 'plain',
+  private static def evaluate(project, revision = 'milestone', outputFormatter = null,
     outputDir = 'build', resolutionStrategy = null, reportfileName = null,
     checkForGradleUpdate = true, gradleReleaseChannel = RELEASE_CANDIDATE.id) {
-    new DependencyUpdates(project, resolutionStrategy, revision, outputFormatter, outputDir,
+    new DependencyUpdates(project, resolutionStrategy, revision, buildOutputFormatter(outputFormatter), outputDir,
       reportfileName, checkForGradleUpdate, gradleReleaseChannel).run()
   }
 
+  private static OutputFormatterArgument buildOutputFormatter(outputFormatter) {
+    if (outputFormatter instanceof String) {
+      return new OutputFormatterArgument.BuiltIn(outputFormatter)
+    } else if (outputFormatter instanceof Reporter) {
+      return new OutputFormatterArgument.CustomReporter(outputFormatter)
+    } else if (outputFormatter instanceof Action<Result>) {
+      return new OutputFormatterArgument.CustomAction(outputFormatter)
+    } else {
+      return new OutputFormatterArgument.BuiltIn("text")
+    }
+  }
+
   private void addRepositoryTo(project) {
     def localMavenRepo = getClass().getResource('/maven/')
     project.repositories {
diff --git a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/KotlinDslUsageSpec.groovy b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/KotlinDslUsageSpec.groovy
index e6af95e8d..b227d9514 100644
--- a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/KotlinDslUsageSpec.groovy
+++ b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/KotlinDslUsageSpec.groovy
@@ -73,4 +73,48 @@ final class KotlinDslUsageSpec extends Specification {
     where:
     gradleVersion << ['5.6']
   }
+
+  @Unroll
+  def "user friendly kotlin-dsl with #outputFormatter produces #expectedOutput"() {
+    given:
+    buildFile << """
+      tasks.named<DependencyUpdatesTask>("dependencyUpdates") {
+          checkForGradleUpdate = true
+          $outputFormatter
+          outputDir = "build/dependencyUpdates"
+          reportfileName = "report"
+          resolutionStrategy {
+            componentSelection {
+              all {
+                if (candidate.version == "3.1" && currentVersion != "") {
+                  reject("Guice 3.1 not allowed")
+                }
+              }
+            }
+          }
+        }
+    """
+
+    when:
+    def result = GradleRunner.create()
+      .withGradleVersion('5.6')
+      .withPluginClasspath()
+      .withProjectDir(testProjectDir.root)
+      .withArguments('dependencyUpdates')
+      .build()
+
+    then:
+    result.output.contains(expectedOutput)
+    result.task(':dependencyUpdates').outcome == SUCCESS
+
+    where:
+    outputFormatter << [
+      'outputFormatter = "json"',
+      'outputFormatter { print("Custom report") }'
+    ]
+    expectedOutput << [
+      'com.google.inject:guice [2.0 -> 3.0]',
+      'Custom report'
+    ]
+  }
 }
diff --git a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/OutputFormatterSpec.groovy b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/OutputFormatterSpec.groovy
index 262244202..c1359ab70 100644
--- a/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/OutputFormatterSpec.groovy
+++ b/gradle-versions-plugin/src/test/groovy/com/github/benmanes/gradle/versions/OutputFormatterSpec.groovy
@@ -73,6 +73,45 @@ final class OutputFormatterSpec extends Specification {
     result.task(':dependencyUpdates').outcome == SUCCESS
   }
 
+  def 'Build fails when unsupported outputFormatter is provided'() {
+    given:
+    buildFile = testProjectDir.newFile('build.gradle')
+    buildFile <<
+      """
+        buildscript {
+          dependencies {
+            classpath files($classpathString)
+          }
+        }
+
+        apply plugin: 'java'
+        apply plugin: 'com.github.ben-manes.versions'
+
+        repositories {
+          maven {
+            url '${mavenRepoUrl}'
+          }
+        }
+
+        dependencies {
+          implementation 'com.google.inject:guice:2.0'
+        }
+
+        dependencyUpdates {
+          outputFormatter = 13
+          checkForGradleUpdate = false // future proof tests from breaking
+        }
+        """.stripIndent()
+
+    when:
+    def runner = GradleRunner.create()
+      .withProjectDir(testProjectDir.root)
+      .withArguments('dependencyUpdates')
+
+    then:
+    runner.buildAndFail()
+  }
+
   def 'outputFormatter defaults to text output'() {
     given:
     def reportFile = new File(reportFolder, "report.txt")