diff --git a/src/main/java/groovy/transform/Monadic.java b/src/main/java/groovy/transform/Monadic.java
new file mode 100644
index 00000000000..c9f5736304f
--- /dev/null
+++ b/src/main/java/groovy/transform/Monadic.java
@@ -0,0 +1,52 @@
+/*
+ * 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 org.apache.groovy.lang.annotation.Incubating;
+
+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 a type as participating in monadic comprehensions (the {@code DO} macro).
+ * Optional members declare the bind/map method names when they diverge from the
+ * structural convention ({@code flatMap}/{@code map}). When both are omitted, the
+ * annotation merely opts the type in and the structural defaults apply.
+ *
+ * Modelled on {@link groovy.transform.Reducer}: a pure marker, read by tooling,
+ * with no AST transformation. The runtime dispatcher and the type checker match
+ * this annotation by simple name ({@code Monadic}), exactly as
+ * {@code groovy.typecheckers.CombinerChecker} matches {@code @Reducer}/{@code @Associative}.
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Monadic {
+ /** The flatMap-shaped bind method name. Empty means the structural default {@code flatMap}. */
+ String bind() default "";
+
+ /** The map method name. Empty means the structural default {@code map}. */
+ String map() default "";
+}
diff --git a/src/main/java/org/apache/groovy/runtime/Comprehensions.java b/src/main/java/org/apache/groovy/runtime/Comprehensions.java
new file mode 100644
index 00000000000..78b85efc73d
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/Comprehensions.java
@@ -0,0 +1,190 @@
+/*
+ * 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.apache.groovy.runtime;
+
+import groovy.lang.Closure;
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.runtime.DefaultGroovyMethods;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.function.Function;
+
+/**
+ * Runtime bind/map dispatcher for monadic comprehensions — the emit target
+ * of the {@code DO} macro.
+ *
+ * The macro runs at {@code SEMANTIC_ANALYSIS}, before type checking, so it
+ * cannot know the carrier's bind-method name and cannot emit it directly. Instead it
+ * emits {@code Comprehensions.bind(carrier) { x -> ... }} calls; this class resolves
+ * the carrier-specific method at runtime (dynamic Groovy), while the
+ * {@code groovy.typecheckers.MonadicChecker} type-checking extension specialises this
+ * one signature under {@code @CompileStatic}.
+ *
+ * This is runtime support invoked from generated bytecode, hence its placement in
+ * core alongside the rest of the Groovy runtime; the {@code DO} macro and the type
+ * checker are compile-time only and remain in their optional modules.
+ *
+ * Participation is resolved first-match-wins:
+ *
+ * standard allow-list ({@link MonadicCarrierRegistry});
+ * structural ({@code flatMap}/{@code map} present);
+ * {@code @Monadic} opt-in (matched by simple name; honours {@code bind}/{@code map} overrides).
+ *
+ * A configured marker interface is a further opt-in mechanism that is not yet
+ * implemented.
+ *
+ * Surface generosity: the carrier's bind method may accept a {@link Function} or a
+ * {@code Closure}; the closure is adapted to whichever the target declares. Monad
+ * laws are not enforced — structural participation, algebraic-law obligation
+ * on the implementer (the {@code @Reducer}/{@code @Associative} treatment).
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public final class Comprehensions {
+
+ private Comprehensions() {}
+
+ /** Bind (flatMap-shaped): {@code carrier.(x -> fn(x))} where {@code fn} yields the same carrier. */
+ public static Object bind(Object carrier, Closure> fn) {
+ return dispatch(carrier, fn, true);
+ }
+
+ /** Map: {@code carrier.(x -> fn(x))} where {@code fn} yields a plain value. */
+ public static Object map(Object carrier, Closure> fn) {
+ return dispatch(carrier, fn, false);
+ }
+
+ private static Object dispatch(Object carrier, Closure> fn, boolean bindRole) {
+ if (carrier == null) {
+ throw new IllegalArgumentException(
+ "Monadic comprehension carrier is null; null cannot participate as a carrier");
+ }
+ Class> type = carrier.getClass();
+ String method = resolveMethodName(carrier, type, bindRole);
+ if (method == null) {
+ String role = bindRole ? "bind (flatMap-shaped)" : "map";
+ String structural = bindRole ? "flatMap" : "map";
+ throw new IllegalArgumentException(
+ "Type " + type.getName() + " does not participate in monadic comprehensions: "
+ + "no " + role + " method found (not in the standard carrier allow-list, "
+ + "has no structural '" + structural + "' method, and is not annotated @Monadic)");
+ }
+ Object arg = adaptClosure(type, method, fn);
+ return InvokerHelper.invokeMethod(carrier, method, arg);
+ }
+
+ private static String resolveMethodName(Object carrier, Class> type, boolean bindRole) {
+ // 1. standard allow-list (Class- or name-keyed)
+ String[] bindMap = MonadicCarrierRegistry.lookupBindMap(carrier);
+ if (bindMap != null) {
+ return bindRole ? bindMap[0] : bindMap[1];
+ }
+ // 2. structural
+ String structural = bindRole ? "flatMap" : "map";
+ if (findSingleArgMethod(type, structural) != null) {
+ return structural;
+ }
+ // 3. @Monadic opt-in (matched by simple name, like @Reducer/@Associative)
+ String monadic = monadicMethodName(type, bindRole);
+ if (monadic != null && findSingleArgMethod(type, monadic) != null) {
+ return monadic;
+ }
+ return null;
+ }
+
+ private static String monadicMethodName(Class> type, boolean bindRole) {
+ for (Class> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
+ String n = readMonadicMember(c.getDeclaredAnnotations(), bindRole);
+ if (n != null) return n;
+ for (Class> i : c.getInterfaces()) {
+ String ni = readMonadicMember(i.getDeclaredAnnotations(), bindRole);
+ if (ni != null) return ni;
+ }
+ }
+ return null;
+ }
+
+ private static String readMonadicMember(Annotation[] annotations, boolean bindRole) {
+ for (Annotation a : annotations) {
+ if (!"Monadic".equals(a.annotationType().getSimpleName())) continue;
+ String configured = invokeStringMember(a, bindRole ? "bind" : "map");
+ if (configured == null || configured.isEmpty()) {
+ return bindRole ? "flatMap" : "map"; // opted in, structural defaults
+ }
+ return configured;
+ }
+ return null;
+ }
+
+ private static String invokeStringMember(Annotation a, String member) {
+ try {
+ Object v = a.annotationType().getMethod(member).invoke(a);
+ return v == null ? null : v.toString();
+ } catch (ReflectiveOperationException ignored) {
+ return null;
+ }
+ }
+
+ private static Method findSingleArgMethod(Class> type, String name) {
+ for (Method m : type.getMethods()) {
+ if (m.getParameterCount() == 1 && m.getName().equals(name)
+ && !m.isBridge() && !m.isSynthetic()) {
+ return m;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Adapt the generator closure to whatever single-arg type the carrier's bind
+ * method declares:
+ *
+ * a {@code Closure}-typed parameter receives the closure directly;
+ * a {@link Function}-typed parameter receives a thin wrapper;
+ * any other functional interface (for example Functional Java's
+ * {@code fj.F}) receives the closure coerced to that interface.
+ *
+ */
+ private static Object adaptClosure(Class> type, String method, final Closure> fn) {
+ Method m = findSingleArgMethod(type, method);
+ Class> pt = (m != null) ? m.getParameterTypes()[0] : null;
+ if (pt == null || Closure.class.isAssignableFrom(pt)) {
+ return fn;
+ }
+ if (pt == Function.class) {
+ // Exact Function (not a supertype): Object is a supertype too, and a
+ // structural Groovy carrier declaring 'flatMap(c)' lands with pt=Object;
+ // the user's body typically calls c.call(x), which would fail against
+ // a Function wrapper. Untyped Object falls through to the asType
+ // branch below, which returns the Closure unchanged.
+ return new Function() {
+ @Override
+ public Object apply(Object value) {
+ return fn.call(value);
+ }
+ };
+ }
+ // General SAM coercion: proxy the closure as the declared interface.
+ // For pt == Object this returns the Closure unchanged.
+ return DefaultGroovyMethods.asType(fn, pt);
+ }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java b/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java
new file mode 100644
index 00000000000..877aa27d318
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java
@@ -0,0 +1,180 @@
+/*
+ * 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.apache.groovy.runtime;
+
+import groovy.concurrent.Awaitable;
+import org.apache.groovy.lang.annotation.Incubating;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletionStage;
+import java.util.stream.Stream;
+
+/**
+ * The standard carrier allow-list for monadic comprehensions: stdlib and
+ * Groovy-core carriers whose bind/map method names diverge from the structural
+ * {@code flatMap}/{@code map} convention.
+ *
+ * Carriers known by {@code Class} (always on the classpath) are matched by
+ * {@code isInstance}; carriers known only by name — third-party libraries
+ * that Groovy must not depend on, such as Functional Java and Vavr — are
+ * matched by walking the value's type hierarchy and comparing fully-qualified
+ * names. The {@code CompletionStage} entry covers {@code CompletableFuture};
+ * the {@code Awaitable} entry covers {@code DataflowVariable}; the Functional
+ * Java entries use that library's {@code bind}/{@code map} convention; the
+ * Vavr entries use the structural {@code flatMap}/{@code map} convention.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public final class MonadicCarrierRegistry {
+
+ /** One {@code Class}-keyed allow-list row. */
+ public static final class Entry {
+ private final Class> carrier;
+ private final String bind;
+ private final String map;
+
+ Entry(Class> carrier, String bind, String map) {
+ this.carrier = carrier;
+ this.bind = bind;
+ this.map = map;
+ }
+
+ public Class> carrier() { return carrier; }
+ public String bind() { return bind; }
+ public String map() { return map; }
+
+ @Override
+ public String toString() {
+ return carrier.getName() + " { bind=" + bind + ", map=" + map + " }";
+ }
+ }
+
+ /** One name-keyed allow-list row, for carriers Groovy must not depend on. */
+ public static final class NamedEntry {
+ private final String carrierName;
+ private final String bind;
+ private final String map;
+
+ NamedEntry(String carrierName, String bind, String map) {
+ this.carrierName = carrierName;
+ this.bind = bind;
+ this.map = map;
+ }
+
+ public String carrierName() { return carrierName; }
+ public String bind() { return bind; }
+ public String map() { return map; }
+
+ @Override
+ public String toString() {
+ return carrierName + " { bind=" + bind + ", map=" + map + " }";
+ }
+ }
+
+ private static final List ENTRIES;
+ private static final List NAMED_ENTRIES;
+ static {
+ List e = new ArrayList();
+ e.add(new Entry(Optional.class, "flatMap", "map"));
+ e.add(new Entry(Stream.class, "flatMap", "map"));
+ e.add(new Entry(CompletionStage.class, "thenCompose", "thenApply")); // covers CompletableFuture
+ e.add(new Entry(Awaitable.class, "thenCompose", "then")); // covers DataflowVariable
+ ENTRIES = Collections.unmodifiableList(e);
+
+ // Functional Java (org.functionaljava) — recognised by name; no dependency.
+ List n = new ArrayList();
+ n.add(new NamedEntry("fj.data.Option", "bind", "map"));
+ n.add(new NamedEntry("fj.data.List", "bind", "map"));
+ n.add(new NamedEntry("fj.data.Stream", "bind", "map"));
+ n.add(new NamedEntry("fj.data.Validation", "bind", "map"));
+ n.add(new NamedEntry("fj.P1", "bind", "map"));
+
+ // Vavr (io.vavr) — recognised by name; no dependency. Vavr's control
+ // carriers use the structural flatMap/map convention, so they are
+ // covered by the default dispatcher even without an entry here; the
+ // entries are retained so the carrier names appear explicitly in the
+ // standard allow-list and pass the MonadicChecker's participation test
+ // without requiring a structural match.
+ n.add(new NamedEntry("io.vavr.control.Option", "flatMap", "map"));
+ n.add(new NamedEntry("io.vavr.control.Try", "flatMap", "map"));
+ n.add(new NamedEntry("io.vavr.control.Either", "flatMap", "map"));
+ n.add(new NamedEntry("io.vavr.control.Validation", "flatMap", "map"));
+
+ NAMED_ENTRIES = Collections.unmodifiableList(n);
+ }
+
+ private MonadicCarrierRegistry() {}
+
+ /** The {@code Class}-keyed allow-list, exposed for the type-checking extension. */
+ public static List entries() {
+ return ENTRIES;
+ }
+
+ /** The name-keyed allow-list, exposed for the type-checking extension. */
+ public static List namedEntries() {
+ return NAMED_ENTRIES;
+ }
+
+ /**
+ * The {@code [bind, map]} method names for the given carrier value, or
+ * {@code null} if it is not on either allow-list. {@code Class} entries are
+ * tried first ({@code isInstance}), then name entries against the value's
+ * full type hierarchy (so {@code fj.data.Some} matches {@code fj.data.Option}).
+ */
+ public static String[] lookupBindMap(Object carrier) {
+ if (carrier == null) return null;
+ for (Entry entry : ENTRIES) {
+ if (entry.carrier().isInstance(carrier)) {
+ return new String[]{entry.bind(), entry.map()};
+ }
+ }
+ if (!NAMED_ENTRIES.isEmpty()) {
+ for (Class> t : supertypes(carrier.getClass())) {
+ String name = t.getName();
+ for (NamedEntry entry : NAMED_ENTRIES) {
+ if (entry.carrierName().equals(name)) {
+ return new String[]{entry.bind(), entry.map()};
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ private static Set> supertypes(Class> start) {
+ Set> seen = new LinkedHashSet>();
+ Deque> queue = new ArrayDeque>();
+ queue.add(start);
+ while (!queue.isEmpty()) {
+ Class> c = queue.poll();
+ if (c == null || !seen.add(c)) continue;
+ if (c.getSuperclass() != null) queue.add(c.getSuperclass());
+ Collections.addAll(queue, c.getInterfaces());
+ }
+ return seen;
+ }
+}
diff --git a/src/spec/doc/core-async-await.adoc b/src/spec/doc/core-async-await.adoc
index 9816b599609..858a52bcda7 100644
--- a/src/spec/doc/core-async-await.adoc
+++ b/src/spec/doc/core-async-await.adoc
@@ -534,3 +534,5 @@ type, e.g. `AtomicInteger` for a shared counter, or thread-safe types from
All keywords (`async`, `await`, `defer`) are contextual — they can still be
used as variable or method names in existing code.
+
+include::../../../subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc[leveloffset=+1]
diff --git a/src/spec/doc/core-parallel-collections.adoc b/src/spec/doc/core-parallel-collections.adoc
index f007e7ac9f6..a00754379d0 100644
--- a/src/spec/doc/core-parallel-collections.adoc
+++ b/src/spec/doc/core-parallel-collections.adoc
@@ -302,6 +302,10 @@ because blocking one doesn't consume an OS thread.
|Mixed
|`async`/`await` with `Pool.io()`
|Virtual threads handle both compute and I/O
+
+|Value composition over a monadic carrier (`Optional`, `Awaitable`, `Stream`, ...)
+|`DO` comprehensions (`groovy-macro-library`)
+|Flattens nested `flatMap`/`thenCompose` chains into uniform `name in source` notation
|===
To illustrate, consider processing 100 items where each involves
diff --git a/subprojects/groovy-macro-library/build.gradle b/subprojects/groovy-macro-library/build.gradle
index f334e87fc97..397fc80a8c1 100644
--- a/subprojects/groovy-macro-library/build.gradle
+++ b/subprojects/groovy-macro-library/build.gradle
@@ -24,6 +24,7 @@ dependencies {
implementation rootProject
implementation projects.groovyMacro
testImplementation projects.groovyTest
+ testImplementation projects.groovyTypecheckers // MonadicChecker @CompileStatic tests
}
groovyLibrary {
diff --git a/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java b/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
index f2e39c11ef2..5a7b08a0613 100644
--- a/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
+++ b/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
@@ -20,24 +20,39 @@
import groovy.lang.GString;
import groovy.lang.NamedValue;
+import org.apache.groovy.runtime.Comprehensions;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+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.GStringExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
import org.codehaus.groovy.macro.runtime.Macro;
import org.codehaus.groovy.macro.runtime.MacroContext;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
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.classX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.closureX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX;
import static org.codehaus.groovy.ast.tools.GeneralUtils.listX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.param;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt;
/**
* Macro library helpers for string and named-value expansion.
@@ -48,6 +63,7 @@ public final class MacroLibGroovyMethods {
private MacroLibGroovyMethods() {}
private static final ClassNode NAMED_VALUE = ClassHelper.make(NamedValue.class);
+ private static final ClassNode COMPREHENSIONS = ClassHelper.make(Comprehensions.class);
/**
* Builds a GString expression that labels each supplied expression with its source text.
@@ -182,4 +198,103 @@ public static List> NVL(Object self, T... args) {
throw new IllegalStateException("MacroLibGroovyMethods.NVL(Object...) should never be called at runtime. Are you sure you are using it correctly?");
}
+ /**
+ * Monadic comprehension macro ({@code DO}). Rewrites a comma-separated list of
+ * {@code name in expression} generators followed by a body closure into a nested
+ * chain of {@link Comprehensions#bind} calls — the do-notation desugaring:
+ *
+ * DO(x in m1, y in f(x)) { body }
+ * ==>
+ * Comprehensions.bind(m1) { x -> Comprehensions.bind(f(x)) { y -> body } }
+ *
+ * Every generator becomes a bind; the body is the innermost closure body and
+ * must itself yield a carrier value (the do-notation rule — no implicit
+ * lifting). Carrier-specific bind dispatch is deferred to runtime
+ * ({@link Comprehensions}) because macros expand before type checking.
+ *
+ * @param ctx the current macro context
+ * @param exps the generators (each {@code name in expression}) followed by the body closure
+ * @return the nested bind-chain expression
+ */
+ @Macro
+ public static Expression DO(MacroContext ctx, final Expression... exps) {
+ if (exps == null || exps.length < 2) {
+ return error(ctx, ctx.getCall(),
+ "DO requires at least one 'name in expression' generator and a trailing closure body");
+ }
+ Expression last = exps[exps.length - 1];
+ if (!(last instanceof ClosureExpression)) {
+ return error(ctx, last, "DO requires a trailing closure body, e.g. DO(x in m1) { ... }");
+ }
+ ClosureExpression body = (ClosureExpression) last;
+ if (body.getParameters() != null && body.getParameters().length > 0) {
+ return error(ctx, body,
+ "DO body closure must not declare parameters; generator names are already in scope");
+ }
+
+ int genCount = exps.length - 1;
+ List names = new ArrayList(genCount);
+ List sources = new ArrayList(genCount);
+ for (int i = 0; i < genCount; i++) {
+ Expression g = exps[i];
+ if (!(g instanceof BinaryExpression)
+ || ((BinaryExpression) g).getOperation().getType() != Types.KEYWORD_IN) {
+ return error(ctx, g, "DO generator must have the form 'name in expression'");
+ }
+ BinaryExpression bin = (BinaryExpression) g;
+ if (!(bin.getLeftExpression() instanceof VariableExpression)) {
+ return error(ctx, bin.getLeftExpression(),
+ "DO generator binding must be a simple name, e.g. x in m1");
+ }
+ names.add(((VariableExpression) bin.getLeftExpression()).getName());
+ sources.add(bin.getRightExpression());
+ }
+
+ // Build innermost-outward: the last generator's closure carries the body.
+ // Copy source positions from the originating user nodes onto every
+ // synthetic AST node we create — fresh AST nodes default to line/col -1,
+ // and several downstream code paths (notably
+ // {@link org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor#addStaticTypeError})
+ // silently drop diagnostics on positionless nodes. Anchoring everything
+ // back to the user's {@code name in expr} clause keeps both error
+ // attribution and IDE navigation pointing at real source.
+ Expression chain = null;
+ for (int i = genCount - 1; i >= 0; i--) {
+ Expression g = exps[i];
+ Expression nameExp = ((BinaryExpression) g).getLeftExpression();
+ Statement closureBody = (i == genCount - 1) ? body.getCode() : block(stmt(chain));
+ Parameter p = param(ClassHelper.dynamicType(), names.get(i));
+ p.setSourcePosition(nameExp);
+ ClosureExpression lambda = closureX(new Parameter[]{p}, closureBody);
+ lambda.setVariableScope(new VariableScope());
+ // innermost lambda mirrors the user's body closure; outer lambdas the generator
+ lambda.setSourcePosition(i == genCount - 1 ? body : g);
+ Expression receiver = classX(COMPREHENSIONS);
+ receiver.setSourcePosition(g);
+ Expression argList = args(sources.get(i), lambda);
+ argList.setSourcePosition(g);
+ chain = callX(receiver, "bind", argList);
+ chain.setSourcePosition(g);
+ }
+ return chain;
+ }
+
+ /**
+ * Runtime stub for {@link #DO(MacroContext, Expression...)}.
+ *
+ * @param self the receiver
+ * @param args the runtime values
+ * @return never returns normally
+ */
+ public static Object DO(Object self, Object... args) {
+ throw new IllegalStateException("MacroLibGroovyMethods.DO(Object...) should never be called at runtime. Are you sure you are using it correctly?");
+ }
+
+ private static Expression error(MacroContext ctx, Expression node, String message) {
+ ctx.getSourceUnit().addError(new SyntaxException(message + '\n', node));
+ // Return a non-macro expression: the error fails compilation, and returning
+ // the original DO(...) call would have it re-expanded ad infinitum.
+ return constX(null);
+ }
+
}
diff --git a/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc b/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc
new file mode 100644
index 00000000000..b3d92dba9d2
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc
@@ -0,0 +1,175 @@
+//////////////////////////////////////////
+
+ 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.
+
+//////////////////////////////////////////
+
+[[monadic-comprehensions]]
+= Monadic comprehensions (Incubating)
+
+The `groovy-macro-library` module provides `DO`, a comprehension macro that
+rewrites a sequence of `name in source` generators followed by a body into a
+chain of bind operations on the *carrier* type. It gives Scala-style
+for-comprehension / Haskell-style do-notation ergonomics to any type with
+monadic shape — `Optional`, `Stream`, `CompletableFuture`, Groovy's
+`Awaitable` and `DataflowVariable`, and user-defined types that opt in.
+
+NOTE: `DO` is incubating (since Groovy 6.0) and may change. The macro is
+compile-time only; the generated code calls the `Comprehensions` runtime
+support in core, so a program using `DO` needs only the core `groovy` jar at
+runtime — `groovy-macro-library` is a compile-time dependency.
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_basic,indent=0]
+----
+
+== Desugaring
+
+Every generator becomes a bind; the body is the innermost closure body and
+must itself yield a value of the carrier type (the do-notation rule — there is
+no implicit lifting in this version). The example above expands to:
+
+[source,groovy]
+----
+Comprehensions.bind(Optional.of(2)) { a ->
+ Comprehensions.bind(Optional.of(3)) { b ->
+ Optional.of(a + b)
+ }
+}
+----
+
+Because the macro expands before type information is available, it does not
+emit a carrier-specific method name directly. `Comprehensions.bind` resolves
+the right operation at runtime; under `@CompileStatic` the
+`groovy.typecheckers.MonadicChecker` extension supplies the static types (see
+<>).
+
+IMPORTANT: For all but the most trivial uses, `DO` under `@CompileStatic` or
+`@TypeChecked` requires the `MonadicChecker` extension
+(`@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')`). The
+runtime dispatcher signature `Comprehensions.bind(Object, Closure):Object`
+erases the carrier's element type and the comprehension's result type, so
+without the extension each generator's bound name and the whole `DO`
+expression both fall through to `Object` — which the static type
+checker will reject as soon as the body or downstream code does anything
+type-specific with either. See <>.
+
+A name bound by an earlier generator is in scope in the source expression of
+every later generator and in the body:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_dependent,indent=0]
+----
+
+Short-circuiting is delivered by the carrier, not by the macro: an empty or
+failed carrier simply propagates and the body is never evaluated.
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_shortcircuit,indent=0]
+----
+
+== Participating carriers
+
+A type participates as a carrier when, in order, the first match winning:
+
+. it is on the standard allow-list — `java.util.Optional`,
+ `java.util.stream.Stream`, `java.util.concurrent.CompletionStage` (covering
+ `CompletableFuture`), and `groovy.concurrent.Awaitable` (covering
+ `DataflowVariable`);
+. it is _structural_ — it has a single-argument `flatMap` (and, for the map
+ role, `map`);
+. it is annotated `@groovy.transform.Monadic`, optionally declaring
+ non-conventional method names.
+
+The allow-list also recognises common Functional Java carriers by name —
+`fj.data.Option`, `fj.data.List`, `fj.data.Stream`, `fj.data.Validation` and
+`fj.P1` — using that library's `bind`/`map` convention. Groovy takes no
+dependency on Functional Java; the names are matched reflectively, and the
+generator closure is coerced to `fj.F` automatically. `fj.data.Either` is not
+directly monadic in Functional Java (bind lives on its `.right()`/`.left()`
+projections) and is not a carrier; use a projection explicitly.
+
+NOTE: `Awaitable` and `DataflowVariable` bind via `thenCompose`; their `then`
+method is the _map_ operation, not bind. `DO` over `Awaitable` therefore
+composes asynchronous values without an imperative `await` at each step:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_awaitable,indent=0]
+----
+
+`Stream` yields the usual cartesian composition:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_stream,indent=0]
+----
+
+A user type opts in with `@Monadic`, which may name a non-conventional bind
+and map method:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_monadic,indent=0]
+----
+
+The monad laws (left identity, right identity, associativity) are not enforced
+by the compiler; as with `@Reducer`/`@Associative`, lawful behaviour is the
+participating type's responsibility.
+
+[[monadic-static]]
+== Static type checking
+
+Activate the `MonadicChecker` type-checking extension to use `DO` under
+`@CompileStatic` or `@TypeChecked`. It types each generator's bound name as
+the carrier's element type (so the body type-checks), restores the
+comprehension's result type, and rejects a non-participating carrier with a
+compile error naming the type and the missing shape:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_static,indent=0]
+----
+
+TIP: The typical symptom of forgetting the extension is a static type
+checking error reporting that some operation cannot be found on `Object`
+— either inside the body (a generator's bound name has erased to
+`Object`) or on the `DO` expression itself (the result has erased to
+`Object`). Adding `extensions = 'groovy.typecheckers.MonadicChecker'` to
+the `@CompileStatic`/`@TypeChecked` annotation resolves it.
+
+[[monadic-when]]
+== When to use `DO`
+
+`DO` is a value-composition notation. It complements, rather than competes
+with, the concurrency constructs: use imperative `async`/`await` when code
+reads as a sequence of dependent steps you intend to run now (see
+<> and <>); reach for `DO`
+when the composed value is the deliverable — an `Awaitable` to combine
+further, an `Optional`/validation result, a parser result, or a custom
+`Result` type — and you want one uniform notation across carriers.
+
+CAUTION: This version is deliberately narrow. The body must yield a carrier
+value (no implicit `pure`/`unit` lifting); there are no `if` guard clauses;
+and each `DO` works over a single carrier (nest `DO` blocks for more).
+`break`/`continue` are not valid in the body, and `return` follows the
+standard closure rule. These are the same closure-body constraints the
+`@Parallel` for-loop transform documents.
diff --git a/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy b/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy
new file mode 100644
index 00000000000..08530207d31
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Worked, inline-tested examples for the "Monadic comprehensions" specification
+ * chapter (_monadic-comprehensions.adoc). Each tagged region is included into the
+ * manual and run as part of the build.
+ */
+final class MonadicComprehensionsSpecTest {
+
+ @Test
+ void basic() {
+ assertScript '''
+ // tag::do_basic[]
+ def result = DO(a in Optional.of(2),
+ b in Optional.of(3)) {
+ Optional.of(a + b)
+ }
+ assert result.get() == 5
+ // end::do_basic[]
+ '''
+ }
+
+ @Test
+ void shortCircuit() {
+ assertScript '''
+ // tag::do_shortcircuit[]
+ def result = DO(a in Optional.empty(),
+ b in Optional.of(3)) {
+ Optional.of(b) // never reached
+ }
+ assert result.isEmpty()
+ // end::do_shortcircuit[]
+ '''
+ }
+
+ @Test
+ void dependentGenerators() {
+ assertScript '''
+ // tag::do_dependent[]
+ def result = DO(a in Optional.of(10),
+ b in Optional.of(a * 2)) { // b's source depends on a
+ Optional.of(a + b)
+ }
+ assert result.get() == 30
+ // end::do_dependent[]
+ '''
+ }
+
+ @Test
+ void stream() {
+ assertScript '''
+ // tag::do_stream[]
+ import java.util.stream.Stream
+
+ def pairs = DO(x in Stream.of(1, 2),
+ y in Stream.of('a', 'b')) {
+ Stream.of("$x$y".toString())
+ }
+ assert pairs.toList() == ['1a', '1b', '2a', '2b']
+ // end::do_stream[]
+ '''
+ }
+
+ @Test
+ void awaitable() {
+ assertScript '''
+ // tag::do_awaitable[]
+ import groovy.concurrent.Awaitable
+ import static org.apache.groovy.runtime.async.AsyncSupport.await
+
+ def total = DO(a in Awaitable.of(2),
+ b in Awaitable.of(40)) {
+ Awaitable.of(a + b)
+ }
+ assert await(total) == 42
+ // end::do_awaitable[]
+ '''
+ }
+
+ @Test
+ void monadicAnnotation() {
+ assertScript '''
+ // tag::do_monadic[]
+ import groovy.transform.Monadic
+ import java.util.function.Function
+
+ @Monadic(bind = 'chain', map = 'transform')
+ class Result {
+ final Object value
+ Result(Object value) { this.value = value }
+ Result chain(Function f) { (Result) f.apply(value) }
+ Result transform(Function f) { new Result(f.apply(value)) }
+ }
+
+ def r = DO(a in new Result(3),
+ b in new Result(4)) {
+ new Result(a * b)
+ }
+ assert r.value == 12
+ // end::do_monadic[]
+ '''
+ }
+
+ @Test
+ void compileStatic() {
+ assertScript '''
+ // tag::do_static[]
+ import groovy.transform.CompileStatic
+
+ @CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')
+ class Calc {
+ static int sum() {
+ DO(a in Optional.of(2),
+ b in Optional.of(3)) {
+ Optional.of(a + b)
+ }.get()
+ }
+ }
+ assert Calc.sum() == 5
+ // end::do_static[]
+ '''
+ }
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy b/subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy
new file mode 100644
index 00000000000..faa6908b1c2
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy
@@ -0,0 +1,29 @@
+/*
+ * 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 fj
+
+/**
+ * Test-only stand-in for Functional Java's {@code fj.F} function interface,
+ * so the name-keyed carrier path can be exercised without depending on the
+ * (unmaintained) Functional Java library. Mirrors its single-abstract-method
+ * shape so the closure-to-SAM coercion is genuinely tested.
+ */
+interface F {
+ B f(A a)
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy b/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy
new file mode 100644
index 00000000000..2d63120cd12
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy
@@ -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 fj.data
+
+import fj.F
+
+/**
+ * Test-only stand-in for Functional Java's {@code fj.data.Option}. It uses FJ's
+ * conventions deliberately: {@code bind}/{@code map} (not {@code flatMap}),
+ * arguments typed {@code fj.F} (neither {@code Closure} nor
+ * {@code java.util.function.Function}), and concrete subclasses so the registry's
+ * supertype walk ({@code fj.data.Some} → {@code fj.data.Option}) is exercised.
+ */
+abstract class Option {
+ abstract boolean defined()
+ abstract A get()
+
+ static Option some(A a) { new Some (a) }
+ static Option none() { new None () }
+
+ Option bind(F f) { defined() ? (Option) f.f(get()) : this }
+ Option map(F f) { defined() ? some(f.f(get())) : this }
+}
+
+final class Some extends Option {
+ private final A v
+ Some(A v) { this.v = v }
+ boolean defined() { true }
+ A get() { v }
+}
+
+final class None extends Option {
+ boolean defined() { false }
+ A get() { throw new NoSuchElementException() }
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy
new file mode 100644
index 00000000000..59253e48867
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy
@@ -0,0 +1,217 @@
+/*
+ * 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.apache.groovy.macrolib
+
+import org.codehaus.groovy.ast.CodeVisitorSupport
+import org.codehaus.groovy.ast.builder.AstBuilder
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.fail
+
+/**
+ * Tests the {@code DO} macro: it rewrites {@code DO(name in expr, ...) { body }}
+ * into the nested {@code Comprehensions.bind} chain, and rejects malformed
+ * comprehensions with sourced compile errors.
+ */
+final class DoMacroTest {
+
+ @Test
+ void singleGenerator_optional() {
+ assertScript '''
+ assert DO(a in Optional.of(2)) { Optional.of(a + 1) }.get() == 3
+ '''
+ }
+
+ @Test
+ void multiGenerator_optional_and_shortCircuit() {
+ assertScript '''
+ assert DO(a in Optional.of(2),
+ b in Optional.of(3)) { Optional.of(a + b) }.get() == 5
+
+ assert !DO(a in Optional.empty(),
+ b in Optional.of(3)) { Optional.of(a + b) }.present
+ '''
+ }
+
+ @Test
+ void laterGeneratorSeesEarlierBinding() {
+ assertScript '''
+ // b's source expression depends on a -> proves generator scoping
+ assert DO(a in Optional.of(2),
+ b in Optional.of(a + 10)) { Optional.of(a + b) }.get() == 14
+ '''
+ }
+
+ @Test
+ void streamCartesian() {
+ assertScript '''
+ import java.util.stream.Stream
+ def r = DO(a in Stream.of(1, 2),
+ b in Stream.of(10, 20)) { Stream.of(a + b) }
+ assert r.toList() == [11, 21, 12, 22]
+ '''
+ }
+
+ @Test
+ void awaitable_usesThenCompose() {
+ assertScript '''
+ import groovy.concurrent.Awaitable
+ import static org.apache.groovy.runtime.async.AsyncSupport.await
+
+ def result = DO(a in Awaitable.of(2),
+ b in Awaitable.of(3)) { Awaitable.of(a + b) }
+ assert await(result) == 5
+ '''
+ }
+
+ @Test
+ void structuralUserType() {
+ assertScript '''
+ import java.util.function.Function
+
+ class Box {
+ final Object v
+ Box(Object v) { this.v = v }
+ Box flatMap(Function f) { (Box) f.apply(v) }
+ Box map(Function f) { new Box(f.apply(v)) }
+ }
+
+ def r = DO(a in new Box(2),
+ b in new Box(40)) { new Box(a + b) }
+ assert r.v == 42
+ '''
+ }
+
+ @Test
+ void monadicAnnotatedUserType() {
+ assertScript '''
+ import groovy.transform.Monadic
+ import java.util.function.Function
+
+ @Monadic(bind = 'chain', map = 'transform')
+ class Res {
+ final Object v
+ Res(Object v) { this.v = v }
+ Res chain(Function f) { (Res) f.apply(v) }
+ Res transform(Function f) { new Res(f.apply(v)) }
+ }
+
+ def r = DO(a in new Res(2),
+ b in new Res(3)) { new Res(a * b) }
+ assert r.v == 6
+ '''
+ }
+
+ @Test
+ void structuralCarrierWithUntypedFlatMapWorksAtRuntime() {
+ // Regression: the dispatcher's adaptClosure used to wrap the closure as a
+ // java.util.function.Function when the carrier's flatMap declared an
+ // Object parameter (the untyped-Groovy default), because
+ // pt.isAssignableFrom(Function.class) is true for pt == Object. The
+ // user's body typically calls c.call(x) expecting Closure semantics,
+ // which fails against a Function wrapper. Untyped Object must now fall
+ // through to asType, leaving the Closure unchanged.
+ assertScript '''
+ class Box {
+ final int v
+ Box(int v) { this.v = v }
+ def flatMap(c) { c.call(v) }
+ def map(c) { new Box(c.call(v)) }
+ }
+ def r = DO(a in new Box(2), b in new Box(3)) { new Box(a + b) }
+ assert r.v == 5
+ '''
+ }
+
+ @Test
+ void rejectsGeneratorWithoutIn() {
+ assertCompileError('DO(a, b in Optional.of(1)) { Optional.of(a) }',
+ "DO generator must have the form 'name in expression'")
+ }
+
+ @Test
+ void rejectsMissingClosureBody() {
+ assertCompileError('def x = DO(a in Optional.of(1))', 'DO requires')
+ }
+
+ @Test
+ void rejectsBodyClosureWithParameters() {
+ assertCompileError('DO(a in Optional.of(1)) { x -> Optional.of(x) }',
+ 'must not declare parameters')
+ }
+
+ @Test
+ void syntheticNodesCarrySourcePositions() {
+ // Macro-emitted nodes default to (line, col) = (-1, -1); positionless
+ // nodes are silently dropped by some STC paths (notably
+ // StaticTypeCheckingVisitor.addStaticTypeError), and they break IDE
+ // navigation. Every synthetic Comprehensions.bind call and lambda the
+ // macro creates must inherit positions from the user's source.
+ def script = '''
+def r = DO(a in Optional.of(2),
+ b in Optional.of(3)) { Optional.of(a + b) }
+'''.trim()
+ def nodes = new AstBuilder().buildFromString(CompilePhase.SEMANTIC_ANALYSIS, false, script)
+ def bindCalls = []
+ def lambdas = []
+ def visitor = new CodeVisitorSupport() {
+ @Override
+ void visitMethodCallExpression(MethodCallExpression call) {
+ if (call.methodAsString == 'bind') bindCalls << call
+ super.visitMethodCallExpression(call)
+ }
+ @Override
+ void visitClosureExpression(ClosureExpression closure) {
+ lambdas << closure
+ super.visitClosureExpression(closure)
+ }
+ }
+ nodes.findAll { it instanceof BlockStatement }.each { it.visit(visitor) }
+
+ // Two generators => two nested Comprehensions.bind calls, both positionful.
+ assert bindCalls.size() == 2
+ bindCalls.each { call ->
+ assert call.lineNumber > 0 : "synthetic bind call missing line: ${call.text}"
+ assert call.columnNumber > 0
+ }
+ // The macro emits one lambda per generator (the user's body closure is
+ // consumed for its statements, not its ClosureExpression); both
+ // synthetic lambdas must carry positions.
+ assert lambdas.size() == 2
+ lambdas.each { lambda ->
+ assert lambda.lineNumber > 0 : "synthetic lambda missing line"
+ assert lambda.columnNumber > 0
+ }
+ }
+
+ private static void assertCompileError(String script, String expectedMessage) {
+ try {
+ new GroovyShell().parse(script)
+ fail("Expected a compilation error containing: $expectedMessage")
+ } catch (MultipleCompilationErrorsException e) {
+ assert e.message.contains(expectedMessage)
+ }
+ }
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy
new file mode 100644
index 00000000000..93ae725d2e1
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy
@@ -0,0 +1,275 @@
+/*
+ * 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.apache.groovy.macrolib
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.fail
+
+/**
+ * The {@code DO} macro under {@code @CompileStatic} with the
+ * {@code groovy.typecheckers.MonadicChecker} extension: the extension
+ * (a) types the generator closure parameter as the carrier element type so the
+ * body type-checks, (b) restores the comprehension result type so downstream
+ * use type-checks, and (c) rejects non-participating carriers at compile time.
+ */
+final class DoStaticTest {
+
+ private static final String CS = "@groovy.transform.CompileStatic(extensions='groovy.typecheckers.MonadicChecker')"
+
+ @Test
+ void boundParameterIsTypedAsElementType_optional() {
+ assertScript """
+ $CS
+ class C {
+ static int run() {
+ def r = DO(a in Optional.of(2),
+ b in Optional.of(3)) { Optional.of(a.intValue() + b.intValue()) }
+ r.get()
+ }
+ }
+ assert C.run() == 5
+ """
+ }
+
+ @Test
+ void dependentGeneratorTypeChecks() {
+ assertScript """
+ $CS
+ class C {
+ static int run() {
+ def r = DO(a in Optional.of(2),
+ b in Optional.of(a + 10)) { Optional.of(a + b) }
+ r.get()
+ }
+ }
+ assert C.run() == 14
+ """
+ }
+
+ @Test
+ void streamResultTypeChecksDownstream() {
+ assertScript """
+ import java.util.stream.Stream
+ $CS
+ class C {
+ static List run() {
+ def r = DO(a in Stream.of(1, 2),
+ b in Stream.of(10, 20)) { Stream.of(a + b) }
+ r.toList()
+ }
+ }
+ assert C.run() == [11, 21, 12, 22]
+ """
+ }
+
+ @Test
+ void awaitableUnderCompileStatic() {
+ assertScript """
+ import groovy.concurrent.Awaitable
+ import static org.apache.groovy.runtime.async.AsyncSupport.await
+ $CS
+ class C {
+ static int run() {
+ def r = DO(a in Awaitable.of(2),
+ b in Awaitable.of(3)) { Awaitable.of(a + b) }
+ await(r)
+ }
+ }
+ assert C.run() == 5
+ """
+ }
+
+ @Test
+ void monadicAnnotatedTypeUnderCompileStatic() {
+ assertScript """
+ import groovy.transform.Monadic
+ import java.util.function.Function
+
+ @Monadic(bind = 'chain', map = 'transform')
+ class Res {
+ final Object v
+ Res(Object v) { this.v = v }
+ Res chain(Function f) { (Res) f.apply(v) }
+ Res transform(Function f) { new Res(f.apply(v)) }
+ }
+
+ $CS
+ class C {
+ static Object run() {
+ def r = DO(a in new Res(2), b in new Res(3)) { new Res(a) }
+ r.v
+ }
+ }
+ assert C.run() == 2
+ """
+ }
+
+ @Test
+ void rejectsNonParticipatingCarrierAtCompileTime() {
+ assertCompileError("""
+ $CS
+ class C {
+ static def run() {
+ DO(a in new Object()) { Optional.of(a) }
+ }
+ }
+ """, 'does not participate in monadic comprehensions')
+ }
+
+ @Test
+ void rejectsBareBodyAtCompileTime() {
+ // Body returns a bare value; the dispatcher's erased (Object,Closure):Object
+ // signature lets STC accept this, but Optional.flatMap would fail at runtime.
+ assertCompileError("""
+ $CS
+ class C {
+ static def run() {
+ DO(a in Optional.of(2)) { a + 1 }
+ }
+ }
+ """, 'must yield java.util.Optional')
+ }
+
+ @Test
+ void rejectsCrossCarrierBodyAtCompileTime() {
+ // Outer carrier Optional, body produces a Stream — well-typed against the
+ // erased dispatcher but rejected by Optional.flatMap at runtime.
+ assertCompileError("""
+ import java.util.stream.Stream
+ $CS
+ class C {
+ static def run() {
+ DO(a in Optional.of(2)) { Stream.of(a) }
+ }
+ }
+ """, 'Mixing carriers in a comprehension is not supported')
+ }
+
+ @Test
+ void rejectsCrossCarrierInNestedDoAtCompileTime() {
+ // The classic gotcha: outer Optional, inner Stream — the outer bind's
+ // closure ends up yielding Stream, contradicting the receiver Optional.
+ assertCompileError("""
+ import java.util.stream.Stream
+ $CS
+ class C {
+ static def run() {
+ DO(a in Optional.of(2),
+ b in Stream.of(a, a + 10)) { Stream.of(b) }
+ }
+ }
+ """, 'Mixing carriers in a comprehension is not supported')
+ }
+
+ @Test
+ void rejectsCarrierWithOnly2ArgFlatMapAtCompileTime() {
+ // Regression: hasMethodNamed used to count any 'flatMap' regardless of
+ // arity, so a 2-arg flatMap satisfied participation statically and
+ // then failed at runtime in the dispatcher (which needs a single-arg
+ // method). Now aligned with the runtime rule.
+ assertCompileError("""
+ class Box {
+ final int v
+ Box(int v) { this.v = v }
+ Box flatMap(int extra, Closure c) { (Box) c.call(v + extra) }
+ }
+ $CS
+ class C {
+ static def run() {
+ DO(a in new Box(2)) { new Box(((Integer) a) + 1) }
+ }
+ }
+ """, 'does not participate in monadic comprehensions')
+ }
+
+ @Test
+ void monadicAnnotationOnSuperclassAcceptedAtCompileTime() {
+ // Regression: the static @Monadic check used to look only at the type's
+ // own annotations; the runtime walks superclasses. Without the walk,
+ // a SubRes whose @Monadic lives on BaseRes would be rejected here yet
+ // accepted at runtime.
+ assertScript """
+ import groovy.transform.Monadic
+
+ @Monadic
+ class BaseRes {
+ final Object v
+ BaseRes(Object v) { this.v = v }
+ BaseRes flatMap(Closure c) { (BaseRes) c.call(v) }
+ BaseRes map(Closure c) { new BaseRes(c.call(v)) }
+ }
+ class SubRes extends BaseRes {
+ SubRes(Object v) { super(v) }
+ }
+
+ $CS
+ class C {
+ static Object run() {
+ def r = DO(a in new SubRes(2),
+ b in new SubRes(3)) { new SubRes(b) }
+ r.v
+ }
+ }
+ assert C.run() == 3
+ """
+ }
+
+ @Test
+ void monadicAnnotationOnInterfaceAcceptedAtCompileTime() {
+ // Regression: @Monadic declared on an interface was rejected by the
+ // static check (which didn't walk interfaces); the runtime accepts it.
+ assertScript """
+ import groovy.transform.Monadic
+
+ @Monadic
+ interface IRes {
+ IRes flatMap(Closure c)
+ IRes map(Closure c)
+ }
+ class Holder implements IRes {
+ final Object v
+ Holder(Object v) { this.v = v }
+ IRes flatMap(Closure c) { (IRes) c.call(v) }
+ IRes map(Closure c) { new Holder(c.call(v)) }
+ }
+
+ $CS
+ class C {
+ static Object run() {
+ def r = DO(a in new Holder(2),
+ b in new Holder(3)) { new Holder(b) }
+ ((Holder) r).v
+ }
+ }
+ assert C.run() == 3
+ """
+ }
+
+ private static void assertCompileError(String script, String expectedMessage) {
+ try {
+ new GroovyShell().parse(script)
+ fail("Expected a compilation error containing: $expectedMessage")
+ } catch (MultipleCompilationErrorsException e) {
+ assert e.message.contains(expectedMessage)
+ }
+ }
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy
new file mode 100644
index 00000000000..ccba099edbf
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy
@@ -0,0 +1,69 @@
+/*
+ * 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.apache.groovy.macrolib
+
+import fj.data.Option
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+
+/**
+ * Functional Java participates via the name-keyed allow-list (it uses
+ * {@code bind}/{@code map} with {@code fj.F} arguments, so structural and
+ * {@code @Monadic} cannot apply). Exercises the registry supertype walk
+ * ({@code fj.data.Some} → {@code fj.data.Option}) and the general
+ * closure-to-SAM coercion (closure → {@code fj.F}).
+ */
+final class FunctionalJavaCarrierTest {
+
+ @Test
+ void composesAndShortCircuits() {
+ def sum = DO(a in Option.some(2),
+ b in Option.some(3)) {
+ Option.some(a + b)
+ }
+ assertEquals(5, sum.get())
+
+ def shorted = DO(a in Option.none(),
+ b in Option.some(3)) {
+ Option.some(b)
+ }
+ assertFalse(shorted.defined())
+ }
+
+ @Test
+ void underCompileStaticViaMonadicChecker() {
+ assertScript '''
+ import fj.data.Option
+
+ @groovy.transform.CompileStatic(extensions='groovy.typecheckers.MonadicChecker')
+ class C {
+ static int run() {
+ DO(a in Option.some(2),
+ b in Option.some(3)) {
+ Option.some(a + b)
+ }.get()
+ }
+ }
+ assert C.run() == 5
+ '''
+ }
+}
diff --git a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy
new file mode 100644
index 00000000000..447862cf23a
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy
@@ -0,0 +1,191 @@
+/*
+ * 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.apache.groovy.macrolib
+
+import groovy.concurrent.Awaitable
+import groovy.concurrent.DataflowVariable
+import groovy.transform.Monadic
+import org.apache.groovy.runtime.Comprehensions
+import org.apache.groovy.runtime.async.AsyncSupport
+import org.junit.jupiter.api.Test
+
+import java.util.concurrent.CompletableFuture
+import java.util.function.Function
+import java.util.stream.Stream
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Verifies the {@link Comprehensions} bind/map dispatcher delivers the
+ * monadic-comprehension semantics across every carrier in the allow-list plus
+ * structural, Closure-surface, and {@code @Monadic} participation.
+ *
+ * Each test writes the nested {@code Comprehensions.bind} chain directly —
+ * the shape the {@code DO} macro emits:
+ *
+ * DO(x in m1, y in f(x)) { body }
+ * ==> Comprehensions.bind(m1) { x -> Comprehensions.bind(f(x)) { y -> body } }
+ *
+ */
+final class MonadicComprehensionsTest {
+
+ @Test
+ void optional_composes_and_short_circuits() {
+ def sum = Comprehensions.bind(Optional.of(2)) { a ->
+ Comprehensions.bind(Optional.of(3)) { b ->
+ Optional.of(a + b)
+ }
+ }
+ assertEquals(Optional.of(5), sum)
+
+ def shorted = Comprehensions.bind(Optional.empty()) { a ->
+ Comprehensions.bind(Optional.of(3)) { b -> Optional.of(a + b) }
+ }
+ assertEquals(Optional.empty(), shorted)
+ }
+
+ @Test
+ void stream_cartesian_composition() {
+ def result = Comprehensions.bind(Stream.of(1, 2)) { a ->
+ Comprehensions.bind(Stream.of(10, 20)) { b ->
+ Stream.of(a + b)
+ }
+ }
+ assertEquals([11, 21, 12, 22], ((Stream) result).toList())
+ }
+
+ @Test
+ void completableFuture_composes() {
+ def fut = Comprehensions.bind(CompletableFuture.completedFuture(2)) { a ->
+ Comprehensions.bind(CompletableFuture.completedFuture(3)) { b ->
+ CompletableFuture.completedFuture(a + b)
+ }
+ }
+ assertEquals(5, ((CompletableFuture) fut).get())
+ }
+
+ @Test
+ void awaitable_composes_via_thenCompose() {
+ // Awaitable.then is map; thenCompose is bind.
+ def result = Comprehensions.bind(Awaitable.of(2)) { a ->
+ Comprehensions.bind(Awaitable.of(3)) { b ->
+ Awaitable.of(a + b)
+ }
+ }
+ assertEquals(5, AsyncSupport.await((Awaitable) result))
+ }
+
+ @Test
+ void dataflowVariable_composes() {
+ def x = new DataflowVariable()
+ def y = new DataflowVariable()
+ x.bind(10)
+ y.bind(5)
+ def sum = Comprehensions.bind(x) { a ->
+ Comprehensions.bind(y) { b ->
+ Awaitable.of(a + b)
+ }
+ }
+ assertEquals(15, AsyncSupport.await((Awaitable) sum))
+ }
+
+ @Test
+ void structural_participation_no_annotation() {
+ def boxed = Comprehensions.bind(new Box(2)) { a ->
+ Comprehensions.bind(new Box(3)) { b ->
+ new Box(a + b)
+ }
+ }
+ assertEquals(5, ((Box) boxed).v)
+ }
+
+ @Test
+ void closure_surface_participation() {
+ // The bind method accepts a Closure rather than a Function.
+ def boxed = Comprehensions.bind(new ClosureBox(2)) { a ->
+ Comprehensions.bind(new ClosureBox(3)) { b ->
+ new ClosureBox(a + b)
+ }
+ }
+ assertEquals(5, ((ClosureBox) boxed).v)
+ }
+
+ @Test
+ void monadic_annotation_with_name_overrides() {
+ def res = Comprehensions.bind(new Res(2)) { a ->
+ Comprehensions.bind(new Res(3)) { b ->
+ new Res(a + b)
+ }
+ }
+ assertEquals(5, ((Res) res).v)
+ }
+
+ @Test
+ void map_role_uses_map_name() {
+ assertEquals(Optional.of(3), Comprehensions.map(Optional.of(2)) { it + 1 })
+ def r = Comprehensions.map(new Res(2)) { it * 10 } // map -> 'transform'
+ assertEquals(20, ((Res) r).v)
+ }
+
+ @Test
+ void non_participating_type_fails_with_a_precise_message() {
+ def ex = assertThrows(IllegalArgumentException) {
+ Comprehensions.bind(new Plain(1)) { a -> new Plain(a) }
+ }
+ assertTrue(ex.message.contains(Plain.name))
+ assertTrue(ex.message.contains('does not participate'))
+ assertTrue(ex.message.contains('@Monadic'))
+ }
+}
+
+/** Structural carrier: conventional flatMap/map taking a java.util.function.Function. */
+class Box {
+ final Object v
+ Box(Object v) { this.v = v }
+ Box flatMap(Function f) { (Box) f.apply(v) }
+ Box map(Function f) { new Box(f.apply(v)) }
+ String toString() { "Box($v)" }
+}
+
+/** Structural carrier whose bind/map take a Closure rather than a Function. */
+class ClosureBox {
+ final Object v
+ ClosureBox(Object v) { this.v = v }
+ ClosureBox flatMap(Closure f) { (ClosureBox) f.call(v) }
+ ClosureBox map(Closure f) { new ClosureBox(f.call(v)) }
+ String toString() { "ClosureBox($v)" }
+}
+
+/** Opt-in carrier with non-conventional method names declared via @Monadic. */
+@Monadic(bind = 'chain', map = 'transform')
+class Res {
+ final Object v
+ Res(Object v) { this.v = v }
+ Res chain(Function f) { (Res) f.apply(v) }
+ Res transform(Function f) { new Res(f.apply(v)) }
+ String toString() { "Res($v)" }
+}
+
+/** A type that does not participate at all (negative case). */
+class Plain {
+ final Object v
+ Plain(Object v) { this.v = v }
+}
diff --git a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy
new file mode 100644
index 00000000000..50f94ba77ab
--- /dev/null
+++ b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy
@@ -0,0 +1,282 @@
+/*
+ * 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.typecheckers
+
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.GenericsType
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.apache.groovy.runtime.MonadicCarrierRegistry
+import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.make
+import static org.codehaus.groovy.ast.tools.GenericsUtils.makeClassSafeWithGenerics
+import static org.objectweb.asm.Opcodes.ACC_BRIDGE
+
+/**
+ * Teaches {@code @CompileStatic}/{@code @TypeChecked} about the {@code DO} macro's
+ * desugared output: calls to {@code org.apache.groovy.runtime.Comprehensions.bind}
+ * and {@code .map}, declared {@code (Object, Closure):Object}.
+ *
+ * Three jobs:
+ *
+ * Enforce receiver shape : the carrier must participate (allow-list,
+ * structural {@code flatMap}/{@code map}, or {@code @Monadic}); otherwise a
+ * precise compile error naming the type and the missing shape.
+ * Enforce closure-return shape (trusted carriers only — registry
+ * or {@code @Monadic}, not structural): {@code bind}'s closure must yield the
+ * same carrier (catches a bare body or a cross-carrier body inside
+ * {@code DO}, which the erased dispatcher signature otherwise lets through);
+ * {@code map}'s closure must not yield the same carrier (the
+ * {@code M>} foot-gun for hand-written {@code Comprehensions.map}).
+ * Assist inference : type the generator closure's parameter as the
+ * carrier's element type (so the body type-checks), and restore the
+ * comprehension's result type (so {@code .get()}/nesting type-check) instead
+ * of the erased {@code Object} the dispatcher signature would yield.
+ *
+ *
+ * Closure-parameter typing works by pre-setting {@code CLOSURE_ARGUMENTS} on the
+ * closure node, which {@code StaticTypeCheckingVisitor.getTypeFromClosureArguments}
+ * consults by parameter name — independent of the {@code Closure>} parameter
+ * not being a SAM type.
+ *
+ * Activate with {@code @CompileStatic(extensions='groovy.typecheckers.MonadicChecker')}.
+ */
+class MonadicChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+
+ private static final String DISPATCHER = 'org.apache.groovy.runtime.Comprehensions'
+
+ @Override
+ Object run() {
+ // Fires after method selection (carrier argument already typed) but before
+ // the generator closure body is visited: the window to type the closure param.
+ onMethodSelection { expr, MethodNode target ->
+ if (!isDispatcherCall(expr, target)) return
+ MethodCallExpression call = (MethodCallExpression) expr
+ def args = call.arguments.expressions
+ def carrierType = safeType(args[0])
+ String role = call.methodAsString
+
+ if (carrierType == null || !participates(carrierType)) {
+ addStaticTypeError(
+ "Type ${typeName(carrierType)} does not participate in monadic comprehensions (DO): " +
+ "no ${role == 'bind' ? "bind (flatMap-shaped)" : 'map'} method " +
+ "(not in the standard carrier allow-list, has no structural " +
+ "'${role == 'bind' ? 'flatMap' : 'map'}' method, and is not annotated @Monadic)",
+ args[0])
+ return
+ }
+
+ def closure = args.find { it instanceof ClosureExpression } as ClosureExpression
+ if (closure != null) {
+ ClassNode elem = elementType(carrierType)
+ closure.putNodeMetaData(StaticTypesMarker.CLOSURE_ARGUMENTS, [elem] as ClassNode[])
+ }
+ }
+
+ // The dispatcher returns erased Object; restore the comprehension's real type.
+ afterMethodCall { call ->
+ if (!(call instanceof MethodCallExpression)) return
+ MethodNode target = call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+ if (!isDispatcherCall(call, target)) return
+ def args = call.arguments.expressions
+ def carrierType = safeType(args[0])
+ if (carrierType == null) return
+ def closure = args.find { it instanceof ClosureExpression } as ClosureExpression
+ ClassNode produced = closureReturnType(closure)
+
+ // Closure-return shape check (trusted carriers only). The dispatcher
+ // signature is (Object, Closure):Object — STC cannot see that the
+ // body must yield the same carrier (bind) or a non-carrier (map);
+ // restore the contract here. Skipped for structural-only carriers
+ // (intentionally permissive, like participates()).
+ //
+ // Anchor at the closure (the actual offender) when present; the DO
+ // macro propagates source positions onto its synthetic lambda, so
+ // STC's addStaticTypeError (which drops positionless nodes) will
+ // surface this. Fall through to args[0] for the hand-written
+ // Comprehensions.bind/map shape where the closure may not be a
+ // literal at this argument slot.
+ String role = call.methodAsString
+ String shape = shapeMsg(role, carrierType, produced)
+ if (shape) addStaticTypeError(shape, closure ?: args[0])
+
+ ClassNode result
+ if (role == 'bind') {
+ // closure yields M; bind yields the same carrier
+ result = produced ?: carrierType
+ } else {
+ // map: closure yields B; result is M
+ ClassNode b = produced ?: OBJECT_TYPE
+ result = makeClassSafeWithGenerics(carrierType.plainNodeReference, new GenericsType(b))
+ }
+ storeType(call, result)
+ }
+ }
+
+ /**
+ * Diagnostic for the dispatcher closure-return contract, or null if acceptable.
+ * Tolerates unknown returns (null/Object); only flags when the carrier mismatch
+ * is statically demonstrable and the receiver is a trusted (registry- or
+ * {@code @Monadic}-keyed) carrier.
+ */
+ private String shapeMsg(String role, ClassNode receiver, ClassNode produced) {
+ if (produced == null || produced == OBJECT_TYPE) return null
+ String recv = trustedCarrierName(receiver)
+ if (recv == null) return null
+ String ret = trustedCarrierName(produced)
+ if (role == 'bind') {
+ if (ret == null) {
+ return "Closure passed to Comprehensions.bind on ${recv} must yield ${recv}; " +
+ "got ${typeName(produced)} (not a carrier). In a DO comprehension, the body " +
+ "must produce the same carrier (e.g. ${recv}.of(...))."
+ }
+ if (ret != recv) {
+ return "Closure passed to Comprehensions.bind on ${recv} must yield ${recv}; " +
+ "got ${ret}. Mixing carriers in a comprehension is not supported."
+ }
+ return null
+ }
+ // role == 'map'
+ if (ret == recv) {
+ return "Closure passed to Comprehensions.map on ${recv} returns a ${recv}, " +
+ "producing ${recv}<${recv}<...>>; use Comprehensions.bind instead."
+ }
+ null
+ }
+
+ /**
+ * The canonical carrier name — the key for same-carrier comparison —
+ * for the given type, restricted to trusted participation paths
+ * (registry allow-list, {@code @Monadic}). Returns {@code null} for
+ * structural-only or non-carrier types; structural participation is
+ * intentionally permissive and not asserted against.
+ */
+ private String trustedCarrierName(ClassNode cn) {
+ if (cn == null) return null
+ ClassNode bare = cn.redirect() ?: cn
+ for (e in MonadicCarrierRegistry.entries()) {
+ if (assignableTo(bare, make(e.carrier()))) return make(e.carrier()).name
+ }
+ for (e in MonadicCarrierRegistry.namedEntries()) {
+ if (assignableTo(bare, make(e.carrierName()))) return e.carrierName()
+ }
+ // @Monadic walks super + interfaces, mirroring the runtime
+ ClassNode mon = classWithMonadic(bare)
+ mon?.name
+ }
+
+ private boolean isDispatcherCall(expr, MethodNode target) {
+ expr instanceof MethodCallExpression &&
+ expr.methodAsString in ['bind', 'map'] &&
+ target?.declaringClass?.name == DISPATCHER
+ }
+
+ private ClassNode safeType(expr) {
+ try { getType(expr) } catch (ignored) { null }
+ }
+
+ private static String typeName(ClassNode cn) {
+ cn == null ? '' : cn.toString(false)
+ }
+
+ private boolean participates(ClassNode cn) {
+ ClassNode bare = cn.redirect() ?: cn
+ // 1. standard allow-list (shared with the runtime dispatcher), Class- and name-keyed
+ if (MonadicCarrierRegistry.entries().any { assignableTo(bare, make(it.carrier())) }) return true
+ if (MonadicCarrierRegistry.namedEntries().any { assignableTo(bare, make(it.carrierName())) }) return true
+ // 2. structural (flatMap covers bind; map covers the map role); arity-1 only
+ // — aligns with the runtime dispatcher's findSingleArgMethod
+ if (hasSingleArgMethod(bare, 'flatMap') || hasSingleArgMethod(bare, 'map')) return true
+ // 3. @Monadic opt-in (matched by simple name, like @Reducer/@Associative);
+ // walk super + interfaces, mirroring the runtime dispatcher
+ return classWithMonadic(bare) != null
+ }
+
+ private boolean assignableTo(ClassNode cn, ClassNode t) {
+ cn == t || cn.isDerivedFrom(t) || cn.implementsInterface(t)
+ }
+
+ /**
+ * True iff any class in the type's superclass-then-interfaces walk declares a
+ * single-argument, non-bridge, non-synthetic method with the given name. The
+ * arity/bridge/synthetic filter aligns the static check with the runtime
+ * dispatcher's {@code findSingleArgMethod}: without it, a 2-arg
+ * {@code flatMap(state, fn)} would pass participation here yet fail at runtime.
+ */
+ private boolean hasSingleArgMethod(ClassNode cn, String name) {
+ for (ClassNode c = cn; c != null && c != OBJECT_TYPE; c = c.superClass) {
+ if (hasSingleArgIn(c, name)) return true
+ if (c.interfaces?.any { hasSingleArgIn(it, name) }) return true
+ }
+ false
+ }
+
+ private static boolean hasSingleArgIn(ClassNode cn, String name) {
+ cn.getMethods(name)?.any { isSingleArgUserMethod(it) } ?: false
+ }
+
+ private static boolean isSingleArgUserMethod(MethodNode m) {
+ m.parameters?.length == 1 && !m.isSynthetic() && (m.modifiers & ACC_BRIDGE) == 0
+ }
+
+ /**
+ * Walks superclasses and their direct interfaces looking for a
+ * {@code @Monadic} annotation (simple-name match, in the manner of
+ * {@code @Reducer}/{@code @Associative}). Returns the {@code ClassNode}
+ * that carries the annotation, or {@code null} if none. Mirrors the
+ * runtime dispatcher's {@code monadicMethodName} walk.
+ */
+ private static ClassNode classWithMonadic(ClassNode start) {
+ for (ClassNode c = start; c != null && c.name != 'java.lang.Object'; c = c.superClass) {
+ if (hasMonadicAnno(c)) return c
+ ClassNode[] ifaces = c.interfaces
+ if (ifaces != null) {
+ for (ClassNode i : ifaces) {
+ if (hasMonadicAnno(i)) return i
+ }
+ }
+ }
+ null
+ }
+
+ private static boolean hasMonadicAnno(ClassNode cn) {
+ cn.annotations?.any { it.classNode?.nameWithoutPackage == 'Monadic' } ?: false
+ }
+
+ private ClassNode elementType(ClassNode carrier) {
+ def gts = carrier?.genericsTypes
+ (gts && gts.length > 0 && gts[0].type) ? gts[0].type : OBJECT_TYPE
+ }
+
+ private ClassNode closureReturnType(ClosureExpression closure) {
+ if (closure == null) return null
+ // STC writes the inferred body-return type as metadata on the closure
+ // (not always reflected as Closure generics on the closure's own
+ // type, especially for bare-expression bodies); consult both.
+ ClassNode inferred = closure.getNodeMetaData(StaticTypesMarker.INFERRED_RETURN_TYPE)
+ if (inferred != null) return inferred
+ def t = safeType(closure)
+ def gts = t?.genericsTypes
+ (gts && gts.length > 0 && gts[0].type) ? gts[0].type : null
+ }
+}
diff --git a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy
new file mode 100644
index 00000000000..c659964f50c
--- /dev/null
+++ b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy
@@ -0,0 +1,279 @@
+/*
+ * 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.typecheckers
+
+import org.apache.groovy.lang.annotation.Incubating
+import org.apache.groovy.runtime.MonadicCarrierRegistry
+import org.apache.groovy.typecheckers.CheckingVisitor
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
+import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.expr.MethodPointerExpression
+import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.make
+
+/**
+ * Compile-time lint for native monadic chains over the standard carrier
+ * allow-list ({@link MonadicCarrierRegistry}).
+ *
+ * Sister to {@link MonadicChecker}: that one repairs erasure on calls routed
+ * through the {@code DO} macro's runtime dispatcher; this one catches shape
+ * bugs in hand-written {@code flatMap}/{@code map} (and
+ * {@code thenCompose}/{@code thenApply}/etc.) chains that the JDK generics
+ * cannot reject:
+ *
+ * {@code bind} returning a non-carrier — e.g.
+ * {@code Optional.flatMap { it + 1 }} where the JDK expects an
+ * {@code Optional}. STC can silently let closures pass this gap.
+ * {@code bind} returning a different carrier — e.g.
+ * {@code Stream.flatMap { Optional.of(it) }}. Almost certainly a bug.
+ * {@code map} returning the same carrier — e.g.
+ * {@code Optional.map { Optional.of(it) }} producing
+ * {@code Optional>}; usually a missed {@code flatMap} (or
+ * {@code thenCompose}).
+ *
+ * Carriers, and the canonical names of their bind/map methods, are read
+ * entirely from {@link MonadicCarrierRegistry}; types annotated
+ * {@link groovy.transform.Monadic} also participate (matched by simple name,
+ * like {@code @Reducer}). Calls whose target is {@code Comprehensions} are
+ * skipped — they are {@link MonadicChecker}'s domain.
+ *
+ * Two modes, selected via the extension option {@code mode}:
+ *
+ * // default (lenient): only flag high-confidence problems
+ * {@code @TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')}
+ *
+ * // strict: also flag chains whose function return cannot be statically resolved
+ * {@code @TypeChecked(extensions = "groovy.typecheckers.MonadicShapeChecker(mode: 'strict')")}
+ *
+ *
+ * @since 6.0.0
+ * @see MonadicCarrierRegistry
+ * @see MonadicChecker
+ * @see groovy.transform.Monadic
+ */
+@Incubating
+class MonadicShapeChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+
+ /** The {@code DO}-macro dispatcher target; calls here are {@link MonadicChecker}'s. */
+ private static final String DISPATCHER = 'org.apache.groovy.runtime.Comprehensions'
+
+ private boolean strict
+
+ @Override
+ Object run() {
+ strict = (options?.mode as String)?.equalsIgnoreCase('strict')
+ // Visit method bodies with a CheckingVisitor — same shape as
+ // CombinerChecker/PurityChecker; avoids the spotty dispatch context
+ // of the afterMethodCall hook in the rewritten DSL.
+ afterVisitMethod { MethodNode mn ->
+ mn.code?.visit(makeVisitor())
+ }
+ }
+
+ private CheckingVisitor makeVisitor() {
+ boolean strict = this.strict
+ new CheckingVisitor() {
+
+ private ClassNode safeType(Expression e) {
+ try { getType(e) } catch (ignored) { null }
+ }
+
+ @Override
+ void visitMethodCallExpression(MethodCallExpression call) {
+ super.visitMethodCallExpression(call)
+
+ MethodNode target = call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+ // Skip the DO-macro dispatcher — that's MonadicChecker's territory.
+ if (target?.declaringClass?.name == DISPATCHER) return
+
+ String name = call.methodAsString
+ if (!name) return
+
+ ClassNode receiverType = safeType(call.objectExpression)
+ CarrierInfo carrier = carrierFor(receiverType)
+ if (carrier == null) return
+
+ String role
+ if (name == carrier.bind) role = 'bind'
+ else if (name == carrier.map) role = 'map'
+ else return
+
+ List args = (call.arguments instanceof ArgumentListExpression) ?
+ ((ArgumentListExpression) call.arguments).expressions : []
+ if (args.isEmpty()) return
+ Expression fn = args[-1] // bind/map take a single function arg
+
+ ClassNode produced = functionReturnType(fn)
+ String msg = diagnose(carrier, role, produced, name, strict)
+ if (msg) addStaticTypeError(msg, call)
+ }
+
+ /**
+ * Best-effort static return-type for the function argument:
+ * STC metadata on a closure literal, generics on a method
+ * reference's resolved method, or null if neither applies.
+ */
+ private ClassNode functionReturnType(Expression fn) {
+ if (fn instanceof ClosureExpression) {
+ ClassNode inferred = fn.getNodeMetaData(StaticTypesMarker.INFERRED_RETURN_TYPE)
+ if (inferred != null && inferred != OBJECT_TYPE) return inferred
+ // Fallback: read Closure generics off the closure expression.
+ def t = safeType(fn)
+ def gts = t?.genericsTypes
+ return (gts && gts.length > 0 && gts[0].type) ? gts[0].type : null
+ }
+ if (fn instanceof MethodPointerExpression) {
+ MethodPointerExpression ref = (MethodPointerExpression) fn
+ String rn = ref.methodName?.text
+ ClassNode owner = (ref.expression instanceof ClassExpression) ?
+ ((ClassExpression) ref.expression).type : safeType(ref.expression)
+ if (rn && owner) {
+ def ms = owner.redirect().getMethods(rn)
+ if (ms) {
+ // prefer the single-arg overload (bind/map shape)
+ MethodNode m = ms.find { it.parameters?.size() == 1 } ?: ms[0]
+ return m?.returnType
+ }
+ }
+ return null
+ }
+ null // ordinary value/variable — no reliable static handle
+ }
+ }
+ }
+
+ /** Pure static analysis: a diagnostic message, or null if the call is acceptable. */
+ private static String diagnose(CarrierInfo carrier, String role,
+ ClassNode produced, String methodName, boolean strict) {
+ boolean knownReturn = produced != null && produced != OBJECT_TYPE
+ CarrierInfo returnCarrier = knownReturn ? carrierFor(produced) : null
+
+ if (role == 'bind') {
+ if (!knownReturn) {
+ return strict ? "MonadicShapeChecker (strict): cannot statically verify that the " +
+ "function passed to '${methodName}' on ${carrier.canonical} returns another " +
+ "${carrier.canonical}." : null
+ }
+ if (returnCarrier == null) {
+ return "MonadicShapeChecker: '${methodName}' on ${carrier.canonical} expects its " +
+ "function to return another ${carrier.canonical}; got ${typeName(produced)}."
+ }
+ if (returnCarrier.canonical != carrier.canonical) {
+ return "MonadicShapeChecker: '${methodName}' on ${carrier.canonical} expects its " +
+ "function to return another ${carrier.canonical}; got ${returnCarrier.canonical} " +
+ "(crossing carrier types is almost certainly a bug)."
+ }
+ return null
+ }
+ // role == 'map'
+ if (!knownReturn) {
+ return strict ? "MonadicShapeChecker (strict): cannot statically verify that the " +
+ "function passed to '${methodName}' on ${carrier.canonical} returns a plain " +
+ "value (and not another ${carrier.canonical})." : null
+ }
+ if (returnCarrier != null && returnCarrier.canonical == carrier.canonical) {
+ return "MonadicShapeChecker: '${methodName}' on ${carrier.canonical} returns its " +
+ "function's result wrapped, producing ${carrier.canonical}<${carrier.canonical}<...>>; " +
+ "did you mean '${carrier.bind}'?"
+ }
+ return null
+ }
+
+ private static String typeName(ClassNode cn) {
+ cn == null ? '' : cn.toString(false)
+ }
+
+ // ---- carrier identification ----
+
+ /** Carrier info: canonical name (for same-carrier comparison) and method names. */
+ private static class CarrierInfo {
+ final String canonical
+ final String bind
+ final String map
+ CarrierInfo(String canonical, String bind, String map) {
+ this.canonical = canonical
+ this.bind = bind
+ this.map = map
+ }
+ }
+
+ /** Carrier info for the given type, or {@code null} if it is not a known carrier. */
+ private static CarrierInfo carrierFor(ClassNode cn) {
+ if (cn == null) return null
+ ClassNode bare = cn.redirect() ?: cn
+
+ // 1. Class-keyed allow-list (assignability)
+ for (e in MonadicCarrierRegistry.entries()) {
+ ClassNode t = make(e.carrier())
+ if (assignableTo(bare, t)) {
+ return new CarrierInfo(t.name, e.bind(), e.map())
+ }
+ }
+ // 2. Name-keyed allow-list (hierarchy walk by FQ name; no library dependency)
+ for (e in MonadicCarrierRegistry.namedEntries()) {
+ if (hasInHierarchyByName(bare, e.carrierName())) {
+ return new CarrierInfo(e.carrierName(), e.bind(), e.map())
+ }
+ }
+ // 3. @Monadic (matched by simple name, walking the hierarchy)
+ for (ClassNode c = bare; c != null && c.name != 'java.lang.Object'; c = c.superClass) {
+ AnnotationNode ann = c.annotations?.find { it.classNode?.nameWithoutPackage == 'Monadic' }
+ if (ann != null) {
+ String bind = readStringMember(ann, 'bind') ?: 'flatMap'
+ String map = readStringMember(ann, 'map') ?: 'map'
+ return new CarrierInfo(c.name, bind, map)
+ }
+ }
+ null
+ }
+
+ private static String readStringMember(AnnotationNode ann, String member) {
+ Expression e = ann.getMember(member)
+ if (e == null) return null
+ String s = e.text
+ s.isEmpty() ? null : s
+ }
+
+ private static boolean assignableTo(ClassNode cn, ClassNode t) {
+ cn == t || cn.isDerivedFrom(t) || cn.implementsInterface(t)
+ }
+
+ private static boolean hasInHierarchyByName(ClassNode cn, String fq) {
+ for (ClassNode c = cn; c != null && c.name != 'java.lang.Object'; c = c.superClass) {
+ if (c.name == fq) return true
+ ClassNode[] ifaces = c.interfaces
+ if (ifaces != null) {
+ for (i in ifaces) {
+ if (i.name == fq) return true
+ if (hasInHierarchyByName(i, fq)) return true
+ }
+ }
+ }
+ false
+ }
+}
diff --git a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy
new file mode 100644
index 00000000000..2b4e7bd1b1b
--- /dev/null
+++ b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy
@@ -0,0 +1,265 @@
+/*
+ * 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.typecheckers
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for {@link MonadicShapeChecker}. Mirrors the {@code CombinerCheckerTest}
+ * harness: two shared shells, one lenient, one strict.
+ */
+final class MonadicShapeCheckerTest {
+
+ private static GroovyShell lenientShell
+ private static GroovyShell strictShell
+
+ @BeforeAll
+ static void setUp() {
+ lenientShell = makeShell(null)
+ strictShell = makeShell('strict')
+ }
+
+ private static GroovyShell makeShell(String mode) {
+ String ext = mode ? "groovy.typecheckers.MonadicShapeChecker(mode: '${mode}')" : 'groovy.typecheckers.MonadicShapeChecker'
+ new GroovyShell(new CompilerConfiguration().tap {
+ def customizer = new ASTTransformationCustomizer(groovy.transform.TypeChecked)
+ customizer.annotationParameters = [extensions: ext]
+ addCompilationCustomizers(customizer)
+ })
+ }
+
+ // ===== Optional =====
+
+ @Test
+ void optional_flatMap_returning_optional_passes() {
+ assertScript lenientShell, '''
+ assert Optional.of(2).flatMap { Integer x -> Optional.of(x + 1) }.get() == 3
+ '''
+ }
+
+ @Test
+ void optional_flatMap_returning_bare_value_fails() {
+ // JDK's flatMap signature requires Optional but STC can miss closures
+ // returning a bare value via Groovy SAM coercion.
+ def err = shouldFail lenientShell, '''
+ Optional.of(2).flatMap { Integer x -> x + 1 }
+ '''
+ assert err.message.contains("'flatMap' on java.util.Optional")
+ assert err.message.contains('expects its function to return another java.util.Optional')
+ }
+
+ @Test
+ void optional_flatMap_returning_stream_flags_cross_carrier() {
+ def err = shouldFail lenientShell, '''
+ import java.util.stream.Stream
+ Optional.of(2).flatMap { Integer x -> Stream.of(x) }
+ '''
+ assert err.message.contains('crossing carrier types is almost certainly a bug')
+ }
+
+ @Test
+ void optional_map_returning_optional_flags_nesting() {
+ def err = shouldFail lenientShell, '''
+ Optional.of(2).map { Integer x -> Optional.of(x + 1) }
+ '''
+ assert err.message.contains("'map' on java.util.Optional")
+ assert err.message.contains("did you mean 'flatMap'")
+ }
+
+ @Test
+ void optional_map_returning_plain_value_passes() {
+ assertScript lenientShell, '''
+ assert Optional.of(2).map { Integer x -> x + 1 }.get() == 3
+ '''
+ }
+
+ // ===== Stream =====
+
+ @Test
+ void stream_flatMap_returning_stream_passes() {
+ assertScript lenientShell, '''
+ import java.util.stream.Stream
+ assert Stream.of(1, 2).flatMap { Integer x -> Stream.of(x, x + 10) }.toList() == [1, 11, 2, 12]
+ '''
+ }
+
+ @Test
+ void stream_map_returning_stream_flags_nesting() {
+ def err = shouldFail lenientShell, '''
+ import java.util.stream.Stream
+ Stream.of(1, 2).map { Integer x -> Stream.of(x) }
+ '''
+ assert err.message.contains("'map' on java.util.stream.Stream")
+ assert err.message.contains("did you mean 'flatMap'")
+ }
+
+ // ===== CompletableFuture / CompletionStage =====
+
+ @Test
+ void cf_thenCompose_returning_cf_passes() {
+ assertScript lenientShell, '''
+ import java.util.concurrent.CompletableFuture
+ def r = CompletableFuture.completedFuture(2)
+ .thenCompose { Integer x -> CompletableFuture.completedFuture(x + 1) }
+ assert r.get() == 3
+ '''
+ }
+
+ @Test
+ void cf_thenCompose_returning_bare_value_fails() {
+ def err = shouldFail lenientShell, '''
+ import java.util.concurrent.CompletableFuture
+ CompletableFuture.completedFuture(2).thenCompose { Integer x -> x + 1 }
+ '''
+ assert err.message.contains("'thenCompose' on java.util.concurrent.CompletionStage")
+ }
+
+ @Test
+ void cf_thenApply_returning_cf_flags_nesting() {
+ def err = shouldFail lenientShell, '''
+ import java.util.concurrent.CompletableFuture
+ CompletableFuture.completedFuture(2).thenApply { Integer x -> CompletableFuture.completedFuture(x) }
+ '''
+ assert err.message.contains("'thenApply' on java.util.concurrent.CompletionStage")
+ assert err.message.contains("did you mean 'thenCompose'")
+ }
+
+ // ===== @Monadic user-declared carriers =====
+
+ @Test
+ void monadic_annotated_carrier_bind_returning_carrier_passes() {
+ assertScript lenientShell, '''
+ import groovy.transform.Monadic
+ @Monadic
+ class Box {
+ final T v
+ Box(T v) { this.v = v }
+ Box flatMap(Closure c) { (Box) c.call(v) }
+ Box map(Closure c) { new Box(c.call(v)) }
+ }
+
+ assert new Box(2).flatMap { Integer x -> new Box(x + 1) }.v == 3
+ '''
+ }
+
+ @Test
+ void monadic_annotated_carrier_map_returning_carrier_flags_nesting() {
+ def err = shouldFail lenientShell, '''
+ import groovy.transform.Monadic
+ @Monadic
+ class Box {
+ final T v
+ Box(T v) { this.v = v }
+ Box flatMap(Closure c) { (Box) c.call(v) }
+ Box map(Closure c) { new Box(c.call(v)) }
+ }
+
+ new Box(2).map { Integer x -> new Box(x + 1) }
+ '''
+ assert err.message.contains("did you mean 'flatMap'")
+ }
+
+ @Test
+ void monadic_with_custom_bind_map_names() {
+ // @Monadic(bind='chain', map='transform') — verify the configured names are honoured.
+ def err = shouldFail lenientShell, '''
+ import groovy.transform.Monadic
+ import java.util.function.Function
+
+ @Monadic(bind = 'chain', map = 'transform')
+ class Res {
+ final Object v
+ Res(Object v) { this.v = v }
+ Res chain(Function f) { (Res) f.apply(v) }
+ Res transform(Function f) { new Res(f.apply(v)) }
+ }
+
+ new Res(2).transform { x -> new Res(x) }
+ '''
+ assert err.message.contains("did you mean 'chain'")
+ }
+
+ // ===== modes =====
+
+ @Test
+ void lenient_does_not_flag_when_return_is_unknown() {
+ // Closure passed as a variable — no static handle on its return type.
+ assertScript lenientShell, '''
+ Closure c = { Integer x -> Optional.of(x + 1) }
+ Optional.of(2).flatMap(c)
+ '''
+ }
+
+ @Test
+ void strict_flags_when_return_is_unknown() {
+ def err = shouldFail strictShell, '''
+ Closure c = { Integer x -> Optional.of(x + 1) }
+ Optional.of(2).flatMap(c)
+ '''
+ assert err.message.contains('cannot statically verify')
+ }
+
+ // ===== passthrough / non-engagement =====
+
+ @Test
+ void non_carrier_receiver_ignored() {
+ // Compile-only: the assertion is the checker does NOT raise a static error
+ // on a non-registered type, even though the shapes here (flatMap returning
+ // bare, map returning the receiver) would be flagged on a carrier.
+ lenientShell.parse '''
+ class Holder {
+ final T v
+ Holder(T v) { this.v = v }
+ Holder flatMap(Closure c) { (Holder) c.call(v) }
+ Holder map(Closure c) { new Holder(c.call(v)) }
+ }
+ void check() {
+ new Holder(2).flatMap { Integer x -> x + 1 }
+ new Holder(2).map { Integer x -> new Holder(x) }
+ }
+ '''
+ }
+
+ @Test
+ void unrelated_map_call_ignored() {
+ // List.collect/Map.collect are 'map'-like but not in the carrier
+ // registry; the checker must not interfere.
+ assertScript lenientShell, '''
+ assert [1, 2, 3].collect { it + 1 } == [2, 3, 4]
+ '''
+ }
+
+ @Test
+ void comprehensions_dispatcher_calls_are_left_to_monadic_checker() {
+ // A direct Comprehensions.bind/map call is MonadicChecker's job; this
+ // checker must not double-flag a (correct) shape there.
+ assertScript lenientShell, '''
+ import org.apache.groovy.runtime.Comprehensions
+ assert ((Optional) Comprehensions.bind(Optional.of(2)) { Integer x ->
+ Comprehensions.bind(Optional.of(3)) { Integer y -> Optional.of(x + y) }
+ }).get() == 5
+ '''
+ }
+}