Configuring a custom task with a collection using a DSL

I’m writing a custom task that needs to be configured with a collection of domain objects, and I’m struggling with how to make this work with a DSL, lazy properties and incremental builds. Here’s what I’d like to be able to write in my build:

val ico by tasks.registering(com.gitlab.svg2ico.Svg2IcoTask::class) {
    source(file("resources/favicon.svg")) {
        output(32, 32)
        output(16, 16)
    }
    source(file("resources/detailed-favicon.svg")) {
        output(64, 64)
    }
    destination = project.layout.buildDirectory.file("icons/favicon.ico")
}

My task has multiple sources (similar to how copy specs can have multiple from declarations), and each source has multiple outputs.

Here’s what I’ve got so far (full source here, for reference):

abstract class Svg2IcoTask @Inject constructor(objects: ObjectFactory) : DefaultTask() {

    private val sources = objects.domainObjectSet(Source::class.java)

    @get:OutputFile
    abstract val destination: RegularFileProperty

    interface OutputDimensionsHandler {
        fun output(width: Int, height: Int)
    }

    class Source(val sourcePath: File) : OutputDimensionsHandler {
        val outputDimensions = mutableListOf<OutputDimension>()
        override fun output(width: Int, height: Int) {
            outputDimensions.add(OutputDimension(width, height))
        }
    }

    data class OutputDimension(val width: Int, val height: Int)

    fun source(sourcePath: File, action: Action<OutputDimensionsHandler>) {
        sources.add(Source(sourcePath).apply {
            action.execute(this)
        })
    }
}

This supports the syntax I want to configure my plugin, but:

  • bypasses Gradle’s incremental build functionality (in fact, it causes the task to be skipped despite changes to sources),
  • the sources are evaluated eagerly, and
  • it forces the sourcePath argument to be a File and nothing else, rather than benefiting from the range of types the destination property supports.

As I understand it, the solution is managed properties, but I don’t understand how to use them for collections, nor how that fits in with the mini DSL I’m defining.

Any pointers would be very welcome!

in fact, it causes the task to be skipped despite changes to sources

Sure, you define an input and an output for the task.
These are looked at for up-to-dateness.
If they didn’t change, the task is up-to-date.
You need to define your source image files and your configured dimensions as inputs too.
You can for example widen the access to sources for example to protected, annotate it with @Nested and then add input annotations like @InputFile to the properties of Source, same for outputDimension.

the sources are evaluated eagerly

Not sure what you mean here

it forces the sourcePath argument to be a File and nothing else, rather than benefiting from the range of types the destination property supports.

This probably depends on your flexibility with the dsl.
You could for example make Source have a RegularFileProperty sourcePath instead and then do

source {
    sourcePath = ...
    output(64, 64)
}

Thanks for the super-quick response, @Vampire! Your suggested on widening the scope of sources to protected and annotating with @Nested was exactly what I needed. In the end, I also decided to change the DSL slightly, to make it more directly reliant on Gradle types.

For reference, here’s what I ended up with:

abstract class Svg2IcoTask @Inject constructor(private val objectFactory: ObjectFactory) : DefaultTask() {

    @get:Nested
    protected abstract val sources: ListProperty<Source>

    @get:OutputFile
    abstract val destination: RegularFileProperty

    abstract class Source @Inject constructor(private val objectFactory: ObjectFactory) {

        @get:InputFile
        abstract val sourcePath: RegularFileProperty

        @get:Nested
        val outputDimensions: ListProperty<OutputDimension> = objectFactory.listProperty(OutputDimension::class.java)
            .convention(listOf(64, 48, 32, 24, 16).map { dimension ->
                objectFactory.newInstance(OutputDimension::class.java).apply {
                    width.set(dimension)
                    height.set(dimension)
                }
            })

        fun output(action: Action<OutputDimension>) {
            outputDimensions.add(objectFactory.newInstance(OutputDimension::class.java).apply {
                action.execute(this)
            })
        }
    }

    abstract class OutputDimension {
        @get:Input
        abstract val width: Property<Int>

        @get:Input
        abstract val height: Property<Int>
    }

    fun source(action: Action<Source>) {
        sources.add(objectFactory.newInstance(Source::class.java).apply {
            action.execute(this)
        })
    }
}

So in use, I have:

val ico by tasks.registering(com.gitlab.svg2ico.Svg2IcoTask::class) {
    source {
        sourcePath = file("resources/favicon.svg")
        output { width = 32; height = 32 }
        output { width = 16; height = 16 }
    }
    source {
        sourcePath = file("resources/detailed-favicon.svg")
        output { width = 64; height = 64 }
    }
    destination = project.layout.buildDirectory.file("icons/favicon.ico")
}
1 Like