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: + *

    + *
  1. standard allow-list ({@link MonadicCarrierRegistry});
  2. + *
  3. structural ({@code flatMap}/{@code map} present);
  4. + *
  5. {@code @Monadic} opt-in (matched by simple name; honours {@code bind}/{@code map} overrides).
  6. + *
+ * 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: + *

+ */ + 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 + ''' + } +}