diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 3eb0dedeede..2cb4526fbf7 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -646,6 +646,7 @@ statement | tryCatchStatement #tryCatchStmtAlt | SYNCHRONIZED expressionInPar nls block #synchronizedStmtAlt | RETURN expression? #returnStmtAlt + | RETURN AT identifier expression? #returnAtStmtAlt | THROW expression #throwStmtAlt | breakStatement #breakStmtAlt | continueStatement #continueStmtAlt diff --git a/src/main/java/groovy/transform/SupportsLoopControl.java b/src/main/java/groovy/transform/SupportsLoopControl.java new file mode 100644 index 00000000000..3fdc31f2194 --- /dev/null +++ b/src/main/java/groovy/transform/SupportsLoopControl.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an iterator-style method as cooperating with the loop-control protocol + * (GROOVY-12126): {@code break} and {@code continue} may be used inside a + * closure passed directly as an argument to the method, with these semantics: + * + * Cooperating methods catch the compiler-internal + * {@link org.codehaus.groovy.runtime.LoopControl} signals around each + * per-element closure invocation. A signal thrown inside a closure passed to a + * method without this annotation propagates to the caller. + * + * @since 6.0.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface SupportsLoopControl { +} 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 aaeb90b9fd7..fa9d5c960fc 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -913,6 +913,20 @@ public ReturnStatement visitReturnStmtAlt(final ReturnStmtAltContext ctx) { ctx); } + @Override + public ReturnStatement visitReturnAtStmtAlt(final ReturnAtStmtAltContext ctx) { + if (switchExpressionRuleContextStack.peek() instanceof SwitchExpressionContext) { + throw createParsingFailedException("switch expression does not support `return`", ctx); + } + + ReturnStatement returnStatement = new ReturnStatement(asBoolean(ctx.expression()) + ? (Expression) this.visit(ctx.expression()) + : ConstantExpression.EMPTY_EXPRESSION); + returnStatement.setTarget(this.visitIdentifier(ctx.identifier())); + + return configureAST(returnStatement, ctx); + } + @Override public ThrowStatement visitThrowStmtAlt(final ThrowStmtAltContext ctx) { return configureAST( diff --git a/src/main/java/org/codehaus/groovy/ast/stmt/ReturnStatement.java b/src/main/java/org/codehaus/groovy/ast/stmt/ReturnStatement.java index 4320e12fdfe..e9fefefd2b3 100644 --- a/src/main/java/org/codehaus/groovy/ast/stmt/ReturnStatement.java +++ b/src/main/java/org/codehaus/groovy/ast/stmt/ReturnStatement.java @@ -42,6 +42,14 @@ public class ReturnStatement extends Statement { private Expression expression; + /** + * The optional non-local return target, i.e. the {@code name} in {@code return@name expr}. + * Null for an ordinary return. + * + * @since 6.0.0 + */ + private String target; + /** * Constructs a return statement from an expression statement by extracting its expression. * @@ -79,9 +87,29 @@ public void setExpression(final Expression expression) { this.expression = expression; } + /** + * Returns the non-local return target for a {@code return@name expr} statement. + * + * @return the name of the enclosing method targeted by this return, or null for an ordinary return + * @since 6.0.0 + */ + public String getTarget() { + return target; + } + + /** + * Sets the non-local return target, i.e. the {@code name} in {@code return@name expr}. + * + * @param target the name of the enclosing method targeted by this return + * @since 6.0.0 + */ + public void setTarget(final String target) { + this.target = target; + } + @Override public String getText() { - return "return " + expression.getText(); + return "return" + (target != null ? "@" + target : "") + " " + expression.getText(); } /** diff --git a/src/main/java/org/codehaus/groovy/control/CompilationUnit.java b/src/main/java/org/codehaus/groovy/control/CompilationUnit.java index 2a5db311b7a..06edc60da39 100644 --- a/src/main/java/org/codehaus/groovy/control/CompilationUnit.java +++ b/src/main/java/org/codehaus/groovy/control/CompilationUnit.java @@ -342,6 +342,10 @@ private void addPhaseOperations() { visitor.visitClass(classNode); }, Phases.CANONICALIZATION); + addPhaseOperation((final SourceUnit source, final GeneratorContext context, final ClassNode classNode) -> { + new NonLocalControlFlowRewriter(source).rewrite(classNode); + }, Phases.CANONICALIZATION); + addPhaseOperation((final SourceUnit source, final GeneratorContext context, final ClassNode classNode) -> { Object callback = classNode.getNodeMetaData(DYNAMIC_OUTER_NODE_CALLBACK); if (callback instanceof IPrimaryClassNodeOperation) { diff --git a/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java b/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java new file mode 100644 index 00000000000..73a7ac0f93e --- /dev/null +++ b/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java @@ -0,0 +1,386 @@ +/* + * 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.control; + +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.ClassCodeVisitorSupport; +import org.codehaus.groovy.ast.InnerClassNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.StaticMethodCallExpression; +import org.codehaus.groovy.ast.expr.TupleExpression; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.BreakStatement; +import org.codehaus.groovy.ast.stmt.CaseStatement; +import org.codehaus.groovy.ast.stmt.CatchStatement; +import org.codehaus.groovy.ast.stmt.ContinueStatement; +import org.codehaus.groovy.ast.stmt.DoWhileStatement; +import org.codehaus.groovy.ast.stmt.EmptyStatement; +import org.codehaus.groovy.ast.stmt.ForStatement; +import org.codehaus.groovy.ast.stmt.IfStatement; +import org.codehaus.groovy.ast.stmt.ReturnStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.ast.stmt.SwitchStatement; +import org.codehaus.groovy.ast.stmt.SynchronizedStatement; +import org.codehaus.groovy.ast.stmt.TryCatchStatement; +import org.codehaus.groovy.ast.stmt.WhileStatement; +import org.codehaus.groovy.classgen.VariableScopeVisitor; +import org.codehaus.groovy.runtime.LoopControl; +import org.codehaus.groovy.runtime.NonLocalReturn; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +import static org.codehaus.groovy.ast.tools.GeneralUtils.args; +import static org.codehaus.groovy.ast.tools.GeneralUtils.block; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.castX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.catchS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.classX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.declS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX; +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.returnS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.throwS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.tryCatchS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; + +/** + * Desugars non-local control flow from closures (GROOVY-12126). + *

+ * A {@code return@name expr} inside a closure whose lexically enclosing method + * is named {@code name} (or {@code return@script} inside a script body) becomes + * a call to {@link NonLocalReturn#raise}, throwing a token-matched signal; the + * target method's body is wrapped so the signal is caught and turned into an + * ordinary return of the carried value. Methods without such returns are left + * untouched. The reserved target {@code script} refers to the script body. + *

+ * A {@code break} or {@code continue} inside a closure passed directly as a + * method call argument — and not bound to a loop (or switch, for break) within + * the closure — becomes {@code throw LoopControl.BREAK/CONTINUE}; cooperating + * iterator methods (marked {@link groovy.transform.SupportsLoopControl}) catch + * the signal per element. Such closures are tagged with node metadata under + * the {@code NonLocalControlFlowRewriter.class} key so the static type checker + * can verify the callee cooperates. + *

+ * Runs at {@link Phases#CANONICALIZATION}, before static type checking and + * class generation, so the desugared form compiles identically under dynamic + * and static compilation. + */ +public class NonLocalControlFlowRewriter extends ClassCodeVisitorSupport { + + private static final ClassNode NLR_TYPE = ClassHelper.make(NonLocalReturn.class); + private static final ClassNode LOOP_CONTROL_TYPE = ClassHelper.make(LoopControl.class); + private static final String TOKEN_NAME = "$nlr$token"; + private static final String SCRIPT_TARGET = "script"; + + private final SourceUnit sourceUnit; + + private ClassNode currentClass; + private MethodNode currentMethod; + private int closureDepth; + private boolean methodNeedsWrapper; + private boolean classNeedsScopeRepair; + + /** Closures passed directly as method call arguments, i.e. eligible for break/continue. */ + private final Set eligibleClosures = Collections.newSetFromMap(new IdentityHashMap<>()); + private ClosureExpression currentClosure; + private int loopDepth; + private int switchDepth; + + public NonLocalControlFlowRewriter(final SourceUnit sourceUnit) { + this.sourceUnit = sourceUnit; + } + + @Override + protected SourceUnit getSourceUnit() { + return sourceUnit; + } + + public void rewrite(final ClassNode classNode) { + currentClass = classNode; + classNeedsScopeRepair = false; + visitClass(classNode); + if (classNeedsScopeRepair) { + // the token variable is declared in the method body and referenced + // inside closures, so captured-variable bookkeeping must be redone + ClassNode outermost = classNode; + while (outermost instanceof InnerClassNode && ((InnerClassNode) outermost).isAnonymous()) { + outermost = outermost.getOuterClass(); + } + new VariableScopeVisitor(sourceUnit).visitClass(outermost); + } + } + + @Override + protected void visitConstructorOrMethod(final MethodNode node, final boolean isConstructor) { + MethodNode previousMethod = currentMethod; + int previousDepth = closureDepth; + boolean previousNeedsWrapper = methodNeedsWrapper; + currentMethod = isConstructor ? null : node; + closureDepth = 0; + methodNeedsWrapper = false; + try { + super.visitConstructorOrMethod(node, isConstructor); + if (methodNeedsWrapper) { + wrapMethod(node); + classNeedsScopeRepair = true; + } + } finally { + currentMethod = previousMethod; + closureDepth = previousDepth; + methodNeedsWrapper = previousNeedsWrapper; + } + } + + @Override + public void visitClosureExpression(final ClosureExpression expression) { + ClosureExpression previousClosure = currentClosure; + int previousLoopDepth = loopDepth; + int previousSwitchDepth = switchDepth; + closureDepth += 1; + currentClosure = expression; + loopDepth = 0; + switchDepth = 0; + try { + super.visitClosureExpression(expression); + } finally { + closureDepth -= 1; + currentClosure = previousClosure; + loopDepth = previousLoopDepth; + switchDepth = previousSwitchDepth; + } + } + + @Override + public void visitMethodCallExpression(final MethodCallExpression call) { + markEligibleClosureArguments(call.getArguments()); + super.visitMethodCallExpression(call); + } + + @Override + public void visitStaticMethodCallExpression(final StaticMethodCallExpression call) { + markEligibleClosureArguments(call.getArguments()); + super.visitStaticMethodCallExpression(call); + } + + private void markEligibleClosureArguments(final Expression arguments) { + if (arguments instanceof TupleExpression) { + for (Expression argument : ((TupleExpression) arguments).getExpressions()) { + if (argument instanceof ClosureExpression) { + eligibleClosures.add((ClosureExpression) argument); + } + } + } + } + + //-------------------------------------------------------------------------- + // break/continue in closure arguments: statement-slot replacement + + /** + * Returns the loop-control signal throw replacing the given statement, or + * null when the statement is not an applicable break/continue (reporting + * a compile error for break/continue misuses inside closures). + */ + private Statement replacementFor(final Statement statement) { + boolean isBreak = statement instanceof BreakStatement; + if (!isBreak && !(statement instanceof ContinueStatement)) return null; + if (closureDepth == 0) return null; // LabelVerifier's domain + if (loopDepth > 0 || (isBreak && switchDepth > 0)) return null; // bound to a real loop/switch in the closure + + String kind = isBreak ? "break" : "continue"; + String label = isBreak ? ((BreakStatement) statement).getLabel() : ((ContinueStatement) statement).getLabel(); + if (label != null) { + addError("labeled " + kind + " is not allowed inside a closure", statement); + return null; + } + if (!eligibleClosures.contains(currentClosure)) { + addError(kind + " inside a closure is only allowed when the closure is a direct argument of a method call", statement); + return null; + } + currentClosure.putNodeMetaData(NonLocalControlFlowRewriter.class, Boolean.TRUE); + Statement replacement = throwS(propX(classX(LOOP_CONTROL_TYPE), isBreak ? "BREAK" : "CONTINUE")); + replacement.setSourcePosition(statement); + return replacement; + } + + @Override + public void visitBlockStatement(final BlockStatement block) { + List statements = block.getStatements(); + for (int i = 0, n = statements.size(); i < n; i += 1) { + Statement replacement = replacementFor(statements.get(i)); + if (replacement != null) statements.set(i, replacement); + } + super.visitBlockStatement(block); + } + + @Override + public void visitIfElse(final IfStatement ifElse) { + Statement replacement = replacementFor(ifElse.getIfBlock()); + if (replacement != null) ifElse.setIfBlock(replacement); + replacement = replacementFor(ifElse.getElseBlock()); + if (replacement != null) ifElse.setElseBlock(replacement); + super.visitIfElse(ifElse); + } + + @Override + public void visitForLoop(final ForStatement forLoop) { + loopDepth += 1; + try { + super.visitForLoop(forLoop); + } finally { + loopDepth -= 1; + } + } + + @Override + public void visitWhileLoop(final WhileStatement loop) { + loopDepth += 1; + try { + super.visitWhileLoop(loop); + } finally { + loopDepth -= 1; + } + } + + @Override + public void visitDoWhileLoop(final DoWhileStatement loop) { + loopDepth += 1; + try { + super.visitDoWhileLoop(loop); + } finally { + loopDepth -= 1; + } + } + + @Override + public void visitSwitch(final SwitchStatement statement) { + switchDepth += 1; + try { + Statement replacement = replacementFor(statement.getDefaultStatement()); + if (replacement != null) statement.setDefaultStatement(replacement); + super.visitSwitch(statement); + } finally { + switchDepth -= 1; + } + } + + @Override + public void visitCaseStatement(final CaseStatement statement) { + Statement replacement = replacementFor(statement.getCode()); + if (replacement != null) statement.setCode(replacement); + super.visitCaseStatement(statement); + } + + @Override + public void visitTryCatchFinally(final TryCatchStatement statement) { + Statement replacement = replacementFor(statement.getTryStatement()); + if (replacement != null) statement.setTryStatement(replacement); + for (CatchStatement catchStatement : statement.getCatchStatements()) { + replacement = replacementFor(catchStatement.getCode()); + if (replacement != null) catchStatement.setCode(replacement); + } + replacement = replacementFor(statement.getFinallyStatement()); + if (replacement != null) statement.setFinallyStatement(replacement); + super.visitTryCatchFinally(statement); + } + + @Override + public void visitSynchronizedStatement(final SynchronizedStatement statement) { + Statement replacement = replacementFor(statement.getCode()); + if (replacement != null) statement.setCode(replacement); + super.visitSynchronizedStatement(statement); + } + + @Override + public void visitReturnStatement(final ReturnStatement statement) { + super.visitReturnStatement(statement); + String target = statement.getTarget(); + if (target == null) return; + + if (currentMethod == null) { + addError("return@" + target + " is only allowed inside a method body (or a closure within one)", statement); + return; + } + if (!isTarget(currentMethod, target)) { + if (isOuterTarget(target)) { + addError("return@" + target + ": cannot return across a class boundary; '" + target + + "' lexically encloses class '" + currentClass.getNameWithoutPackage() + + "' but is not one of its methods", statement); + } else { + String hint = currentClass.isScript() && currentMethod.isScriptBody() + ? "; use return@script to return from a script body" : ""; + addError("return@" + target + ": no lexically enclosing method named '" + target + "'" + hint, statement); + } + return; + } + + statement.setTarget(null); + if (closureDepth == 0) return; // plain return suffices at method level + + Expression value = statement.getExpression() == ConstantExpression.EMPTY_EXPRESSION + ? nullX() : statement.getExpression(); + Expression raise = callX(NLR_TYPE, "raise", args(varX(TOKEN_NAME, ClassHelper.OBJECT_TYPE), value)); + raise.setSourcePosition(statement); + statement.setExpression(raise); + methodNeedsWrapper = true; + } + + private static boolean isTarget(final MethodNode method, final String target) { + return target.equals(method.getName()) || (SCRIPT_TARGET.equals(target) && method.isScriptBody()); + } + + private boolean isOuterTarget(final String target) { + ClassNode cn = currentClass; + while (cn != null) { + MethodNode enclosing = cn.getEnclosingMethod(); + if (enclosing != null && isTarget(enclosing, target)) return true; + cn = cn.getOuterClass(); + } + return false; + } + + private static void wrapMethod(final MethodNode method) { + Parameter signal = new Parameter(NLR_TYPE, "$nlr$e"); + ClassNode returnType = method.getReturnType(); + Statement returnCarriedValue = ClassHelper.isPrimitiveVoid(returnType) + ? new ReturnStatement(ConstantExpression.EMPTY_EXPRESSION) + : returnS(castX(returnType, callX(varX(signal), "getValue"))); + Statement catchBody = block( + ifS(notX(callX(varX(signal), "matches", args(varX(TOKEN_NAME, ClassHelper.OBJECT_TYPE)))), throwS(varX(signal))), + returnCarriedValue); + TryCatchStatement handler = tryCatchS(method.getCode(), EmptyStatement.INSTANCE, catchS(signal, catchBody)); + BlockStatement newCode = block( + declS(localVarX(TOKEN_NAME, ClassHelper.OBJECT_TYPE), ctorX(ClassHelper.OBJECT_TYPE)), + handler); + newCode.setSourcePosition(method.getCode()); + method.setCode(newCode); + } +} diff --git a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java index 8c3a14acac2..a0d5e435ea4 100644 --- a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java +++ b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java @@ -43,6 +43,7 @@ import groovy.lang.Range; import groovy.lang.SpreadMap; import groovy.lang.Tuple2; +import groovy.transform.SupportsLoopControl; import groovy.transform.stc.ClosureParams; import groovy.transform.stc.FirstParam; import groovy.transform.stc.FromString; @@ -242,6 +243,17 @@ public int compare(Map.Entry e1, Map.Entry e2) { private static final NumberAwareComparator COMPARABLE_NUMBER_AWARE_COMPARATOR = new NumberAwareComparator<>(); + // internal helper method: calls the closure translating loop-control signals; + // returns false if the iteration should stop (LoopControl.BREAK), true otherwise + private static boolean callResumable(Closure closure, Object arg) { + try { + closure.call(arg); + return true; + } catch (LoopControl signal) { + return signal != LoopControl.BREAK; + } + } + // internal helper method protected static T callClosureForLine(@ClosureParams(value=FromString.class, options={"String","String,Integer"}) Closure closure, String line, int counter) { if (closure.getMaximumNumberOfParameters() == 2) { @@ -2438,6 +2450,7 @@ public static Collection collect(Object self) { * @return a List of the transformed values * @since 1.0 */ + @SupportsLoopControl public static List collect(Object self, Closure transform) { return collect(self, new ArrayList<>(), transform); } @@ -2452,6 +2465,7 @@ public static List collect(Object self, Closure transform) { * @return the collector with all transformed values added to it * @since 1.0 */ + @SupportsLoopControl public static > C collect(Object self, C collector, Closure transform) { return collect(InvokerHelper.asIterator(self), collector, transform); } @@ -2465,6 +2479,7 @@ public static > C collect(Object self, C collector, C * @return a List of the transformed values * @since 2.5.0 */ + @SupportsLoopControl public static List collect(Iterator self, @ClosureParams(FirstParam.FirstGenericType.class) Closure transform) { return collect(self, new ArrayList<>(), transform); } @@ -2619,9 +2634,17 @@ public void remove() { * @return the collector with all transformed values added to it * @since 2.5.0 */ + @SupportsLoopControl public static > C collect(Iterator self, C collector, @ClosureParams(FirstParam.FirstGenericType.class) Closure transform) { while (self.hasNext()) { - collector.add(transform.call(self.next())); + T transformed; + try { + transformed = transform.call(self.next()); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + continue; + } + collector.add(transformed); } return collector; } @@ -2667,6 +2690,7 @@ public static List collect(Iterable self) { * @return a List of the transformed values * @since 2.5.0 */ + @SupportsLoopControl public static List collect(Iterable self, @ClosureParams(FirstParam.FirstGenericType.class) Closure transform) { return collect(self.iterator(), transform); } @@ -2699,9 +2723,17 @@ public static List collect(Iterable self, Function> C collect(Iterable self, C collector, @ClosureParams(FirstParam.FirstGenericType.class) Closure transform) { for (E element : self) { - collector.add(transform.call(element)); + T transformed; + try { + transformed = transform.call(element); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + continue; + } + collector.add(transformed); if (transform.getDirective() == Closure.DONE) { break; } @@ -2774,9 +2806,17 @@ public static List collect(Iterable self, BiFunction> C collect(Map self, C collector, @ClosureParams(MapEntryOrKeyValue.class) Closure transform) { for (Map.Entry entry : self.entrySet()) { - collector.add(callClosureForMapEntry(transform, entry)); + T transformed; + try { + transformed = callClosureForMapEntry(transform, entry); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + continue; + } + collector.add(transformed); } return collector; } @@ -2795,6 +2835,7 @@ public static > C collect(Map self, C col * @return the resultant list of transformed values * @since 1.0 */ + @SupportsLoopControl public static List collect(Map self, @ClosureParams(MapEntryOrKeyValue.class) Closure transform) { return collect(self, new ArrayList<>(self.size()), transform); } @@ -4257,12 +4298,13 @@ public static Number div(Character left, Character right) { * @param closure the closure to call * @since 1.0 */ + @SupportsLoopControl public static void downto(Number self, Number to, @ClosureParams(FirstParam.class) Closure closure) { int self1 = self.intValue(); int to1 = to.intValue(); if (self1 >= to1) { for (int i = self1; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4278,11 +4320,12 @@ public static void downto(Number self, Number to, @ClosureParams(FirstParam.clas * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(long self, Number to, @ClosureParams(FirstParam.class) Closure closure) { long to1 = to.longValue(); if (self >= to1) { for (long i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4298,11 +4341,12 @@ public static void downto(long self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(Long self, Number to, @ClosureParams(FirstParam.class) Closure closure) { long to1 = to.longValue(); if (self >= to1) { for (long i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4318,11 +4362,12 @@ public static void downto(Long self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(float self, Number to, @ClosureParams(FirstParam.class) Closure closure) { float to1 = to.floatValue(); if (self >= to1) { for (float i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4337,11 +4382,12 @@ public static void downto(float self, Number to, @ClosureParams(FirstParam.class * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(Float self, Number to, @ClosureParams(FirstParam.class) Closure closure) { float to1 = to.floatValue(); if (self >= to1) { for (float i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4356,11 +4402,12 @@ public static void downto(Float self, Number to, @ClosureParams(FirstParam.class * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(double self, Number to, @ClosureParams(FirstParam.class) Closure closure) { double to1 = to.doubleValue(); if (self >= to1) { for (double i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4375,11 +4422,12 @@ public static void downto(double self, Number to, @ClosureParams(FirstParam.clas * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(Double self, Number to, @ClosureParams(FirstParam.class) Closure closure) { double to1 = to.doubleValue(); if (self >= to1) { for (double i = self; i >= to1; i--) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4394,6 +4442,7 @@ public static void downto(Double self, Number to, @ClosureParams(FirstParam.clas * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(BigInteger self, Number to, @ClosureParams(FirstParam.class) Closure closure) { if (to instanceof BigDecimal to1) { final BigDecimal one = BigDecimal.valueOf(10, 1); // That's what you get for "1.0". @@ -4411,7 +4460,7 @@ public static void downto(BigInteger self, Number to, @ClosureParams(FirstParam. final BigInteger one = BigInteger.valueOf(1); if (self.compareTo(to1) >= 0) { for (BigInteger i = self; i.compareTo(to1) >= 0; i = i.subtract(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException( @@ -4423,7 +4472,7 @@ public static void downto(BigInteger self, Number to, @ClosureParams(FirstParam. final BigInteger to1 = new BigInteger(to.toString()); if (self.compareTo(to1) >= 0) { for (BigInteger i = self; i.compareTo(to1) >= 0; i = i.subtract(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException( @@ -4448,12 +4497,13 @@ public static void downto(BigInteger self, Number to, @ClosureParams(FirstParam. * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void downto(BigDecimal self, Number to, @ClosureParams(FirstParam.class) Closure closure) { final BigDecimal one = BigDecimal.valueOf(10, 1); // Quick way to get "1.0". if (to instanceof BigDecimal to1) { if (self.compareTo(to1) >= 0) { for (BigDecimal i = self; i.compareTo(to1) >= 0; i = i.subtract(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else { throw new GroovyRuntimeException("The argument (" + to + @@ -4463,7 +4513,7 @@ public static void downto(BigDecimal self, Number to, @ClosureParams(FirstParam. BigDecimal to1 = new BigDecimal((BigInteger) to); if (self.compareTo(to1) >= 0) { for (BigDecimal i = self; i.compareTo(to1) >= 0; i = i.subtract(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -4471,7 +4521,7 @@ public static void downto(BigDecimal self, Number to, @ClosureParams(FirstParam. BigDecimal to1 = NumberMath.toBigDecimal(to); if (self.compareTo(to1) >= 0) { for (BigDecimal i = self; i.compareTo(to1) >= 0; i = i.subtract(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -5068,6 +5118,7 @@ public static String dump(Object self) { * @return the self Object * @since 1.0 */ + @SupportsLoopControl public static T each(T self, @ClosureParams(value=FromString.class, options="?") Closure closure) { each(InvokerHelper.asIterator(self), closure); return self; @@ -5080,6 +5131,7 @@ public static T each(T self, @ClosureParams(value=FromString.class, options= * @param closure the closure applied on each element found * @return the self Iterable */ + @SupportsLoopControl public static Iterable each(Iterable self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { each(self.iterator(), closure); return self; @@ -5114,10 +5166,11 @@ public static Iterable each(Iterable self, Consumer consume * @return the (now exhausted) self Iterator * @since 2.4.0 */ + @SupportsLoopControl public static Iterator each(Iterator self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { while (self.hasNext()) { Object arg = self.next(); - closure.call(arg); + if (!callResumable(closure, arg)) break; } return self; } @@ -5130,6 +5183,7 @@ public static Iterator each(Iterator self, @ClosureParams(FirstParam.F * @return the self Collection * @since 2.4.0 */ + @SupportsLoopControl public static Collection each(Collection self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (Collection) each((Iterable) self, closure); } @@ -5142,6 +5196,7 @@ public static Collection each(Collection self, @ClosureParams(FirstPar * @return the self List * @since 2.4.0 */ + @SupportsLoopControl public static List each(List self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (List) each((Iterable) self, closure); } @@ -5154,6 +5209,7 @@ public static List each(List self, @ClosureParams(FirstParam.FirstGene * @return the self Set * @since 2.4.0 */ + @SupportsLoopControl public static Set each(Set self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (Set) each((Iterable) self, closure); } @@ -5166,6 +5222,7 @@ public static Set each(Set self, @ClosureParams(FirstParam.FirstGeneri * @return the self SortedSet * @since 2.4.0 */ + @SupportsLoopControl public static SortedSet each(SortedSet self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (SortedSet) each((Iterable) self, closure); } @@ -5192,9 +5249,14 @@ public static SortedSet each(SortedSet self, @ClosureParams(FirstParam * @return returns the self parameter * @since 1.5.0 */ + @SupportsLoopControl public static Map each(Map self, @ClosureParams(MapEntryOrKeyValue.class) Closure closure) { for (Map.Entry entry : self.entrySet()) { - callClosureForMapEntry(closure, entry); + try { + callClosureForMapEntry(closure, entry); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + } } return self; } @@ -5260,6 +5322,7 @@ public static Iterator> eachPermutation(Iterable self, Closure cl * @return the self Object * @since 1.0 */ + @SupportsLoopControl public static T eachWithIndex(T self, @ClosureParams(value=FromString.class, options="?,Integer") Closure closure) { final Object[] args = new Object[2]; int counter = 0; @@ -5281,6 +5344,7 @@ public static T eachWithIndex(T self, @ClosureParams(value=FromString.class, * @return the self Iterable * @since 2.3.0 */ + @SupportsLoopControl public static Iterable eachWithIndex(Iterable self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { eachWithIndex(self.iterator(), closure); return self; @@ -5319,13 +5383,18 @@ public static Iterable eachWithIndex(Iterable self, ObjIntConsumer Iterator eachWithIndex(Iterator self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { final Object[] args = new Object[2]; int counter = 0; while (self.hasNext()) { args[0] = self.next(); args[1] = counter++; - closure.call(args); + try { + closure.call(args); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + } } return self; } @@ -5340,6 +5409,7 @@ public static Iterator eachWithIndex(Iterator self, @ClosureParams(val * @return the self Collection * @since 2.4.0 */ + @SupportsLoopControl public static Collection eachWithIndex(Collection self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { return (Collection) eachWithIndex((Iterable) self, closure); } @@ -5354,6 +5424,7 @@ public static Collection eachWithIndex(Collection self, @ClosureParams * @return the self List * @since 2.4.0 */ + @SupportsLoopControl public static List eachWithIndex(List self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { return (List) eachWithIndex((Iterable) self, closure); } @@ -5368,6 +5439,7 @@ public static List eachWithIndex(List self, @ClosureParams(value=FromS * @return the self Set * @since 2.4.0 */ + @SupportsLoopControl public static Set eachWithIndex(Set self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { return (Set) eachWithIndex((Iterable) self, closure); } @@ -5382,6 +5454,7 @@ public static Set eachWithIndex(Set self, @ClosureParams(value=FromStr * @return the self SortedSet * @since 2.4.0 */ + @SupportsLoopControl public static SortedSet eachWithIndex(SortedSet self, @ClosureParams(value=FromString.class, options="T,Integer") Closure closure) { return (SortedSet) eachWithIndex((Iterable) self, closure); } @@ -5404,10 +5477,15 @@ public static SortedSet eachWithIndex(SortedSet self, @ClosureParams(v * @return the self Object * @since 1.5.0 */ + @SupportsLoopControl public static Map eachWithIndex(Map self, @ClosureParams(value=MapEntryOrKeyValue.class, options="index=true") Closure closure) { int counter = 0; for (Map.Entry entry : self.entrySet()) { - callClosureForMapEntryAndCounter(closure, entry, counter++); + try { + callClosureForMapEntryAndCounter(closure, entry, counter++); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + } } return self; } @@ -6037,11 +6115,19 @@ public static Map.Entry find(Map self, @ClosureParams(MapEntr * @return a new subMap * @since 1.0 */ + @SupportsLoopControl public static Map findAll(Map self, @ClosureParams(MapEntryOrKeyValue.class) Closure closure) { Map answer = createSimilarMap(self); BooleanClosureWrapper bcw = new BooleanClosureWrapper(closure); for (Map.Entry entry : self.entrySet()) { - if (bcw.callForMap(entry)) { + boolean keep; + try { + keep = bcw.callForMap(entry); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + continue; + } + if (keep) { answer.put(entry.getKey(), entry.getValue()); } } @@ -6084,6 +6170,7 @@ public static Map findAll(Map self, BiPredicate Set findAll(Set self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (Set) findAll((Collection) self, closure); } @@ -6142,6 +6229,7 @@ public static Set findAll(Set self, BiPredicate List findAll(List self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return (List) findAll((Collection) self, closure); } @@ -6155,6 +6243,7 @@ public static List findAll(List self, @ClosureParams(FirstParam.FirstG * @return a Collection of matching values * @since 1.5.6 */ + @SupportsLoopControl public static Collection findAll(Collection self, @ClosureParams(FirstParam.FirstGenericType.class) Closure closure) { return findMany(createSimilarCollection(self), self.iterator(), closure); } @@ -6282,6 +6371,7 @@ public static Collection findAll(Collection self) { * @since 1.6.0 */ @SuppressWarnings("unchecked") + @SupportsLoopControl public static List findAll(Object self, Closure closure) { return findMany(new ArrayList(), InvokerHelper.asIterator(self), closure); } @@ -6308,7 +6398,14 @@ static > C findMany(C collector, Iterator List> inits(Iterable self) { * @see #inject(Object, Object, Closure) * @since 1.8.7 */ + @SupportsLoopControl public static T inject(Object self, @ClosureParams(value=FromString.class,options="T,?") Closure closure) { Iterator iter = InvokerHelper.asIterator(self); if (!iter.hasNext()) { @@ -9573,6 +9671,7 @@ public static T inject(Object self, @ClosureParams(value=FromSt * @see #inject(Iterable, Object, Closure) * @since 5.0.0 */ + @SupportsLoopControl public static T inject(Iterable self, @ClosureParams(value=FromString.class,options="T,E") Closure closure) { Iterator iter = self.iterator(); if (!iter.hasNext()) { @@ -9629,6 +9728,7 @@ public static T inject(Iterable self, BinaryOperator operator) { * @see #inject(Iterator, Object, Closure) * @since 1.5.0 */ + @SupportsLoopControl public static T inject(Object self, U initialValue, @ClosureParams(value=FromString.class,options="T,?") Closure closure) { Iterator iter = InvokerHelper.asIterator(self); return (T) inject(iter, initialValue, closure); @@ -9676,6 +9776,7 @@ public static T inject(Object self, U initialValue * @return the result of the last closure call * @since 5.0.0 */ + @SupportsLoopControl public static T inject(Iterable self, U initialValue, @ClosureParams(value=FromString.class,options="T,E") Closure closure) { return inject(self.iterator(), initialValue, closure); } @@ -9725,13 +9826,19 @@ public static U inject(Iterable self, U initialValue, BiFunction T inject(Iterator self, U initialValue, @ClosureParams(value=FromString.class,options="T,E") Closure closure) { T value = initialValue; Object[] params = new Object[2]; while (self.hasNext()) { params[0] = value; params[1] = self.next(); - value = closure.call(params); + try { + value = closure.call(params); + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + // CONTINUE: keep the prior accumulator value + } } return value; } @@ -9760,13 +9867,19 @@ public static T inject(Iterator self, U init * @return the result of the last closure call * @since 1.8.1 */ + @SupportsLoopControl public static T inject(Map self, U initialValue, @ClosureParams(value=FromString.class,options={"T,Map.Entry","T,K,V"}) Closure closure) { T value = initialValue; for (Map.Entry entry : self.entrySet()) { - if (closure.getMaximumNumberOfParameters() == 3) { - value = closure.call(value, entry.getKey(), entry.getValue()); - } else { - value = closure.call(value, entry); + try { + if (closure.getMaximumNumberOfParameters() == 3) { + value = closure.call(value, entry.getKey(), entry.getValue()); + } else { + value = closure.call(value, entry); + } + } catch (LoopControl signal) { + if (signal == LoopControl.BREAK) break; + // CONTINUE: keep the prior accumulator value } } return value; @@ -15422,6 +15535,7 @@ public static String sprintf(Object self, String format, Object arg) { * @param closure the closure to call * @since 1.0 */ + @SupportsLoopControl public static void step(Number self, Number to, Number stepNumber, Closure closure) { if (self instanceof BigDecimal || to instanceof BigDecimal || stepNumber instanceof BigDecimal) { final BigDecimal zero = BigDecimal.valueOf(0, 1); // Same as "0.0". @@ -15430,11 +15544,11 @@ public static void step(Number self, Number to, Number stepNumber, Closure closu BigDecimal stepNumber1 = NumberMath.toBigDecimal(stepNumber); if (stepNumber1.compareTo(zero) > 0 && to1.compareTo(self1) > 0) { for (BigDecimal i = self1; i.compareTo(to1) < 0; i = i.add(stepNumber1)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if (stepNumber1.compareTo(zero) < 0 && to1.compareTo(self1) < 0) { for (BigDecimal i = self1; i.compareTo(to1) > 0; i = i.add(stepNumber1)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if(self1.compareTo(to1) != 0) throw new GroovyRuntimeException("Infinite loop in " + self1 + ".step(" + to1 + ", " + stepNumber1 + ")"); @@ -15445,11 +15559,11 @@ public static void step(Number self, Number to, Number stepNumber, Closure closu BigInteger stepNumber1 = (stepNumber instanceof BigInteger) ? (BigInteger) stepNumber : new BigInteger(stepNumber.toString()); if (stepNumber1.compareTo(zero) > 0 && to1.compareTo(self1) > 0) { for (BigInteger i = self1; i.compareTo(to1) < 0; i = i.add(stepNumber1)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if (stepNumber1.compareTo(zero) < 0 && to1.compareTo(self1) < 0) { for (BigInteger i = self1; i.compareTo(to1) > 0; i = i.add(stepNumber1)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if(self1.compareTo(to1) != 0) throw new GroovyRuntimeException("Infinite loop in " + self1 + ".step(" + to1 + ", " + stepNumber1 + ")"); @@ -15459,11 +15573,11 @@ public static void step(Number self, Number to, Number stepNumber, Closure closu int stepNumber1 = stepNumber.intValue(); if (stepNumber1 > 0 && to1 > self1) { for (int i = self1; i < to1; i += stepNumber1) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if (stepNumber1 < 0 && to1 < self1) { for (int i = self1; i > to1; i += stepNumber1) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else if(self1 != to1) throw new GroovyRuntimeException("Infinite loop in " + self1 + ".step(" + to1 + ", " + stepNumber1 + ")"); @@ -16452,9 +16566,10 @@ public void remove() { * @param closure the closure to call a number of times * @since 1.0 */ + @SupportsLoopControl public static void times(Number self, @ClosureParams(value=SimpleType.class,options="int") Closure closure) { for (int i = 0, size = self.intValue(); i < size; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; if (closure.getDirective() == Closure.DONE) { break; } @@ -18537,12 +18652,13 @@ public Iterator iterator() { * @param closure the closure to call * @since 1.0 */ + @SupportsLoopControl public static void upto(Number self, Number to, @ClosureParams(FirstParam.class) Closure closure) { int self1 = self.intValue(); int to1 = to.intValue(); if (self1 <= to1) { for (int i = self1; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18558,11 +18674,12 @@ public static void upto(Number self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(long self, Number to, @ClosureParams(FirstParam.class) Closure closure) { long to1 = to.longValue(); if (self <= to1) { for (long i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18578,11 +18695,12 @@ public static void upto(long self, Number to, @ClosureParams(FirstParam.class) C * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(Long self, Number to, @ClosureParams(FirstParam.class) Closure closure) { long to1 = to.longValue(); if (self <= to1) { for (long i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18598,11 +18716,12 @@ public static void upto(Long self, Number to, @ClosureParams(FirstParam.class) C * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(float self, Number to, @ClosureParams(FirstParam.class) Closure closure) { float to1 = to.floatValue(); if (self <= to1) { for (float i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18618,11 +18737,12 @@ public static void upto(float self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(Float self, Number to, @ClosureParams(FirstParam.class) Closure closure) { float to1 = to.floatValue(); if (self <= to1) { for (float i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18638,11 +18758,12 @@ public static void upto(Float self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(double self, Number to, @ClosureParams(FirstParam.class) Closure closure) { double to1 = to.doubleValue(); if (self <= to1) { for (double i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18658,11 +18779,12 @@ public static void upto(double self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(Double self, Number to, @ClosureParams(FirstParam.class) Closure closure) { double to1 = to.doubleValue(); if (self <= to1) { for (double i = self; i <= to1; i++) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18682,13 +18804,14 @@ public static void upto(Double self, Number to, @ClosureParams(FirstParam.class) * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(BigInteger self, Number to, @ClosureParams(FirstParam.class) Closure closure) { if (to instanceof BigDecimal to1) { final BigDecimal one = BigDecimal.valueOf(10, 1); BigDecimal self1 = new BigDecimal(self); if (self1.compareTo(to1) <= 0) { for (BigDecimal i = self1; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException( @@ -18699,7 +18822,7 @@ public static void upto(BigInteger self, Number to, @ClosureParams(FirstParam.cl final BigInteger one = BigInteger.valueOf(1); if (self.compareTo(to1) <= 0) { for (BigInteger i = self; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException( @@ -18710,7 +18833,7 @@ public static void upto(BigInteger self, Number to, @ClosureParams(FirstParam.cl BigInteger to1 = new BigInteger(to.toString()); if (self.compareTo(to1) <= 0) { for (BigInteger i = self; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException(MessageFormat.format( @@ -18732,12 +18855,13 @@ public static void upto(BigInteger self, Number to, @ClosureParams(FirstParam.cl * @param closure the code to execute for each number * @since 1.0 */ + @SupportsLoopControl public static void upto(BigDecimal self, Number to, @ClosureParams(FirstParam.class) Closure closure) { final BigDecimal one = BigDecimal.valueOf(10, 1); // That's what you get for "1.0". if (to instanceof BigDecimal to1) { if (self.compareTo(to1) <= 0) { for (BigDecimal i = self; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18746,7 +18870,7 @@ public static void upto(BigDecimal self, Number to, @ClosureParams(FirstParam.cl BigDecimal to1 = new BigDecimal((BigInteger) to); if (self.compareTo(to1) <= 0) { for (BigDecimal i = self; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + @@ -18755,7 +18879,7 @@ public static void upto(BigDecimal self, Number to, @ClosureParams(FirstParam.cl BigDecimal to1 = NumberMath.toBigDecimal(to); if (self.compareTo(to1) <= 0) { for (BigDecimal i = self; i.compareTo(to1) <= 0; i = i.add(one)) { - closure.call(i); + if (!callResumable(closure, i)) break; } } else throw new GroovyRuntimeException("The argument (" + to + diff --git a/src/main/java/org/codehaus/groovy/runtime/LoopControl.java b/src/main/java/org/codehaus/groovy/runtime/LoopControl.java new file mode 100644 index 00000000000..56768c6e3f6 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/LoopControl.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * Compiler-internal loop-control signals used to implement {@code break} and + * {@code continue} inside closures passed to cooperating iterator methods + * (GROOVY-12126). The compiler lowers {@code break}/{@code continue} in an + * eligible closure to {@code throw LoopControl.BREAK}/{@code CONTINUE}; + * iterator methods marked {@link groovy.transform.SupportsLoopControl} catch + * the signal per element and stop or skip accordingly. + *

+ * The two signals are preallocated, carry no stack trace, and cannot be + * instantiated by user code, so the per-element cost of a cooperating loop is + * a try/catch region that is free unless a signal is actually thrown. If a + * signal reaches a non-cooperating caller it surfaces with an explanatory + * message rather than being silently swallowed. + * + * @since 6.0.0 + */ +public final class LoopControl extends RuntimeException { + + private static final long serialVersionUID = 7972103766131505698L; + + /** Signal that the current iteration should stop (loop {@code break}). */ + public static final LoopControl BREAK = new LoopControl("break"); + + /** Signal that the current element should be skipped (loop {@code continue}). */ + public static final LoopControl CONTINUE = new LoopControl("continue"); + + private LoopControl(final String kind) { + super("'" + kind + "' used in a closure passed to a method that does not support loop control", + null, false, false); + } +} diff --git a/src/main/java/org/codehaus/groovy/runtime/NonLocalReturn.java b/src/main/java/org/codehaus/groovy/runtime/NonLocalReturn.java new file mode 100644 index 00000000000..c1ee9c8c132 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/NonLocalReturn.java @@ -0,0 +1,80 @@ +/* + * 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; + +/** + * Compiler-internal signal used to implement non-local return from closures + * ({@code return@methodName expr}). The compiler synthesizes a unique token per + * activation of the target method and wraps the method body in a handler that + * catches this exception, returning the carried value when the token matches. + *

+ * Instances carry no stack trace, so the throw/catch round trip is cheap. If a + * closure containing a non-local return escapes and is invoked after its target + * activation has completed, the token no longer matches any live handler and + * this exception surfaces to the caller rather than corrupting an unrelated + * activation. + *

+ * This class is not intended for direct use in user code. + * + * @since 6.0.0 + */ +public class NonLocalReturn extends RuntimeException { + + private static final long serialVersionUID = -6045879712662530680L; + + private final transient Object token; + private final transient Object value; + + public NonLocalReturn(final Object token, final Object value) { + super("Non-local return escaped: the closure was invoked outside the dynamic extent of its target method activation", + null, false, false); + this.token = token; + this.value = value; + } + + /** + * The per-activation token identifying the target method invocation. + */ + public Object getToken() { + return token; + } + + /** + * The value to be returned from the target method. + */ + public Object getValue() { + return value; + } + + /** + * Whether this signal targets the activation identified by the given token. + */ + public boolean matches(final Object candidate) { + return token == candidate; + } + + /** + * Throws a {@code NonLocalReturn} carrying the given token and value. + * Called from compiler-generated code at {@code return@target} sites; + * declared with a return type so call sites can occupy value positions. + */ + public static Object raise(final Object token, final Object value) { + throw new NonLocalReturn(token, value); + } +} 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 0e7b4d99ce5..6ba63777eb7 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -390,6 +390,8 @@ public class StaticTypeCheckingVisitor extends ClassCodeVisitorSupport { protected static final ClassNode CLOSUREPARAMS_CLASSNODE = ClassHelper.make(ClosureParams.class); /** Cached {@link NamedParams} annotation type. */ protected static final ClassNode NAMED_PARAMS_CLASSNODE = ClassHelper.make(NamedParams.class); + /** Cached {@link groovy.transform.SupportsLoopControl} annotation type. */ + protected static final ClassNode SUPPORTS_LOOP_CONTROL = ClassHelper.make(groovy.transform.SupportsLoopControl.class); /** Cached {@link NamedParam} annotation type. */ protected static final ClassNode NAMED_PARAM_CLASSNODE = ClassHelper.make(NamedParam.class); @Deprecated(forRemoval = true, since = "4.0.0") @@ -3680,6 +3682,7 @@ protected void visitMethodCallArguments(final ClassNode receiver, final Argument expression.putNodeMetaData(PARAMETER_TYPE, targetType); } if (expression instanceof ClosureExpression) { + checkLoopControlSupported(selectedMethod, expression); checkClosureWithDelegatesTo(receiver, selectedMethod, args(expressions), parameters, expression, target); if (i > 0 || !(selectedMethod instanceof ExtensionMethodNode)) { inferClosureParameterTypes(receiver, arguments, (ClosureExpression) expression, target, selectedMethod); @@ -4094,6 +4097,21 @@ private void resolveGenericsFromTypeHint(final ClassNode receiver, final Express } } + /** + * Checks that a closure using break/continue (tagged by the non-local + * control flow rewriter, GROOVY-12126) is passed to a method that + * cooperates with the loop-control protocol. + */ + private void checkLoopControlSupported(final MethodNode mn, final Expression closure) { + if (closure.getNodeMetaData(org.codehaus.groovy.control.NonLocalControlFlowRewriter.class) == null) return; + MethodNode target = mn instanceof ExtensionMethodNode ? ((ExtensionMethodNode) mn).getExtensionMethodNode() : mn; + if (target.getAnnotations(SUPPORTS_LOOP_CONTROL).isEmpty()) { + addStaticTypeError("closure uses break/continue but the target method " + + prettyPrintTypeName(target.getDeclaringClass()) + "#" + target.getName() + + " is not marked @SupportsLoopControl", closure); + } + } + private void checkClosureWithDelegatesTo(final ClassNode receiver, final MethodNode mn, final ArgumentListExpression arguments, final Parameter[] params, final Expression expression, final Parameter param) { List annotations = param.getAnnotations(DELEGATES_TO); if (annotations != null && !annotations.isEmpty()) { diff --git a/src/test-resources/core/ReturnAt_01.groovy b/src/test-resources/core/ReturnAt_01.groovy new file mode 100644 index 00000000000..fbe897fd374 --- /dev/null +++ b/src/test-resources/core/ReturnAt_01.groovy @@ -0,0 +1,42 @@ +/* + * 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. + */ +def firstMatch(rows, pred) { + rows.each { row -> + row.each { cell -> + if (pred(cell)) return@firstMatch cell + } + } + null +} + +def m() { + [1, 2, 3].each { + if (it > 2) return@m + } + [1, 2, 3].each { + return @m it * 2 + } + return@m 42; +} + +void n() { + def c = { return@n } +} + +[[1]].each { return@script it } diff --git a/src/test-resources/fail/ReturnAt_01x.groovy b/src/test-resources/fail/ReturnAt_01x.groovy new file mode 100644 index 00000000000..2a9e5a47248 --- /dev/null +++ b/src/test-resources/fail/ReturnAt_01x.groovy @@ -0,0 +1,23 @@ +/* + * 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. + */ +def m() { + [1].each { + return@1 it + } +} diff --git a/src/test-resources/fail/ReturnAt_02x.groovy b/src/test-resources/fail/ReturnAt_02x.groovy new file mode 100644 index 00000000000..3f3c668eb1d --- /dev/null +++ b/src/test-resources/fail/ReturnAt_02x.groovy @@ -0,0 +1,23 @@ +/* + * 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. + */ +def m() { + [1].each { + return@ 99 + } +} diff --git a/src/test/groovy/gls/closures/LoopControlTest.groovy b/src/test/groovy/gls/closures/LoopControlTest.groovy new file mode 100644 index 00000000000..2350d0e9b08 --- /dev/null +++ b/src/test/groovy/gls/closures/LoopControlTest.groovy @@ -0,0 +1,253 @@ +/* + * 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 gls.closures + +import groovy.transform.CompileStatic +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.MultipleCompilationErrorsException +import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript +import static groovy.test.GroovyAssert.shouldFail + +/** + * Tests for break/continue inside closure arguments to cooperating iterator + * methods (GROOVY-12126). + */ +final class LoopControlTest { + + private static final GroovyShell CS_SHELL = new GroovyShell( + new CompilerConfiguration().addCompilationCustomizers(new ASTTransformationCustomizer(CompileStatic))) + + /** Runs the script dynamically and under @CompileStatic. */ + private static void assertScriptBoth(String text) { + assertScript(text) + assertScript(CS_SHELL, text) + } + + @Test + void testEachBreakContinue() { + assertScriptBoth ''' + def seen = [] + [1, 2, 3, 4, 5].each { + if (it == 2) continue + if (it == 4) break + seen << it + } + assert seen == [1, 3] + ''' + } + + @Test + void testTimesUptoStep() { + assertScriptBoth ''' + def seen = [] + 5.times { + if (it == 3) break + seen << it + } + assert seen == [0, 1, 2] + + seen = [] + 1.upto(9) { + if (it % 2 == 0) continue + if (it > 5) break + seen << it + } + assert seen == [1, 3, 5] + + seen = [] + 0.step(10, 2) { + if (it == 4) continue + if (it == 8) break + seen << it + } + assert seen == [0, 2, 6] + ''' + } + + @Test + void testCollectExcludesAndSkips() { + assertScriptBoth ''' + assert [1, 2, 3, 4].collect { + if (it == 2) continue + if (it == 4) break + it * 10 + } == [10, 30] + ''' + } + + @Test + void testFindAllAndInject() { + assertScriptBoth ''' + assert [1, 2, 3, 4, 5].findAll { + if (it == 4) break + it % 2 == 1 + } == [1, 3] + + assert [1, 2, 3, 4].inject(0) { acc, it -> + if (it == 2) continue + if (it == 4) break + acc + it + } == 4 + ''' + } + + @Test + void testMapIteration() { + assertScriptBoth ''' + def seen = [] + [a: 1, b: 2, c: 3].each { k, v -> + if (v == 2) continue + if (v == 3) break + seen << k + } + assert seen == ['a'] + ''' + } + + @Test + void testNestedClosuresBindInnermostIteration() { + assertScriptBoth ''' + def seen = [] + [[1, 2, 3], [4, 5]].each { row -> + row.each { cell -> + if (cell == 2 || cell == 5) break + seen << cell + } + } + assert seen == [1, 4] + ''' + } + + @Test + void testBreakBoundToRealLoopInsideClosureIsUntouched() { + assertScriptBoth ''' + def seen = [] + [1, 2].each { outer -> + for (i in 10..14) { + if (i == 12) break + seen << i + } + seen << outer + } + assert seen == [10, 11, 1, 10, 11, 2] + ''' + } + + @Test + void testContinueBoundToRealLoopInsideClosureIsUntouched() { + assertScriptBoth ''' + def seen = [] + [1].each { + for (i in 1..5) { + if (i % 2 == 0) continue + seen << i + } + } + assert seen == [1, 3, 5] + ''' + } + + @Test + void testBreakBoundToSwitchInsideClosureIsUntouched() { + assertScriptBoth ''' + def seen = [] + [1, 2, 3].each { + switch (it) { + case 2: + seen << 'two' + break + default: + seen << it + } + } + assert seen == [1, 'two', 3] + ''' + } + + @Test + void testUncooperativeCalleeEscapesLoudlyWhenDynamic() { + assertScript ''' + import org.codehaus.groovy.runtime.LoopControl + + try { + 'x'.with { break } + assert false, 'expected LoopControl to escape' + } catch (LoopControl expected) { + assert expected.message.contains('does not support loop control') + } + ''' + } + + @Test + void testUncooperativeCalleeIsCompileErrorUnderStaticTypeChecking() { + def err = shouldFail MultipleCompilationErrorsException, ''' + @groovy.transform.TypeChecked + void m() { + 'x'.with { break } + } + m() + ''' + assert err.message.contains('is not marked @SupportsLoopControl') + } + + @Test + void testBreakOutsideCallArgumentClosureIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def c = { break } + ''' + assert err.message.contains('break inside a closure is only allowed when the closure is a direct argument of a method call') + } + + @Test + void testContinueOutsideCallArgumentClosureIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def c = { continue } + ''' + assert err.message.contains('continue inside a closure is only allowed when the closure is a direct argument of a method call') + } + + @Test + void testLabeledBreakInClosureIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + outer: + for (i in 1..3) { + [1].each { break outer } + } + ''' + assert err.message.contains('labeled break is not allowed inside a closure') + } + + @Test + void testBreakInClosureInsideLexicalLoopBindsIteration() { + // previously leaked through LabelVerifier and broke at codegen; now well-defined + assertScriptBoth ''' + def seen = [] + for (i in 1..2) { + [10, 20, 30].each { + if (it == 20) break + seen << it + } + } + assert seen == [10, 10] + ''' + } +} diff --git a/src/test/groovy/gls/closures/NonLocalReturnTest.groovy b/src/test/groovy/gls/closures/NonLocalReturnTest.groovy new file mode 100644 index 00000000000..58d4185ec31 --- /dev/null +++ b/src/test/groovy/gls/closures/NonLocalReturnTest.groovy @@ -0,0 +1,271 @@ +/* + * 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 gls.closures + +import groovy.transform.CompileStatic +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.MultipleCompilationErrorsException +import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript +import static groovy.test.GroovyAssert.shouldFail + +/** + * Tests for non-local return from closures via {@code return@target} (GROOVY-12126). + */ +final class NonLocalReturnTest { + + private static final GroovyShell CS_SHELL = new GroovyShell( + new CompilerConfiguration().addCompilationCustomizers(new ASTTransformationCustomizer(CompileStatic))) + + /** Runs the script dynamically and under @CompileStatic. */ + private static void assertScriptBoth(String text) { + assertScript(text) + assertScript(CS_SHELL, text) + } + + @Test + void testReturnFromNestedClosures() { + assertScriptBoth ''' + def firstMatch(List> rows, Closure pred) { + rows.each { row -> + row.each { cell -> + if (pred(cell)) return@firstMatch cell + } + } + null + } + assert firstMatch([[1, 2], [3, 4]]) { Integer it -> it > 2 } == 3 + assert firstMatch([[1, 2], [3, 4]]) { Integer it -> it > 9 } == null + ''' + } + + @Test + void testReturnWithoutValue() { + assertScriptBoth ''' + def m(List items) { + def seen = [] + items.each { + if (it > 2) return@m seen + seen << it + } + seen << 'end' + seen + } + assert m([1, 2, 3, 4]) == [1, 2] + assert m([1, 2]) == [1, 2, 'end'] + ''' + } + + @Test + void testVoidMethod() { + assertScriptBoth ''' + class C { + List seen = [] + void scan(List items) { + items.each { + if (it == 0) return@scan + seen << it + } + seen << 'end' + } + } + def c = new C() + c.scan([1, 0, 2]) + assert c.seen == [1] + ''' + } + + @Test + void testPrimitiveReturnType() { + assertScriptBoth ''' + int firstBig(List items) { + items.each { if (it > 10) return@firstBig it } + -1 + } + assert firstBig([1, 20, 3]) == 20 + assert firstBig([1, 2, 3]) == -1 + ''' + } + + @Test + void testStaticMethod() { + assertScriptBoth ''' + class Util { + static firstEven(List nums) { + nums.each { if (it % 2 == 0) return@firstEven it } + null + } + } + assert Util.firstEven([1, 3, 4, 5]) == 4 + ''' + } + + @Test + void testTargetAtMethodLevelIsPlainReturn() { + assertScriptBoth ''' + def m() { + return@m 42 + } + assert m() == 42 + ''' + } + + @Test + void testRecursionUsesPerActivationToken() { + assertScriptBoth ''' + def search(int depth) { + [1].each { + if (depth == 0) return@search 'hit' + } + 'wrapped(' + search(depth - 1) + ')' + } + assert search(2) == 'wrapped(wrapped(hit))' + ''' + } + + @Test + void testFinallyRunsDuringUnwind() { + assertScriptBoth ''' + def m(List log, List items) { + try { + items.each { + log << it + if (it == 2) return@m 'done' + } + } finally { + log << 'fin' + } + 'end' + } + def log = [] + assert m(log, [1, 2, 3]) == 'done' + assert log == [1, 2, 'fin'] + ''' + } + + @Test + void testReturnFromScriptBody() { + assertScriptBoth ''' + def seen = [] + [1, 2, 3].each { + seen << it + if (it == 2) { + assert seen == [1, 2] + return@script + } + } + throw new IllegalStateException('should not be reached') + ''' + } + + @Test + void testEscapedClosureSurfacesLoudly() { + assertScriptBoth ''' + import org.codehaus.groovy.runtime.NonLocalReturn + + def maker(List stash) { + stash << { -> return@maker 1 } + 'made' + } + List stash = [] + assert maker(stash) == 'made' + try { + stash[0].call() + assert false, 'expected NonLocalReturn to escape' + } catch (NonLocalReturn expected) { + } + ''' + } + + @Test + void testStaleActivationRethrowsNotHijacks() { + assertScriptBoth ''' + import org.codehaus.groovy.runtime.NonLocalReturn + + def maker(List stash, boolean capture) { + if (capture) { + stash << { -> return@maker 'stolen' } + return 'made' + } + stash[0].call() + 'not reached' + } + List stash = [] + assert maker(stash, true) == 'made' + try { + maker(stash, false) + assert false, 'expected NonLocalReturn to escape the mismatched activation' + } catch (NonLocalReturn expected) { + } + ''' + } + + @Test + void testUnknownTargetIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + def m() { + [1].each { return@nosuch 1 } + } + ''' + assert err.message.contains("no lexically enclosing method named 'nosuch'") + } + + @Test + void testCrossClassBoundaryIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + class C { + def outer(items) { + def r = new Runnable() { + void run() { + [1].each { return@outer 1 } + } + } + r.run() + } + } + ''' + assert err.message.contains('cannot return across a class boundary') + } + + @Test + void testConstructorIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + class C { + C() { + [1].each { return@C 1 } + } + } + ''' + assert err.message.contains('only allowed inside a method body') + } + + @Test + void testScriptTargetOutsideScriptBodyIsCompileError() { + def err = shouldFail MultipleCompilationErrorsException, ''' + class C { + def m() { + [1].each { return@script 1 } + } + } + ''' + assert err.message.contains("no lexically enclosing method named 'script'") + } +} diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index 3ca7c7b28cc..b45bd5b1d10 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -342,6 +342,11 @@ final class GroovyParserTest { doTest('core/Return_01.groovy') } + @Test + void 'groovy core - ReturnAt'() { + doTest('core/ReturnAt_01.groovy') + } + @Test void 'groovy core - Throw'() { doTest('core/Throw_01.groovy') diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index bbe0175f72c..fa2fc54f7b6 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -78,6 +78,12 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/Break_02x.groovy') } + @Test + void 'groovy core - ReturnAt'() { + TestUtils.doRunAndShouldFail('fail/ReturnAt_01x.groovy') + TestUtils.doRunAndShouldFail('fail/ReturnAt_02x.groovy') + } + @Test void 'groovy core - UnexpectedCharacter 1'() { TestUtils.doRunAndShouldFail('fail/UnexpectedCharacter_01x.groovy') diff --git a/src/test/groovy/org/codehaus/groovy/runtime/LoopControlDGMTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/LoopControlDGMTest.groovy new file mode 100644 index 00000000000..3ec3cf92f59 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/runtime/LoopControlDGMTest.groovy @@ -0,0 +1,241 @@ +/* + * 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 org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertThrows + +/** + * Tests the loop-control runtime protocol (GROOVY-12126): DGM iterator methods + * marked with {@code @SupportsLoopControl} catch the {@link LoopControl} + * signals around each per-element closure invocation. The signals are thrown + * directly here; the compiler sugar (break/continue in closures) has its own + * tests in gls.closures.LoopControlTest. + */ +final class LoopControlDGMTest { + + @Test + void testEachBreak() { + def seen = [] + [1, 2, 3, 4].each { + if (it == 3) throw LoopControl.BREAK + seen << it + } + assert seen == [1, 2] + } + + @Test + void testEachContinue() { + def seen = [] + [1, 2, 3, 4].each { + if (it % 2 == 0) throw LoopControl.CONTINUE + seen << it + } + assert seen == [1, 3] + } + + @Test + void testEachIterator() { + def seen = [] + [1, 2, 3].iterator().each { + if (it == 2) throw LoopControl.BREAK + seen << it + } + assert seen == [1] + } + + @Test + void testEachMap() { + def seen = [] + [a: 1, b: 2, c: 3].each { k, v -> + if (v == 2) throw LoopControl.CONTINUE + if (v == 3) throw LoopControl.BREAK + seen << k + } + assert seen == ['a'] + } + + @Test + void testEachWithIndex() { + def seen = [] + ['a', 'b', 'c', 'd'].eachWithIndex { item, i -> + if (item == 'b') throw LoopControl.CONTINUE + if (i == 3) throw LoopControl.BREAK + seen << "$item$i".toString() + } + assert seen == ['a0', 'c2'] + } + + @Test + void testEachWithIndexMap() { + def seen = [] + [a: 1, b: 2, c: 3].eachWithIndex { entry, i -> + if (i == 1) throw LoopControl.CONTINUE + if (i == 2) throw LoopControl.BREAK + seen << entry.key + } + assert seen == ['a'] + } + + @Test + void testTimes() { + def seen = [] + 5.times { + if (it == 1) throw LoopControl.CONTINUE + if (it == 3) throw LoopControl.BREAK + seen << it + } + assert seen == [0, 2] + } + + @Test + void testUptoDowntoStep() { + def seen = [] + 1.upto(5) { + if (it == 4) throw LoopControl.BREAK + seen << it + } + assert seen == [1, 2, 3] + + seen = [] + 5.downto(1) { + if (it == 4) throw LoopControl.CONTINUE + if (it == 2) throw LoopControl.BREAK + seen << it + } + assert seen == [5, 3] + + seen = [] + 1.0.upto(3.0) { + if (it == 2.0) throw LoopControl.BREAK + seen << it + } + assert seen == [1.0] + + seen = [] + 0.step(10, 2) { + if (it == 6) throw LoopControl.BREAK + seen << it + } + assert seen == [0, 2, 4] + } + + @Test + void testCollectBreakExcludesCurrentElement() { + assert [1, 2, 3, 4].collect { + if (it == 3) throw LoopControl.BREAK + it * 10 + } == [10, 20] + } + + @Test + void testCollectContinueSkipsContribution() { + assert [1, 2, 3, 4].collect { + if (it % 2 == 0) throw LoopControl.CONTINUE + it * 10 + } == [10, 30] + } + + @Test + void testCollectWithCollector() { + assert [1, 2, 3].collect(new LinkedList()) { + if (it == 3) throw LoopControl.BREAK + it + 1 + } == [2, 3] + } + + @Test + void testCollectMap() { + assert [a: 1, b: 2, c: 3].collect { k, v -> + if (v == 1) throw LoopControl.CONTINUE + if (v == 3) throw LoopControl.BREAK + "$k$v".toString() + } == ['b2'] + } + + @Test + void testFindAll() { + assert [1, 2, 3, 4, 5].findAll { + if (it == 2) throw LoopControl.CONTINUE + if (it == 4) throw LoopControl.BREAK + it % 2 == 1 + } == [1, 3] + } + + @Test + void testFindAllMap() { + assert [a: 1, b: 2, c: 3].findAll { k, v -> + if (v == 2) throw LoopControl.BREAK + true + } == [a: 1] + } + + @Test + void testInjectBreakReturnsPriorAccumulator() { + assert [1, 2, 3, 4].inject(0) { acc, it -> + if (it == 3) throw LoopControl.BREAK + acc + it + } == 3 + } + + @Test + void testInjectContinueKeepsPriorValue() { + assert [1, 2, 3, 4].inject(0) { acc, it -> + if (it % 2 == 0) throw LoopControl.CONTINUE + acc + it + } == 4 + } + + @Test + void testInjectMap() { + assert [a: 1, b: 2, c: 3].inject(0) { acc, entry -> + if (entry.value == 2) throw LoopControl.CONTINUE + acc + entry.value + } == 4 + } + + @Test + void testClosureDoneStillHonored() { + def seen = [] + def c + c = { seen << it; if (it == 1) c.directive = Closure.DONE } + 5.times(c) + assert seen == [0, 1] + + def t + t = { if (it == 2) t.directive = Closure.DONE; it * 10 } + assert [1, 2, 3].collect([], t) == [10, 20] + } + + @Test + void testSignalEscapesUncooperativeMethod() { + // 'with' is not a loop and does not cooperate; the signal must surface loudly + def e = assertThrows(LoopControl) { + 'x'.with { throw LoopControl.BREAK } + } + assert e.message.contains('does not support loop control') + } + + @Test + void testSignalsCarryNoStackTrace() { + assert LoopControl.BREAK.stackTrace.length == 0 + assert LoopControl.CONTINUE.stackTrace.length == 0 + } +}