Wednesday, March 31, 2010

Gradle Plugin Conventions: Groovy Magic Explained

Hackergarten last Friday was a big success. About 10 of us (not all Canooies!) stayed up late and created an Announce plugin for Gradle. In the 0.9 release you will be able to announce build events to Twitter, Snarl, and the Ubuntu notification service (sorry, no Growl yet, it is coming). It was fun, educational, and energizing, and you should sign up for the Google Group and come to the next event.

While writing the plugin, we were briefly delayed by a technical detail. The user guide explains how to pass simple parameters to a plugin, but does not explain how to nest parameters within a closure. For instance, in the announce plugin you specify the Twitter username and password within an announce block:

announce {
username 'your-username'
password 'your-password'
}
It turns out to be easy (just not documented, but we will fix that soon). Here is the full gradle build that shows you how to do it, with a detailed explanation of how it works below. As an example, we'll extend the Gradle documentation greeter custom plugin. This whole thing is runnable from the command line with "gradle hello":
apply plugin: GreetingPlugin

greet {
message = 'Hi from Gradle'
}

class GreetingPlugin implements Plugin<Project> {
def void apply(Project project) {

project.convention.plugins.greeting = new GreetingPluginConvention()
project.task('hello') << {
println project.convention.plugins.greeting.message
}
}
}

class GreetingPluginConvention {
String message

def greet(Closure closure) {
closure.delegate = this
closure()
}
}
Starting at the top... there is a property declaration called "message" within a "greet" block. This is a much nicer configuration option than just using build-global variables, and your plugin should do something like this. It is important to understand what this block actually is and how it works. In Groovy, parentheses on method calls are optional and curly braces declare a closure. So this greet block is really an invocation of a method with type "Object greet(Closure)". To read and capture this message variable, you must somewhere declare and wire in this "Object greet(Closure)" method within your plugin.

Moving on to the Plugin declaration. Plugin configuration is captured in something Gradle calls a "Convention" object. It is just a plain old Groovy object, and there is no Gradle magic... all the magic happending is standard Groovy. Let's examine the apply() method closely:
def void apply(Project project) {

project.convention.plugins.greeting = new GreetingPluginConvention()
project.task('hello') &lt;&lt; {
println project.convention.plugins.greeting.message
}
}
During the configuration phase (before any targets are run) you will be given a Project object, and that Project object has a Convention object that holds onto a Map of all the project conventions. Three pieces of Groovy magic: Getters can be accessed with property notation, Map entries can be accessed with dot notation, and Map.put can be called with the = sign. So this line:
project.convention.plugins.greeting = new GreetingPluginConvention()
Is the same as the Java equivalent:
project.getConvention().getPlugins().put("greeting", new GreetingPluginConvention());
During the build, Gradle is going to execute the build script. Any time an unknown property is assigned or an undeclared method is invoked, Gradle will look at this plugins Map and use the respondTo method to check if any of the Plugin conventions accepts that variable. If it does, then your convention object gets invoked and that variable is set. Gradle takes care of translating an unknown method call of "Object greet(Closure)" into a call to your convention object. All that you need to do is make sure the greet(Closure) method is defined properly:
class GreetingPluginConvention {
String message

def greet(Closure closure) {
closure.delegate = this
closure()
}
}
Now it is up to you to dispatch the parameters declared within the closure into fields on your convention object. If you just try to execute the closure then you will receive a nice PropertyMissingException because the "message" field is not declared anywhere. You get around this by defining a "message" field on your object and then setting "this" to the closure's delegate. Groovy method dispatch is flexible (complex?). Closures have a delegate object that will act as a sort of "this" reference when the closure executes. So the message field was out of scope when the closure was created, but adding a delegate makes the message field from the convention object in scope. When the closure assigns message a value, it will be the message on your Convention object. Check out my old blog post Fun with Closures for a more in depth explanation.

Now your convention object has the message String, hooray! You can do something interesting with it, like printing it out to the console:
println project.convention.plugins.greeting.message
Or maybe you had something more interesting in mind for your cool plugin. Let us hope.

By the way, you can just as easily declare a settings variable. This is an equivilent implementation of apply:
def settings = new GreetingPluginConvention()
project.convention.plugins.greeting = settings
project.task('hello') << {
println settings.message
}
And now there is just one last piece of Groovy magic left unexplained:
apply plugin: GreetingPlugin
This too is plain old Groovy. Method parenthesis are optional. Method parameters can be passed in as name value pairs. And Class literals can be referenced without the .class extension. The apply plugin statement is really just a plain old method call in disguise, and it is an invocation of apply(Map). The apply statement can be written in Java as:
Map map = new HashMap();
map.put("plugin", GreetingPlugin.class);
apply(map);
Groovy magic is good. Thanks for taking some time to understand it. Hopefully this example makes it to the Gradle user guide in the next few days.

1 comment:

salient1 said...

Nice writeup, Hamlet. Thanks for doing it.