Skip to content

Reference implementation: non-local flow control for closures (GROOVY-12126)#2677

Draft
paulk-asert wants to merge 5 commits into
apache:masterfrom
paulk-asert:groovy12126
Draft

Reference implementation: non-local flow control for closures (GROOVY-12126)#2677
paulk-asert wants to merge 5 commits into
apache:masterfrom
paulk-asert:groovy12126

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This is NOT to be submitted as is - it is a reference implementation for on-going discussion.

The return story is that bare returns remain as is, for a closure that is returning from the closure call method. "at" returns (semi Kotlin familiar) return from the surrounding method, e.g.:

def m() {
    [1, 2, 3].each {
        if (it > 2) return@m
    }
    [1, 2, 3].each {
        return@m it * 2
    }
    return@m 42;
}

Implementation is via stack-trace-free exceptions.

The continue/break story summary is that we rework DGM methods like so:

@SupportsLoopControl
public static <T, K, V, C extends Collection<T>> C collect(Map<K, V> self, C collector, @ClosureParams(MapEntryOrKeyValue.class) Closure<? extends T> transform) {
    for (Map.Entry<K, V> entry : self.entrySet()) {
        T transformed;
        try {
            transformed = callClosureForMapEntry(transform, entry);
        } catch (LoopControl signal) {
            if (signal == LoopControl.BREAK) break;
            continue;
        }
        collector.add(transformed);
    }
    return collector;
}

Then users can write:

assert [1, 2, 3, 4].collect {
    if (it == 2) continue
    if (it == 4) break
    it * 10
} == [10, 30]

Which is lowered to this:

assert [1, 2, 3, 4].collect {
    if (it == 2) throw LoopControl.CONTINUE
    if (it == 4) throw LoopControl.BREAK
    it * 10
} == [10, 30]

We might decide to take some, all or none of these forms. It is also only a partial implementation, see notes below.

Deferred surface (deferred purely to limit the spike; zero new machinery needed) / known edges:

  • ArrayGroovyMethods (all DGM array overloads are deprecated delegators to it)
  • the String/IO/Resource eachLine/eachFile families, reverseEach, and collectNested/collectMany
  • @tailrecursive + return@ is unsupported
  • a catch (Exception) between the closure call and the target method swallows NonLocalReturn (inherent to the exception-based design)

One improvement the rewrite brought for free — break in a closure lexically inside a loop, which previously slipped past LabelVerifier into broken codegen, now has defined semantics.

The genuinely open design questions — the real "more work needed" tier:

  • eachFileRecurse / eachDirRecurse: does break abort the entire recursive walk, or just the current directory level? The whole-walk reading is probably what users expect, but it means the signal must propagate through the recursion rather than be caught per level — a semantic decision the JIRA issue's per-iterator table doesn't yet cover.
  • collectNested: same shape of question — break in a nested traversal could mean "stop everything" or "stop this sublist".
  • collectMany / collectEntries: need a ruling that break excludes the current element's entire contribution (collection/entry), which is consistent with the collect rule but should be stated and tested.
  • Truly lazy iterators (e.g. iterator-returning transformers): the closure runs at consumption time, potentially far from the call site, so a break signal would surface wherever the iterator is drained. Whether to support, forbid, or document that is open — it's the same escaping-closure hazard as Part 1, but easier to hit accidentally.

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)
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)
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)
…trol 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)
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)
@paulk-asert paulk-asert marked this pull request as draft July 8, 2026 05:16
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.15789% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.7355%. Comparing base (23780cf) to head (da4e8fb).

Files with missing lines Patch % Lines
.../codehaus/groovy/runtime/DefaultGroovyMethods.java 63.6364% 16 Missing and 16 partials ⚠️
...us/groovy/control/NonLocalControlFlowRewriter.java 93.2515% 1 Missing and 10 partials ⚠️
...va/org/apache/groovy/parser/antlr4/AstBuilder.java 71.4286% 1 Missing and 1 partial ⚠️
.../org/codehaus/groovy/ast/stmt/ReturnStatement.java 75.0000% 0 Missing and 1 partial ⚠️
...va/org/codehaus/groovy/runtime/NonLocalReturn.java 87.5000% 1 Missing ⚠️
...roovy/transform/stc/StaticTypeCheckingVisitor.java 87.5000% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2677        +/-   ##
==================================================
+ Coverage     68.7019%   68.7355%   +0.0336%     
- Complexity      34123      34215        +92     
==================================================
  Files            1536       1539         +3     
  Lines          128912     129153       +241     
  Branches        23369      23451        +82     
==================================================
+ Hits            88565      88774       +209     
- Misses          32517      32521         +4     
- Partials         7830       7858        +28     
Files with missing lines Coverage Δ
...a/org/codehaus/groovy/control/CompilationUnit.java 80.6794% <100.0000%> (+0.1239%) ⬆️
.../java/org/codehaus/groovy/runtime/LoopControl.java 100.0000% <100.0000%> (ø)
.../org/codehaus/groovy/ast/stmt/ReturnStatement.java 78.9474% <75.0000%> (-2.3026%) ⬇️
...va/org/codehaus/groovy/runtime/NonLocalReturn.java 87.5000% <87.5000%> (ø)
...roovy/transform/stc/StaticTypeCheckingVisitor.java 87.4585% <87.5000%> (+0.0001%) ⬆️
...va/org/apache/groovy/parser/antlr4/AstBuilder.java 86.6978% <71.4286%> (-0.0456%) ⬇️
...us/groovy/control/NonLocalControlFlowRewriter.java 93.2515% <93.2515%> (ø)
.../codehaus/groovy/runtime/DefaultGroovyMethods.java 74.9473% <63.6364%> (-0.0461%) ⬇️

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@testlens-app

testlens-app Bot commented Jul 8, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: da4e8fb
▶️ Tests: 103476 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

@paulk-asert paulk-asert changed the title GROOVY-12126: reference implementation non-local flow control for closures Reference implementation non-local flow control for closures (GROOVY-12126) Jul 8, 2026
@paulk-asert paulk-asert changed the title Reference implementation non-local flow control for closures (GROOVY-12126) Reference implementation: non-local flow control for closures (GROOVY-12126) Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants