Skip to content

Commit 3f18de2

Browse files
committed
GROOVY-11909: Add a @Modifies annotation to groovy-contracts
1 parent 3d23516 commit 3f18de2

7 files changed

Lines changed: 676 additions & 2 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.contracts;
20+
21+
import org.apache.groovy.lang.annotation.Incubating;
22+
import org.codehaus.groovy.transform.GroovyASTTransformationClass;
23+
24+
import java.lang.annotation.ElementType;
25+
import java.lang.annotation.Repeatable;
26+
import java.lang.annotation.Retention;
27+
import java.lang.annotation.RetentionPolicy;
28+
import java.lang.annotation.Target;
29+
30+
/**
31+
* Declares the <b>frame condition</b> for a method: the set of fields and parameters
32+
* that the method is allowed to modify. All other state is implicitly declared unchanged.
33+
* <p>
34+
* The closure value lists the modifiable targets as field references ({@code this.fieldName})
35+
* or parameter references ({@code paramName}). Multiple targets can be specified using a list:
36+
* <pre>
37+
* &#064;Modifies({ this.items })
38+
* void addItem(Item item) { ... }
39+
*
40+
* &#064;Modifies({ [this.items, this.count] })
41+
* void addAndCount(Item item) { ... }
42+
* </pre>
43+
* <p>
44+
* Multiple {@code @Modifies} annotations can also be used (via {@link Repeatable}):
45+
* <pre>
46+
* &#064;Modifies({ this.items })
47+
* &#064;Modifies({ this.count })
48+
* void addAndCount(Item item) { ... }
49+
* </pre>
50+
* <p>
51+
* When both {@code @Modifies} and {@link Ensures} are present on a method,
52+
* the {@code old} variable in the postcondition may only reference fields
53+
* declared in {@code @Modifies}. A compile error is reported otherwise.
54+
* <p>
55+
* {@code @Modifies} does not generate runtime assertion code. It serves as
56+
* a specification for humans and tools (including AI) to reason about method behavior.
57+
*
58+
* @since 6.0.0
59+
* @see Ensures
60+
* @see Requires
61+
*/
62+
@Retention(RetentionPolicy.RUNTIME)
63+
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
64+
@Incubating
65+
@Repeatable(ModifiesConditions.class)
66+
@GroovyASTTransformationClass({
67+
"org.apache.groovy.contracts.ast.ModifiesASTTransformation",
68+
"org.apache.groovy.contracts.ast.ModifiesEnsuresValidationTransformation"
69+
})
70+
public @interface Modifies {
71+
Class value();
72+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package groovy.contracts;
20+
21+
import org.apache.groovy.lang.annotation.Incubating;
22+
23+
import java.lang.annotation.ElementType;
24+
import java.lang.annotation.Retention;
25+
import java.lang.annotation.RetentionPolicy;
26+
import java.lang.annotation.Target;
27+
28+
/**
29+
* Container for multiple {@link Modifies} annotations on the same method.
30+
*
31+
* @since 6.0.0
32+
* @see Modifies
33+
*/
34+
@Retention(RetentionPolicy.RUNTIME)
35+
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
36+
@Incubating
37+
public @interface ModifiesConditions {
38+
Modifies[] value();
39+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.groovy.contracts.ast;
20+
21+
import org.codehaus.groovy.ast.ASTNode;
22+
import org.codehaus.groovy.ast.AnnotationNode;
23+
import org.codehaus.groovy.ast.ClassNode;
24+
import org.codehaus.groovy.ast.MethodNode;
25+
import org.codehaus.groovy.ast.Parameter;
26+
import org.codehaus.groovy.ast.expr.ClosureExpression;
27+
import org.codehaus.groovy.ast.expr.Expression;
28+
import org.codehaus.groovy.ast.expr.ListExpression;
29+
import org.codehaus.groovy.ast.expr.PropertyExpression;
30+
import org.codehaus.groovy.ast.expr.VariableExpression;
31+
import org.codehaus.groovy.ast.stmt.BlockStatement;
32+
import org.codehaus.groovy.ast.stmt.ExpressionStatement;
33+
import org.codehaus.groovy.ast.stmt.Statement;
34+
import org.codehaus.groovy.control.CompilePhase;
35+
import org.codehaus.groovy.control.SourceUnit;
36+
import org.codehaus.groovy.syntax.SyntaxException;
37+
import org.codehaus.groovy.transform.ASTTransformation;
38+
import org.codehaus.groovy.transform.GroovyASTTransformation;
39+
40+
import java.util.LinkedHashSet;
41+
import java.util.List;
42+
import java.util.Set;
43+
44+
/**
45+
* Handles {@link groovy.contracts.Modifies} annotations placed on methods.
46+
* Extracts the declared modification targets (fields and parameters) from
47+
* the annotation closure and stores them as node metadata on the {@link MethodNode}.
48+
* <p>
49+
* The metadata key is {@value #MODIFIES_FIELDS_KEY} and the value is a
50+
* {@code Set<String>} of field/parameter names. Downstream processors
51+
* (such as {@code @Ensures} validation) can read this metadata.
52+
*
53+
* @since 6.0.0
54+
* @see groovy.contracts.Modifies
55+
*/
56+
@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
57+
public class ModifiesASTTransformation implements ASTTransformation {
58+
59+
/** Node metadata key for the set of modifiable field/parameter names. */
60+
public static final String MODIFIES_FIELDS_KEY = "groovy.contracts.modifiesFields";
61+
62+
@Override
63+
@SuppressWarnings("unchecked")
64+
public void visit(final ASTNode[] nodes, final SourceUnit source) {
65+
if (nodes.length != 2) return;
66+
if (!(nodes[0] instanceof AnnotationNode annotation)) return;
67+
if (!(nodes[1] instanceof MethodNode methodNode)) return;
68+
69+
// For @Repeatable, each annotation triggers this transform separately,
70+
// so merge with any existing metadata from prior invocations.
71+
Set<String> modifiesSet = (Set<String>) methodNode.getNodeMetaData(MODIFIES_FIELDS_KEY);
72+
if (modifiesSet == null) {
73+
modifiesSet = new LinkedHashSet<>();
74+
}
75+
76+
extractFromAnnotation(annotation, methodNode, modifiesSet, source);
77+
78+
if (!modifiesSet.isEmpty()) {
79+
methodNode.putNodeMetaData(MODIFIES_FIELDS_KEY, modifiesSet);
80+
}
81+
}
82+
83+
private static void extractFromAnnotation(AnnotationNode annotation, MethodNode methodNode, Set<String> modifiesSet, SourceUnit source) {
84+
Expression value = annotation.getMember("value");
85+
if (!(value instanceof ClosureExpression closureExpr)) return;
86+
87+
Expression expr = extractExpression(closureExpr);
88+
if (expr == null) {
89+
source.addError(new SyntaxException(
90+
"@Modifies closure must contain a field reference, parameter reference, or list of references",
91+
annotation.getLineNumber(), annotation.getColumnNumber()));
92+
return;
93+
}
94+
95+
if (expr instanceof ListExpression listExpr) {
96+
for (Expression element : listExpr.getExpressions()) {
97+
addValidatedName(element, methodNode, modifiesSet, source);
98+
}
99+
} else {
100+
addValidatedName(expr, methodNode, modifiesSet, source);
101+
}
102+
}
103+
104+
private static void addValidatedName(Expression expr, MethodNode methodNode, Set<String> modifiesSet, SourceUnit source) {
105+
if (expr instanceof PropertyExpression propExpr) {
106+
Expression objExpr = propExpr.getObjectExpression();
107+
if (objExpr instanceof VariableExpression varExpr && "this".equals(varExpr.getName())) {
108+
String fieldName = propExpr.getPropertyAsString();
109+
ClassNode declaringClass = methodNode.getDeclaringClass();
110+
if (declaringClass != null && declaringClass.getField(fieldName) == null) {
111+
source.addError(new SyntaxException(
112+
"@Modifies references field '" + fieldName + "' which does not exist in " + declaringClass.getName(),
113+
expr.getLineNumber(), expr.getColumnNumber()));
114+
} else {
115+
modifiesSet.add(fieldName);
116+
}
117+
return;
118+
}
119+
}
120+
if (expr instanceof VariableExpression varExpr) {
121+
String name = varExpr.getName();
122+
if (!"this".equals(name)) {
123+
boolean isParam = false;
124+
for (Parameter param : methodNode.getParameters()) {
125+
if (param.getName().equals(name)) {
126+
isParam = true;
127+
break;
128+
}
129+
}
130+
if (!isParam) {
131+
source.addError(new SyntaxException(
132+
"@Modifies references '" + name + "' which is not a parameter of " + methodNode.getName() + "()",
133+
expr.getLineNumber(), expr.getColumnNumber()));
134+
} else {
135+
modifiesSet.add(name);
136+
}
137+
return;
138+
}
139+
}
140+
source.addError(new SyntaxException(
141+
"@Modifies elements must be field references (this.field) or parameter references",
142+
expr.getLineNumber(), expr.getColumnNumber()));
143+
}
144+
145+
private static Expression extractExpression(ClosureExpression closureExpression) {
146+
BlockStatement block = (BlockStatement) closureExpression.getCode();
147+
List<Statement> statements = block.getStatements();
148+
if (statements.size() != 1) return null;
149+
Statement stmt = statements.get(0);
150+
if (stmt instanceof ExpressionStatement exprStmt) {
151+
return exprStmt.getExpression();
152+
}
153+
return null;
154+
}
155+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.groovy.contracts.ast;
20+
21+
import org.apache.groovy.contracts.ast.visitor.AnnotationClosureVisitor;
22+
import org.codehaus.groovy.ast.ASTNode;
23+
import org.codehaus.groovy.ast.AnnotationNode;
24+
import org.codehaus.groovy.ast.MethodNode;
25+
import org.codehaus.groovy.control.CompilePhase;
26+
import org.codehaus.groovy.control.SourceUnit;
27+
import org.codehaus.groovy.syntax.SyntaxException;
28+
import org.codehaus.groovy.transform.ASTTransformation;
29+
import org.codehaus.groovy.transform.GroovyASTTransformation;
30+
31+
import java.util.Set;
32+
33+
/**
34+
* Validates that {@code @Ensures} postconditions on a method only reference
35+
* fields via {@code old.xxx} that are declared as modifiable by {@code @Modifies}.
36+
* <p>
37+
* Runs at {@link CompilePhase#INSTRUCTION_SELECTION} after both:
38+
* <ul>
39+
* <li>{@link ModifiesASTTransformation} has stored the modification set, and</li>
40+
* <li>{@link AnnotationClosureVisitor} has recorded the {@code old} references</li>
41+
* </ul>
42+
* Then simply compares the two metadata sets.
43+
*
44+
* @since 6.0.0
45+
* @see groovy.contracts.Modifies
46+
*/
47+
@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
48+
public class ModifiesEnsuresValidationTransformation implements ASTTransformation {
49+
50+
@Override
51+
@SuppressWarnings("unchecked")
52+
public void visit(final ASTNode[] nodes, final SourceUnit source) {
53+
if (nodes.length != 2) return;
54+
if (!(nodes[0] instanceof AnnotationNode annotation)) return;
55+
if (!(nodes[1] instanceof MethodNode methodNode)) return;
56+
57+
Set<String> modifiesSet = (Set<String>) methodNode.getNodeMetaData(ModifiesASTTransformation.MODIFIES_FIELDS_KEY);
58+
if (modifiesSet == null || modifiesSet.isEmpty()) return;
59+
60+
Set<String> oldRefs = (Set<String>) methodNode.getNodeMetaData(AnnotationClosureVisitor.OLD_REFERENCES_KEY);
61+
if (oldRefs == null || oldRefs.isEmpty()) return;
62+
63+
for (String ref : oldRefs) {
64+
if (!modifiesSet.contains(ref)) {
65+
source.addError(new SyntaxException(
66+
"@Ensures references old." + ref + " but @Modifies does not declare '" + ref + "' as modifiable",
67+
annotation.getLineNumber(), annotation.getColumnNumber()));
68+
}
69+
}
70+
}
71+
}

subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,9 @@ protected SourceUnit getSourceUnit() {
459459
}
460460
}
461461

462+
/** Node metadata key for the set of field names referenced via {@code old.xxx} in postconditions. */
463+
public static final String OLD_REFERENCES_KEY = "groovy.contracts.oldReferences";
464+
462465
private static class OldPropertyExpressionTransformer extends ClassCodeExpressionTransformer {
463466
private final MethodNode methodNode;
464467
private CastExpression currentCast;
@@ -472,6 +475,7 @@ protected SourceUnit getSourceUnit() {
472475
return null;
473476
}
474477

478+
@SuppressWarnings("unchecked")
475479
@Override
476480
public Expression transform(Expression expr) {
477481
if (expr instanceof CastExpression) {
@@ -488,6 +492,13 @@ public Expression transform(Expression expr) {
488492
if (objExpr instanceof VariableExpression varExpr) {
489493
if ("old".equals(varExpr.getName())) {
490494
String propName = propExpr.getPropertyAsString();
495+
// Record the old reference for @Modifies validation
496+
java.util.Set<String> oldRefs = (java.util.Set<String>) methodNode.getNodeMetaData(OLD_REFERENCES_KEY);
497+
if (oldRefs == null) {
498+
oldRefs = new java.util.LinkedHashSet<>();
499+
methodNode.putNodeMetaData(OLD_REFERENCES_KEY, oldRefs);
500+
}
501+
oldRefs.add(propName);
491502
ClassNode declaringClass = methodNode.getDeclaringClass();
492503
if (declaringClass != null && declaringClass.getField(propName) != null) {
493504
CastExpression adjusted = new CastExpression(declaringClass.getField(propName).getType(), expr);

0 commit comments

Comments
 (0)