You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/05/01 10:32:22 UTC
(groovy) branch master updated: GROOVY-11964: Additional multi-assignment forms
This is an automated email from the ASF dual-hosted git repository.
paulk-asert 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 661e6cfc78 GROOVY-11964: Additional multi-assignment forms
661e6cfc78 is described below
commit 661e6cfc78a3500622c016148dfd4e16f541631d
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Apr 25 19:39:39 2026 +1000
GROOVY-11964: Additional multi-assignment forms
---
src/antlr/GroovyParser.g4 | 7 +-
.../apache/groovy/parser/antlr4/AstBuilder.java | 47 +-
.../groovy/ast/MultipleAssignmentMetadata.java | 32 +
.../classgen/asm/BinaryExpressionHelper.java | 145 +-
.../groovy/runtime/MultipleAssignmentSupport.java | 167 ++
.../transformers/BinaryExpressionTransformer.java | 17 +-
.../transform/stc/StaticTypeCheckingVisitor.java | 368 +++-
.../MultipleAssignmentDeclarationTest.groovy | 1760 ++++++++++++++++++++
.../groovy/transform/stc/STCAssignmentTest.groovy | 10 +-
9 files changed, 2536 insertions(+), 17 deletions(-)
diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4
index 8501b7658e..d0b39c0b7a 100644
--- a/src/antlr/GroovyParser.g4
+++ b/src/antlr/GroovyParser.g4
@@ -578,10 +578,15 @@ variableDeclaration[int t]
typeNamePairs
: LPAREN typeNamePair (COMMA typeNamePair)* RPAREN
+ | LPAREN keyedPair (COMMA keyedPair)* RPAREN
;
typeNamePair
- : type? variableDeclaratorId
+ : (DEF | VAR | type)? MUL? variableDeclaratorId
+ ;
+
+keyedPair
+ : key=identifier COLON (DEF | VAR | type)? variableDeclaratorId
;
variableNames
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index ab506a3483..aa43a3feff 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -56,6 +56,7 @@ import org.codehaus.groovy.ast.EnumConstantClassNode;
import org.codehaus.groovy.ast.FieldNode;
import org.codehaus.groovy.ast.GenericsType;
import org.codehaus.groovy.ast.ImportNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
import org.codehaus.groovy.ast.InnerClassNode;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.ModifierNode;
@@ -2205,16 +2206,56 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
@Override
public List<Expression> visitTypeNamePairs(final TypeNamePairsContext ctx) {
- return ctx.typeNamePair().stream().map(this::visitTypeNamePair).collect(Collectors.toList());
+ if (asBoolean(ctx.keyedPair())) { // GEP-20 map-style: def (name: n, age: a) = person
+ return ctx.keyedPair().stream().map(this::visitKeyedPair).collect(Collectors.toList());
+ }
+ List<Expression> pairs = ctx.typeNamePair().stream().map(this::visitTypeNamePair).collect(Collectors.toList());
+ // GEP-20: at most one rest binding (*) per parens form
+ boolean seenRest = false;
+ for (Expression e : pairs) {
+ if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+ if (seenRest) {
+ throw createParsingFailedException("Only one rest binding (*) is allowed in a multi-assignment", e);
+ }
+ seenRest = true;
+ }
+ }
+ return pairs;
}
@Override
public VariableExpression visitTypeNamePair(final TypeNamePairContext ctx) {
- return configureAST(
+ boolean isRest = asBoolean(ctx.MUL());
+ // GEP-20: typed rest (e.g. `def (h, List<Integer> *t) = list`) is accepted; the
+ // declared container type is honoured by static type checking and runtime coercion.
+ // `def` and `var` are also accepted in place of a type (equivalent to omitting it),
+ // for symmetry with switch case patterns and the bracket-form declaration grammar.
+ VariableExpression ve = configureAST(
new VariableExpression(
this.visitVariableDeclaratorId(ctx.variableDeclaratorId()).getName(),
- this.visitType(ctx.type())),
+ binderType(ctx.DEF(), ctx.VAR(), ctx.type())),
ctx);
+ if (isRest) {
+ ve.putNodeMetaData(MultipleAssignmentMetadata.REST_BINDING, Boolean.TRUE);
+ }
+ return ve;
+ }
+
+ @Override
+ public VariableExpression visitKeyedPair(final KeyedPairContext ctx) {
+ VariableExpression ve = configureAST(
+ new VariableExpression(
+ this.visitVariableDeclaratorId(ctx.variableDeclaratorId()).getName(),
+ binderType(ctx.DEF(), ctx.VAR(), ctx.type())),
+ ctx);
+ ve.putNodeMetaData(MultipleAssignmentMetadata.MAP_KEY, this.visitIdentifier(ctx.key));
+ return ve;
+ }
+
+ /** GEP-20: resolve a binder's declared type — `def`/`var` produce the dynamic type, same as omitting a type. */
+ private ClassNode binderType(final TerminalNode defNode, final TerminalNode varNode, final TypeContext typeCtx) {
+ if (asBoolean(defNode) || asBoolean(varNode)) return ClassHelper.dynamicType();
+ return this.visitType(typeCtx);
}
@Override
diff --git a/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java b/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java
new file mode 100644
index 0000000000..a237e12b98
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java
@@ -0,0 +1,32 @@
+/*
+ * 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.codehaus.groovy.ast;
+
+/**
+ * AST node metadata keys used by the multi-assignment destructuring pipeline
+ * introduced in GEP-20. Each key is attached to a {@code VariableExpression}
+ * appearing inside the {@code TupleExpression} on the LHS of a
+ * {@code DeclarationExpression}.
+ */
+public enum MultipleAssignmentMetadata {
+ /** Marker on the rest binder (the {@code *ident} slot). Value is {@link Boolean#TRUE}. */
+ REST_BINDING,
+ /** Value is the key name (String) used for a map-style {@code key: ident} binder. */
+ MAP_KEY
+}
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
index b452df8cff..a2d83e8a37 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.classgen.asm;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
import org.codehaus.groovy.ast.Variable;
import org.codehaus.groovy.ast.expr.ArrayExpression;
import org.codehaus.groovy.ast.expr.BinaryExpression;
@@ -43,6 +44,7 @@ import org.codehaus.groovy.ast.tools.GenericsUtils;
import org.codehaus.groovy.ast.tools.WideningCategories;
import org.codehaus.groovy.classgen.AsmClassGenerator;
import org.codehaus.groovy.classgen.BytecodeExpression;
+import org.codehaus.groovy.runtime.MultipleAssignmentSupport;
import org.codehaus.groovy.runtime.ScriptBytecodeAdapter;
import org.codehaus.groovy.syntax.Token;
import org.objectweb.asm.Label;
@@ -53,10 +55,12 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
import static org.codehaus.groovy.ast.tools.GeneralUtils.binX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.boolX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.classX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.elvisX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.notX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.propX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.ternaryX;
import static org.codehaus.groovy.syntax.Types.ASSIGN;
import static org.codehaus.groovy.syntax.Types.BITWISE_AND;
@@ -505,6 +509,101 @@ public class BinaryExpressionHelper {
leftExpression.visit(acg);
operandStack.remove(operandStack.getStackLength() - mark);
} else { // multiple declaration or assignment
+ TupleExpression tuple = (TupleExpression) leftExpression;
+ java.util.List<Expression> elements = tuple.getExpressions();
+ int tupleSize = elements.size();
+ int restIndex = -1;
+ for (int idx = 0; idx < tupleSize; idx++) {
+ if (Boolean.TRUE.equals(elements.get(idx).getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+ restIndex = idx;
+ break;
+ }
+ }
+ boolean hasRest = (restIndex >= 0);
+ boolean tailRest = hasRest && restIndex == tupleSize - 1;
+ boolean isMapStyle = !elements.isEmpty()
+ && elements.get(0).getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null;
+
+ // GEP-20 map-style destructuring: def (name: n, age: a) = person
+ // Each binder is emitted as a property access on the RHS, dispatched via the MOP
+ // (Map → key lookup, bean → getter, GroovyObject → getProperty).
+ if (isMapStyle) {
+ for (Expression e : elements) {
+ String key = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+ // Property access is a read here; the surrounding pushLHS(true) above would
+ // otherwise mark it as a store target.
+ compileStack.popLHS();
+ propX(rhsValueLoader, key).visit(acg);
+ compileStack.pushLHS(true);
+ assignOneMultiAssignSlot(e, defineVariable, operandStack, compileStack, acg);
+ }
+ compileStack.popLHS();
+ if (returnRightValue) rhsValueLoader.visit(acg);
+ compileStack.removeVar(rhsValueId);
+ return;
+ }
+
+ // GEP-20 degenerate case: `def (*t) = rhs` — single rest binder; equivalent to `def t = rhs`.
+ if (tailRest && tupleSize == 1) {
+ rhsValueLoader.visit(acg);
+ if (defineVariable) {
+ Variable v = (Variable) elements.get(0);
+ operandStack.doGroovyCast(v);
+ compileStack.defineVariable(v, true);
+ operandStack.remove(1);
+ } else {
+ elements.get(0).visit(acg);
+ }
+ compileStack.popLHS();
+ if (returnRightValue) rhsValueLoader.visit(acg);
+ compileStack.removeVar(rhsValueId);
+ return;
+ }
+
+ // GEP-20 head/middle rest: def (*f, last) = list, def (l, *m, r) = list, etc.
+ // Requires a sized, indexable RHS (Path B only — no iterator fallback).
+ // Load-bearing ordering (GEP lines 177-186): the IntRange call for the rest slot
+ // must be emitted BEFORE any negative-index call, so that an iterator/stream RHS
+ // fails fast with MissingMethodException instead of hanging via materialisation.
+ if (hasRest && !tailRest) {
+ // 1. Emit the IntRange call for the rest slot first, via the helper that
+ // returns an empty slice for inverted ranges (short RHS) and fails fast
+ // for non-indexable RHS (iterator/stream/set), per GEP lines 177-186.
+ // Number of fixed slots after the rest = tupleSize - restIndex - 1; their negative
+ // indices span [-k, -1]; the rest slice therefore ends at -(k+1) = -(tupleSize - restIndex).
+ // e.g. def (*f,last): -2; def (l,*m,r): -2; def (a,b,*m,y,z): -3
+ int toIdx = -(tupleSize - restIndex);
+ MethodCallExpression sliceCall = callX(
+ classX(MultipleAssignmentSupport.class),
+ "nonTailRestSlice",
+ args(rhsValueLoader, constX(restIndex, true), constX(toIdx, true)));
+ sliceCall.setImplicitThis(false);
+ sliceCall.visit(acg);
+ assignOneMultiAssignSlot(elements.get(restIndex), defineVariable, operandStack, compileStack, acg);
+
+ // 2. Positive-index fixed slots (before rest), left-to-right.
+ for (int idx = 0; idx < restIndex; idx++) {
+ MethodCallExpression call = callX(rhsValueLoader, "getAt", constX(idx, true));
+ call.setImplicitThis(false);
+ call.visit(acg);
+ assignOneMultiAssignSlot(elements.get(idx), defineVariable, operandStack, compileStack, acg);
+ }
+
+ // 3. Negative-index fixed slots (after rest), left-to-right.
+ for (int idx = restIndex + 1; idx < tupleSize; idx++) {
+ int negIdx = -(tupleSize - idx);
+ MethodCallExpression call = callX(rhsValueLoader, "getAt", constX(negIdx, true));
+ call.setImplicitThis(false);
+ call.visit(acg);
+ assignOneMultiAssignSlot(elements.get(idx), defineVariable, operandStack, compileStack, acg);
+ }
+
+ compileStack.popLHS();
+ if (returnRightValue) rhsValueLoader.visit(acg);
+ compileStack.removeVar(rhsValueId);
+ return;
+ }
+
MethodCallExpression iterator = callX(rhsValueLoader, "iterator");
iterator.setImplicitThis(false);
iterator.visit(acg);
@@ -529,9 +628,17 @@ public class BinaryExpressionHelper {
mv.visitJumpInsn(IF_ACMPEQ, useGetAt);
boolean first = true;
- for (Expression e : (TupleExpression) leftExpression) {
- if (first) {
- first = false;
+ for (int idx = 0; idx < tupleSize; idx++) {
+ Expression e = elements.get(idx);
+ if (idx == restIndex) { // tail rest: dispatch Path B (slice) vs Path C (iterator) at runtime
+ MethodCallExpression restCall = callX(
+ classX(MultipleAssignmentSupport.class),
+ "tailRest",
+ args(rhsValueLoader, constX(idx, true), seq));
+ restCall.setImplicitThis(false);
+ restCall.visit(acg);
+ } else if (first) {
+ first = false; // value already on stack from next() above
} else {
ternaryX(hasNext, next, nullX()).visit(acg);
}
@@ -553,11 +660,19 @@ public class BinaryExpressionHelper {
mv.visitLabel(useGetAt_noPop);
- int i = 0;
- for (Expression e : (TupleExpression) leftExpression) {
- MethodCallExpression getAt = callX(rhsValueLoader, "getAt", constX(i++, true));
- getAt.setImplicitThis(false);
- getAt.visit(acg);
+ for (int idx = 0; idx < tupleSize; idx++) {
+ Expression e = elements.get(idx);
+ MethodCallExpression call;
+ if (idx == restIndex) { // tail rest: dispatch via helper so empty RHS / non-indexable cases are handled uniformly
+ call = callX(
+ classX(MultipleAssignmentSupport.class),
+ "tailRest",
+ args(rhsValueLoader, constX(idx, true), seq));
+ } else {
+ call = callX(rhsValueLoader, "getAt", constX(idx, true));
+ }
+ call.setImplicitThis(false);
+ call.visit(acg);
if (defineVariable) {
Variable v = (Variable) e;
@@ -585,6 +700,20 @@ public class BinaryExpressionHelper {
compileStack.removeVar(rhsValueId);
}
+ /** GEP-20: assign the single value currently on the operand stack to the given declarator slot. */
+ private void assignOneMultiAssignSlot(final Expression e, final boolean defineVariable,
+ final OperandStack operandStack, final CompileStack compileStack,
+ final AsmClassGenerator acg) {
+ if (defineVariable) {
+ Variable v = (Variable) e;
+ operandStack.doGroovyCast(v);
+ compileStack.defineVariable(v, true);
+ operandStack.remove(1);
+ } else {
+ e.visit(acg);
+ }
+ }
+
protected void evaluateCompareExpression(final MethodCaller compareMethod, final BinaryExpression expression) {
Expression leftExp = expression.getLeftExpression();
Expression rightExp = expression.getRightExpression();
diff --git a/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java b/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java
new file mode 100644
index 0000000000..78dfbf56f0
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java
@@ -0,0 +1,167 @@
+/*
+ * 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.codehaus.groovy.runtime;
+
+import groovy.lang.IntRange;
+
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import static org.codehaus.groovy.runtime.DefaultGroovyMethodsSupport.createSimilarList;
+
+/**
+ * Runtime helpers used by the GEP-20 multi-assignment destructuring bytecode.
+ *
+ * <p>These are called from generated bytecode only. User code should not rely on them.</p>
+ */
+public final class MultipleAssignmentSupport {
+
+ private MultipleAssignmentSupport() {}
+
+ private static final Class<?>[] INT_RANGE_PARAM = { IntRange.class };
+
+ /**
+ * Resolve the value for a tail rest binding ({@code *t}) at declarator index
+ * {@code fromIndex}. The GEP-20 dispatch model has three paths:
+ *
+ * <ul>
+ * <li><b>Path A</b> — {@code Stream<T>}: wrap the already-advanced iterator
+ * in a fresh sequential Stream and chain {@code onClose} to the
+ * original. The new Stream reports no spliterator characteristics
+ * (preserving them would require reading the original spliterator,
+ * which is mutually exclusive with the iterator path used for head
+ * extraction).</li>
+ * <li><b>Path B</b> — receiver with a resolvable {@code getAt(IntRange)}.
+ * Implemented as fast-path {@code instanceof} branches for
+ * {@code List}, {@code CharSequence}, and Java arrays (size-aware so
+ * out-of-bounds {@code fromIndex} returns the canonical empty value
+ * without calling user code), with an MOP-dispatched fallback for
+ * any other Path B receiver — {@link java.util.BitSet} (returns
+ * {@code BitSet}), user custom classes, etc.</li>
+ * <li><b>Path C</b> — anything else iterable: return the already-advanced
+ * iterator.</li>
+ * </ul>
+ *
+ * <p>The actual {@code instanceof} checks are ordered for performance
+ * (List/CharSequence/array fast paths first, then Stream, then MOP
+ * fallback, then iterator) but the conceptual dispatch order is A, then
+ * B, then C.</p>
+ *
+ * <p>Primitive streams ({@code IntStream}, {@code LongStream},
+ * {@code DoubleStream}) are not subtypes of {@code Stream<T>} and therefore
+ * fall through to Path B (if they expose {@code getAt(IntRange)}) or
+ * Path C; their elements are boxed by the underlying iterator.</p>
+ *
+ * @param rhs the original RHS value
+ * @param fromIndex the declarator position of the rest binding
+ * @param seq the iterator that has already yielded the preceding fixed-slot values
+ */
+ public static Object tailRest(final Object rhs, final int fromIndex, final Iterator<?> seq) {
+ if (rhs instanceof List) {
+ if (fromIndex >= ((List<?>) rhs).size()) return createSimilarList((List<?>) rhs, 0);
+ IntRange range = new IntRange(true, fromIndex, -1);
+ return InvokerHelper.invokeMethod(rhs, "getAt", range);
+ }
+ if (rhs instanceof CharSequence) {
+ if (fromIndex >= ((CharSequence) rhs).length()) return "";
+ IntRange range = new IntRange(true, fromIndex, -1);
+ return InvokerHelper.invokeMethod(rhs, "getAt", range);
+ }
+ if (rhs != null && rhs.getClass().isArray()) {
+ // Match the non-empty branch: ArrayGroovyMethods.getAt(T[], IntRange) returns a
+ // mutable List<T>, so the empty case must also be a mutable List<T> — not an empty
+ // T[] — for typed rest binders like `List<T> *t` to round-trip consistently, and
+ // not Collections.emptyList() so that downstream mutation of an empty rest binding
+ // behaves the same as when it captured one or more elements.
+ if (fromIndex >= Array.getLength(rhs)) return new ArrayList<>();
+ IntRange range = new IntRange(true, fromIndex, -1);
+ return InvokerHelper.invokeMethod(rhs, "getAt", range);
+ }
+ if (rhs instanceof Stream) {
+ Spliterator<?> spliterator = Spliterators.spliteratorUnknownSize(seq, 0);
+ Stream<?> tail = StreamSupport.stream(spliterator, false);
+ return tail.onClose(((Stream<?>) rhs)::close);
+ }
+ // MOP fallback: any other receiver with getAt(IntRange) — BitSet, user classes, etc.
+ // Picked deliberately AFTER the Stream branch so a Stream RHS never materialises via
+ // StreamGroovyMethods.getAt(Stream, IntRange).
+ if (rhs != null && InvokerHelper.getMetaClass(rhs).pickMethod("getAt", INT_RANGE_PARAM) != null) {
+ IntRange range = new IntRange(true, fromIndex, -1);
+ return InvokerHelper.invokeMethod(rhs, "getAt", range);
+ }
+ return seq;
+ }
+
+ /**
+ * Resolve the value for a head or middle rest binding. Requires a sized, indexable RHS
+ * (List, CharSequence, or array — or any other receiver with a non-reversing
+ * {@code getAt(IntRange)}); for other RHS types this routes through the MOP so that:
+ *
+ * <ul>
+ * <li>Truly non-indexable sources (iterators, sets, custom Iterables without a
+ * {@code getAt(IntRange)} extension) fail fast with
+ * {@code MissingMethodException} — preventing silent materialisation of
+ * unbounded sources.</li>
+ * <li>{@code Stream} RHS resolves {@code StreamGroovyMethods.getAt(Stream, IntRange)},
+ * which rejects the destructuring slice's reverse range with
+ * {@code IllegalArgumentException("reverse range")} — also fail-fast, also
+ * no materialisation. (Head/middle rest from {@code Stream} is rejected at
+ * compile time under {@code @CompileStatic}; the IAE only surfaces in
+ * dynamic mode.)</li>
+ * </ul>
+ *
+ * <p>When the RHS is shorter than the sum of fixed slots around the rest, the computed range
+ * is inverted. DGM's {@code getAt(IntRange)} would reverse an inverted range; for destructuring
+ * we want an empty slice instead, so this helper short-circuits that case for the sized fast
+ * paths (List/CharSequence/array). Other indexable receivers are responsible for their own
+ * bounds handling.</p>
+ *
+ * @param rhs the original RHS value
+ * @param fromIndex positive index where the slice begins
+ * @param toIndex negative index where the slice ends (inclusive)
+ */
+ public static Object nonTailRestSlice(final Object rhs, final int fromIndex, final int toIndex) {
+ int size = -1;
+ if (rhs instanceof List) size = ((List<?>) rhs).size();
+ else if (rhs instanceof CharSequence) size = ((CharSequence) rhs).length();
+ else if (rhs != null && rhs.getClass().isArray()) size = Array.getLength(rhs);
+
+ if (size >= 0) {
+ int effectiveTo = toIndex < 0 ? size + toIndex : toIndex;
+ if (fromIndex > effectiveTo || fromIndex >= size || effectiveTo < 0) {
+ // Empty-slice returns a mutable list: the non-empty path delegates to
+ // getAt(IntRange) which returns a fresh mutable List<T> (for both List
+ // and array RHS), so the empty path must agree — both for the array
+ // List<T>-vs-T[] alignment and for downstream mutation consistency.
+ if (rhs instanceof CharSequence) return "";
+ if (rhs instanceof List) return createSimilarList((List<?>) rhs, 0);
+ return new ArrayList<>();
+ }
+ }
+
+ IntRange range = new IntRange(true, fromIndex, toIndex);
+ return InvokerHelper.invokeMethod(rhs, "getAt", range);
+ }
+}
diff --git a/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java b/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
index 6928ceefc5..41548ccb89 100644
--- a/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
+++ b/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.transform.sc.transformers;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
import org.codehaus.groovy.ast.Parameter;
import org.codehaus.groovy.ast.expr.ArrayExpression;
import org.codehaus.groovy.ast.expr.BinaryExpression;
@@ -111,7 +112,12 @@ public class BinaryExpressionTransformer {
if (expr != null) return expr;
if (leftExpression instanceof TupleExpression
&& rightExpression instanceof ListExpression) {
- return transformMultipleAssignment(bin);
+ // GEP-20: rest and map-style binders can't be flattened into per-position
+ // single assignments — leave them for the regular codegen path
+ // (BinaryExpressionHelper.evaluateEqual) which knows how to slice / lookup.
+ if (!hasGep20Binder((TupleExpression) leftExpression)) {
+ return transformMultipleAssignment(bin);
+ }
}
break;
case Types.KEYWORD_IN:
@@ -343,6 +349,15 @@ public class BinaryExpressionTransformer {
return null;
}
+ /** GEP-20: detect a rest binder ({@code *t}) or map-style binder ({@code key: x}) on the LHS. */
+ private static boolean hasGep20Binder(final TupleExpression tuple) {
+ for (Expression e : tuple.getExpressions()) {
+ if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) return true;
+ if (e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null) return true;
+ }
+ return false;
+ }
+
private Expression transformMultipleAssignment(final BinaryExpression bin) {
ListOfExpressionsExpression list = new ListOfExpressionsExpression();
List<Expression> leftExpressions = ((TupleExpression) bin.getLeftExpression()).getExpressions();
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
index d36a1fba5a..13d4377fbc 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
@@ -46,6 +46,7 @@ import org.codehaus.groovy.ast.GenericsType.GenericsTypeName;
import org.codehaus.groovy.ast.GroovyCodeVisitor;
import org.codehaus.groovy.ast.InnerClassNode;
import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
import org.codehaus.groovy.ast.Parameter;
import org.codehaus.groovy.ast.PropertyNode;
import org.codehaus.groovy.ast.Variable;
@@ -1317,7 +1318,28 @@ out: if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
target.setGenericsTypes(genericsTypes);
}
- private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpression, Expression rightExpression) {
+ private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpression, final Expression rightExpression) {
+ // GEP-20 dispatch by LHS shape. Detect rest binders and map-style binders up front;
+ // the existing literal-RHS pathway lives inside typeCheckMultipleAssignmentPositional.
+ // The rest- and map-style helpers are currently scaffolding that delegates back to the
+ // positional path; subsequent slices fill them in (rest-aware size handling, container-
+ // type inference, property-typed map-style binders, declared-type RHS support).
+ TupleExpression tuple = (TupleExpression) leftExpression;
+ boolean hasRest = false, hasMapKey = false;
+ for (Expression e : tuple.getExpressions()) {
+ if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) hasRest = true;
+ if (e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null) hasMapKey = true;
+ }
+ if (hasMapKey) {
+ return typeCheckMultipleAssignmentMapStyle(leftExpression, rightExpression);
+ }
+ if (hasRest) {
+ return typeCheckMultipleAssignmentRest(leftExpression, rightExpression);
+ }
+ return typeCheckMultipleAssignmentPositional(leftExpression, rightExpression);
+ }
+
+ private boolean typeCheckMultipleAssignmentPositional(final Expression leftExpression, Expression rightExpression) {
if (rightExpression instanceof VariableExpression || rightExpression instanceof PropertyExpression || rightExpression instanceof MethodCall) {
ClassNode inferredType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
GenericsType[] genericsTypes = inferredType.getGenericsTypes();
@@ -1331,6 +1353,8 @@ out: if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
}
if (!listExpression.getExpressions().isEmpty()) {
rightExpression = listExpression;
+ } else {
+ rightExpression = synthesizeIndexableRhs(leftExpression, rightExpression, inferredType);
}
} else if (rightExpression instanceof RangeExpression) {
ListExpression listExpression = new ListExpression();
@@ -1347,6 +1371,14 @@ out: if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
}
}
+ // Final indexable-RHS fallback: anything still not a ListExpression (e.g. String /
+ // GString constants, closure-call results that didn't match Tuple1..16) — try the
+ // indexable synthesis based on the RHS's static type.
+ if (!(rightExpression instanceof ListExpression)) {
+ ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+ rightExpression = synthesizeIndexableRhs(leftExpression, rightExpression, rhsType);
+ }
+
if (!(rightExpression instanceof ListExpression values)) {
addStaticTypeError("Multiple assignments without list or tuple on the right-hand side are unsupported in static type checking mode", rightExpression);
return false;
@@ -1374,6 +1406,297 @@ out: if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
return true;
}
+ /**
+ * GEP-20 rest binding under static checking. Handles both list-literal RHS and
+ * declared-type RHS (List, array, String, Range, Set, Iterator, Stream — anything with a
+ * typed {@code getAt(int)} or {@code iterator()} inferable element type). The rest
+ * variable's inferred type tracks the runtime dispatch in
+ * {@code MultipleAssignmentSupport.tailRest}: {@code List<T>} for Path B receivers
+ * (those that support {@code getAt(IntRange)}); {@code Stream<T>} for {@code Stream}
+ * RHS (Path A, tail rest only); {@code Iterator<T>} for any other Path C receiver
+ * (tail rest only). Head and middle rest reject Path C and Path A receivers as a
+ * static type error.
+ */
+ private boolean typeCheckMultipleAssignmentRest(final Expression leftExpression, final Expression rightExpression) {
+ if (rightExpression instanceof ListExpression values) {
+ return typeCheckRestAgainstListLiteral((TupleExpression) leftExpression, values, rightExpression);
+ }
+ // Any RHS with a statically-resolvable getAt(int) (or known shape via declared type)
+ // routes through the declared-type path; this covers variables, properties, method
+ // calls, constants (e.g. String literals), closure invocations, etc.
+ return typeCheckRestAgainstDeclaredType((TupleExpression) leftExpression, rightExpression);
+ }
+
+ private boolean typeCheckRestAgainstListLiteral(final TupleExpression tuple, final ListExpression values, final Expression rightExpression) {
+ List<Expression> tupleExpressions = tuple.getExpressions();
+ List<Expression> valueExpressions = values.getExpressions();
+ int tupleSize = tupleExpressions.size();
+ int valueSize = valueExpressions.size();
+
+ int restIndex = restIndexOf(tuple);
+ int fixedAfter = tupleSize - restIndex - 1;
+
+ // Per GEP-20: lower sizes are forgiving (getAt(int) returns null for OOB,
+ // getAt(IntRange) returns empty for inverted ranges). Skip the strict size check.
+
+ // Fixed slots before the rest binding: pairwise from index 0.
+ for (int i = 0; i < restIndex; i++) {
+ if (i >= valueSize) break;
+ ClassNode valueType = getType(valueExpressions.get(i));
+ ClassNode targetType = getType(tupleExpressions.get(i));
+ if (!isAssignableTo(valueType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ return false;
+ }
+ storeType(tupleExpressions.get(i), valueType);
+ }
+
+ // Fixed slots after the rest binding: anchored to the right of the value list.
+ for (int j = 0; j < fixedAfter; j++) {
+ int lhsIdx = restIndex + 1 + j;
+ int rhsIdx = valueSize - fixedAfter + j;
+ if (rhsIdx < 0 || rhsIdx >= valueSize) continue;
+ ClassNode valueType = getType(valueExpressions.get(rhsIdx));
+ ClassNode targetType = getType(tupleExpressions.get(lhsIdx));
+ if (!isAssignableTo(valueType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ return false;
+ }
+ storeType(tupleExpressions.get(lhsIdx), valueType);
+ }
+
+ // Rest position: container type derived from the LUB of absorbed value types.
+ int restFromIdx = Math.min(restIndex, valueSize);
+ int restToIdx = Math.max(restFromIdx, valueSize - fixedAfter);
+ Expression restExpr = tupleExpressions.get(restIndex);
+ ClassNode elementType;
+ if (restToIdx > restFromIdx) {
+ elementType = getType(valueExpressions.get(restFromIdx));
+ for (int k = restFromIdx + 1; k < restToIdx; k++) {
+ elementType = lowestUpperBound(elementType, getType(valueExpressions.get(k)));
+ }
+ } else {
+ // Rest absorbs zero values (e.g. `def (Integer a, List<Integer> *t, Integer b) = [1, 2]`).
+ // Default to OBJECT_TYPE, but if the rest binder is typed with a single generic
+ // argument, use that as the element type so `List<Integer> *t` doesn't get rejected
+ // as `List<Object>` not assignable to `List<Integer>`.
+ elementType = OBJECT_TYPE;
+ if (restExpr instanceof Variable v) {
+ ClassNode declared = v.getOriginType();
+ if (declared != null && !ClassHelper.isDynamicTyped(declared) && !declared.equals(OBJECT_TYPE)) {
+ GenericsType[] gts = declared.getGenericsTypes();
+ if (gts != null && gts.length == 1) {
+ elementType = getCombinedBoundType(gts[0]);
+ }
+ }
+ }
+ }
+ // Generics can't take primitives; box (int → Integer) so List<Integer> matches.
+ elementType = ClassHelper.getWrapper(elementType);
+ ClassNode restType = GenericsUtils.makeClassSafeWithGenerics(LIST_TYPE, new GenericsType(elementType));
+ if (!checkRestAssignability(restExpr, restType, rightExpression)) return false;
+ storeType(restExpr, restType);
+
+ return true;
+ }
+
+ private boolean typeCheckRestAgainstDeclaredType(final TupleExpression tuple, final Expression rightExpression) {
+ ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+ ClassNode elementType = inferComponentType(rhsType, int_TYPE);
+ if (elementType == null) {
+ // Non-indexable RHS — let positional surface the existing rejection.
+ return typeCheckMultipleAssignmentPositional(tuple, rightExpression);
+ }
+
+ List<Expression> tupleExpressions = tuple.getExpressions();
+ int tupleSize = tupleExpressions.size();
+ int restIndex = restIndexOf(tuple);
+ boolean tailRest = restIndex == tupleSize - 1;
+ // Path B is now defined by capability, not class membership: anything with a resolvable
+ // getAt(IntRange) participates. Probe and reuse the inferred slice type as the rest's
+ // declared type so it matches the actual runtime return (BitSet → BitSet, String →
+ // String, MyContainer → MyContainer, List<T>/T[] → List<T>, ...).
+ ClassNode pathASliceType = inferRangeSliceType(rhsType);
+ boolean pathA = pathASliceType != null;
+
+ if (!tailRest && !pathA) {
+ addStaticTypeError("Head or middle rest binding requires an indexable right-hand side; "
+ + prettyPrintType(rhsType) + " does not support getAt(IntRange)", rightExpression);
+ return false;
+ }
+
+ // Non-rest positions all share the element type derived from getAt(int).
+ for (int i = 0; i < tupleSize; i++) {
+ if (i == restIndex) continue;
+ ClassNode targetType = getType(tupleExpressions.get(i));
+ if (!isAssignableTo(elementType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(elementType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ return false;
+ }
+ storeType(tupleExpressions.get(i), elementType);
+ }
+
+ // Rest type tracks runtime dispatch:
+ // Path B → the actual getAt(IntRange) return type (probed above).
+ // Stream RHS → Stream<T> (Path A, tail rest only — laziness + onClose preserved).
+ // Path C → Iterator<T> (tail rest only).
+ // Box primitives so generic containers match (e.g. List<Integer>, not List<int>).
+ ClassNode restType;
+ if (pathA) {
+ restType = pathASliceType;
+ } else if (GeneralUtils.isOrImplements(rhsType, STREAM_TYPE)) {
+ restType = GenericsUtils.makeClassSafeWithGenerics(STREAM_TYPE,
+ new GenericsType(ClassHelper.getWrapper(elementType)));
+ } else {
+ restType = GenericsUtils.makeClassSafeWithGenerics(Iterator_TYPE,
+ new GenericsType(ClassHelper.getWrapper(elementType)));
+ }
+ Expression restExpr = tupleExpressions.get(restIndex);
+ if (!checkRestAssignability(restExpr, restType, rightExpression)) return false;
+ storeType(restExpr, restType);
+
+ return true;
+ }
+
+ /**
+ * GEP-20 typed rest support: when the user pins a type on a rest binder
+ * ({@code def (h, List<Integer> *t) = list}), verify that the runtime container type
+ * (derived from RHS shape and Path B/C selection) is assignable to it. Untyped rest
+ * binders ({@code originType == dynamicType}) skip the check.
+ */
+ private boolean checkRestAssignability(final Expression restExpr, final ClassNode inferredRestType, final Expression rightExpression) {
+ if (!(restExpr instanceof Variable v)) return true;
+ ClassNode declared = v.getOriginType();
+ if (declared == null || ClassHelper.isDynamicTyped(declared) || declared.equals(OBJECT_TYPE)) return true;
+ if (!isAssignableTo(inferredRestType, declared)) {
+ addStaticTypeError("Cannot assign rest value of type " + prettyPrintType(inferredRestType)
+ + " to variable of type " + prettyPrintType(declared), rightExpression);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Build a synthesised ListExpression of {@code rhs.getAt(i)} accesses, one per LHS slot,
+ * tagged with the inferred element type. Used by {@link #typeCheckMultipleAssignmentPositional}
+ * when the RHS isn't a literal list/range/Tuple-typed expression. Returns the original
+ * RHS unchanged if its static type doesn't support {@code getAt(int)}.
+ */
+ private Expression synthesizeIndexableRhs(final Expression leftExpression, final Expression rightExpression, final ClassNode rhsType) {
+ ClassNode elementType = inferComponentType(rhsType, int_TYPE);
+ if (elementType == null) return rightExpression;
+ TupleExpression tuple = (TupleExpression) leftExpression;
+ ListExpression synth = new ListExpression();
+ synth.setSourcePosition(rightExpression);
+ for (int i = 0; i < tuple.getExpressions().size(); i++) {
+ Expression idxExpr = indexX(rightExpression, constX(i, true));
+ idxExpr.putNodeMetaData(INFERRED_TYPE, elementType);
+ synth.addExpression(idxExpr);
+ }
+ return synth;
+ }
+
+ private static int restIndexOf(final TupleExpression tuple) {
+ List<Expression> tupleExpressions = tuple.getExpressions();
+ for (int i = 0; i < tupleExpressions.size(); i++) {
+ if (Boolean.TRUE.equals(tupleExpressions.get(i).getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * GEP-20 map-style destructuring under static checking. Handles both {@code MapExpression}
+ * literal RHS and declared-type RHS:
+ * <ul>
+ * <li>{@code Map<K, V>} (and subtypes) — binder type derived from the map's V generic.</li>
+ * <li>Bean / class with declared properties — resolved via STC's existing property lookup.</li>
+ * <li>{@code GroovyObject} / dynamic — STC reports the standard "No such property" error
+ * for keys that aren't statically declared (matches GEP failure-modes table for the
+ * static case).</li>
+ * </ul>
+ */
+ private boolean typeCheckMultipleAssignmentMapStyle(final Expression leftExpression, final Expression rightExpression) {
+ if (rightExpression instanceof MapExpression mapExpr) {
+ return typeCheckMapStyleAgainstMapLiteral((TupleExpression) leftExpression, mapExpr, rightExpression);
+ }
+ // Any RHS with a statically-resolvable type (variable, parameter, method call,
+ // closure invocation, constant) routes through the declared-type path so STC's
+ // property-resolution machinery can resolve the map keys against it.
+ return typeCheckMapStyleAgainstDeclaredType((TupleExpression) leftExpression, rightExpression);
+ }
+
+ private boolean typeCheckMapStyleAgainstMapLiteral(final TupleExpression tuple, final MapExpression mapExpr, final Expression rightExpression) {
+ // Build key→value lookup from constant-key entries; non-constant keys are skipped
+ // so the binder falls through to the missing-key path (Object).
+ Map<String, Expression> rhsByKey = new HashMap<>();
+ for (MapEntryExpression entry : mapExpr.getMapEntryExpressions()) {
+ if (entry.getKeyExpression() instanceof ConstantExpression key) {
+ rhsByKey.put(String.valueOf(key.getValue()), entry.getValueExpression());
+ }
+ }
+ for (Expression e : tuple.getExpressions()) {
+ String mapKey = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+ Expression valueExpr = rhsByKey.get(mapKey);
+ ClassNode targetType = getType(e);
+ if (valueExpr != null) {
+ ClassNode valueType = getType(valueExpr);
+ if (!isAssignableTo(valueType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ return false;
+ }
+ storeType(e, valueType);
+ } else {
+ // Missing key — runtime semantics give null. Store Object as inferred type so
+ // any read uses the broadest type; declared types on binders are preserved.
+ storeType(e, OBJECT_TYPE);
+ }
+ }
+ return true;
+ }
+
+ private boolean typeCheckMapStyleAgainstDeclaredType(final TupleExpression tuple, final Expression rightExpression) {
+ ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+
+ // Map<K, V> RHS: every binder resolves to V. STC won't complain about static "missing keys"
+ // because Map keys aren't statically known.
+ if (isOrImplements(rhsType, MAP_TYPE)) {
+ ClassNode parameterized = rhsType.equals(MAP_TYPE) ? rhsType : GenericsUtils.parameterizeType(rhsType, MAP_TYPE);
+ GenericsType[] gts = parameterized.getGenericsTypes();
+ ClassNode valueType = (gts != null && gts.length == 2) ? getCombinedBoundType(gts[1]) : OBJECT_TYPE;
+ for (Expression e : tuple.getExpressions()) {
+ ClassNode targetType = getType(e);
+ if (!isAssignableTo(valueType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ return false;
+ }
+ storeType(e, valueType);
+ }
+ return true;
+ }
+
+ // Bean / GroovyObject / arbitrary class RHS: resolve each MAP_KEY as a property on the
+ // RHS's static type. STC's normal property-resolution path handles bean accessors and
+ // surfaces "No such property" errors for keys that aren't statically declared.
+ boolean ok = true;
+ for (Expression e : tuple.getExpressions()) {
+ String mapKey = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+ PropertyExpression pexp = new PropertyExpression(rightExpression, mapKey);
+ pexp.setSourcePosition(e);
+ visitPropertyExpression(pexp); // resolves type or records "No such property"
+ ClassNode resolvedType = getType(pexp);
+ ClassNode targetType = getType(e);
+ if (resolvedType != null && !isAssignableTo(resolvedType, targetType)) {
+ addStaticTypeError("Cannot assign value of type " + prettyPrintType(resolvedType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+ ok = false;
+ continue;
+ }
+ storeType(e, resolvedType != null ? resolvedType : OBJECT_TYPE);
+ }
+ return ok;
+ }
+
private ClassNode adjustTypeForSpreading(final ClassNode rightExpressionType, final Expression leftExpression) {
// given "list*.foo = 100" or "map*.value = 100", then the assignment must be checked against [100], not 100
if (leftExpression instanceof PropertyExpression && ((PropertyExpression) leftExpression).isSpreadSafe()) {
@@ -4943,6 +5266,49 @@ trying: for (ClassNode[] signature : signatures) {
return Number_TYPE;
}
+ /**
+ * GEP-20: probe the static return type of {@code rhs.getAt(IntRange)} on
+ * {@code receiverType}, so the rest binder can be typed against the actual
+ * slice type rather than a hard-coded {@code List<T>}. Returns the resolved
+ * slice type (e.g. {@code String} for a String receiver, {@code BitSet} for
+ * a BitSet receiver, the user-declared return type for custom classes), or
+ * {@code null} if the receiver has no resolvable {@code getAt(IntRange)}.
+ *
+ * <p>Mirrors {@link #inferComponentType} but for the IntRange overload.
+ * Method resolution runs under a swallowed error collector so a missing
+ * overload doesn't surface as a static-type error.</p>
+ */
+ protected ClassNode inferRangeSliceType(final ClassNode receiverType) {
+ if (receiverType == null || receiverType.equals(OBJECT_TYPE)) return null;
+ // Stream has a getAt(IntRange) extension that materialises eagerly into a List —
+ // antithetical to GEP-20's "no silent materialisation of unbounded sources" rule.
+ // Stream RHS routes through Path A (lazy iterator wrap) instead, so exclude it
+ // from Path B here regardless of getAt(IntRange) availability.
+ if (GeneralUtils.isOrImplements(receiverType, STREAM_TYPE)) return null;
+ if (receiverType.isArray()) {
+ // Arrays don't carry a getAt(IntRange) method per se — it's a DGM extension
+ // (ArrayGroovyMethods.getAt(T[], IntRange)) returning List<T>. Mirror that
+ // here so we don't depend on STC catching the extension on array types.
+ return GenericsUtils.makeClassSafeWithGenerics(LIST_TYPE,
+ new GenericsType(ClassHelper.getWrapper(receiverType.getComponentType())));
+ }
+ MethodCallExpression mce = callX(varX("#", receiverType), "getAt",
+ varX("range", ClassHelper.make(IntRange.class)));
+ mce.setImplicitThis(false);
+ typeCheckingContext.pushErrorCollector();
+ try {
+ visitMethodCallExpression(mce);
+ } finally {
+ typeCheckingContext.popErrorCollector();
+ }
+ MethodNode target = mce.getNodeMetaData(DIRECT_METHOD_CALL_TARGET);
+ // No matching overload, or resolved to a too-generic catch-all on Object — punt.
+ if (target == null || target.getDeclaringClass().equals(OBJECT_TYPE)) return null;
+ ClassNode sliceType = getType(mce);
+ if (sliceType == null || sliceType.equals(OBJECT_TYPE)) return null;
+ return sliceType;
+ }
+
protected ClassNode inferComponentType(final ClassNode receiverType, final ClassNode subscriptType) {
ClassNode componentType = null;
if (receiverType.isArray()) { // GROOVY-11335
diff --git a/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy b/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
index 8f8af583ba..5391ea5a8b 100644
--- a/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
+++ b/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
@@ -258,4 +258,1764 @@ final class MultipleAssignmentDeclarationTest {
assert baz == 'baz'
'''
}
+
+ // GEP-20 tail-rest tests
+
+ @Test
+ void testTailRestFromList() {
+ assertScript '''
+ def (h, *t) = [1, 2, 3, 4]
+ assert h == 1
+ assert t == [2, 3, 4]
+ '''
+ }
+
+ @Test
+ void testTailRestWithMultipleHeads() {
+ assertScript '''
+ def (a, b, *rest) = [10, 20, 30, 40, 50]
+ assert a == 10
+ assert b == 20
+ assert rest == [30, 40, 50]
+ '''
+ }
+
+ @Test
+ void testTailRestWithTypedHead() {
+ assertScript '''
+ def (int h, *t) = [1, 2, 3]
+ assert h == 1
+ assert h instanceof Integer
+ assert t == [2, 3]
+ '''
+ }
+
+ @Test
+ void testTailRestFromEmptyList() {
+ assertScript '''
+ def (h, *t) = []
+ assert h == null
+ assert t == []
+ '''
+ }
+
+ @Test
+ void testTailRestFromString() {
+ assertScript '''
+ def (c, *cs) = "hello"
+ assert c == 'h'
+ assert cs == "ello"
+ '''
+ }
+
+ @Test
+ void testTailRestFromIterator() {
+ assertScript '''
+ def (h, t) = [1, 2, 3].iterator() // baseline: same iterator semantics as existing
+ assert h == 1
+ assert t == 2
+
+ // tail-rest binds the advanced iterator (Path C)
+ def it = [10, 20, 30].iterator()
+ def (head, *tail) = it
+ assert head == 10
+ assert tail instanceof Iterator
+ assert tail.next() == 20
+ assert tail.next() == 30
+ assert !tail.hasNext()
+ '''
+ }
+
+ @Test
+ void testTailRestFromSet() {
+ assertScript '''
+ def s = new LinkedHashSet<>([7, 8, 9])
+ def (sh, *st) = s
+ assert sh == 7
+ def remaining = []
+ while (st.hasNext()) remaining << st.next()
+ assert remaining == [8, 9]
+ '''
+ }
+
+ @Test
+ void testTailRestFromStream() {
+ // Path A: Stream RHS preserves Stream-ness in the rest binder.
+ assertScript '''import java.util.stream.Stream
+ def (first, *rest) = Stream.of('a', 'b', 'c')
+ assert first == 'a'
+ assert rest instanceof Stream
+ assert rest.toList() == ['b', 'c']
+ '''
+ }
+
+ @Test
+ void testTailRestFromStream_pipelineContinues() {
+ // The whole point of Path A: downstream operations on the rest stream still work.
+ assertScript '''import java.util.stream.Stream
+ def (first, *rest) = Stream.of(1, 2, 3, 4, 5)
+ assert first == 1
+ assert rest.filter { it % 2 == 0 }.map { it * 10 }.toList() == [20, 40]
+ '''
+ }
+
+ @Test
+ void testTailRestFromStream_singleElement() {
+ // Stream of one element: head consumes everything, tail is an empty Stream.
+ assertScript '''import java.util.stream.Stream
+ def (only, *rest) = Stream.of('only')
+ assert only == 'only'
+ assert rest instanceof Stream
+ assert rest.toList() == []
+ '''
+ }
+
+ @Test
+ void testTailRestFromStream_onCloseChained() {
+ // Closing the rest Stream must close the original — for sources like Files.lines(p)
+ // that hold OS resources, the rest binder is the canonical owner.
+ assertScript '''import java.util.stream.Stream
+ def closed = false
+ def src = Stream.of('a', 'b', 'c').onClose { closed = true }
+ def (h, *t) = src
+ assert h == 'a'
+ t.close()
+ assert closed
+ '''
+ }
+
+ @Test
+ void testTailRestFromIntStream_fallsThroughToIterator() {
+ // IntStream is not a subtype of Stream<T>, so it falls through to Path C
+ // (Iterator). Elements are boxed by PrimitiveIterator.OfInt::next.
+ assertScript '''import java.util.stream.IntStream
+ def (first, *rest) = IntStream.of(1, 2, 3)
+ assert first == 1
+ assert rest instanceof Iterator
+ assert rest.next() == 2
+ assert rest.next() == 3
+ '''
+ }
+
+ @Test
+ void testTailRestFromIntStream_boxedFirst_givesStream() {
+ // The user-facing workaround for Path A on primitive streams: .boxed() first.
+ assertScript '''import java.util.stream.IntStream
+ def (first, *rest) = IntStream.of(1, 2, 3).boxed()
+ assert first == 1
+ assert rest instanceof java.util.stream.Stream
+ assert rest.toList() == [2, 3]
+ '''
+ }
+
+ @Test
+ void testTailRestFromUnboundedIterator() {
+ assertScript '''
+ // ensure lazy Path C: no materialisation
+ def source = new Iterator<Integer>() {
+ int i = 0
+ boolean hasNext() { true }
+ Integer next() { i++ }
+ }
+ def (h, *t) = source
+ assert h == 0
+ assert t.next() == 1
+ assert t.next() == 2
+ assert t.next() == 3
+ '''
+ }
+
+ @Test
+ void testTailRestSingleRestBinder() {
+ assertScript '''
+ def (*t) = [1, 2, 3]
+ assert t == [1, 2, 3]
+ '''
+ }
+
+ @Test
+ void testTailRestRejectsMultipleRest() {
+ def e = shouldFail '''
+ def (a, *b, *c) = [1, 2, 3]
+ '''
+ assert e.message.contains('Only one rest binding')
+ }
+
+ // GEP-20 typed rest: the declared container type is honoured by static checking
+ // (path B vs path C compatibility) and by runtime coercion.
+
+ @Test
+ void testTypedRest_listContainerType_listLiteral() {
+ assertScript '''
+ def (h, List<Integer> *t) = [1, 2, 3, 4]
+ assert h == 1
+ assert t == [2, 3, 4]
+ assert t instanceof List
+ '''
+ }
+
+ @Test
+ void testTypedRest_listMiddleRest() {
+ assertScript '''
+ def (l, List<Integer> *m, r) = [1, 2, 3, 4, 5]
+ assert l == 1
+ assert m == [2, 3, 4]
+ assert r == 5
+ '''
+ }
+
+ @Test
+ void testTypedRest_iteratorContainer_setRhs() {
+ // Path C: declared Iterator<Integer> matches the runtime iterator returned by tailRest.
+ assertScript '''
+ Set<Integer> s = new LinkedHashSet<>([7, 8, 9])
+ def (h, Iterator<Integer> *t) = s
+ assert h == 7
+ assert t.next() == 8
+ assert t.next() == 9
+ '''
+ }
+
+ @Test
+ void testTypedRest_elementMismatchRuntimeFailure() {
+ // Dynamic mode: declared Integer (not a container) for *t — runtime coercion fails
+ // when assigning the slice/iterator to an Integer variable.
+ shouldFail org.codehaus.groovy.runtime.typehandling.GroovyCastException, '''
+ def (h, Integer *t) = [1, 2, 3]
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, List<Integer> *t) = [1, 2, 3, 4]
+ assert h == 1
+ assert t == [2, 3, 4]
+ int total = 0
+ for (Integer i : t) total += i
+ assert total == 9
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_listVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (int h, List<Integer> *t) = src
+ assert h == 10
+ assert t == [20, 30]
+ }
+ go([10, 20, 30])
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_iteratorContainer_pathC() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Set<Integer> src) {
+ def (h, Iterator<Integer> *t) = src
+ assert h == 5
+ assert t.next() == 6
+ assert t.next() == 7
+ }
+ go(new LinkedHashSet<>([5, 6, 7]))
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_containerMismatch_listVsIterator() {
+ // List RHS gives Path B (List<T>); user declared Iterator<Integer> for the rest.
+ // STC catches the mismatch at compile time.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (h, Iterator<Integer> *t) = src
+ }
+ go([1, 2, 3])
+ '''
+ assert e.message.contains('Cannot assign rest value')
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_containerMismatch_iteratorVsList() {
+ // Set RHS gives Path C (Iterator<T>); user declared List<Integer> for the rest.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Set<Integer> src) {
+ def (h, List<Integer> *t) = src
+ }
+ go([1, 2, 3] as Set)
+ '''
+ assert e.message.contains('Cannot assign rest value')
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_streamContainer_pathA() {
+ // Path A: Stream RHS, declared Stream<Integer> rest — STC accepts and runtime preserves.
+ assertScript '''import groovy.transform.CompileStatic
+ import java.util.stream.Stream
+ @CompileStatic
+ def go(Stream<Integer> src) {
+ def (h, Stream<Integer> *t) = src
+ assert h == 10
+ assert t.toList() == [20, 30]
+ }
+ go(Stream.of(10, 20, 30))
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_streamRhsUntyped() {
+ // Untyped rest from Stream RHS infers Stream<T> at compile time.
+ assertScript '''import groovy.transform.CompileStatic
+ import java.util.stream.Stream
+ @CompileStatic
+ def go(Stream<String> src) {
+ def (h, *t) = src
+ assert h == 'a'
+ // Static check: t typed as Stream<String> — chained ops must compile.
+ assert t.map { it.toUpperCase() }.toList() == ['B', 'C']
+ }
+ go(Stream.of('a', 'b', 'c'))
+ '''
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_containerMismatch_streamVsIterator() {
+ // Stream RHS gives Path A (Stream<T>); user declared Iterator<Integer>.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ import java.util.stream.Stream
+ @CompileStatic
+ def go(Stream<Integer> src) {
+ def (h, Iterator<Integer> *t) = src
+ }
+ go(Stream.of(1, 2, 3))
+ '''
+ assert e.message.contains('Cannot assign rest value')
+ }
+
+ @Test
+ void testHeadRestFromStream_compileStaticRejected() {
+ // Head/middle rest requires Path B — Stream is Path A, so STC rejects.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ import java.util.stream.Stream
+ @CompileStatic
+ def go(Stream<Integer> src) {
+ def (*front, last) = src
+ }
+ go(Stream.of(1, 2, 3))
+ '''
+ assert e.message.contains('Head or middle rest binding requires an indexable right-hand side')
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_elementTypeMismatch() {
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, List<String> *t) = [1, 2, 3]
+ }
+ '''
+ assert e.message.contains('Cannot assign')
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_genericsAreInvariant() {
+ // Java generics aren't covariant: List<Integer> is NOT a List<Number>.
+ // STC follows the Java rule, so an Integer-element literal won't fit a List<Number>.
+ // Users wanting widening should accept the inferred List<Integer> or use ? extends.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, List<Number> *t) = [1, 2, 3]
+ }
+ '''
+ assert e.message.contains('Cannot assign')
+ }
+
+ @Test
+ void testTypedRest_CompileStatic_extendsBoundAccepts() {
+ // ? extends Number lets the user widen the element type explicitly.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, List<? extends Number> *t) = [1, 2, 3]
+ assert h == 1
+ assert t == [2, 3]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRestUnderCompileStatic() {
+ // Static type checker currently doesn't special-case rest; verify it at least
+ // does not crash for a typed head with a untyped rest and produces a usable slice.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (int h, t) = [1, 2] // baseline positional (no rest) under CS
+ assert h == 1 && t == 2
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRestUnderTypeChecked() {
+ // Dynamic semantics under @TypeChecked should still work (runtime dispatch unchanged)
+ assertScript '''import groovy.transform.TypeChecked
+ @TypeChecked
+ def go() {
+ def (h, t) = [1, 2]
+ assert h == 1 && t == 2
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 head/middle rest tests
+
+ @Test
+ void testHeadRestFromList() {
+ assertScript '''
+ def (*f, last) = [1, 2, 3, 4]
+ assert f == [1, 2, 3]
+ assert last == 4
+ '''
+ }
+
+ @Test
+ void testHeadRestWithTypedTail() {
+ assertScript '''
+ def (*f, int last) = [1, 2, 3]
+ assert f == [1, 2]
+ assert last == 3
+ assert last instanceof Integer
+ '''
+ }
+
+ @Test
+ void testMiddleRestFromList() {
+ assertScript '''
+ def (l, *m, r) = [1, 2, 3, 4, 5]
+ assert l == 1
+ assert m == [2, 3, 4]
+ assert r == 5
+ '''
+ }
+
+ @Test
+ void testMiddleRestWithMultipleFixedSlots() {
+ assertScript '''
+ def (a, b, *m, y, z) = [1, 2, 3, 4, 5, 6, 7]
+ assert a == 1
+ assert b == 2
+ assert m == [3, 4, 5]
+ assert y == 6
+ assert z == 7
+ '''
+ }
+
+ @Test
+ void testMiddleRestWithTypedEnds() {
+ assertScript '''
+ def (String l, *m, int r) = ['a', 2, 3, 4, 5]
+ assert l == 'a'
+ assert m == [2, 3, 4]
+ assert r == 5
+ assert r instanceof Integer
+ '''
+ }
+
+ @Test
+ void testMiddleRestWithEmptyMiddle() {
+ assertScript '''
+ def (l, *m, r) = [10, 20]
+ assert l == 10
+ assert m == []
+ assert r == 20
+ '''
+ }
+
+ @Test
+ void testHeadRestFromString() {
+ assertScript '''
+ def (*head, tail) = "hello"
+ assert head == 'hell'
+ assert tail == 'o'
+ '''
+ }
+
+ @Test
+ void testHeadRestFromArray() {
+ assertScript '''
+ Integer[] arr = [1, 2, 3, 4]
+ def (*front, last) = arr
+ assert front == [1, 2, 3]
+ assert last == 4
+ '''
+ }
+
+ @Test
+ void testNonTailRestFromIteratorFailsFast() {
+ // Per GEP: head/middle rest requires sized, indexable RHS.
+ // An iterator doesn't support getAt(IntRange) so it must fail fast (not hang).
+ shouldFail MissingMethodException, '''
+ def it = (1..1_000_000_000).iterator()
+ def (l, *m, r) = it
+ '''
+ }
+
+ @Test
+ void testNonTailRestFromSetFailsFast() {
+ // Sets don't have a natural order via getAt(IntRange) — fail fast.
+ shouldFail MissingMethodException, '''
+ def s = new LinkedHashSet<>([1, 2, 3, 4])
+ def (l, *m, r) = s
+ '''
+ }
+
+ // GEP-20 rest under @CompileStatic / @TypeChecked with literal-list RHS
+
+ @Test
+ void testTailRestUnderCompileStatic_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, *t) = [1, 2, 3, 4]
+ assert h == 1
+ assert t == [2, 3, 4]
+ return t
+ }
+ assert go() instanceof List
+ '''
+ }
+
+ @Test
+ void testTailRestUnderCompileStatic_typedHead() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (int h, *t) = [10, 20, 30]
+ assert h == 10
+ assert t == [20, 30]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testHeadRestUnderCompileStatic_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (*f, last) = [1, 2, 3]
+ assert f == [1, 2]
+ assert last == 3
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMiddleRestUnderCompileStatic_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (l, *m, r) = [1, 2, 3, 4, 5]
+ assert l == 1
+ assert m == [2, 3, 4]
+ assert r == 5
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRestUnderTypeChecked_listLiteral() {
+ assertScript '''import groovy.transform.TypeChecked
+ @TypeChecked
+ def go() {
+ def (h, *t) = [1, 2, 3]
+ assert h == 1
+ assert t == [2, 3]
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 map-style destructuring tests
+
+ @Test
+ void testMapStyleFromMap() {
+ assertScript '''
+ def (name: n, age: a) = [name: 'Alice', age: 30]
+ assert n == 'Alice'
+ assert a == 30
+ '''
+ }
+
+ @Test
+ void testMapStyleWithTypedBinders() {
+ assertScript '''
+ def (name: String n, age: int a) = [name: 'Bob', age: 42]
+ assert n == 'Bob'
+ assert n instanceof String
+ assert a == 42
+ assert a instanceof Integer
+ '''
+ }
+
+ @Test
+ void testMapStyleRenaming() {
+ assertScript '''
+ def (host: hostname, port: portNum) = [host: 'localhost', port: 8080]
+ assert hostname == 'localhost'
+ assert portNum == 8080
+ '''
+ }
+
+ @Test
+ void testMapStyleMissingKeyOnMapReturnsNull() {
+ assertScript '''
+ def (name: n, missing: m) = [name: 'x']
+ assert n == 'x'
+ assert m == null
+ '''
+ }
+
+ @Test
+ void testMapStyleFromBean() {
+ assertScript '''
+ class Person {
+ String name
+ int age
+ }
+ def p = new Person(name: 'Carol', age: 25)
+ def (name: n, age: a) = p
+ assert n == 'Carol'
+ assert a == 25
+ '''
+ }
+
+ @Test
+ void testMapStyleBeanMissingPropertyThrows() {
+ shouldFail MissingPropertyException, '''
+ class Person { String name }
+ def p = new Person(name: 'x')
+ def (name: n, missing: m) = p
+ '''
+ }
+
+ @Test
+ void testMapStyleFromGroovyObjectViaGetProperty() {
+ assertScript '''
+ class GO {
+ def getProperty(String name) {
+ "<$name>".toString()
+ }
+ }
+ def (foo: f, bar: b) = new GO()
+ assert f == '<foo>'
+ assert b == '<bar>'
+ '''
+ }
+
+ @Test
+ void testMapStyleSingleEntry() {
+ assertScript '''
+ def (only: x) = [only: 99]
+ assert x == 99
+ '''
+ }
+
+ @Test // GEP-20 keyword-as-key limitation
+ void testMapStyleRejectsKeywordKeyInGrammar() {
+ // `class` is a reserved keyword in declarator position; the keyedPair rule
+ // accepts only ordinary identifiers, so this fails to parse. Users needing
+ // such keys fall back to `def c = m['class']`.
+ shouldFail '''
+ def m = [class: 'pretender', name: 'real']
+ def (class: c, name: n) = m
+ '''
+ }
+
+ // GEP-20 map-style under @CompileStatic / @TypeChecked with literal-map RHS
+
+ @Test
+ void testMapStyleUnderCompileStatic_mapLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: n, age: a) = [name: 'Alice', age: 30]
+ assert n == 'Alice'
+ assert a == 30
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyleUnderCompileStatic_typedBinders() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: String n, age: int a) = [name: 'Bob', age: 42]
+ assert n == 'Bob'
+ assert a == 42
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyleUnderCompileStatic_typeMismatchErrors() {
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: int n) = [name: 'not a number']
+ }
+ go()
+ '''
+ assert e.message.contains('Cannot assign')
+ }
+
+ @Test
+ void testMapStyleUnderCompileStatic_missingKeyTolerated() {
+ // STC matches dynamic semantics: missing Map key → null binding (Object inferred type).
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: n, missing: m) = [name: 'x']
+ assert n == 'x'
+ assert m == null
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyleUnderTypeChecked_mapLiteral() {
+ assertScript '''import groovy.transform.TypeChecked
+ @TypeChecked
+ def go() {
+ def (host: h, port: p) = [host: 'localhost', port: 8080]
+ assert h == 'localhost'
+ assert p == 8080
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 positional multi-assignment with declared-type RHS under
+ // @CompileStatic / @TypeChecked — lifts the previous literal-only restriction
+ // ("Multiple assignments without list or tuple on the right-hand side").
+
+ @Test
+ void testPositional_CompileStatic_ListVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (a, b) = src
+ assert a == 10
+ assert b == 20
+ }
+ go([10, 20])
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_typedBinders_ListVariable() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (int a, int b) = src
+ assert a == 10
+ assert b == 20
+ }
+ go([10, 20])
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_ArrayVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Integer[] src) {
+ def (a, b) = src
+ assert a == 1
+ assert b == 2
+ }
+ Integer[] arr = [1, 2]
+ go(arr)
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_StringVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(String src) {
+ def (a, b) = src
+ assert a == 'h'
+ assert b == 'i'
+ }
+ go('hi')
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_methodReturningList() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def src() { [1, 2, 3] }
+ @CompileStatic
+ def go() {
+ def (a, b, c) = src()
+ assert [a, b, c] == [1, 2, 3]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_typeMismatch() {
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<String> src) {
+ def (int a, int b) = src
+ }
+ go(['x', 'y'])
+ '''
+ assert e.message.contains('Cannot assign')
+ }
+
+ // GEP-20 map-style with declared-type RHS under @CompileStatic
+ // (Map<K, V>, beans, GroovyObject — resolved via STC's property lookup)
+
+ @Test
+ void testMapStyle_CompileStatic_MapVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Map<String, Object> src) {
+ def (name: n, age: a) = src
+ assert n == 'Alice'
+ assert a == 30
+ }
+ go([name: 'Alice', age: 30])
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_TypedMapVariableRhs() {
+ // Map<String, Integer> → all binders inferred as Integer.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Map<String, Integer> src) {
+ def (port: p, max: m) = src
+ int total = p + m
+ assert total == 8090
+ }
+ go([port: 80, max: 8010])
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_BeanVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ class Person { String name; int age }
+ @CompileStatic
+ def go(Person p) {
+ def (name: n, age: a) = p
+ assert n == 'Bob'
+ assert a == 42
+ }
+ go(new Person(name: 'Bob', age: 42))
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_BeanTypedBinders() {
+ assertScript '''import groovy.transform.CompileStatic
+ class Person { String name; int age }
+ @CompileStatic
+ def go(Person p) {
+ def (name: String n, age: int a) = p
+ assert n == 'Carol'
+ assert a == 25
+ String greeting = "hi $n"
+ assert greeting == 'hi Carol'
+ }
+ go(new Person(name: 'Carol', age: 25))
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_BeanMissingPropertyErrors() {
+ // Per GEP failure-modes table: bean RHS with no such static property → compile error.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ class Person { String name }
+ @CompileStatic
+ def go(Person p) {
+ def (name: n, missing: m) = p
+ }
+ '''
+ assert e.message.contains('No such property') || e.message.contains('missing')
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_BeanTypeMismatch() {
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ class Person { String name }
+ @CompileStatic
+ def go(Person p) {
+ def (name: int n) = p
+ }
+ '''
+ assert e.message.contains('Cannot assign')
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_methodReturningBean() {
+ assertScript '''import groovy.transform.CompileStatic
+ class Person { String name; int age }
+ @CompileStatic
+ class Holder {
+ Person makePerson() { new Person(name: 'Dan', age: 7) }
+ def go() {
+ def (name: n, age: a) = makePerson()
+ assert n == 'Dan' && a == 7
+ }
+ }
+ new Holder().go()
+ '''
+ }
+
+ // GEP-20 rest with declared-type RHS under @CompileStatic / @TypeChecked
+ // (List, array, String, Set, Iterator — Path B or Path C chosen from declared type)
+
+ @Test
+ void testTailRest_CompileStatic_ListVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (h, *t) = src
+ assert h == 1
+ assert t == [2, 3]
+ }
+ go([1, 2, 3])
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_ArrayVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Integer[] src) {
+ def (h, *t) = src
+ assert h == 1
+ assert t == [2, 3]
+ }
+ Integer[] arr = [1, 2, 3]
+ go(arr)
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_StringVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(String src) {
+ def (h, *t) = src
+ assert h == 'h'
+ assert t == 'ello'
+ }
+ go('hello')
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_PathB_setRhs() {
+ // Set is Path C → rest inferred as Iterator<T>.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Set<Integer> src) {
+ def (h, *t) = src
+ assert h == 7
+ assert t instanceof Iterator
+ assert t.next() == 8
+ assert t.next() == 9
+ assert !t.hasNext()
+ }
+ go(new LinkedHashSet<>([7, 8, 9]))
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_PathB_iteratorRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Iterator<Integer> src) {
+ def (h, *t) = src
+ assert h == 1
+ assert t.next() == 2
+ assert t.next() == 3
+ }
+ go([1, 2, 3].iterator())
+ '''
+ }
+
+ @Test
+ void testHeadRest_CompileStatic_ListVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (*f, last) = src
+ assert f == [1, 2, 3]
+ assert last == 4
+ }
+ go([1, 2, 3, 4])
+ '''
+ }
+
+ @Test
+ void testMiddleRest_CompileStatic_ListVariableRhs() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (l, *m, r) = src
+ assert l == 1
+ assert m == [2, 3, 4]
+ assert r == 5
+ }
+ go([1, 2, 3, 4, 5])
+ '''
+ }
+
+ @Test
+ void testNonTailRest_CompileStatic_pathCRhsRejectedAtCompileTime() {
+ // Set RHS for head/middle rest is Path C only; STC must reject.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Set<Integer> src) {
+ def (l, *m, r) = src
+ }
+ go([1, 2, 3, 4] as Set)
+ '''
+ assert e.message.contains('rest binding requires an indexable')
+ || e.message.contains('does not support getAt(IntRange)')
+ }
+
+ @Test
+ void testTailRest_CompileStatic_typedHeadAgainstListVariable() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (int h, *t) = src
+ assert h == 10
+ assert t == [20, 30]
+ }
+ go([10, 20, 30])
+ '''
+ }
+
+ // GEP-20 cross-cutting @CompileStatic coverage: parallels of the dynamic-mode
+ // tests plus second-order interactions (empty RHS, degenerate single-rest,
+ // unbounded iterator, method-returning-typed, destructure-inside-closure).
+
+ @Test
+ void testTailRest_CompileStatic_emptyList() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (h, *t) = []
+ assert h == null
+ assert t == []
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_stringLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (c, *cs) = "hello"
+ assert c == 'h'
+ assert cs == "ello"
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_arrayLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ Integer[] arr = [1, 2, 3]
+ def (h, *t) = arr
+ assert h == 1
+ assert t == [2, 3] || (t as List) == [2, 3]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_singleRestBinder() {
+ // Degenerate: def (*t) = rhs is equivalent to def t = rhs.
+ // BinaryExpressionHelper has a fast-path for this — verify it under CS.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (*t) = [1, 2, 3]
+ assert t == [1, 2, 3]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_unboundedIterator_lazy() {
+ // Path C with an unbounded source — must NOT materialise.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ class Counter implements Iterator<Integer> {
+ int i = 0
+ boolean hasNext() { true }
+ Integer next() { i++ }
+ void remove() { throw new UnsupportedOperationException() }
+ }
+ @CompileStatic
+ def go() {
+ def (h, *t) = (Iterator<Integer>) new Counter()
+ assert h == 0
+ assert t.next() == 1
+ assert t.next() == 2
+ assert t.next() == 3
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testHeadRest_CompileStatic_stringLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (*head, tail) = "hello"
+ assert head == 'hell'
+ assert tail == 'o'
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testHeadRest_CompileStatic_arrayLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ Integer[] arr = [1, 2, 3, 4]
+ def (*front, last) = arr
+ assert front == [1, 2, 3]
+ assert last == 4
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMiddleRest_CompileStatic_emptyMiddle() {
+ // Short RHS — middle absorbs zero elements; nonTailRestSlice helper handles
+ // the inverted-range case by returning an empty list (not the DGM-reverse default).
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (l, *m, r) = [10, 20]
+ assert l == 10
+ assert m == []
+ assert r == 20
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMiddleRest_CompileStatic_typedEnds() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (String l, *m, int r) = ['a', 2, 3, 4, 5]
+ assert l == 'a'
+ assert m == [2, 3, 4]
+ assert r == 5
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_renaming() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (host: hostname, port: portNum) = [host: 'localhost', port: 8080]
+ assert hostname == 'localhost'
+ assert portNum == 8080
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_singleEntry() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (only: x) = [only: 99]
+ assert x == 99
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_methodReturningTypedMap() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ class Holder {
+ Map<String, Integer> stats() { [hits: 7, misses: 2] }
+ def go() {
+ def (hits: h, misses: m) = stats()
+ assert h == 7
+ assert m == 2
+ }
+ }
+ new Holder().go()
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_methodReturningArray() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ class Holder {
+ Integer[] arr() { [10, 20, 30] as Integer[] }
+ def go() {
+ def (a, b, c) = arr()
+ assert [a, b, c] == [10, 20, 30]
+ }
+ }
+ new Holder().go()
+ '''
+ }
+
+ @Test
+ void testTailRest_CompileStatic_closureCallRhs() {
+ // Closure invocation returning List<T> — exercises the indexable-RHS synthesis
+ // for declared-type positional plus the rest dispatcher.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def src = { -> [1, 2, 3] as List<Integer> }
+ def (h, *t) = src()
+ assert h == 1
+ assert t == [2, 3]
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMixed_CompileStatic_destructureInsideClosure() {
+ // Verify that destructuring inside a closure body works under @CompileStatic
+ // (closure scoping doesn't break the synthetic temp / rest dispatch).
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def total = 0
+ [[1,2,3], [10,20,30]].each { List<Integer> row ->
+ def (a, *rest) = row
+ total += a
+ for (Integer r : rest) total += r
+ }
+ assert total == 66
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testMapStyle_CompileStatic_destructureInsideClosure() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ List<String> names = []
+ List<Map<String, Integer>> people = [[id: 1, age: 30], [id: 2, age: 42]]
+ people.each { Map<String, Integer> p ->
+ def (id: i, age: a) = p
+ names << "id=$i age=$a".toString()
+ }
+ assert names == ['id=1 age=30', 'id=2 age=42']
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 def/var binders: accepted in place of a type, equivalent to omitting it.
+ // Provided for symmetry with switch case patterns and the GEP-19 bracket-form grammar.
+
+ @Test
+ void testDefBinder_positional() {
+ assertScript '''
+ def (def a, def b) = [1, 2]
+ assert a == 1 && b == 2
+ '''
+ }
+
+ @Test
+ void testVarBinder_positional() {
+ assertScript '''
+ def (var a, var b) = [1, 2]
+ assert a == 1 && b == 2
+ '''
+ }
+
+ @Test
+ void testMixedDefVarTypedBinder() {
+ assertScript '''
+ def (def a, var b, int c) = [1, 2, 3]
+ assert a == 1 && b == 2 && c == 3
+ '''
+ }
+
+ @Test
+ void testVarBinder_withRest() {
+ assertScript '''
+ def (var h, *t) = [1, 2, 3]
+ assert h == 1
+ assert t == [2, 3]
+ '''
+ }
+
+ @Test
+ void testVarBinder_mapStyle() {
+ assertScript '''
+ def (name: var n, age: var a) = [name: 'X', age: 9]
+ assert n == 'X' && a == 9
+ '''
+ }
+
+ @Test
+ void testVarBinder_CompileStatic_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (var a, var b) = [10, 20]
+ assert a == 10 && b == 20
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testVarBinder_CompileStatic_mapStyle() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: var n) = [name: 'Z']
+ assert n == 'Z'
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 edge cases: empty Map literal, null RHS, Map missing-key with primitive
+ // vs Object binders, classic for-init multi-assignment, method-returning-typed.
+
+ @Test
+ void testMapStyle_emptyMapLiteral_dynamic() {
+ assertScript '''
+ def (name: n, age: a) = [:]
+ assert n == null
+ assert a == null
+ '''
+ }
+
+ @Test
+ void testMapStyle_emptyMapLiteral_compileStatic() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (name: n, age: a) = [:]
+ assert n == null
+ assert a == null
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testRest_nullRhs_compileStatic() {
+ // Declared-type RHS makes STC accept; runtime NPE on iterator() is the natural failure
+ // mode (matches the dynamic equivalent).
+ shouldFail NullPointerException, '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(List<Integer> src) {
+ def (h, *t) = src
+ }
+ go(null)
+ '''
+ }
+
+ @Test
+ void testMapStyle_compileStatic_objectBinderTolerant() {
+ // Object binder against Map<String, Integer> — missing key gives null. n is Object.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Map<String, Integer> m) {
+ def (name: n, missing: x) = m
+ assert n == 1
+ assert x == null
+ }
+ go([name: 1])
+ '''
+ }
+
+ @Test
+ void testMapStyle_compileStatic_primitiveBinderRuntimeFailureOnMissingKey() {
+ // STC stores the Map's V as the binder type; primitive int binder accepts.
+ // At runtime, missing key → null, and null→int throws GroovyCastException —
+ // standard Groovy semantics, not a GEP-20 specific behaviour.
+ shouldFail org.codehaus.groovy.runtime.typehandling.GroovyCastException, '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Map<String, Integer> m) {
+ def (missing: int x) = m
+ }
+ go([name: 1])
+ '''
+ }
+
+ @Test
+ void testForInit_destructure_dynamic() {
+ // Classic for-init style with multi-assignment — pre-GEP behaviour, regression check.
+ assertScript '''
+ int sum = 0
+ for (def (a, b) = [1, 2]; a < 5; a++, b++) {
+ sum += a + b
+ }
+ assert sum == (1+2) + (2+3) + (3+4) + (4+5)
+ '''
+ }
+
+ @Test
+ void testForInit_destructure_compileStatic() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ int sum = 0
+ for (def (int a, int b) = [1, 2]; a < 4; a++, b++) {
+ sum += a + b
+ }
+ return sum
+ }
+ assert go() == (1+2) + (2+3) + (3+4)
+ '''
+ }
+
+ @Test
+ void testRest_compileStatic_methodReturningTypedList() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ class Holder {
+ List<Integer> data() { [10, 20, 30, 40] }
+ def go() {
+ def (h, *t) = data()
+ assert h == 10
+ assert t == [20, 30, 40]
+ }
+ }
+ new Holder().go()
+ '''
+ }
+
+ @Test
+ void testPositional_CompileStatic_objectRhsRuntimeError() {
+ // Object-declared RHS is lenient at compile time (the MOP fallback resolves getAt(int)),
+ // so the failure mode for an actually non-indexable value shifts from STC error to a
+ // runtime MissingMethodException. Users who want compile-time rejection should declare
+ // a more specific RHS type.
+ def e = shouldFail '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go(Object src) {
+ def (a, b) = src
+ }
+ go(42)
+ '''
+ assert e instanceof MissingMethodException
+ assert e.message.contains('getAt')
+ }
+
+ // GEP-20 array-rest consistency: empty and non-empty array rest both yield List<T>.
+ // Prior to GROOVY-11964 patch, the empty branch returned an empty array which would fail
+ // assignment to a typed `List<T> *t` rest binder. These tests use arrays sized exactly to
+ // the fixed slots so the rest absorbs zero — exercising the empty branch in tailRest
+ // without tripping the orthogonal AIOOBE on getAt(0) for fully-empty arrays.
+
+ @Test
+ void testTailRest_arrayExactlyConsumed_emptyRestYieldsList() {
+ assertScript '''
+ Integer[] arr = [1, 2, 3] as Integer[]
+ def (a, b, c, *t) = arr
+ assert a == 1
+ assert b == 2
+ assert c == 3
+ assert t == []
+ assert t instanceof List
+ '''
+ }
+
+ @Test
+ void testTailRest_arrayExactlyConsumed_typedListBinder() {
+ assertScript '''
+ Integer[] arr = [1, 2, 3] as Integer[]
+ def (a, b, c, List<Integer> *t) = arr
+ assert a == 1
+ assert b == 2
+ assert c == 3
+ assert t == []
+ '''
+ }
+
+ @Test
+ void testMiddleRest_shortArray_emptyMiddleYieldsEmptyList() {
+ // Short array RHS — middle rest absorbs zero elements; empty slice is List, not array.
+ assertScript '''
+ Integer[] arr = [10, 20] as Integer[]
+ def (l, *m, r) = arr
+ assert l == 10
+ assert m == []
+ assert m instanceof List
+ assert r == 20
+ '''
+ }
+
+ @Test
+ void testMiddleRest_shortArray_typedListMiddleBinder() {
+ assertScript '''
+ Integer[] arr = [10, 20] as Integer[]
+ def (Integer l, List<Integer> *m, Integer r) = arr
+ assert l == 10
+ assert m == []
+ assert r == 20
+ '''
+ }
+
+ // GEP-20 empty rest mutability: when the rest binding captures zero elements, the
+ // result must be a mutable list — not Collections.emptyList() — so that downstream
+ // mutation (`rest << x`, `rest.add(x)`) behaves the same whether the rest absorbed
+ // zero or N elements. Mirrors Groovy's getAt(EmptyRange) contract on List/array.
+
+ @Test
+ void testTailRest_emptyRest_isMutable_listRhs() {
+ assertScript '''
+ def (a, *t) = [1]
+ assert t == []
+ t << 2
+ assert t == [2]
+ '''
+ }
+
+ @Test
+ void testTailRest_emptyRest_isMutable_arrayRhs() {
+ assertScript '''
+ Integer[] arr = [1, 2, 3] as Integer[]
+ def (a, b, c, *t) = arr
+ assert t == []
+ t << 4
+ assert t == [4]
+ '''
+ }
+
+ @Test
+ void testMiddleRest_emptyMiddle_isMutable_listRhs() {
+ assertScript '''
+ def (l, *m, r) = [10, 20]
+ assert m == []
+ m << 15
+ assert m == [15]
+ '''
+ }
+
+ @Test
+ void testMiddleRest_emptyMiddle_isMutable_arrayRhs() {
+ assertScript '''
+ Integer[] arr = [10, 20] as Integer[]
+ def (l, *m, r) = arr
+ assert m == []
+ m << 15
+ assert m == [15]
+ '''
+ }
+
+ @Test
+ void testTailRest_emptyRest_preservesListSubtype() {
+ assertScript '''
+ def src = new LinkedList<Integer>([1])
+ def (a, *t) = src
+ assert t == []
+ assert t instanceof LinkedList
+ t << 2
+ assert t == [2]
+ '''
+ }
+
+ // GEP-20 empty rest slice element-type inference: when a list-literal RHS is too short
+ // for the rest to absorb anything, fall back to the rest binder's declared element type
+ // rather than Object — otherwise `List<Integer> *t` is rejected as List<Object>.
+
+ @Test
+ void testTypedRest_CompileStatic_emptySlice_listLiteral() {
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (Integer a, List<Integer> *t, Integer b) = [1, 2]
+ assert a == 1
+ assert t == []
+ assert b == 2
+ }
+ go()
+ '''
+ }
+
+ // GEP-20 Path B by capability: the rest binder type tracks the actual return type of
+ // getAt(IntRange) on the receiver, not a hard-coded List<T>. Covers String/GString
+ // (return String), CharSequence (return CharSequence), BitSet (return BitSet), and
+ // user custom classes that declare a self-similar slice type.
+
+ @Test
+ void testTailRest_String_restIsString_dynamic() {
+ assertScript '''
+ def (c, *cs) = "hello"
+ assert c == 'h'
+ assert cs == "ello"
+ assert cs instanceof String
+ assert cs.toUpperCase() == "ELLO" // String API survives destructuring
+ '''
+ }
+
+ @Test
+ void testTailRest_String_restIsString_compileStatic() {
+ // Smoke-tests that STC infers cs : String — calling a String-only method must compile.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def (c, *cs) = "hello"
+ assert c == 'h'
+ assert cs.toUpperCase() == "ELLO"
+ assert cs.startsWith("ell")
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_BitSet_restIsBitSet_dynamic() {
+ assertScript '''
+ def bs = new BitSet()
+ bs.set(0); bs.set(2); bs.set(5)
+ def (h, *t) = bs
+ assert h == true
+ assert t instanceof BitSet
+ assert t.get(1) // bit 2 in original is at index 1 of slice
+ assert t.get(4) // bit 5 in original is at index 4 of slice
+ '''
+ }
+
+ @Test
+ void testTailRest_BitSet_restIsBitSet_compileStatic() {
+ // STC must infer t : BitSet so BitSet-only API calls compile.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def bs = new BitSet()
+ bs.set(0); bs.set(2); bs.set(5)
+ def (h, *t) = bs
+ assert h == true
+ assert t.cardinality() == 2 // BitSet-only method
+ assert t.get(1) && t.get(4)
+ }
+ go()
+ '''
+ }
+
+ @Test
+ void testTailRest_userClass_selfSimilarSlice_dynamic() {
+ assertScript '''
+ class MyCol {
+ List<Integer> data
+ MyCol(List<Integer> d) { this.data = d }
+ Integer getAt(int i) { data[i] }
+ MyCol getAt(IntRange r) { new MyCol(data[r]) }
+ Iterator<Integer> iterator() { data.iterator() }
+ }
+ def m = new MyCol([10, 20, 30, 40])
+ def (h, *t) = m
+ assert h == 10
+ assert t instanceof MyCol
+ assert t.data == [20, 30, 40]
+ '''
+ }
+
+ @Test
+ void testTailRest_userClass_selfSimilarSlice_compileStatic() {
+ // The bytecode dispatches the rest slice as `rhs.getAt(new IntRange(true, 1, -1))`,
+ // so the user's getAt(IntRange) must handle the negative end-index. Delegating to
+ // the inner List's range subscript is the simplest robust pattern.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ class MyCol {
+ List<Integer> data
+ MyCol(List<Integer> d) { this.data = d }
+ Integer getAt(int i) { data[i] }
+ MyCol getAt(IntRange r) { new MyCol(data[r]) }
+ Iterator<Integer> iterator() { data.iterator() }
+ }
+ @CompileStatic
+ def go(MyCol src) {
+ def (h, *t) = src
+ assert h == 10
+ // STC should type t as MyCol so .data resolves at compile time.
+ assert t.data == [20, 30, 40]
+ }
+ go(new MyCol([10, 20, 30, 40]))
+ '''
+ }
+
+ @Test
+ void testHeadRest_BitSet_compileStatic() {
+ // BitSet now participates in head/middle rest under STC because Path B is determined
+ // by capability (resolvable getAt(IntRange)), not by membership in a closed list.
+ assertScript '''import groovy.transform.CompileStatic
+ @CompileStatic
+ def go() {
+ def bs = new BitSet()
+ bs.set(0); bs.set(1); bs.set(2)
+ def (*front, last) = bs
+ assert front instanceof BitSet
+ assert last == true
+ }
+ go()
+ '''
+ }
}
diff --git a/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy b/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
index 64df131143..f2708ed78e 100644
--- a/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
@@ -1328,11 +1328,15 @@ class STCAssignmentTest extends StaticTypeCheckingTestCase {
@Test
void testMultipleAssignmentFromVariable() {
- shouldFailWithMessages '''
+ // GEP-20: declared-type RHS is accepted when the type has a statically-resolvable
+ // getAt(int). The previous "Multiple assignments without list or tuple" restriction
+ // has been lifted for this case.
+ assertScript '''
def list = [1,2,3]
def (x,y) = list
- ''',
- 'Multiple assignments without list or tuple on the right-hand side are unsupported in static type checking mode'
+ assert x == 1
+ assert y == 2
+ '''
}
// GROOVY-8223, GROOVY-8887, GROOVY-10063