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. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
