Writing AST Transformations & Macros

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). An AST transformation is a piece of compiler code that rewrites the syntax tree (the abstract syntax tree, AST) of a program between parsing and bytecode generation. Every annotation in AST Transformations: Code Generation & Class Design and AST Transformations: Logging, Safety, Concurrency & Directives is one. Here you write your own. Macros (stable in 6.0) let you write the code to inject as ordinary Groovy instead of building tree nodes by hand. The AST classes are Groovy internals: the API has been stable over many releases, but it is not covered by the same compatibility promise as the language, so a transformation may need updates for a new Groovy version.

The greenhouse running scenario is used throughout: tracing a valve, counting what a bed class declares, and guarding a nullable sensor label.

How the examples were built

A transformation runs inside the compiler, so its classes must already be compiled and on the compiler’s class path when the code that uses it is compiled. All the transformation sources below were compiled once with groovyc -d classes into a directory of their own, and the code that uses them was then run with groovy -cp classes script.groovy. The scripts that use the transformations are the only files shown with their output; the transformation sources are shown in full.

Compilation phases

The Groovy compiler processes every source unit through nine phases (org.codehaus.groovy.control.CompilePhase); each phase finishes for all the sources of a compilation before the next phase begins.

Phase Name What happens

1

INITIALIZATION

Source files are opened and the environment is configured.

2

PARSING

The grammar turns the source into a tree of tokens.

3

CONVERSION

The AST is created from the token tree.

4

SEMANTIC_ANALYSIS

Consistency checks the grammar cannot do, and class resolution.

5

CANONICALIZATION

The AST is completed.

6

INSTRUCTION_SELECTION

Preparation for class generation. Static type checking runs here.

7

CLASS_GENERATION

The bytecode of each class is created in memory.

8

OUTPUT

The binary output is written to the file system.

9

FINALIZATION

Final clean-up.

Global transformations can run in any phase; local transformations only from SEMANTIC_ANALYSIS (4) on. In general, more type information is available later, so a transformation that reads the tree prefers a later phase, while one that writes the tree prefers an earlier phase, when the tree is sparser.

flowchart TB subgraph GLOBAL["Global transformations: any phase"] direction TB P1["1 Initialization"] --> P2["2 Parsing"] --> P3["3 Conversion"] P3 --> LOCAL_IN subgraph LOCAL_IN["Local transformations: phase 4 or later"] direction TB P4["4 Semantic analysis"] --> P5["5 Canonicalization"] --> P6["6 Instruction selection"] --> P7["7 Class generation"] end P7 --> P8["8 Output"] --> P9["9 Finalization"] end N4["classes resolved, so most custom transformations run here"] -.-> P4 N5["@ToString and similar generators have run"] -.-> P5 N6["static type checking happens here"] -.-> P6 N7["bytecode created in memory; property accessors appear"] -.-> P7

The phases can be watched. A CompilationUnit accepts an operation per phase, and the one below prints the method names of a class after each of them. @ToString is a code-generation transformation; the example shows when its toString appears, and that the property accessors and the MetaClass plumbing appear only at class generation.

import org.codehaus.groovy.control.CompilationUnit
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.SourceUnit

def source = '''
import groovy.transform.ToString
@ToString
class Sensor { String id; int reading }
'''
def cu = new CompilationUnit(new CompilerConfiguration(), null, new GroovyClassLoader())
cu.addSource('Sensor.groovy', source)

[CompilePhase.CONVERSION, CompilePhase.SEMANTIC_ANALYSIS, CompilePhase.CANONICALIZATION,
 CompilePhase.INSTRUCTION_SELECTION, CompilePhase.CLASS_GENERATION].each { phase ->
    cu.addPhaseOperation({ SourceUnit su ->
        def node = su.AST.classes.find { it.name == 'Sensor' }
        println "after ${phase.name().padRight(21)} methods=${node.methods*.name.sort()}"
    } as CompilationUnit.ISourceUnitOperation, phase.phaseNumber)
}
cu.compile(CompilePhase.CLASS_GENERATION.phaseNumber)
after CONVERSION            methods=[]
after SEMANTIC_ANALYSIS     methods=[]
after CANONICALIZATION      methods=[toString]
after INSTRUCTION_SELECTION methods=[toString]
after CLASS_GENERATION      methods=[$getStaticMetaClass, getId, getMetaClass, getReading, setId, setMetaClass, setReading, super$1$toString, toString]

Local transformations

A local transformation is triggered by an annotation. Two pieces are needed: the annotation, linked to its implementation with @GroovyASTTransformationClass, and a class that implements org.codehaus.groovy.transform.ASTTransformation and is annotated with @GroovyASTTransformation(phase = ...). The compiler calls visit(ASTNode[] nodes, SourceUnit source) once per use of the annotation: nodes[0] is the annotation node and nodes[1] the annotated element.

The example is @Traced: it prints a message when a method is entered and another when it is left, even if the method throws.

package greenhouse.ast

import org.codehaus.groovy.transform.GroovyASTTransformationClass
import java.lang.annotation.ElementType
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
import java.lang.annotation.Target

@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.METHOD])
@GroovyASTTransformationClass('greenhouse.ast.TracedTransformation')
@interface Traced {
}
package greenhouse.ast

import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.Statement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.syntax.SyntaxException
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

import static org.codehaus.groovy.ast.tools.GeneralUtils.*

@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class TracedTransformation implements ASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        if (!(nodes[1] instanceof MethodNode)) {
            source.addError(new SyntaxException('@Traced can only be applied to methods', nodes[0]))
            return
        }
        MethodNode method = (MethodNode) nodes[1]
        if (method.abstract || !(method.code instanceof BlockStatement)) {
            return
        }
        // enter message, then the original body inside try/finally so that
        // the exit message is printed on every path and the return value is untouched
        BlockStatement original = (BlockStatement) method.code
        method.code = block(
            say("-> ${method.name}"),
            tryCatchS(original, block(say("<- ${method.name}"))))
    }

    private static Statement say(String message) {
        stmt(callThisX('println', args(constX(message))))
    }
}

Both files were compiled with groovyc -d classes. Using it:

import greenhouse.ast.Traced

class Pump {
    @Traced
    String start(boolean fail) {
        if (fail) throw new IllegalStateException('pump jammed')
        'running'
    }
}
def p = new Pump()
println p.start(false)
try { p.start(true) } catch (IllegalStateException e) { println "caught: ${e.message}" }
-> start
<- start
running
-> start
<- start
caught: pump jammed

Three points in that source are worth stopping on.

  • @CompileStatic on the transformation class is optional, but recommended by the Groovy documentation because it makes the compiler faster.

  • The first version of this example appended the exit message as a last statement of the body. It printed the right messages, and broke every method that returned its last expression: Groovy turns the last statement of a method into its implicit return, and the appended println became the last statement, so int reading() { 21 } returned null and threw GroovyCastException: Cannot cast 'null' to class 'int'. Wrapping the original block in a try/finally, as above, leaves the implicit return alone.

  • A transformation should check the node it receives. The annotation’s @Target restricts it for well-behaved code, but a mistake is better reported with source.addError (a compile error with a line number) than as a ClassCastException inside the compiler. With @Traced placed on a field, the output of groovy -cp classes is @Traced can only be applied to methods @ line 4, column 5.

Inspecting the result

The groovy-console module ships a class that prints an AST back as Groovy source. It is the quickest way to see what a transformation did, with the phase chosen:

import groovy.console.ui.AstNodeToScriptAdapter
import org.codehaus.groovy.control.CompilePhase

def source = '''
class Valve {
    @greenhouse.ast.Traced
    void open() { println 'opening' }
}
'''
println new AstNodeToScriptAdapter().compileToScript(source, CompilePhase.SEMANTIC_ANALYSIS.phaseNumber, this.class.classLoader, true, true)
public class Valve extends java.lang.Object {

    @greenhouse.ast.Traced
    public void open() {
        this.println('-> open')
        try {
            this.println('opening')
        }
        finally {
            this.println('<- open')
        }
    }

}

The same tree can be browsed in the AST browser of the Groovy Console; see groovysh & groovyConsole.

The transformation has to be precompiled

A common beginner error is to put the transformation in the same source tree as the code that uses it. Every phase processes all the sources of a compilation before the next one starts, so the transformation class does not yet exist as a class when the annotated code reaches the phase that would run it. The failure is silent: the code compiles and runs without the transformation. A script that declares the annotation, the transformation and the user in one file:

import org.codehaus.groovy.transform.GroovyASTTransformationClass
import java.lang.annotation.*

@Retention(RetentionPolicy.SOURCE)
@Target([ElementType.METHOD])
@GroovyASTTransformationClass('MyXform')
@interface MyTraced {}

class MyXform implements org.codehaus.groovy.transform.ASTTransformation {
    void visit(org.codehaus.groovy.ast.ASTNode[] nodes, org.codehaus.groovy.control.SourceUnit su) { println 'xform ran' }
}

class Valve {
    @MyTraced void open() { println 'valve opening' }
}
new Valve().open()

prints only valve opening: xform ran never appears. Passing the annotation, transformation and user sources to one groovyc command gave the same silent result. Keep the transformation in its own module or source set, which Maven and Gradle do by default (see Build Tools: Gradle, Maven & Ant).

Global transformations

A global transformation has no annotation and applies to every class compiled while it is on the compiler’s class path. It is discovered through the Java service-provider mechanism: the jar (or directory) holds a file META-INF/services/org.codehaus.groovy.transform.ASTTransformation with one line, the transformation’s class name. The class needs a no-argument constructor, implements ASTTransformation, and uses the SourceUnit rather than the nodes array, which is empty for a global transformation. Because it runs against every source, it can slow every compilation: the documentation calls this a last resort and warns against expensive scans of the whole AST.

package greenhouse.global

import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class CountMethodsTransformation implements ASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        // nodes is empty for a global transformation: work from the source unit
        for (ClassNode cls : source.AST.classes) {
            System.out.println("[global] ${new File(source.name).name}: ${cls.name} has ${cls.methods.size()} method(s)")
        }
    }
}

with META-INF/services/org.codehaus.groovy.transform.ASTTransformation containing greenhouse.global.CountMethodsTransformation. The user code has no import and no annotation:

class Zone { String id; def water() { 'ok' } }
println new Zone(id: 'A').water()

Run with groovy -cp gclasses usesglobal.groovy:

[global] usesglobal.groovy: usesglobal has 2 method(s)
[global] usesglobal.groovy: Zone has 1 method(s)
ok

The script itself is a class (usesglobal, with main and run), and Zone has the one method water at this phase (the property accessors do not exist yet at canonicalization). Without -cp gclasses only ok is printed. The two lines come from compile time, before the program’s own output.

A transformation that must apply only to code you choose is usually better as a local one or, for scripts you evaluate in your application, as a compilation customizer that adds a local transformation to a GroovyShell (see Compilation Customizers & Secure Scripting).

The AST API

The classes of the syntax tree live in org.codehaus.groovy.ast. The ones used most often:

Class Represents

ModuleNode

One source file: its classes, imports, and (for a script) the statements of run().

ClassNode

A class, interface, trait, enum or record: fields, properties, methods, constructors, annotations.

MethodNode, ConstructorNode

A method or constructor, with its Parameter array, return type and code, a Statement.

FieldNode, PropertyNode

A field, or a property (a field plus the accessors Groovy will generate).

Statement subclasses

BlockStatement, ExpressionStatement, ReturnStatement, IfStatement, ForStatement, WhileStatement, TryCatchStatement…​

Expression subclasses

ConstantExpression, VariableExpression, MethodCallExpression, BinaryExpression, ClosureExpression…​

Types are ClassNode references, built with ClassHelper (for example ClassHelper.STRING_TYPE, ClassHelper.int_TYPE, ClassHelper.make(Foo)). The tree can be read from source text and extended by hand; GeneralUtils, a class of static factory methods, keeps the hand-written constructions short (constX, varX, callX, callThisX, args, stmt, block, returnS, plusX, ternaryX, notNullX, tryCatchS…​):

import org.codehaus.groovy.ast.ClassHelper
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.Parameter
import org.codehaus.groovy.ast.builder.AstStringCompiler
import org.codehaus.groovy.control.CompilePhase
import static org.codehaus.groovy.ast.tools.GeneralUtils.*
import static java.lang.reflect.Modifier.PUBLIC

// 1. parse source text into AST nodes
def nodes = new AstStringCompiler().compile('class Zone { String id; int slots; void water() {} }', CompilePhase.SEMANTIC_ANALYSIS, true)
ClassNode zone = nodes.find { it instanceof ClassNode && it.name == 'Zone' }
println "class ${zone.name}: properties=${zone.properties*.name}, fields=${zone.fields*.name}, methods=${zone.methods*.name}"
println "water() returns ${zone.getDeclaredMethods('water')[0].returnType.name}, public=${zone.getDeclaredMethods('water')[0].public}"

// 2. build a method by hand with GeneralUtils helpers
def body = block(returnS(plusX(constX(1), constX(2))))
def m = new MethodNode('three', PUBLIC, ClassHelper.int_TYPE, Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, body)
zone.addMethod(m)
println "after addMethod: ${zone.methods*.name}"
println "body: ${body.statements[0].class.simpleName} -> ${body.statements[0].expression.class.simpleName} ${body.statements[0].expression.text}"
class Zone: properties=[id, slots], fields=[id, slots], methods=[water]
water() returns void, public=true
after addMethod: [water, three]
body: ReturnStatement -> BinaryExpression (1 + 2)

Two base classes save work. AbstractASTTransformation implements ASTTransformation and adds helpers (init(nodes, source) validates the annotation and source unit, and there are utilities for reading annotation members, reporting errors and checking for existing members); almost every transformation shipped with Groovy extends it. ClassCodeExpressionTransformer walks a tree and lets you replace the expressions you recognize, which is how a transformation such as an expression rewriter is usually written. Bytecode access flags are not Groovy classes in the public API (the ASM library Groovy uses is shaded), so java.lang.reflect.Modifier.PUBLIC is used above for method modifiers.

Transformations and static compilation

The phase of a transformation matters beyond convenience once the code it generates can end up under @TypeChecked or @CompileStatic. Static type checking runs at instruction selection. Code that a transformation adds in an earlier phase is checked like code the user wrote, and under static compilation each call in it receives the resolution metadata the bytecode writers need. Code added during or after instruction selection is never visited by the type checker; the Groovy documentation describes the result for statically compiled classes as errors such as "Target method for method call expression hasn’t been set", a VerifyError or a "Cannot find matching method" that only appears under @CompileStatic. Its advice is to generate code before instruction selection (for a local transformation, usually semantic analysis), or to mark the generated methods @CompileDynamic.

The experiment below uses a small @Stamp transformation that inserts a call to the static method stamp(String) of the annotated method’s own class. @Stamp runs at SEMANTIC_ANALYSIS; @LateStamp is the same code scheduled at CLASS_GENERATION:

import greenhouse.ast.*
import groovy.transform.CompileStatic

@CompileStatic
class Late {
    static void stamp(String what) { println "stamped ${what}" }
    @LateStamp void run() { println 'late body' }
    @Stamp void run2() { println 'early body' }
}
new Late().run2()
new Late().run()
stamped run2
early body
late body

The early transformation worked under @CompileStatic. The late one did not fail: its statement is silently absent, because the class had already been generated when it ran. A run of the same @Stamp variant at INSTRUCTION_SELECTION worked in this simple case, so the "Target method has not been set" failure from the documentation was not reproduced here; it depends on what the generated call looks like. The practical rule stands: pick the earliest phase that has the information you need, and test the annotated class under @CompileStatic as well as dynamically.

Macros

Writing new ReturnStatement(new ConstantExpression("42")) is not how you think about the code you want. A macro lets the code to generate be written as ordinary Groovy inside a closure; the compiler converts the closure body into AST nodes and your transformation uses them. The macro method needs the groovy-macro module on the class path of the code that contains the transformation (it is part of the Groovy distribution and of org.apache.groovy:groovy-macro). The macro framework (the @Macro annotation and its related classes) was promoted out of incubation in Groovy 6.0.

Form Result

macro { ... }

A statement (or a single expression when the closure body is one expression), built in the current phase.

macro(boolean) { ... }

true wraps the code into a statement (a BlockStatement); false returns an expression.

macro(CompilePhase, Closure)

The same, with names resolved as of the given phase.

macro(CompilePhase, boolean, Closure)

Both options.

$v { expression } inside a macro substitutes an AST expression you built outside, which is how a macro receives values computed by the transformation. When the closure uses a class or static method outside the default imports, write it fully qualified, and pass CompilePhase.SEMANTIC_ANALYSIS so the name is resolved at the first phase with type information.

The transformation below adds two methods to an annotated class: fixed(), whose body is a macro with a constant, and describe(), whose body substitutes values counted at compile time.

package greenhouse.macros

import org.codehaus.groovy.ast.*
import org.codehaus.groovy.ast.expr.Expression
import org.codehaus.groovy.ast.stmt.BlockStatement
import org.codehaus.groovy.ast.stmt.ReturnStatement
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.*

import static org.codehaus.groovy.ast.tools.GeneralUtils.*
import static java.lang.reflect.Modifier.PUBLIC

@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class DescribeTransformation extends AbstractASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        init(nodes, source)
        ClassNode cls = (ClassNode) nodes[1]
        // 1. a constant, built with a macro instead of node constructors
        ReturnStatement constant = macro { return 'fixed text' }
        cls.addMethod(new MethodNode('fixed', PUBLIC, ClassHelper.STRING_TYPE,
            Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, constant))

        // 2. a value computed at compile time and substituted with $v
        Expression count = constX(cls.properties.size())
        Expression name = constX(cls.nameWithoutPackage)
        BlockStatement body = macro(CompilePhase.SEMANTIC_ANALYSIS, true) {
            return $v { name } + ': ' + $v { count } + ' properties'
        }
        cls.addMethod(new MethodNode('describe', PUBLIC, ClassHelper.STRING_TYPE,
            Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, body))
    }
}

The transformation class is not @CompileStatic: macro is expanded by the compiler, and the first attempt with @CompileStatic on it was rejected with the error "Cannot assign value of type java.lang.Object to variable of type BlockStatement". The annotation is declared like @Traced (@Target([ElementType.TYPE]), @GroovyASTTransformationClass('greenhouse.macros.DescribeTransformation')).

import greenhouse.macros.Describe

@Describe
class Bed { String id; int slots; List crops }

def bed = new Bed()
println bed.fixed()
println bed.describe()
fixed text
Bed: 3 properties

MacroClass

macro produces statements and expressions only. To produce whole members (methods, fields), write them in a class template built with new MacroClass() { class Template { ... } } and copy the generated members into the target. Types inside the template must be written fully qualified (java.lang.Integer, not Integer), and the method that builds it must be @CompileDynamic because the template is used as if it were an implementation of the abstract class MacroClass.

package greenhouse.macros

import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.macro.transform.MacroClass
import org.codehaus.groovy.transform.AbstractASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

import static org.codehaus.groovy.ast.tools.GeneralUtils.constX

@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class InventoryTransformation extends AbstractASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        init(nodes, source)
        ClassNode target = (ClassNode) nodes[1]
        ClassNode template = buildTemplate(target)
        template.methods.each { MethodNode m -> target.addMethod(m) }
    }

    @CompileDynamic
    ClassNode buildTemplate(ClassNode reference) {
        def members = constX(reference.fields.size() + reference.methods.size())
        def properties = constX(reference.properties.size())
        return new MacroClass() {
            class Template {
                java.lang.Integer getMemberCount() { return $v { members } }
                java.lang.Integer getPropertyCount() { return $v { properties } }
            }
        }
    }
}
import greenhouse.macros.Inventory

@Inventory
class Bed {
    String id
    int slots
    private int hidden
    void water() {}
}
def bed = new Bed()
println "properties: ${bed.propertyCount}, members: ${bed.memberCount}"
properties: 2, members: 4

The count is made at the CANONICALIZATION phase: three fields (id, slots, hidden) and one method (water); the accessors for the properties do not exist yet, which is why a phase choice also decides what a transformation can count.

@Macro methods

macro itself is a macro method: a method whose call is expanded at compile time instead of being dispatched at run time. You can define your own. Write a class with a static method annotated @Macro that takes a MacroContext and the MethodCallExpression being expanded, and returns the Expression that replaces the call. Register the class like an extension module: a descriptor META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule on the class path.

package greenhouse.macros

import org.codehaus.groovy.ast.expr.Expression
import org.codehaus.groovy.ast.expr.MethodCallExpression
import org.codehaus.groovy.macro.runtime.Macro
import org.codehaus.groovy.macro.runtime.MacroContext

import static org.codehaus.groovy.ast.tools.GeneralUtils.*

class GreenhouseMacroMethods {
    /** safe(x.foo()) expands to (x != null ? x.foo() : null) at compile time. */
    @Macro
    static Expression safe(MacroContext ctx, MethodCallExpression call) {
        ternaryX(notNullX(call.objectExpression), call, constX(null))
    }
}

with the descriptor

moduleName=greenhouse-macros
moduleVersion=1.0
extensionClasses=greenhouse.macros.GreenhouseMacroMethods
def label = null
def name = 'north wing'

println safe(name.toUpperCase())
println safe(label.toUpperCase())
try {
    label.toUpperCase()
} catch (NullPointerException e) {
    println 'unguarded call: NullPointerException'
}
NORTH WING
null
unguarded call: NullPointerException

Run with groovy -cp xclasses. Without the module on the class path, safe(...) is an ordinary call and fails with MissingMethodException: No signature of method: safe. The groovy-macro-library module that ships with Groovy 6.0 is built from the same mechanism (its DO comprehension macro, for instance). Individual macro methods can be turned off for a compilation with -Dgroovy.macro.disable=Name1,Name2 (bare method names, comma separated without spaces, read once at compiler startup).

AST transforms on loop statements (6.0, incubating)

Before Groovy 6.0 an annotation on a for, while or do/while statement was accepted without validation. Groovy 6.0 validates it: an annotation placed on a loop (or on an import) must declare the Groovy-only targets LOOP (or IMPORT) with the new meta-annotation @groovy.lang.annotation.ExtendedTarget. The built-in annotations that qualify are @Parallel, @Invariant, @Decreases and @ASTTest. The feature is incubating in 6.0. A custom annotation opts in the same way:

package greenhouse.loops

import groovy.lang.annotation.ExtendedElementType
import groovy.lang.annotation.ExtendedTarget
import org.codehaus.groovy.transform.GroovyASTTransformationClass
import java.lang.annotation.*

/** Prints "tick" at the start of every iteration of the annotated loop. */
@Retention(RetentionPolicy.SOURCE)
@ExtendedTarget(ExtendedElementType.LOOP)
@GroovyASTTransformationClass('greenhouse.loops.TickTransformation')
@interface Tick {}

/** Same transformation, but without the LOOP target declaration. */
@Retention(RetentionPolicy.SOURCE)
@GroovyASTTransformationClass('greenhouse.loops.TickTransformation')
@interface UndeclaredTick {}
package greenhouse.loops

import groovy.transform.CompileStatic
import org.codehaus.groovy.ast.ASTNode
import org.codehaus.groovy.ast.stmt.*
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.transform.ASTTransformation
import org.codehaus.groovy.transform.GroovyASTTransformation

import static org.codehaus.groovy.ast.tools.GeneralUtils.*

@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class TickTransformation implements ASTTransformation {
    @Override
    void visit(ASTNode[] nodes, SourceUnit source) {
        ASTNode target = nodes[1]
        Statement body = target instanceof ForStatement ? ((ForStatement) target).loopBlock
                : target instanceof WhileStatement ? ((WhileStatement) target).loopBlock
                : target instanceof DoWhileStatement ? ((DoWhileStatement) target).loopBlock : null
        if (body instanceof BlockStatement) {
            ((BlockStatement) body).statements.add(0, stmt(callThisX('println', args(constX('tick')))))
        }
        System.out.println("[compile time] @Tick found on ${target.class.simpleName}")
    }
}
import greenhouse.loops.Tick

int total = 0
@Tick
for (int i in 1..3) { total += i }
println "total = $total"

int n = 0
@Tick
while (n < 2) { n++ }
println "n = $n"
[compile time] @Tick found on ForStatement
[compile time] @Tick found on WhileStatement
tick
tick
tick
total = 6
tick
tick
n = 2

The loop node arrives as nodes[1], a ForStatement, WhileStatement or DoWhileStatement. Using @UndeclaredTick on a loop is rejected before anything runs:

loop2.groovy: 2: Annotation @greenhouse.loops.UndeclaredTick is not allowed on element STATEMENT
 @ line 2, column 1.
   @UndeclaredTick

The built-in @Parallel runs each iteration as its own task, on virtual threads for JDK 21 and later and platform threads otherwise. The loop below ran correctly with an untyped, def or Integer loop variable:

import groovy.transform.Parallel
import java.util.concurrent.ConcurrentLinkedQueue

def seen = new ConcurrentLinkedQueue<Integer>()
@Parallel
for (i in 1..4) {
    seen.add(i * i)
}
println seen.sort()
[1, 4, 9, 16]

The 6.0 release notes show the loop as for (int i in 1..4). With Groovy 6.0.0 on JDK 26 that form, with the primitive int loop variable, failed at run time here with GroovyCastException: Cannot cast 'null' to class 'int', in every placement tried (script, method, static method). Use an untyped or boxed variable until that is resolved; see the Groovy release notes for the status. Loop annotations of the Groovy-Contracts module (@Invariant, @Decreases) are on Design by Contract with Groovy-Contracts. Parallel iteration in general is on Parallel Collections & Dataflow.

The fluent AST query API (6.0, incubating)

Finding nodes with a CodeVisitorSupport subclass needs a class, overridden visitXxx methods and mutable fields to collect results. Groovy 6.0 adds a small, read-only, fluent query API in org.codehaus.groovy.ast.query: AstQuery starts at a node, selects descendants of given types, narrows them with predicates, and ends with list(), any(), none(), count(), findFirst() or forEach. notInto(Type...) stops the descent at nodes of those types and into(Type...) restricts which nodes are entered. The API is incubating in 6.0 and the release notes describe it for authors of transformations, type checkers and tools.

import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.ast.MethodNode
import org.codehaus.groovy.ast.expr.MethodCallExpression
import org.codehaus.groovy.ast.expr.ClosureExpression
import org.codehaus.groovy.ast.query.AstQuery
import org.codehaus.groovy.control.CompilationUnit
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.CompilerConfiguration

def source = '''
class Irrigation {
    List<String> zones = ['A', 'B']
    void water() {
        zones.each { z -> log("watering $z"); pump(z) }
    }
    void pump(String z) { log("pump $z") }
    void log(String m) { println m }
}
'''
def cu = new CompilationUnit(new CompilerConfiguration(), null, new GroovyClassLoader())
cu.addSource('Irrigation.groovy', source)
cu.compile(CompilePhase.SEMANTIC_ANALYSIS.phaseNumber)
ClassNode cls = cu.AST.classes.find { it.name == 'Irrigation' }

// 1. every method call below water(), by name
MethodNode water = cls.getDeclaredMethods('water')[0]
def calls = AstQuery.from(water.code).descendants(MethodCallExpression).list()
println "calls in water(): ${calls*.methodAsString}"

// 2. stop at nested closures
def outer = AstQuery.from(water.code).descendants(MethodCallExpression).notInto(ClosureExpression).list()
println "calls outside closures: ${outer*.methodAsString}"

// 3. with the context of each hit
AstQuery.from(water.code).descendants(MethodCallExpression).forEach { call, ctx ->
    println "${call.methodAsString} at line ${call.lineNumber}"
}

// 4. yes/no questions and counting
println "pump() calls log(): ${AstQuery.from(cls.getDeclaredMethods('pump')[0].code).descendants(MethodCallExpression).where { it.methodAsString == 'log' }.any()}"
println "water() calls pump(): ${AstQuery.from(water.code).descendants(MethodCallExpression).where { it.methodAsString == 'pump' }.any()}"
println "method calls in the whole class: ${AstQuery.from(cls).descendants(MethodCallExpression).count()}"
calls in water(): [each, log, pump]
calls outside closures: [each]
each at line 5
log at line 5
pump at line 5
pump() calls log(): true
water() calls pump(): true
method calls in the whole class: 5

The query for "calls outside closures" returns only each: the log and pump calls are inside the closure passed to it. The count of five is each, log and pump in water(), log in pump() and println in log().

Testing and debugging transformations

Test a transformation by compiling and running a snippet that uses it, in a build that compiles the transformation first (a separate source set or module). Three techniques cover most needs.

  • Test the observable behaviour. GroovyAssert.assertScript (from groovy.test, also available in GroovyTestCase) compiles and runs a script string when the test executes, so the code under test is compiled with the transformation on the class path.

  • Debug with a breakpoint. An IDE compiles the test class before the debugger is attached, so the transformation has already run and a breakpoint inside it is never hit. Moving the annotated code into an assertScript string compiles it at test time, with debugging active.

  • Assert on the tree. ASTMatcher compares AST nodes; a pattern can mark placeholder variables with withConstraints.

import groovy.test.GroovyAssert
import org.codehaus.groovy.ast.expr.Expression
import org.codehaus.groovy.macro.matcher.ASTMatcher

// 1. run the transformed code in a fresh compilation (so a debugger can stop in the transformation)
GroovyAssert.assertScript '''
    import greenhouse.ast.Traced
    class Probe {
        @Traced int reading() { 21 }
    }
    assert new Probe().reading() == 21
'''
println 'assertScript passed'

// 2. assert on the tree that was built, not on behaviour
use(ASTMatcher) {
    Expression built = macro(false) { 1 + 1 }
    Expression same = macro(false) { 1 + 1 }
    Expression other = macro(false) { 1 + 2 }
    assert built.matches(same)
    assert !built.matches(other)

    // the receiver is the node under test, the argument is the pattern
    Expression pattern = macro(false) { a + a }.withConstraints { placeholder 'a' }
    assert macro(false) { x + x }.matches(pattern)
    assert macro(false) { 3 + 3 }.matches(pattern)
    assert !macro(false) { x + y }.matches(pattern)
}
println 'ASTMatcher assertions passed'
-> reading
<- reading
assertScript passed
ASTMatcher assertions passed

The two ->/<- lines are the traced reading() call inside the script. In a first attempt the pattern and the node under test were the other way round (pattern.matches(node)); the assertion then failed silently as false, so remember the direction. @ASTTest, covered with the other directives on AST Transformations: Logging, Safety, Concurrency & Directives, is the third tool: it asserts on the state of the tree during compilation, and is the way the Groovy project tests its own transformations. Two switches that matter for transformations which execute user code during compilation are the system properties groovy.grape.enable (for @Grab) and groovy.asttest.enable; both default to true, and a custom transformation that evaluates the user’s code at compile time should offer the same kind of switch so that a build compiling untrusted source can turn the behaviour off. The general testing tools are on Testing with JUnit & groovy-test.

Where the book is outdated

A book from the Groovy 1.x and 2.x era describes writing transformations with AstBuilder (buildFromCode, buildFromString, buildFromSpec), because macros did not exist before Groovy 2.5. They remain available in the groovy-astbuilder module, but the Groovy documentation now presents macros as the way to avoid writing nodes by hand, and the macro framework is out of incubation in Groovy 6.0. The phase table and the local and global split described there still hold. Loop-statement targets, the fluent AST query API and @ExtendedTarget are new in 6.0 and not covered, and the stricter annotation validation of Groovy 6 is a breaking change for code that placed an annotation on an import or a loop without declaring the matching extended target.