From 7fa961f6bcd52d92e1f4584a1f1b9177f6999fc4 Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 8 Jul 2026 14:24:38 +1000 Subject: [PATCH 1/5] GROOVY-12126: grammar and AST support for return@target Adds a returnAtStmtAlt alternative to the statement rule (RETURN AT identifier expression?), an optional target field on ReturnStatement, and the corresponding AstBuilder visitor. return @foo was previously unparseable, so the new alternative is unambiguous; no nls between the tokens keeps 'return' followed by an annotated declaration on the next line parsing as before. The target is recorded but not yet acted upon; the desugaring pass follows in a separate commit. Assisted-by: Claude Fable 5 (Anthropic) --- src/antlr/GroovyParser.g4 | 1 + .../groovy/parser/antlr4/AstBuilder.java | 14 +++++++ .../groovy/ast/stmt/ReturnStatement.java | 30 ++++++++++++- src/test-resources/core/ReturnAt_01.groovy | 42 +++++++++++++++++++ src/test-resources/fail/ReturnAt_01x.groovy | 23 ++++++++++ src/test-resources/fail/ReturnAt_02x.groovy | 23 ++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 5 +++ .../parser/antlr4/SyntaxErrorTest.groovy | 6 +++ 8 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/test-resources/core/ReturnAt_01.groovy create mode 100644 src/test-resources/fail/ReturnAt_01x.groovy create mode 100644 src/test-resources/fail/ReturnAt_02x.groovy 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/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/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/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') From 973cc6d8981fe63d0bfad5bc4b24624bcc19a094 Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 8 Jul 2026 14:35:31 +1000 Subject: [PATCH 2/5] GROOVY-12126: non-local return from closures via return@target Desugars return@name inside a closure into a call to NonLocalReturn.raise(token, value), a stack-trace-free signal carrying a per-activation token synthesized at entry to the target method; the target method's body is wrapped in a handler that returns the carried value when the token matches and rethrows otherwise. Runs as a new CANONICALIZATION operation (NonLocalControlFlowRewriter) before static type checking, so dynamic and @CompileStatic code compile identically. Methods without targeted returns are untouched; return@m at method level lowers to a plain return; return@script targets the script body. Targets that name no lexically enclosing method, cross a class boundary, or appear in constructors/initializers are compile errors. Variable scopes are repaired after rewriting since closures capture the token local. Assisted-by: Claude Fable 5 (Anthropic) --- .../groovy/control/CompilationUnit.java | 4 + .../control/NonLocalControlFlowRewriter.java | 201 +++++++++++++ .../groovy/runtime/NonLocalReturn.java | 80 ++++++ .../gls/closures/NonLocalReturnTest.groovy | 271 ++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java create mode 100644 src/main/java/org/codehaus/groovy/runtime/NonLocalReturn.java create mode 100644 src/test/groovy/gls/closures/NonLocalReturnTest.groovy 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..00ec0ba0e70 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java @@ -0,0 +1,201 @@ +/* + * 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.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.EmptyStatement; +import org.codehaus.groovy.ast.stmt.ReturnStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.ast.stmt.TryCatchStatement; +import org.codehaus.groovy.classgen.VariableScopeVisitor; +import org.codehaus.groovy.runtime.NonLocalReturn; + +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.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.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. + *

+ * 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 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; + + 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) { + closureDepth += 1; + try { + super.visitClosureExpression(expression); + } finally { + closureDepth -= 1; + } + } + + @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/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/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'") + } +} From 4c5b4165e1c3c23bc7b8ad33e5413f7a0fa5d345 Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 8 Jul 2026 14:46:51 +1000 Subject: [PATCH 3/5] GROOVY-12126: loop-control runtime protocol for iterator methods Adds LoopControl, a pair of preallocated, stack-trace-free, non-user-constructible signals (BREAK/CONTINUE), and the groovy.transform.SupportsLoopControl marker annotation. Cooperating DefaultGroovyMethods iterators catch the signals around each per-element closure invocation: each/eachWithIndex/times/upto/downto/ step stop on BREAK and proceed on CONTINUE; collect/findAll/inject stop excluding the current element's contribution on BREAK and skip it on CONTINUE (inject keeps the prior accumulator). The legacy Closure.DONE directive remains honored where it was before. Signals reaching a non-cooperating method surface with an explanatory message. Array (ArrayGroovyMethods), String and IO iterator families are left for follow-up. Assisted-by: Claude Fable 5 (Anthropic) --- .../groovy/transform/SupportsLoopControl.java | 51 ++++ .../groovy/runtime/DefaultGroovyMethods.java | 216 ++++++++++++---- .../codehaus/groovy/runtime/LoopControl.java | 51 ++++ .../groovy/runtime/LoopControlDGMTest.groovy | 241 ++++++++++++++++++ 4 files changed, 513 insertions(+), 46 deletions(-) create mode 100644 src/main/java/groovy/transform/SupportsLoopControl.java create mode 100644 src/main/java/org/codehaus/groovy/runtime/LoopControl.java create mode 100644 src/test/groovy/org/codehaus/groovy/runtime/LoopControlDGMTest.groovy 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/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/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 + } +} From 713e134dba68615d0387d37d634ba46e13ae087e Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 8 Jul 2026 14:53:40 +1000 Subject: [PATCH 4/5] GROOVY-12126: compile break/continue in closure arguments to loop-control signals Extends NonLocalControlFlowRewriter: a break/continue inside a closure passed directly as a method call argument - and not bound to a loop (or switch, for break) within the closure - is lowered to 'throw LoopControl.BREAK/CONTINUE', which cooperating iterator methods catch per element. The closure is tagged with node metadata so static type checking can later verify the callee is marked @SupportsLoopControl. Labeled break/continue in closures and break/continue in non-argument closures are compile errors. As a side effect, break inside a closure lexically nested in a loop - which previously slipped past LabelVerifier into broken codegen - now has well-defined semantics (binds the closure's iteration). Assisted-by: Claude Fable 5 (Anthropic) --- .../control/NonLocalControlFlowRewriter.java | 185 ++++++++++++++ .../gls/closures/LoopControlTest.groovy | 241 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 src/test/groovy/gls/closures/LoopControlTest.groovy diff --git a/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java b/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java index 00ec0ba0e70..73a7ac0f93e 100644 --- a/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java +++ b/src/main/java/org/codehaus/groovy/control/NonLocalControlFlowRewriter.java @@ -27,25 +27,46 @@ 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; @@ -61,6 +82,14 @@ * 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. @@ -68,6 +97,7 @@ 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"; @@ -79,6 +109,12 @@ public class NonLocalControlFlowRewriter extends ClassCodeVisitorSupport { 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; } @@ -126,14 +162,163 @@ protected void visitConstructorOrMethod(final MethodNode node, final boolean isC @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); diff --git a/src/test/groovy/gls/closures/LoopControlTest.groovy b/src/test/groovy/gls/closures/LoopControlTest.groovy new file mode 100644 index 00000000000..0e6cb8af023 --- /dev/null +++ b/src/test/groovy/gls/closures/LoopControlTest.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 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 testUncooperativeCalleeEscapesLoudly() { + assertScriptBoth ''' + 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 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] + ''' + } +} From da4e8fbd9f19a048285e3a6d9bff4b84e8eb31ab Mon Sep 17 00:00:00 2001 From: Paul King Date: Wed, 8 Jul 2026 14:57:07 +1000 Subject: [PATCH 5/5] GROOVY-12126: enforce @SupportsLoopControl under static type checking When a closure tagged by the non-local control flow rewriter as using break/continue is passed to a statically resolved method, the static type checker now requires the target method (unwrapping extension method nodes for DGM) to carry @SupportsLoopControl, turning the dynamic runtime escape into a compile-time error under @TypeChecked/@CompileStatic. Assisted-by: Claude Fable 5 (Anthropic) --- .../stc/StaticTypeCheckingVisitor.java | 18 ++++++++++++++++++ .../groovy/gls/closures/LoopControlTest.groovy | 16 ++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) 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/groovy/gls/closures/LoopControlTest.groovy b/src/test/groovy/gls/closures/LoopControlTest.groovy index 0e6cb8af023..2350d0e9b08 100644 --- a/src/test/groovy/gls/closures/LoopControlTest.groovy +++ b/src/test/groovy/gls/closures/LoopControlTest.groovy @@ -184,8 +184,8 @@ final class LoopControlTest { } @Test - void testUncooperativeCalleeEscapesLoudly() { - assertScriptBoth ''' + void testUncooperativeCalleeEscapesLoudlyWhenDynamic() { + assertScript ''' import org.codehaus.groovy.runtime.LoopControl try { @@ -197,6 +197,18 @@ final class LoopControlTest { ''' } + @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, '''