This is an automated email from the ASF dual-hosted git repository.
paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 3f18de2a1d GROOVY-11909: Add a @Modifies annotation to groovy-contracts
3f18de2a1d is described below
commit 3f18de2a1d29c5e9c55940a3cd1b2f2d37d4603c
Author: Paul King <[email protected]>
AuthorDate: Tue Apr 7 10:15:54 2026 +1000
GROOVY-11909: Add a @Modifies annotation to groovy-contracts
---
.../src/main/java/groovy/contracts/Modifies.java | 72 +++++++
.../java/groovy/contracts/ModifiesConditions.java | 39 ++++
.../contracts/ast/ModifiesASTTransformation.java | 155 ++++++++++++++
.../ModifiesEnsuresValidationTransformation.java | 71 +++++++
.../ast/visitor/AnnotationClosureVisitor.java | 11 +
.../src/spec/doc/contracts-userguide.adoc | 95 ++++++++-
.../contracts/tests/post/ModifiesTests.groovy | 235 +++++++++++++++++++++
7 files changed, 676 insertions(+), 2 deletions(-)
diff --git
a/subprojects/groovy-contracts/src/main/java/groovy/contracts/Modifies.java
b/subprojects/groovy-contracts/src/main/java/groovy/contracts/Modifies.java
new file mode 100644
index 0000000000..694d0f8a84
--- /dev/null
+++ b/subprojects/groovy-contracts/src/main/java/groovy/contracts/Modifies.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.contracts;
+
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Declares the <b>frame condition</b> for a method: the set of fields and
parameters
+ * that the method is allowed to modify. All other state is implicitly
declared unchanged.
+ * <p>
+ * The closure value lists the modifiable targets as field references ({@code
this.fieldName})
+ * or parameter references ({@code paramName}). Multiple targets can be
specified using a list:
+ * <pre>
+ * @Modifies({ this.items })
+ * void addItem(Item item) { ... }
+ *
+ * @Modifies({ [this.items, this.count] })
+ * void addAndCount(Item item) { ... }
+ * </pre>
+ * <p>
+ * Multiple {@code @Modifies} annotations can also be used (via {@link
Repeatable}):
+ * <pre>
+ * @Modifies({ this.items })
+ * @Modifies({ this.count })
+ * void addAndCount(Item item) { ... }
+ * </pre>
+ * <p>
+ * When both {@code @Modifies} and {@link Ensures} are present on a method,
+ * the {@code old} variable in the postcondition may only reference fields
+ * declared in {@code @Modifies}. A compile error is reported otherwise.
+ * <p>
+ * {@code @Modifies} does not generate runtime assertion code. It serves as
+ * a specification for humans and tools (including AI) to reason about method
behavior.
+ *
+ * @since 6.0.0
+ * @see Ensures
+ * @see Requires
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
+@Incubating
+@Repeatable(ModifiesConditions.class)
+@GroovyASTTransformationClass({
+ "org.apache.groovy.contracts.ast.ModifiesASTTransformation",
+
"org.apache.groovy.contracts.ast.ModifiesEnsuresValidationTransformation"
+})
+public @interface Modifies {
+ Class value();
+}
diff --git
a/subprojects/groovy-contracts/src/main/java/groovy/contracts/ModifiesConditions.java
b/subprojects/groovy-contracts/src/main/java/groovy/contracts/ModifiesConditions.java
new file mode 100644
index 0000000000..8cc4d86595
--- /dev/null
+++
b/subprojects/groovy-contracts/src/main/java/groovy/contracts/ModifiesConditions.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.contracts;
+
+import org.apache.groovy.lang.annotation.Incubating;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Container for multiple {@link Modifies} annotations on the same method.
+ *
+ * @since 6.0.0
+ * @see Modifies
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
+@Incubating
+public @interface ModifiesConditions {
+ Modifies[] value();
+}
diff --git
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
new file mode 100644
index 0000000000..45b1f1984c
--- /dev/null
+++
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.contracts.ast;
+
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+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 java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Handles {@link groovy.contracts.Modifies} annotations placed on methods.
+ * Extracts the declared modification targets (fields and parameters) from
+ * the annotation closure and stores them as node metadata on the {@link
MethodNode}.
+ * <p>
+ * The metadata key is {@value #MODIFIES_FIELDS_KEY} and the value is a
+ * {@code Set<String>} of field/parameter names. Downstream processors
+ * (such as {@code @Ensures} validation) can read this metadata.
+ *
+ * @since 6.0.0
+ * @see groovy.contracts.Modifies
+ */
+@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
+public class ModifiesASTTransformation implements ASTTransformation {
+
+ /** Node metadata key for the set of modifiable field/parameter names. */
+ public static final String MODIFIES_FIELDS_KEY =
"groovy.contracts.modifiesFields";
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void visit(final ASTNode[] nodes, final SourceUnit source) {
+ if (nodes.length != 2) return;
+ if (!(nodes[0] instanceof AnnotationNode annotation)) return;
+ if (!(nodes[1] instanceof MethodNode methodNode)) return;
+
+ // For @Repeatable, each annotation triggers this transform separately,
+ // so merge with any existing metadata from prior invocations.
+ Set<String> modifiesSet = (Set<String>)
methodNode.getNodeMetaData(MODIFIES_FIELDS_KEY);
+ if (modifiesSet == null) {
+ modifiesSet = new LinkedHashSet<>();
+ }
+
+ extractFromAnnotation(annotation, methodNode, modifiesSet, source);
+
+ if (!modifiesSet.isEmpty()) {
+ methodNode.putNodeMetaData(MODIFIES_FIELDS_KEY, modifiesSet);
+ }
+ }
+
+ private static void extractFromAnnotation(AnnotationNode annotation,
MethodNode methodNode, Set<String> modifiesSet, SourceUnit source) {
+ Expression value = annotation.getMember("value");
+ if (!(value instanceof ClosureExpression closureExpr)) return;
+
+ Expression expr = extractExpression(closureExpr);
+ if (expr == null) {
+ source.addError(new SyntaxException(
+ "@Modifies closure must contain a field reference,
parameter reference, or list of references",
+ annotation.getLineNumber(), annotation.getColumnNumber()));
+ return;
+ }
+
+ if (expr instanceof ListExpression listExpr) {
+ for (Expression element : listExpr.getExpressions()) {
+ addValidatedName(element, methodNode, modifiesSet, source);
+ }
+ } else {
+ addValidatedName(expr, methodNode, modifiesSet, source);
+ }
+ }
+
+ private static void addValidatedName(Expression expr, MethodNode
methodNode, Set<String> modifiesSet, SourceUnit source) {
+ if (expr instanceof PropertyExpression propExpr) {
+ Expression objExpr = propExpr.getObjectExpression();
+ if (objExpr instanceof VariableExpression varExpr &&
"this".equals(varExpr.getName())) {
+ String fieldName = propExpr.getPropertyAsString();
+ ClassNode declaringClass = methodNode.getDeclaringClass();
+ if (declaringClass != null &&
declaringClass.getField(fieldName) == null) {
+ source.addError(new SyntaxException(
+ "@Modifies references field '" + fieldName + "'
which does not exist in " + declaringClass.getName(),
+ expr.getLineNumber(), expr.getColumnNumber()));
+ } else {
+ modifiesSet.add(fieldName);
+ }
+ return;
+ }
+ }
+ if (expr instanceof VariableExpression varExpr) {
+ String name = varExpr.getName();
+ if (!"this".equals(name)) {
+ boolean isParam = false;
+ for (Parameter param : methodNode.getParameters()) {
+ if (param.getName().equals(name)) {
+ isParam = true;
+ break;
+ }
+ }
+ if (!isParam) {
+ source.addError(new SyntaxException(
+ "@Modifies references '" + name + "' which is not
a parameter of " + methodNode.getName() + "()",
+ expr.getLineNumber(), expr.getColumnNumber()));
+ } else {
+ modifiesSet.add(name);
+ }
+ return;
+ }
+ }
+ source.addError(new SyntaxException(
+ "@Modifies elements must be field references (this.field) or
parameter references",
+ expr.getLineNumber(), expr.getColumnNumber()));
+ }
+
+ private static Expression extractExpression(ClosureExpression
closureExpression) {
+ BlockStatement block = (BlockStatement) closureExpression.getCode();
+ List<Statement> statements = block.getStatements();
+ if (statements.size() != 1) return null;
+ Statement stmt = statements.get(0);
+ if (stmt instanceof ExpressionStatement exprStmt) {
+ return exprStmt.getExpression();
+ }
+ return null;
+ }
+}
diff --git
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
new file mode 100644
index 0000000000..1a457bb6e3
--- /dev/null
+++
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.contracts.ast;
+
+import org.apache.groovy.contracts.ast.visitor.AnnotationClosureVisitor;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.MethodNode;
+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 java.util.Set;
+
+/**
+ * Validates that {@code @Ensures} postconditions on a method only reference
+ * fields via {@code old.xxx} that are declared as modifiable by {@code
@Modifies}.
+ * <p>
+ * Runs at {@link CompilePhase#INSTRUCTION_SELECTION} after both:
+ * <ul>
+ * <li>{@link ModifiesASTTransformation} has stored the modification set,
and</li>
+ * <li>{@link AnnotationClosureVisitor} has recorded the {@code old}
references</li>
+ * </ul>
+ * Then simply compares the two metadata sets.
+ *
+ * @since 6.0.0
+ * @see groovy.contracts.Modifies
+ */
+@GroovyASTTransformation(phase = CompilePhase.INSTRUCTION_SELECTION)
+public class ModifiesEnsuresValidationTransformation implements
ASTTransformation {
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void visit(final ASTNode[] nodes, final SourceUnit source) {
+ if (nodes.length != 2) return;
+ if (!(nodes[0] instanceof AnnotationNode annotation)) return;
+ if (!(nodes[1] instanceof MethodNode methodNode)) return;
+
+ Set<String> modifiesSet = (Set<String>)
methodNode.getNodeMetaData(ModifiesASTTransformation.MODIFIES_FIELDS_KEY);
+ if (modifiesSet == null || modifiesSet.isEmpty()) return;
+
+ Set<String> oldRefs = (Set<String>)
methodNode.getNodeMetaData(AnnotationClosureVisitor.OLD_REFERENCES_KEY);
+ if (oldRefs == null || oldRefs.isEmpty()) return;
+
+ for (String ref : oldRefs) {
+ if (!modifiesSet.contains(ref)) {
+ source.addError(new SyntaxException(
+ "@Ensures references old." + ref + " but @Modifies
does not declare '" + ref + "' as modifiable",
+ annotation.getLineNumber(),
annotation.getColumnNumber()));
+ }
+ }
+ }
+}
diff --git
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
index 024d4168d1..5f418364c2 100644
---
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
+++
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
@@ -459,6 +459,9 @@ public class AnnotationClosureVisitor extends BaseVisitor
implements ASTNodeMeta
}
}
+ /** Node metadata key for the set of field names referenced via {@code
old.xxx} in postconditions. */
+ public static final String OLD_REFERENCES_KEY =
"groovy.contracts.oldReferences";
+
private static class OldPropertyExpressionTransformer extends
ClassCodeExpressionTransformer {
private final MethodNode methodNode;
private CastExpression currentCast;
@@ -472,6 +475,7 @@ public class AnnotationClosureVisitor extends BaseVisitor
implements ASTNodeMeta
return null;
}
+ @SuppressWarnings("unchecked")
@Override
public Expression transform(Expression expr) {
if (expr instanceof CastExpression) {
@@ -488,6 +492,13 @@ public class AnnotationClosureVisitor extends BaseVisitor
implements ASTNodeMeta
if (objExpr instanceof VariableExpression varExpr) {
if ("old".equals(varExpr.getName())) {
String propName = propExpr.getPropertyAsString();
+ // Record the old reference for @Modifies validation
+ java.util.Set<String> oldRefs =
(java.util.Set<String>) methodNode.getNodeMetaData(OLD_REFERENCES_KEY);
+ if (oldRefs == null) {
+ oldRefs = new java.util.LinkedHashSet<>();
+ methodNode.putNodeMetaData(OLD_REFERENCES_KEY,
oldRefs);
+ }
+ oldRefs.add(propName);
ClassNode declaringClass =
methodNode.getDeclaringClass();
if (declaringClass != null &&
declaringClass.getField(propName) != null) {
CastExpression adjusted = new
CastExpression(declaringClass.getField(propName).getType(), expr);
diff --git a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
index 2fe98b130c..e69431171e 100644
--- a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
+++ b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
@@ -22,8 +22,8 @@
= Groovy Contracts – design by contract support for Groovy
This module provides contract annotations that support the specification of
class-invariants,
-pre- and post-conditions on Groovy classes and interfaces, and loop invariants
on
-`for`, `while`, and `do-while` loops.
+pre- and post-conditions on Groovy classes and interfaces, loop invariants on
+`for`, `while`, and `do-while` loops, and frame conditions declaring which
fields a method may modify.
Special support is provided so that post-conditions may refer to the old value
of variables
or to the result value associated with calling a method.
@@ -44,6 +44,7 @@ Groovy contracts supports the following feature set:
* definition of class invariants, pre- and post-conditions via @Invariant,
@Requires and @Ensures
* definition of loop invariants on `for`, `while`, and `do-while` loops via
@Invariant
+* declaration of frame conditions (which fields a method may modify) via
@Modifies
* inheritance of class invariants, pre- and post-conditions of concrete
predecessor classes
* inheritance of class invariants, pre- and post-conditions in implemented
interfaces
* usage of old and result variable in post-condition assertions
@@ -140,3 +141,93 @@
include::../test/ContractsTest.groovy[tags=loop_invariant_multiple_example,inden
* Assignment operators and state-changing postfix/prefix operators are
not supported inside the closure.
+== Frame Conditions with @Modifies
+
+The `@Modifies` annotation declares which fields or parameters a method is
allowed to modify.
+All other state is implicitly declared unchanged. This is known as a _frame
condition_ in
+design-by-contract terminology, inspired by similar constructs in Dafny and
JML.
+
+`@Modifies` does not generate runtime assertion code — it serves as a
specification
+for humans and tools (including AI) to reason about what a method changes.
+
+=== Basic usage
+
+Use `this.fieldName` to declare that a field may be modified:
+
+[source,groovy]
+----
+@Modifies({ this.items })
+void addItem(String item) {
+ items.add(item)
+}
+----
+
+For multiple fields, use a list expression:
+
+[source,groovy]
+----
+@Modifies({ [this.items, this.count] })
+void addAndCount(String item) {
+ items.add(item)
+ count++
+}
+----
+
+Or use multiple `@Modifies` annotations (via `@Repeatable`):
+
+[source,groovy]
+----
+@Modifies({ this.items })
+@Modifies({ this.count })
+void addAndCount(String item) { ... }
+----
+
+=== Parameter references
+
+Method parameters can also be declared as modifiable:
+
+[source,groovy]
+----
+@Modifies({ arr })
+void fillArray(int[] arr) {
+ for (int i = 0; i < arr.length; i++) {
+ arr[i] = i
+ }
+}
+----
+
+=== Interaction with @Ensures
+
+When both `@Modifies` and `@Ensures` are present on a method, a compile-time
check
+ensures that the `old` variable in the postcondition only references fields
declared
+in `@Modifies`. Referencing a field via `old` that is not in the modification
set
+produces a compile error:
+
+[source,groovy]
+----
+@Modifies({ this.items })
+@Ensures({ old -> old.items.size() < items.size() }) // OK: items is declared
+void addItem(String item) { ... }
+
+@Modifies({ this.items })
+@Ensures({ old -> old.count == count }) // ERROR: count not declared in
@Modifies
+void addItem(String item) { ... }
+----
+
+=== Compile-time validation
+
+The `@Modifies` annotation validates at compile time that:
+
+* Field references (`this.fieldName`) refer to fields that exist on the class.
+* Parameter references refer to actual parameters of the method.
+* If `@Ensures` is also present, `old.fieldName` references only access fields
+ declared in `@Modifies`.
+
+=== Rules for @Modifies closures
+
+* The closure must contain a single field reference (`this.field`), parameter
+ reference (`param`), or a list of such references.
+* Field references use the `this.fieldName` syntax.
+* Parameter references use the bare parameter name.
+* The closure is not executed at runtime — it is only analyzed at compile time.
+
diff --git
a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
new file mode 100644
index 0000000000..5eed2df0db
--- /dev/null
+++
b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.contracts.tests.post
+
+import org.apache.groovy.contracts.tests.basic.BaseTestClass
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.junit.jupiter.api.Test
+
+class ModifiesTests extends BaseTestClass {
+
+ @Test
+ void modifies_alone_compiles_and_runs() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+
+ @Modifies({ this.items })
+ void addItem(String item) {
+ items.add(item)
+ }
+ }
+ ''')
+ instance.addItem('hello')
+ assert instance.items == ['hello']
+ }
+
+ @Test
+ void modifies_with_list_syntax() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+ int count = 0
+
+ @Modifies({ [this.items, this.count] })
+ void addItem(String item) {
+ items.add(item)
+ count++
+ }
+ }
+ ''')
+ instance.addItem('hello')
+ assert instance.items == ['hello']
+ assert instance.count == 1
+ }
+
+ @Test
+ void modifies_with_parameter_reference() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ @Modifies({ arr })
+ void fillArray(int[] arr) {
+ for (int i = 0; i < arr.length; i++) {
+ arr[i] = i
+ }
+ }
+ }
+ ''')
+ def arr = new int[3]
+ instance.fillArray(arr)
+ assert arr == [0, 1, 2] as int[]
+ }
+
+ @Test
+ void modifies_with_ensures_valid_old_reference() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ @Ensures({ old -> old.count < count })
+ void increment() {
+ count++
+ }
+ }
+ ''')
+ instance.increment()
+ assert instance.count == 1
+ }
+
+ @Test
+ void modifies_with_ensures_valid_old_reference_list_syntax() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+ int count = 0
+
+ @Modifies({ [this.items, this.count] })
+ @Ensures({ old -> old.count < count })
+ void addItem(String item) {
+ items.add(item)
+ count++
+ }
+ }
+ ''')
+ instance.addItem('hello')
+ assert instance.count == 1
+ }
+
+ @Test
+ void modifies_with_ensures_invalid_old_reference_causes_compile_error() {
+ shouldFail MultipleCompilationErrorsException, {
+ add_class_to_classpath('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+ int count = 0
+
+ @Modifies({ this.items })
+ @Ensures({ old -> old.count == count })
+ void addItem(String item) {
+ items.add(item)
+ }
+ }
+ ''')
+ }
+ }
+
+ @Test
+ void ensures_without_modifies_no_error() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Ensures({ old -> old.count < count })
+ void increment() {
+ count++
+ }
+ }
+ ''')
+ instance.increment()
+ assert instance.count == 1
+ }
+
+ @Test
+ void modifies_without_ensures_no_error() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void increment() {
+ count++
+ }
+ }
+ ''')
+ instance.increment()
+ assert instance.count == 1
+ }
+
+ @Test
+ void multiple_modifies_via_repeatable() {
+ def instance = create_instance_of('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+ int count = 0
+
+ @Modifies({ this.items })
+ @Modifies({ this.count })
+ @Ensures({ old -> old.count < count })
+ void addItem(String item) {
+ items.add(item)
+ count++
+ }
+ }
+ ''')
+ instance.addItem('hello')
+ assert instance.count == 1
+ }
+
+ @Test
+ void modifies_with_nonexistent_field_causes_compile_error() {
+ shouldFail MultipleCompilationErrorsException, {
+ add_class_to_classpath('''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+
+ @Modifies({ this.nonExistent })
+ void addItem(String item) {
+ items.add(item)
+ }
+ }
+ ''')
+ }
+ }
+
+ @Test
+ void modifies_with_nonexistent_param_causes_compile_error() {
+ shouldFail MultipleCompilationErrorsException, {
+ add_class_to_classpath('''
+ import groovy.contracts.*
+
+ class A {
+ @Modifies({ noSuchParam })
+ void addItem(String item) {
+ }
+ }
+ ''')
+ }
+ }
+}