Skip to content

Commit

Permalink
Support Kotlin trailing lambda syntax for custom output formatters (#690
Browse files Browse the repository at this point in the history
)

* 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).
  • Loading branch information
vjgarciag96 committed Sep 11, 2022
1 parent 682a0ac commit cc714f8
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 33 deletions.
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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>,
Expand All @@ -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)
}
}
}
Expand Down
@@ -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
Expand Down Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -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() {
Expand Down
@@ -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")
}
}
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Expand Up @@ -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'
]
}
}
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion 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
Expand Down

0 comments on commit cc714f8

Please sign in to comment.