Skip to content

Commit 2c25b58

Browse files
committed
GROOVY-11910: Add ModifiesChecker type checking extension to verify @Modifies frame conditions (recognize JetBrains and checker framework annotations too)
1 parent 77e8752 commit 2c25b58

3 files changed

Lines changed: 462 additions & 11 deletions

File tree

subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/ModifiesChecker.groovy

Lines changed: 153 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,25 +91,82 @@ class ModifiesChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL
9191
)
9292

9393
private static final Set<String> PURE_ANNOS = Set.of('Pure')
94+
private static final Set<String> SIDE_EFFECT_FREE_ANNOS = Set.of('SideEffectFree')
95+
private static final Set<String> CONTRACT_ANNOS = Set.of('Contract')
9496

9597
@Override
9698
Object run() {
9799
afterVisitMethod { MethodNode mn ->
98100
// Key matches ModifiesASTTransformation.MODIFIES_FIELDS_KEY — no hard dependency on groovy-contracts
99101
Set<String> modifiesSet = mn.getNodeMetaData('groovy.contracts.modifiesFields') as Set<String>
100-
if (modifiesSet == null && hasPureAnno(mn)) {
101-
modifiesSet = Collections.emptySet() // @Pure implies @Modifies({})
102+
if (modifiesSet == null && isPureEquivalent(mn)) {
103+
modifiesSet = Collections.emptySet() // @Pure/@SideEffectFree/@Contract(pure=true) implies @Modifies({})
102104
}
103-
if (modifiesSet == null) return // no @Modifies or @Pure on this method — nothing to check
105+
if (modifiesSet == null) return // no @Modifies or purity annotation — nothing to check
104106

105107
mn.code?.visit(makeVisitor(modifiesSet, mn))
106108
}
107109
}
108110

111+
/** Checks for @Pure, @SideEffectFree, or @Contract(pure=true). */
112+
private static boolean isPureEquivalent(MethodNode method) {
113+
hasPureAnno(method) || hasSideEffectFreeAnno(method) || hasContractPureAnno(method)
114+
}
115+
109116
private static boolean hasPureAnno(MethodNode method) {
110117
method.annotations?.any { it.classNode?.nameWithoutPackage in PURE_ANNOS } ?: false
111118
}
112119

120+
private static boolean hasSideEffectFreeAnno(MethodNode method) {
121+
method.annotations?.any { it.classNode?.nameWithoutPackage in SIDE_EFFECT_FREE_ANNOS } ?: false
122+
}
123+
124+
/**
125+
* Checks for {@code @Contract(pure = true)} (JetBrains annotations).
126+
*/
127+
private static boolean hasContractPureAnno(MethodNode method) {
128+
method.annotations?.any { anno ->
129+
anno.classNode?.nameWithoutPackage in CONTRACT_ANNOS &&
130+
anno.getMember('pure')?.text == 'true'
131+
} ?: false
132+
}
133+
134+
/**
135+
* Parses {@code @Contract(mutates = "this,param1")} into a set of mutation targets.
136+
* Returns null if no mutates attribute is present.
137+
* Maps "this" to field names (all fields) and "param1","param2" etc. to parameter names.
138+
*/
139+
private static Set<String> parseContractMutates(MethodNode callee) {
140+
for (anno in callee.annotations) {
141+
if (anno.classNode?.nameWithoutPackage in CONTRACT_ANNOS) {
142+
def mutatesExpr = anno.getMember('mutates')
143+
if (mutatesExpr != null) {
144+
String mutatesStr = mutatesExpr.text
145+
if (mutatesStr == null || mutatesStr.isEmpty()) return Collections.emptySet()
146+
Set<String> result = new LinkedHashSet<>()
147+
def params = callee.parameters
148+
for (String token : mutatesStr.split(',')) {
149+
token = token.trim()
150+
if (token == 'this') {
151+
// "this" means the callee mutates its own receiver
152+
result.add('this')
153+
} else if (token.startsWith('param')) {
154+
// "param1" = first parameter (1-based)
155+
try {
156+
int idx = Integer.parseInt(token.substring(5)) - 1
157+
if (idx >= 0 && idx < params.length) {
158+
result.add(params[idx].name)
159+
}
160+
} catch (NumberFormatException ignored) {}
161+
}
162+
}
163+
return result
164+
}
165+
}
166+
}
167+
null
168+
}
169+
113170
private CheckingVisitor makeVisitor(Set<String> modifiesSet, MethodNode methodNode) {
114171
Set<String> paramNames = methodNode.parameters*.name as Set<String>
115172

@@ -147,6 +204,12 @@ class ModifiesChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL
147204
checkMethodCall(call.objectExpression, call)
148205
}
149206

207+
@Override
208+
void visitStaticMethodCallExpression(StaticMethodCallExpression call) {
209+
super.visitStaticMethodCallExpression(call)
210+
checkStaticCall(call)
211+
}
212+
150213
// ---- helpers ----
151214

152215
private void checkWriteTarget(Expression target, Expression context) {
@@ -193,13 +256,26 @@ class ModifiesChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL
193256
addStaticTypeError("@Modifies violation: call to '${targetMethod.name}()' modifies '${field}' which is not in this method's @Modifies", call)
194257
}
195258
}
196-
} else if (hasPureAnno(targetMethod)) {
197-
// @Pure methods are safe
198-
} else if (SAFE_METHOD_NAMES.contains(call.methodAsString)) {
199-
// Known-safe method name
259+
} else if (isPureEquivalent(targetMethod)) {
260+
// @Pure, @SideEffectFree, @Contract(pure=true) methods are safe
200261
} else {
201-
// Unknown effects — warn
202-
addStaticTypeError("@Modifies warning: call to 'this.${call.methodAsString}()' has unknown effects (consider adding @Modifies or @Pure)", call)
262+
// Check @Contract(mutates=...) on callee
263+
Set<String> contractMutates = parseContractMutates(targetMethod)
264+
if (contractMutates != null) {
265+
if (contractMutates.isEmpty()) return // mutates nothing — pure
266+
// Check parameter mutations (arguments that map to mutated params)
267+
checkContractParamMutations(contractMutates, targetMethod, call)
268+
// "this" in mutates means the callee mutates its receiver
269+
if (contractMutates.contains('this')) {
270+
addStaticTypeError("@Modifies warning: call to 'this.${call.methodAsString}()' declares @Contract(mutates=\"this\") — may modify fields not in @Modifies", call)
271+
}
272+
return
273+
} else if (SAFE_METHOD_NAMES.contains(call.methodAsString)) {
274+
// Known-safe method name
275+
} else {
276+
// Unknown effects — warn
277+
addStaticTypeError("@Modifies warning: call to 'this.${call.methodAsString}()' has unknown effects (consider adding @Modifies or @Pure)", call)
278+
}
203279
}
204280
}
205281

@@ -216,8 +292,29 @@ class ModifiesChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL
216292
ClassNode receiverType = getType(receiver)
217293
if (receiverType && ImmutablePropertyUtils.isBuiltinImmutable(receiverType.name)) return
218294

219-
// @Pure method
220-
if (targetMethod instanceof MethodNode && hasPureAnno(targetMethod)) return
295+
// @Pure, @SideEffectFree, @Contract(pure=true) method
296+
if (targetMethod instanceof MethodNode && isPureEquivalent(targetMethod)) return
297+
298+
// @Contract(mutates=...) — check if this call mutates the receiver
299+
if (targetMethod instanceof MethodNode) {
300+
Set<String> contractMutates = parseContractMutates(targetMethod)
301+
if (contractMutates != null) {
302+
if (contractMutates.isEmpty()) return // mutates nothing
303+
// Check if the callee mutates "this" (the receiver variable)
304+
// and map paramN to the actual argument expressions
305+
if (!contractMutates.contains('this')) {
306+
// Callee doesn't mutate its receiver — safe for our variable
307+
// But check if any mutated params map to our non-modifiable variables
308+
checkContractParamMutations(contractMutates, targetMethod, call)
309+
return
310+
}
311+
// Callee mutates "this" = our receiver variable
312+
if (receiverName) {
313+
addStaticTypeError("@Modifies warning: call to '${receiverName}.${call.methodAsString}()' may modify '${receiverName}' which is not in @Modifies", call)
314+
}
315+
return
316+
}
317+
}
221318

222319
// Known-safe method name
223320
if (SAFE_METHOD_NAMES.contains(call.methodAsString)) return
@@ -228,6 +325,51 @@ class ModifiesChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL
228325
}
229326
}
230327

328+
private void checkStaticCall(StaticMethodCallExpression call) {
329+
def targetMethod = call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
330+
if (!(targetMethod instanceof MethodNode)) return
331+
332+
// Check @Contract(mutates=...) on static callee
333+
Set<String> contractMutates = parseContractMutates(targetMethod)
334+
if (contractMutates != null && !contractMutates.isEmpty()) {
335+
// Static methods can't mutate "this", but can mutate params
336+
def args = call.arguments
337+
if (args instanceof org.codehaus.groovy.ast.expr.TupleExpression) {
338+
def argList = args.expressions
339+
def params = targetMethod.parameters
340+
for (int i = 0; i < params.length && i < argList.size(); i++) {
341+
if (contractMutates.contains(params[i].name)) {
342+
String argName = resolveReceiverName(argList[i])
343+
if (argName && !modifiesSet.contains(argName)) {
344+
addStaticTypeError("@Modifies warning: argument '${argName}' passed to '${call.methodAsString}()' parameter '${params[i].name}' which is declared as mutated", call)
345+
}
346+
}
347+
}
348+
}
349+
}
350+
}
351+
352+
/**
353+
* For @Contract(mutates="param1,param2"), check if the actual arguments
354+
* at the call site are variables not in our modifies set.
355+
*/
356+
private void checkContractParamMutations(Set<String> contractMutates, MethodNode callee, MethodCallExpression call) {
357+
def args = call.arguments
358+
if (!(args instanceof org.codehaus.groovy.ast.expr.TupleExpression)) return
359+
def argList = args.expressions
360+
def params = callee.parameters
361+
362+
for (int i = 0; i < params.length && i < argList.size(); i++) {
363+
if (contractMutates.contains(params[i].name)) {
364+
// This parameter is mutated — check what argument was passed
365+
String argName = resolveReceiverName(argList[i])
366+
if (argName && !modifiesSet.contains(argName)) {
367+
addStaticTypeError("@Modifies warning: argument '${argName}' passed to '${call.methodAsString}()' parameter '${params[i].name}' which is declared as mutated", call)
368+
}
369+
}
370+
}
371+
}
372+
231373
private String resolveReceiverName(Expression receiver) {
232374
if (receiver instanceof VariableExpression) {
233375
return receiver.name

subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,57 @@ void process(List list1, List list2) {
761761
}
762762
----
763763
764+
=== Supported annotations from other libraries
765+
766+
The `ModifiesChecker` recognises purity and frame condition annotations from multiple
767+
libraries by simple name:
768+
769+
[cols="2,2,3",options="header"]
770+
|===
771+
| Annotation | Library | Semantics
772+
773+
| `@Pure`
774+
| Groovy, Checker Framework, Xtext
775+
| Implies `@Modifies({})` -- no field mutations
776+
777+
| `@SideEffectFree`
778+
| Checker Framework
779+
| Implies `@Modifies({})` -- no field mutations
780+
781+
| `@Contract(pure = true)`
782+
| JetBrains Annotations
783+
| Implies `@Modifies({})` -- works with CLASS retention
784+
785+
| `@Contract(mutates = "...")`
786+
| JetBrains Annotations
787+
| Parsed as a frame condition on callees (see below)
788+
|===
789+
790+
==== @Contract(mutates) support
791+
792+
When a callee has `@Contract(mutates = "...")`, the checker parses the mutates string
793+
to determine what the callee modifies:
794+
795+
* `mutates = ""` -- callee mutates nothing (equivalent to `@Pure`)
796+
* `mutates = "this"` -- callee mutates its receiver
797+
* `mutates = "param1"` -- callee mutates its first parameter
798+
* `mutates = "this,param1"` -- callee mutates both
799+
800+
The checker maps `param1`, `param2`, etc. to the actual arguments at the call site
801+
and warns if a mutated argument is not in the caller's `@Modifies` set:
802+
803+
[source,groovy]
804+
----
805+
@Modifies({ this.items })
806+
void process() {
807+
addToList(items, 'x') // OK: items is in @Modifies
808+
addToList(other, 'x') // WARNING: other is not in @Modifies
809+
}
810+
811+
@Contract(mutates = "param1")
812+
static void addToList(List list, String item) { list.add(item) }
813+
----
814+
764815
=== Interaction with other contract annotations
765816
766817
The `ModifiesChecker` works alongside the other design-by-contract annotations to provide

0 commit comments

Comments
 (0)