diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 8501b7658e8..d0b39c0b7a2 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 ab506a3483f..aa43a3feff8 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.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 @@ private boolean isFieldDeclaration(final ModifierManager modifierManager, final @Override public List 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 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 *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 00000000000..a237e12b98f --- /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 a1461f120ef..4f8199cf120 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 @@ 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.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.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; @@ -498,6 +502,101 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin leftExpression.visit(acg); operandStack.remove(operandStack.getStackLength() - mark); } else { // multiple declaration or assignment + TupleExpression tuple = (TupleExpression) leftExpression; + java.util.List 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); @@ -522,9 +621,17 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin 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); } @@ -546,11 +653,19 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin 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; @@ -578,6 +693,20 @@ public void evaluateEqual(final BinaryExpression expression, final boolean defin 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 00000000000..78dfbf56f0b --- /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. + * + *

These are called from generated bytecode only. User code should not rely on them.

+ */ +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: + * + *
    + *
  • Path A — {@code Stream}: 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).
  • + *
  • Path 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.
  • + *
  • Path C — anything else iterable: return the already-advanced + * iterator.
  • + *
+ * + *

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.

+ * + *

Primitive streams ({@code IntStream}, {@code LongStream}, + * {@code DoubleStream}) are not subtypes of {@code Stream} and therefore + * fall through to Path B (if they expose {@code getAt(IntRange)}) or + * Path C; their elements are boxed by the underlying iterator.

+ * + * @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, so the empty case must also be a mutable List — not an empty + // T[] — for typed rest binders like `List *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: + * + *
    + *
  • 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.
  • + *
  • {@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.)
  • + *
+ * + *

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.

+ * + * @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 (for both List + // and array RHS), so the empty path must agree — both for the array + // List-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 6928ceefc57..41548ccb897 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 @@ 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 Expression transformBinaryExpression(final BinaryExpression bin) { 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 @@ private Expression transformRelationComparison(final BinaryExpression bin) { 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 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 d36a1fba5ae..13d4377fbc1 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.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 @@ private void adjustGenerics(final ClassNode source, final ClassNode target) { 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 @@ private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpr } 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 @@ private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpr } } + // 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 @@ private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpr 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} for Path B receivers + * (those that support {@code getAt(IntRange)}); {@code Stream} for {@code Stream} + * RHS (Path A, tail rest only); {@code Iterator} 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 tupleExpressions = tuple.getExpressions(); + List 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 *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 *t` doesn't get rejected + // as `List` not assignable to `List`. + 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 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 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[] → List, ...). + 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 (Path A, tail rest only — laziness + onClose preserved). + // Path C → Iterator (tail rest only). + // Box primitives so generic containers match (e.g. List, not List). + 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 *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 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: + *
    + *
  • {@code Map} (and subtypes) — binder type derived from the map's V generic.
  • + *
  • Bean / class with declared properties — resolved via STC's existing property lookup.
  • + *
  • {@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).
  • + *
+ */ + 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 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 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 @@ protected static ClassNode getGroupOperationResultType(final ClassNode a, final 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}. 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)}. + * + *

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.

+ */ + 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. 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 483030fbd1b..07fae9a0b3d 100644 --- a/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy +++ b/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy @@ -226,4 +226,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, 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() { + 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 *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 *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 matches the runtime iterator returned by tailRest. + assertScript ''' + Set s = new LinkedHashSet<>([7, 8, 9]) + def (h, Iterator *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 *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 src) { + def (int h, List *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 src) { + def (h, Iterator *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); user declared Iterator for the rest. + // STC catches the mismatch at compile time. + def e = shouldFail '''import groovy.transform.CompileStatic + @CompileStatic + def go(List src) { + def (h, Iterator *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); user declared List for the rest. + def e = shouldFail '''import groovy.transform.CompileStatic + @CompileStatic + def go(Set src) { + def (h, List *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 rest — STC accepts and runtime preserves. + assertScript '''import groovy.transform.CompileStatic + import java.util.stream.Stream + @CompileStatic + def go(Stream src) { + def (h, Stream *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 at compile time. + assertScript '''import groovy.transform.CompileStatic + import java.util.stream.Stream + @CompileStatic + def go(Stream src) { + def (h, *t) = src + assert h == 'a' + // Static check: t typed as Stream — 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); user declared Iterator. + def e = shouldFail '''import groovy.transform.CompileStatic + import java.util.stream.Stream + @CompileStatic + def go(Stream src) { + def (h, Iterator *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 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 *t) = [1, 2, 3] + } + ''' + assert e.message.contains('Cannot assign') + } + + @Test + void testTypedRest_CompileStatic_genericsAreInvariant() { + // Java generics aren't covariant: List is NOT a List. + // STC follows the Java rule, so an Integer-element literal won't fit a List. + // Users wanting widening should accept the inferred List or use ? extends. + def e = shouldFail '''import groovy.transform.CompileStatic + @CompileStatic + def go() { + def (h, List *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 *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 == '' + assert b == '' + ''' + } + + @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 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 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 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, beans, GroovyObject — resolved via STC's property lookup) + + @Test + void testMapStyle_CompileStatic_MapVariableRhs() { + assertScript '''import groovy.transform.CompileStatic + @CompileStatic + def go(Map src) { + def (name: n, age: a) = src + assert n == 'Alice' + assert a == 30 + } + go([name: 'Alice', age: 30]) + ''' + } + + @Test + void testMapStyle_CompileStatic_TypedMapVariableRhs() { + // Map → all binders inferred as Integer. + assertScript '''import groovy.transform.CompileStatic + @CompileStatic + def go(Map 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 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. + assertScript '''import groovy.transform.CompileStatic + @CompileStatic + def go(Set 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 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 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 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 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 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 { + int i = 0 + boolean hasNext() { true } + Integer next() { i++ } + void remove() { throw new UnsupportedOperationException() } + } + @CompileStatic + def go() { + def (h, *t) = (Iterator) 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 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 — 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 } + 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 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 names = [] + List> people = [[id: 1, age: 30], [id: 2, age: 42]] + people.each { Map 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 src) { + def (h, *t) = src + } + go(null) + ''' + } + + @Test + void testMapStyle_compileStatic_objectBinderTolerant() { + // Object binder against Map — missing key gives null. n is Object. + assertScript '''import groovy.transform.CompileStatic + @CompileStatic + def go(Map 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 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 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. + // Prior to GROOVY-11964 patch, the empty branch returned an empty array which would fail + // assignment to a typed `List *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 *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 *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([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 *t` is rejected as List. + + @Test + void testTypedRest_CompileStatic_emptySlice_listLiteral() { + assertScript '''import groovy.transform.CompileStatic + @CompileStatic + def go() { + def (Integer a, List *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. 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 data + MyCol(List d) { this.data = d } + Integer getAt(int i) { data[i] } + MyCol getAt(IntRange r) { new MyCol(data[r]) } + Iterator 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 data + MyCol(List d) { this.data = d } + Integer getAt(int i) { data[i] } + MyCol getAt(IntRange r) { new MyCol(data[r]) } + Iterator 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 64df1311434..f2708ed78e2 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