Skip to content
AgileLogics
7 min read

Compile-Time Metaprogramming

Prerequisites: AST transformations, Runtime metaprogramming

AST transformations like @ToString run inside the compiler and change the code before bytecode is generated. You can write your own — it's how Spock, Micronaut, and many Groovy libraries work — and you can configure the compiler to apply transformations automatically.

Compiler configuration

import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import org.codehaus.groovy.control.customizers.ImportCustomizer
import groovy.transform.ToString

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ImportCustomizer().addStarImports('java.time'),
    new ASTTransformationCustomizer(ToString, includeNames: true)
)

def shell = new GroovyShell(config)
println shell.evaluate('''
    class Event { String name; LocalDate day }
    new Event(name: 'launch', day: LocalDate.of(2026, 10, 1))
''')
Event(name:launch, day:2026-10-01)

The script being compiled never imported java.time or used @ToString — the customizers added both. Gradle and Grails use compiler configuration like this, and so can you: groovyc --configscript config.groovy applies a configuration to a whole project (for example, @CompileStatic on every class).

Writing a local AST transformation

A transformation has two parts: an annotation that marks where it applies, and a transformation class that edits the AST.

import org.codehaus.groovy.ast.*
import org.codehaus.groovy.control.*
import org.codehaus.groovy.transform.*
import java.lang.annotation.*
import java.lang.reflect.Modifier
import static org.codehaus.groovy.ast.tools.GeneralUtils.*

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
@GroovyASTTransformationClass('VersionedTransform')
@interface Versioned { String value() }

@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class VersionedTransform extends AbstractASTTransformation {
    void visit(ASTNode[] nodes, SourceUnit source) {
        def annotation = nodes[0] as AnnotationNode
        def target = nodes[1] as ClassNode
        def version = getMemberStringValue(annotation, 'value')
        target.addMethod('getVersion', Modifier.PUBLIC, ClassHelper.STRING_TYPE,
            Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, returnS(constX(version)))
    }
}

// The transformation must be compiled before the code that uses it,
// so this demo compiles the using code with a second GroovyShell:
def shell = new GroovyShell(this.class.classLoader)
println shell.evaluate('''
    @Versioned('1.4.2')
    class Api {}
    new Api().version
''')
1.4.2
  • @GroovyASTTransformationClass links the annotation to the class that implements it.
  • visit receives the annotation and the annotated node, and can add methods, fields, or statements. GeneralUtils has helpers such as returnS (a return statement) and constX (a constant expression).
  • The phase decides when it runs; SEMANTIC_ANALYSIS and CANONICALIZATION are the usual choices.

In a real project the annotation and transformation live in their own module (compiled first), and code in other modules just uses @Versioned.

Macros

The groovy-macro module lets you write AST fragments as ordinary code instead of building nodes by hand — macro { return "hello" } produces the AST for that statement. It makes larger transformations far more readable.

Inspecting the AST

groovyConsole has Script → Inspect AST (and groovyc has -Dgroovy.ast=xml), which shows the tree for any code at any phase — the best way to learn which nodes your transformation needs to produce.

Guidelines

  • Reach for an existing transformation first — groovy.transform covers most needs.
  • Keep transformations small and well tested; compile errors from a buggy transformation are confusing for users.
  • Report problems with addError(message, node) so users see a normal compile error pointing at their code.