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 |
|
Source files are opened and the environment is configured. |
2 |
|
The grammar turns the source into a tree of tokens. |
3 |
|
The AST is created from the token tree. |
4 |
|
Consistency checks the grammar cannot do, and class resolution. |
5 |
|
The AST is completed. |
6 |
|
Preparation for class generation. Static type checking runs here. |
7 |
|
The bytecode of each class is created in memory. |
8 |
|
The binary output is written to the file system. |
9 |
|
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.
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.
-
@CompileStaticon 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 appendedprintlnbecame the last statement, soint reading() { 21 }returnednulland threwGroovyCastException: Cannot cast 'null' to class 'int'. Wrapping the original block in atry/finally, as above, leaves the implicit return alone. -
A transformation should check the node it receives. The annotation’s
@Targetrestricts it for well-behaved code, but a mistake is better reported withsource.addError(a compile error with a line number) than as aClassCastExceptioninside the compiler. With@Tracedplaced on a field, the output ofgroovy -cp classesis@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')
}
}
}
Source: Groovy API documentation: AstNodeToScriptAdapter and Groovy documentation: Metaprogramming, debugging AST transformations
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 |
|---|---|
|
One source file: its classes, imports, and (for a script) the statements of |
|
A class, interface, trait, enum or record: fields, properties, methods, constructors, annotations. |
|
A method or constructor, with its |
|
A field, or a property (a field plus the accessors Groovy will generate). |
|
|
|
|
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 |
|---|---|
|
A statement (or a single expression when the closure body is one expression), built in the current phase. |
|
|
|
The same, with names resolved as of the given phase. |
|
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(fromgroovy.test, also available inGroovyTestCase) 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
assertScriptstring compiles it at test time, with debugging active. -
Assert on the tree.
ASTMatchercompares AST nodes; a pattern can mark placeholder variables withwithConstraints.
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.