[
https://issues.apache.org/jira/browse/GROOVY-12021?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18082175#comment-18082175
]
ASF GitHub Bot commented on GROOVY-12021:
-----------------------------------------
Copilot commented on code in PR #2545:
URL: https://github.com/apache/groovy/pull/2545#discussion_r3270189092
##########
subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy:
##########
@@ -0,0 +1,243 @@
+/*
+ * 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
+
+/**
+ * Teaches {@code @CompileStatic}/{@code @TypeChecked} about the {@code DO}
macro's
+ * desugared output: calls to {@code
org.apache.groovy.macrolib.Comprehensions.bind}
+ * and {@code .map}, declared {@code (Object, Closure):Object}.
+ *
+ * Three jobs:
+ * <ul>
+ * <li><b>Enforce receiver shape</b>: 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.</li>
+ * <li><b>Enforce closure-return shape</b> (trusted carriers only —
registry
+ * or {@code @Monadic}, not structural): {@code bind}'s closure must
yield the
+ * <em>same</em> 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 <em>not</em> yield the same carrier (the
+ * {@code M<M<T>>} foot-gun for hand-written {@code
Comprehensions.map}).</li>
+ * <li><b>Assist inference</b>: 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.</li>
+ * </ul>
+ *
+ * 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<B>; bind yields the same carrier
+ result = produced ?: carrierType
+ } else {
+ // map: closure yields B; result is M<B>
+ 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 <em>trusted</em> 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()
+ }
+ for (ClassNode c = bare; c != null && c.name != 'java.lang.Object'; c
= c.superClass) {
+ if (c.annotations?.any { it.classNode?.nameWithoutPackage ==
'Monadic' }) {
+ return c.name
+ }
+ }
+ null
+ }
+
+ 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 ? '<unknown>' : 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)
+ if (hasMethodNamed(bare, 'flatMap') || hasMethodNamed(bare, 'map'))
return true
+ // 3. @Monadic opt-in (matched by simple name, like
@Reducer/@Associative)
+ return bare.annotations.any { it.classNode?.nameWithoutPackage ==
'Monadic' }
+ }
+
+ private boolean assignableTo(ClassNode cn, ClassNode t) {
+ cn == t || cn.isDerivedFrom(t) || cn.implementsInterface(t)
+ }
+
+ private boolean hasMethodNamed(ClassNode cn, String name) {
+ for (ClassNode c = cn; c != null && c != OBJECT_TYPE; c =
c.superClass) {
+ if (c.getMethods(name)) return true
+ if (c.interfaces.any { it.getMethods(name) }) return true
Review Comment:
`participates` uses `hasMethodNamed` to detect structural participation, but
`hasMethodNamed` returns true for *any* method called `flatMap`/`map`
regardless of arity. The runtime dispatcher
(`Comprehensions.findSingleArgMethod`) requires a single-argument method, so
this can allow a carrier through under `@CompileStatic` that will later fail at
runtime. Align `hasMethodNamed` with the runtime rule by requiring exactly one
parameter (and ideally skipping bridge/synthetic methods similarly).
##########
src/main/java/org/apache/groovy/runtime/Comprehensions.java:
##########
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ * <p>
+ * The macro runs at {@code SEMANTIC_ANALYSIS}, <em>before</em> 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}.
+ * <p>
+ * 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.
+ * <p>
+ * Participation is resolved first-match-wins:
+ * <ol>
+ * <li>standard allow-list ({@link MonadicCarrierRegistry});</li>
+ * <li>structural ({@code flatMap}/{@code map} present);</li>
+ * <li>{@code @Monadic} opt-in (matched by simple name; honours {@code
bind}/{@code map} overrides).</li>
+ * </ol>
+ * A configured marker interface is a further opt-in mechanism that is not yet
+ * implemented.
+ * <p>
+ * 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.<bind>(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.<map>(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:
+ * <ul>
+ * <li>a {@code Closure}-typed parameter receives the closure
directly;</li>
+ * <li>a {@link Function}-typed parameter receives a thin wrapper;</li>
+ * <li>any other functional interface (for example Functional Java's
+ * {@code fj.F}) receives the closure coerced to that interface.</li>
+ * </ul>
+ */
+ 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.isAssignableFrom(Function.class)) { //
java.util.function.Function (or a supertype)
+ return new Function<Object, Object>() {
+ @Override
+ public Object apply(Object value) {
+ return fn.call(value);
+ }
+ };
+ }
Review Comment:
`adaptClosure` treats any parameter type that is a *supertype* of `Function`
(including `Object`) as “Function-typed” because it uses
`pt.isAssignableFrom(Function.class)`. That will wrap the closure in a
`Function` even when a structural carrier’s `flatMap/map` is declared with an
untyped/`Object` parameter (common in Groovy), and such implementations
typically expect a `Closure` (e.g. invoke via `fn.call(...)`), so DO will fail
at runtime. Consider narrowing this to `pt == Function.class` (or otherwise
only when the selected overload actually requires a `Function`), and/or prefer
a `Closure`-accepting overload when present.
##########
subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy:
##########
@@ -0,0 +1,243 @@
+/*
+ * 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
+
+/**
+ * Teaches {@code @CompileStatic}/{@code @TypeChecked} about the {@code DO}
macro's
+ * desugared output: calls to {@code
org.apache.groovy.macrolib.Comprehensions.bind}
Review Comment:
Javadoc refers to `org.apache.groovy.macrolib.Comprehensions.bind/.map`, but
the dispatcher class added/used elsewhere is
`org.apache.groovy.runtime.Comprehensions`. Updating this reference will avoid
confusion for users enabling the extension.
##########
subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy:
##########
@@ -0,0 +1,243 @@
+/*
+ * 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
+
+/**
+ * Teaches {@code @CompileStatic}/{@code @TypeChecked} about the {@code DO}
macro's
+ * desugared output: calls to {@code
org.apache.groovy.macrolib.Comprehensions.bind}
+ * and {@code .map}, declared {@code (Object, Closure):Object}.
+ *
+ * Three jobs:
+ * <ul>
+ * <li><b>Enforce receiver shape</b>: 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.</li>
+ * <li><b>Enforce closure-return shape</b> (trusted carriers only —
registry
+ * or {@code @Monadic}, not structural): {@code bind}'s closure must
yield the
+ * <em>same</em> 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 <em>not</em> yield the same carrier (the
+ * {@code M<M<T>>} foot-gun for hand-written {@code
Comprehensions.map}).</li>
+ * <li><b>Assist inference</b>: 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.</li>
+ * </ul>
+ *
+ * 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<B>; bind yields the same carrier
+ result = produced ?: carrierType
+ } else {
+ // map: closure yields B; result is M<B>
+ 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 <em>trusted</em> 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()
+ }
+ for (ClassNode c = bare; c != null && c.name != 'java.lang.Object'; c
= c.superClass) {
+ if (c.annotations?.any { it.classNode?.nameWithoutPackage ==
'Monadic' }) {
+ return c.name
+ }
+ }
+ null
+ }
+
+ 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 ? '<unknown>' : 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)
+ if (hasMethodNamed(bare, 'flatMap') || hasMethodNamed(bare, 'map'))
return true
+ // 3. @Monadic opt-in (matched by simple name, like
@Reducer/@Associative)
+ return bare.annotations.any { it.classNode?.nameWithoutPackage ==
'Monadic' }
+ }
+
Review Comment:
The `@Monadic` participation check differs from the runtime dispatcher:
`participates` only checks `bare.annotations` (no superclass/interface walk),
and `trustedCarrierName` walks superclasses but not interfaces.
`Comprehensions.monadicMethodName` searches superclasses *and* interfaces for a
`Monadic` annotation, so under `@CompileStatic` this can incorrectly reject (or
fail to “trust”) carriers that work at runtime when `@Monadic` is declared on a
supertype or interface.
> Add DO macro for monadic comprehensions over Optional/Stream/Awaitable and
> @Monadic types
> -----------------------------------------------------------------------------------------
>
> Key: GROOVY-12021
> URL: https://issues.apache.org/jira/browse/GROOVY-12021
> Project: Groovy
> Issue Type: New Feature
> Reporter: Paul King
> Priority: Major
>
--
This message was sent by Atlassian Jira
(v8.20.10#820010)