[ 
https://issues.apache.org/jira/browse/GROOVY-11970?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18076830#comment-18076830
 ] 

ASF GitHub Bot commented on GROOVY-11970:
-----------------------------------------

Copilot commented on code in PR #2499:
URL: https://github.com/apache/groovy/pull/2499#discussion_r3154088844


##########
src/main/java/org/codehaus/groovy/transform/OperatorRenameASTTransformation.java:
##########
@@ -122,9 +137,20 @@ public Expression transform(Expression expr) {
         if (expr == null) return null;
         if (expr instanceof BinaryExpression be) {
             int type = be.getOperation().getType();
+            boolean isEqualOperator = removeAssignment(type) != type;
+            // GEP-15: a *Assign rename takes precedence over the base rename 
for compound-assign tokens
+            if (isEqualOperator) {
+                String assignOldName = getAssignOperationName(type);
+                if (assignOldName != null && 
assignNameTable.containsKey(assignOldName)) {
+                    Expression left = transform(be.getLeftExpression());
+                    Expression right = transform(be.getRightExpression());
+                    Expression result = callX(left, 
assignNameTable.get(assignOldName), right);
+                    result.setSourcePosition(be);
+                    return result;
+                }

Review Comment:
   The `plusAssign` (and other `*Assign`) rename path rewrites `x op= y` to a 
plain method call and returns that call expression. This changes 
compound-assignment semantics in expression context: `(x += y)` should evaluate 
to the (mutated) `x`, not the return value of `x.<assignMethod>(y)` (often 
`void`/`null`). The transform should preserve the original expression value 
contract while still skipping reassignment/setter.



##########
src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingSupport.java:
##########
@@ -524,6 +524,31 @@ static String getOperationName(final int op) {
         };
     }
 
+    /**
+     * GEP-15: returns the dedicated compound-assignment method name for a 
compound-assign
+     * operator token (e.g. {@code PLUS_EQUAL} -> {@code "plusAssign"}), or 
{@code null} if
+     * the token is not one of the twelve operators in scope. {@code 
INTDIV_EQUAL},
+     * {@code MOD_EQUAL}, {@code ELVIS_EQUAL}, {@code LOGICAL_OR_EQUAL} and
+     * {@code LOGICAL_AND_EQUAL} are intentionally excluded.
+     */
+    public static String getAssignOperationName(final int op) {
+        return switch (op) {
+            case PLUS_EQUAL                 -> "plusAssign";
+            case MINUS_EQUAL                -> "minusAssign";
+            case MULTIPLY_EQUAL             -> "multiplyAssign";
+            case DIVIDE_EQUAL               -> "divAssign";
+            case REMAINDER_EQUAL            -> "remainderAssign";
+            case POWER_EQUAL                -> "powerAssign";
+            case LEFT_SHIFT_EQUAL           -> "leftShiftAssign";
+            case RIGHT_SHIFT_EQUAL          -> "rightShiftAssign";
+            case RIGHT_SHIFT_UNSIGNED_EQUAL -> "rightShiftUnsignedAssign";
+            case BITWISE_AND_EQUAL          -> "andAssign";
+            case BITWISE_OR_EQUAL           -> "orAssign";
+            case BITWISE_XOR_EQUAL          -> "xorAssign";
+            default                         -> null;
+        };

Review Comment:
   `getAssignOperationName` only maps `REMAINDER_EQUAL`, but Groovy still 
defines/uses `MOD_EQUAL` (`%=`) as a distinct token in some parser paths. Since 
codegen now routes `MOD_EQUAL` through `remainderAssign` (see 
`BinaryExpressionHelper`), the STC helper should likely map `MOD_EQUAL` to 
`"remainderAssign"` as well; otherwise statically compiled `%=` may miss 
`remainderAssign` depending on which token the frontend produces.



##########
src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionMultiTypeDispatcher.java:
##########
@@ -332,6 +332,15 @@ protected void 
evaluateBinaryExpressionWithAssignment(final String method, final
         super.evaluateBinaryExpressionWithAssignment(method, binExp);
     }
 
+    @Override
+    protected void evaluateCompoundAssign(final String assignName, final 
String baseName, final BinaryExpression binExp) {
+        // GEP-15: keep the primitive-int/long/float/double fast paths for int 
i += j etc.;
+        // they cannot have a *Assign override anyway since primitives are 
excluded.
+        if (doAssignmentToArray(binExp)) return;
+        if (doAssignmentToLocalVariable(baseName, binExp)) return;
+        super.evaluateCompoundAssign(assignName, baseName, binExp);
+    }
+

Review Comment:
   `evaluateCompoundAssign` currently reuses 
`doAssignmentToLocalVariable(baseName, ...)`, which emits bytecode for `x = 
x.<baseOp>(y)` and bypasses the new `compoundAssign` dispatcher. For 
non-primitive locals this will ignore a receiver's `*Assign` overload (e.g. 
`plusAssign`) in dynamic mode. Limit the local-variable fast path to true 
primitive cases (or delegate to `super.evaluateCompoundAssign(...)` for 
non-primitive LHS) so `*Assign` is still discoverable.
   ```suggestion
   
           Expression left = binExp.getLeftExpression();
           if (left instanceof VariableExpression) {
               ClassNode leftType = 
controller.getTypeChooser().resolveType(left, controller.getClassNode());
               if (isPrimitiveType(leftType) && 
doAssignmentToLocalVariable(baseName, binExp)) return;
           }
   
           super.evaluateCompoundAssign(assignName, baseName, binExp);
       }
   
       private static boolean isPrimitiveType(final ClassNode type) {
           return type == boolean_TYPE
                   || type == byte_TYPE
                   || type == char_TYPE
                   || type == double_TYPE
                   || type == float_TYPE
                   || type == int_TYPE
                   || type == long_TYPE
                   || type == short_TYPE;
       }
   ```



##########
src/test/groovy/groovy/operator/CompoundAssignmentTest.groovy:
##########
@@ -0,0 +1,301 @@
+/*
+ *  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.operator
+
+import groovy.test.GroovyTestCase
+
+/**
+ * GEP-15: tests for dedicated compound-assignment operator overloads
+ * (plusAssign, minusAssign, ...).
+ *
+ * Test scripts use a uniquely-named entry method (not run()) so they do not
+ * collide with the script class's auto-generated run() method.
+ */
+final class CompoundAssignmentTest extends GroovyTestCase {
+
+    void testStaticPlusAssignChosenOverPlus() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class Acc {
+                int total = 0
+                int plusCalls = 0
+                int plusAssignCalls = 0
+                void plusAssign(int n) { total += n; plusAssignCalls++ }
+                Acc plus(int n) { plusCalls++; def a = new Acc(); a.total = 
total + n; a }
+            }
+
+            @CompileStatic
+            void exercise() {
+                def a = new Acc()
+                a += 5
+                assert a.total == 5
+                assert a.plusAssignCalls == 1
+                assert a.plusCalls == 0
+                def b = a + 10
+                assert a.plusCalls == 1
+                assert b.total == 15
+            }
+            exercise()
+        '''
+    }
+
+    void testStaticPlusFallbackWhenNoPlusAssign() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class V {
+                int n = 0
+                V plus(int x) { def v = new V(); v.n = n + x; v }
+            }
+
+            @CompileStatic
+            void exercise() {
+                def v = new V()
+                v += 7
+                assert v.n == 7
+            }
+            exercise()
+        '''
+    }
+
+    void testStaticExpressionValueIsReceiver() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class C {
+                int n = 0
+                void plusAssign(int x) { n += x }
+            }
+
+            @CompileStatic
+            void exercise() {
+                def c = new C()
+                def r = (c += 4)
+                assert c.n == 4
+                assert r.is(c)
+            }
+            exercise()
+        '''
+    }
+
+    void testDynamicPlusAssignChosenOverPlus() {
+        assertScript '''
+            class Acc {
+                int total = 0
+                int plusCalls = 0
+                int plusAssignCalls = 0
+                void plusAssign(int n) { total += n; plusAssignCalls++ }
+                Acc plus(int n) { plusCalls++; def a = new Acc(); a.total = 
total + n; a }
+            }
+
+            def a = new Acc()
+            a += 5
+            assert a.total == 5
+            assert a.plusAssignCalls == 1
+            assert a.plusCalls == 0
+        '''
+    }
+
+    void testDynamicPlusFallbackWhenNoPlusAssign() {
+        assertScript '''
+            class V {
+                int n = 0
+                V plus(int x) { def v = new V(); v.n = n + x; v }
+            }
+
+            def v = new V()
+            v += 7
+            assert v.n == 7
+        '''
+    }
+
+    void testStaticPropertyCompoundAssignSkipsSetter() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            class Counter {
+                int n = 0
+                void plusAssign(int x) { n += x }
+            }
+
+            @CompileStatic
+            class Holder {
+                Counter counter = new Counter()
+                int setCounterCalls = 0
+                void setCounter(Counter c) { setCounterCalls++; this.@counter 
= c }
+            }
+
+            @CompileStatic
+            void exercise() {
+                def h = new Holder()
+                h.counter += 3
+                assert h.counter.n == 3
+                assert h.setCounterCalls == 0
+            }
+            exercise()
+        '''
+    }
+
+    void testStaticPrimitiveFastPathUnaffected() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            int sum(int n) {
+                int total = 0
+                for (i in 0..n) total += i
+                total
+            }
+
+            assert sum(10) == 55
+        '''
+    }
+
+    void testStaticFinalLocalWithPlusAssign() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class Counter {
+                int n = 0
+                void plusAssign(int x) { n += x }
+            }
+
+            @CompileStatic
+            void exercise() {
+                final Counter c = new Counter()
+                c += 5
+                assert c.n == 5
+            }
+            exercise()
+        '''
+    }
+
+    void testStaticFinalFieldWithPlusAssign() {
+        assertScript '''
+            import groovy.transform.CompileStatic
+
+            @CompileStatic
+            class Counter {
+                int n = 0
+                void plusAssign(int x) { n += x }
+            }
+
+            @CompileStatic
+            class Holder {
+                final Counter c = new Counter()
+            }
+
+            @CompileStatic
+            void exercise() {
+                def h = new Holder()
+                h.c += 5
+                assert h.c.n == 5
+            }
+            exercise()
+        '''
+    }
+
+    void testOperatorRenameAssignVariant() {
+        assertScript '''
+            import groovy.transform.OperatorRename
+
+            class Bag {
+                int total = 0
+                void addInPlace(int n) { total += n }
+                int add(int n) { total + n }
+            }
+
+            @OperatorRename(plus="add", plusAssign="addInPlace")
+            def exercise() {
+                def b = new Bag()
+                b += 5
+                assert b.total == 5
+                def s = b + 3
+                assert s == 8
+                assert b.total == 5
+            }
+            exercise()
+        '''
+    }

Review Comment:
   There are tests for expression-value semantics of `+=` in general, but none 
covering the `@OperatorRename(… plusAssign=…)` path. Adding a test like `def r 
= (b += 1); assert r.is(b)` (or equivalent) would help catch regressions where 
the rename transform accidentally returns the `*Assign` method's return value 
instead of the mutated receiver.



##########
src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java:
##########
@@ -755,6 +760,44 @@ protected void 
evaluateBinaryExpressionWithAssignment(final String method, final
         controller.getCompileStack().popLHS();
     }
 
+    /**
+     * GEP-15: dynamic-mode compound-assign codegen. Routes through
+     * {@link ScriptBytecodeAdapter#compoundAssign(Object, Object, String, 
String)}
+     * which dispatches to {@code assignName} when the receiver responds to it,
+     * and falls back to {@code baseName} otherwise. The caller stores the 
helper's
+     * return value into the LHS — for the in-place branch this is a no-op 
store
+     * of the receiver back to itself; for the fallback branch it is the usual
+     * "x = x.op(y)" assignment.
+     */
+    protected void evaluateCompoundAssign(final String assignName, final 
String baseName, final BinaryExpression expression) {
+        Expression leftExpression = expression.getLeftExpression();
+        if (leftExpression instanceof BinaryExpression bexp
+                && bexp.getOperation().getType() == LEFT_SQUARE_BRACKET) {
+            // Subscript LHS (e.g. a[i] += b) is intentionally out of scope 
for GEP-15;
+            // keep the legacy getAt/putAt-based path.
+            evaluateArrayAssignmentWithOperator(baseName, expression, bexp);
+            return;
+        }
+
+        StaticMethodCallExpression helperCall = new StaticMethodCallExpression(
+                ClassHelper.make(ScriptBytecodeAdapter.class),
+                "compoundAssign",
+                new ArgumentListExpression(new Expression[]{
+                        leftExpression,
+                        expression.getRightExpression(),
+                        new ConstantExpression(assignName),
+                        new ConstantExpression(baseName)
+                })

Review Comment:
   `evaluateCompoundAssign` doesn't currently account for null-safe compound 
assignments (`expression.isSafe()`), because 
`ScriptBytecodeAdapter.compoundAssign` will fall back to invoking the base 
operator even when the receiver is `null`. This breaks safe-navigation 
semantics (the operation should short-circuit without invoking `plus`/etc when 
safe and the LHS resolves to null). Consider either handling 
`expression.isSafe()` here by keeping the legacy safe path, or extending the 
helper to treat `safe && receiver==null` as a short-circuit.



##########
src/main/java/org/codehaus/groovy/transform/OperatorRenameASTTransformation.java:
##########
@@ -140,6 +166,24 @@ public Expression transform(Expression expr) {
         return expr.transformExpression(this);
     }
 
+    static String getAssignOperationName(final int op) {
+        return switch (op) {
+            case PLUS_EQUAL                 -> "plusAssign";
+            case MINUS_EQUAL                -> "minusAssign";
+            case MULTIPLY_EQUAL             -> "multiplyAssign";
+            case DIVIDE_EQUAL               -> "divAssign";
+            case REMAINDER_EQUAL            -> "remainderAssign";
+            case POWER_EQUAL                -> "powerAssign";
+            case LEFT_SHIFT_EQUAL           -> "leftShiftAssign";
+            case RIGHT_SHIFT_EQUAL          -> "rightShiftAssign";
+            case RIGHT_SHIFT_UNSIGNED_EQUAL -> "rightShiftUnsignedAssign";
+            case BITWISE_AND_EQUAL          -> "andAssign";
+            case BITWISE_OR_EQUAL           -> "orAssign";
+            case BITWISE_XOR_EQUAL          -> "xorAssign";
+            default                         -> null;
+        };
+    }

Review Comment:
   Same token-mapping issue exists here: `getAssignOperationName` doesn't 
include `MOD_EQUAL`, so `@OperatorRename(… plusAssign=…)` precedence logic may 
not trigger for `%=` depending on which token (`MOD_EQUAL` vs 
`REMAINDER_EQUAL`) the parser emits. Consider mapping both tokens to 
`remainderAssign` for consistency with codegen and the user-facing operator 
table.



##########
src/spec/doc/core-operators.adoc:
##########
@@ -1007,3 +1007,37 @@ Here is a complete list of the operators and their 
corresponding methods:
 | `~a`
 | a.bitwiseNegate()
 |====
+
+[[Compound-Assignment-Overloading]]
+=== Compound assignment operator overloading
+
+Compound assignment operators (`+=`, `-=`, `\*=`, ...) normally desugar to `x 
= x.op(y)`,
+which creates a new value and reassigns the variable. Since Groovy 5.0 
(GEP-15), classes can
+define dedicated *Assign methods to override compound-assign independently of 
the base operator.
+When a `*Assign` method exists on the receiver type it is called directly: the 
receiver is mutated
+in place, no reassignment occurs (so `final` fields and variables can use 
compound assignment),
+and for property LHS the setter is *not* invoked.

Review Comment:
   The text implies `final` locals/fields can use compound assignment in 
general, but the compiler-side final-reassignment suppression currently keys 
off `StaticTypesMarker.COMPOUND_ASSIGN_TARGET`, which is only set when the 
static type checker resolves a `*Assign` target. If dynamic compilation (no 
STC) is still expected to reject `final x += y`, this section should clarify 
that the `final` relaxation applies when the compiler can resolve a `*Assign` 
method (e.g. `@CompileStatic`). Otherwise, consider extending the 
final-variable analysis to cover dynamic compilation when the receiver type is 
known.
   ```suggestion
   When a `*Assign` method exists on the receiver type and the compiler can 
resolve it directly
   (for example, under `@CompileStatic`), it is called directly: the receiver 
is mutated in place,
   no reassignment occurs (so `final` fields and variables can use compound 
assignment in that
   case), and for property LHS the setter is *not* invoked.
   ```





> Provide support for compound assignment operator overloading (GEP-15)
> ---------------------------------------------------------------------
>
>                 Key: GROOVY-11970
>                 URL: https://issues.apache.org/jira/browse/GROOVY-11970
>             Project: Groovy
>          Issue Type: New Feature
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to