This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 9a52d3de8734961929d26537906c8e040ec22286
Author: Paul King <[email protected]>
AuthorDate: Wed May 6 16:37:59 2026 +1000

    GROOVY-11998: Better support of intersection types (part 1)
    Grammar + AST. intersectionType rule, IntersectionTypeClassNode, AstBuilder 
updates. Includes parser tests; no semantic impact (ResolveVisitor errors out 
on it).
---
 src/antlr/GroovyParser.g4                          |  13 +-
 .../apache/groovy/parser/antlr4/AstBuilder.java    |  47 +++++-
 .../groovy/ast/IntersectionTypeClassNode.java      |  65 ++++++++
 .../antlr4/IntersectionCastParserTest.groovy       | 173 +++++++++++++++++++++
 4 files changed, 291 insertions(+), 7 deletions(-)

diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4
index e6931ef2d5..3eb0dedeed 100644
--- a/src/antlr/GroovyParser.g4
+++ b/src/antlr/GroovyParser.g4
@@ -728,7 +728,16 @@ forUpdate
 // EXPRESSIONS
 
 castParExpression
-    :   LPAREN type RPAREN
+    :   LPAREN intersectionType RPAREN
+    ;
+
+intersectionType
+    :   type (BITAND nls type)*
+    ;
+
+coercionType
+    :   castParExpression                                                      
                 // (T) or (A & B & ...)
+    |   type                                                                   
                 // T
     ;
 
 parExpression
@@ -833,7 +842,7 @@ expression
 
     // boolean relational expressions (level 7)
     |   left=expression nls op=INSTANCEOF nls matchingType                     
             #relationalExprAlt
-    |   left=expression nls op=(AS | NOT_INSTANCEOF) nls type                  
             #relationalExprAlt
+    |   left=expression nls op=(AS | NOT_INSTANCEOF) nls coercionType          
             #relationalExprAlt
     |   left=expression nls op=(LE | GE | GT | LT | IN | NOT_IN) nls 
right=expression       #relationalExprAlt
 
     // equality/inequality (==/!=) (level 8)
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java 
b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index bc8f2cf2b9..aaeb90b9fd 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -58,6 +58,7 @@ import org.codehaus.groovy.ast.GenericsType;
 import org.codehaus.groovy.ast.ImportNode;
 import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.InnerClassNode;
+import org.codehaus.groovy.ast.IntersectionTypeClassNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.ModifierNode;
 import org.codehaus.groovy.ast.ModuleNode;
@@ -2475,7 +2476,32 @@ public class AstBuilder extends 
GroovyParserBaseVisitor<Object> {
 
     @Override
     public ClassNode visitCastParExpression(final CastParExpressionContext 
ctx) {
-        return this.visitType(ctx.type());
+        return this.visitIntersectionType(ctx.intersectionType());
+    }
+
+    @Override
+    public ClassNode visitIntersectionType(final IntersectionTypeContext ctx) {
+        List<? extends TypeContext> typeCtxs = ctx.type();
+        if (typeCtxs.size() == 1) {
+            return this.visitType(typeCtxs.get(0));
+        }
+        ClassNode[] components = new ClassNode[typeCtxs.size()];
+        Set<String> seenNames = new HashSet<>();
+        for (int i = 0, n = typeCtxs.size(); i < n; i += 1) {
+            ClassNode component = this.visitType(typeCtxs.get(i));
+            if (!seenNames.add(component.getName())) {
+                throw createParsingFailedException("Duplicate type in 
intersection: " + component.getName(), ctx);
+            }
+            components[i] = component;
+        }
+        return configureAST(new IntersectionTypeClassNode(components), ctx);
+    }
+
+    @Override
+    public ClassNode visitCoercionType(final CoercionTypeContext ctx) {
+        return ctx.castParExpression() != null
+                ? this.visitCastParExpression(ctx.castParExpression())
+                : this.visitType(ctx.type());
     }
 
     @Override
@@ -3204,7 +3230,7 @@ public class AstBuilder extends 
GroovyParserBaseVisitor<Object> {
             if (expr instanceof VariableExpression && ((VariableExpression) 
expr).isSuperExpression()) {
                 throw this.createParsingFailedException("Cannot cast or coerce 
`super`", ctx); // GROOVY-9391
             }
-            Expression cast = 
CastExpression.asExpression(this.visitType(ctx.type()), expr);
+            Expression cast = 
CastExpression.asExpression(this.visitCoercionType(ctx.coercionType()), expr);
             return configureAST(
                     cast,
                     ctx);
@@ -3218,14 +3244,25 @@ public class AstBuilder extends 
GroovyParserBaseVisitor<Object> {
                             this.visitMatchingType(ctx.matchingType())),
                     ctx);
 
-          case NOT_INSTANCEOF:
-            ctx.type().putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, 
Boolean.TRUE);
+          case NOT_INSTANCEOF: {
+            CoercionTypeContext coercionCtx = ctx.coercionType();
+            if (coercionCtx.castParExpression() != null
+                    && 
coercionCtx.castParExpression().intersectionType().type().size() > 1) {
+                throw this.createParsingFailedException("Intersection types 
are not supported as the right-hand side of !instanceof", ctx);
+            }
+            ClassNode notInstType = this.visitCoercionType(coercionCtx);
+            // GROOVY-11998: keep IS_INSIDE_INSTANCEOF_EXPR on the parser 
context for the resolver
+            (coercionCtx.type() != null
+                    ? coercionCtx.type()
+                    : 
coercionCtx.castParExpression().intersectionType().type(0)
+            ).putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE);
             return configureAST(
                     new BinaryExpression(
                             (Expression) this.visit(ctx.left),
                             this.createGroovyToken(ctx.op),
-                            configureAST(new 
ClassExpression(this.visitType(ctx.type())), ctx.type())),
+                            configureAST(new ClassExpression(notInstType), 
coercionCtx)),
                     ctx);
+          }
 
           case GT:
           case GE:
diff --git 
a/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.java 
b/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.java
new file mode 100644
index 0000000000..d2afb8b341
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/ast/IntersectionTypeClassNode.java
@@ -0,0 +1,65 @@
+/*
+ *  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.codehaus.groovy.ast;
+
+import 
org.codehaus.groovy.ast.tools.WideningCategories.LowestUpperBoundClassNode;
+
+/**
+ * Represents a user-written intersection type used as the target of a cast
+ * expression or {@code as} coercion, e.g.
+ * <pre>
+ *     (Runnable &amp; Serializable) () -&gt; ...
+ *     value as (A &amp; B)
+ * </pre>
+ *
+ * <p>Distinct from the implicit lowest-upper-bound nodes that
+ * {@link org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor}
+ * synthesises during inference: an instance of this class records the ordered
+ * list of components exactly as written by the user. That ordering is needed
+ * for cast-conversion checks, error messages and (in later phases) bytecode
+ * generation via {@code LambdaMetafactory.altMetafactory} markers.
+ *
+ * <p>This node is built by the parser's {@code AstBuilder} from the
+ * {@code intersectionType} rule. At parse time the components have not yet
+ * been resolved to bound {@link ClassNode}s, so the {@code upper} bound
+ * passed to the parent constructor is a placeholder and the
+ * {@code interfaces} array is just the components in user order; resolution
+ * and class-vs-interface classification are completed in later phases.
+ *
+ * @since 5.0.0
+ */
+public final class IntersectionTypeClassNode extends LowestUpperBoundClassNode 
{
+
+    private final ClassNode[] components;
+
+    public IntersectionTypeClassNode(final ClassNode[] components) {
+        super("IntersectionType", ClassHelper.OBJECT_TYPE, components.clone());
+        if (components.length < 2) {
+            throw new IllegalArgumentException("IntersectionTypeClassNode 
requires at least two components");
+        }
+        this.components = components.clone();
+    }
+
+    /**
+     * Returns the components of this intersection type in user-written order.
+     */
+    public ClassNode[] getComponents() {
+        return components.clone();
+    }
+}
diff --git 
a/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy
 
b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy
new file mode 100644
index 0000000000..5e6a54db0d
--- /dev/null
+++ 
b/src/test/groovy/org/apache/groovy/parser/antlr4/IntersectionCastParserTest.groovy
@@ -0,0 +1,173 @@
+/*
+ *  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.parser.antlr4
+
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.IntersectionTypeClassNode
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.CastExpression
+import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.DeclarationExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.BinaryExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.ParserPlugin
+import org.codehaus.groovy.control.ParserPluginFactory
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertThrows
+
+/**
+ * Tests for the parser-level handling of intersection types in cast 
expressions
+ * and {@code as} coercion (GROOVY-11998 PR1: grammar + AST).
+ */
+final class IntersectionCastParserTest {
+
+    @Test
+    void 'cast with intersection type builds IntersectionTypeClassNode'() {
+        ClassNode type = singleCastTargetType('def r = (Runnable & 
java.io.Serializable) { -> }')
+        assert type instanceof IntersectionTypeClassNode
+        IntersectionTypeClassNode it = (IntersectionTypeClassNode) type
+        ClassNode[] components = it.components
+        assertEquals(2, components.length)
+        assertEquals('Runnable', components[0].name)
+        assertEquals('java.io.Serializable', components[1].name)
+    }
+
+    @Test
+    void 'cast with three-component intersection preserves order'() {
+        ClassNode type = singleCastTargetType('def x = (A & B & C) value')
+        assert type instanceof IntersectionTypeClassNode
+        ClassNode[] components = ((IntersectionTypeClassNode) type).components
+        assertEquals(['A', 'B', 'C'], components*.name)
+    }
+
+    @Test
+    void 'as coercion with parenthesised intersection builds 
IntersectionTypeClassNode'() {
+        ClassNode type = singleCastTargetType('def r = { -> } as (Runnable & 
java.io.Serializable)')
+        assert type instanceof IntersectionTypeClassNode
+        ClassNode[] components = ((IntersectionTypeClassNode) type).components
+        assertEquals(['Runnable', 'java.io.Serializable'], components*.name)
+    }
+
+    @Test
+    void 'single-type cast still parses and is not an intersection node'() {
+        ClassNode type = singleCastTargetType('def x = (String) "hello"')
+        assert !(type instanceof IntersectionTypeClassNode)
+        assertEquals('String', type.name)
+    }
+
+    @Test
+    void 'single-type as coercion still parses unchanged'() {
+        ClassNode type = singleCastTargetType('def x = "hello" as Integer')
+        assert !(type instanceof IntersectionTypeClassNode)
+        assertEquals('Integer', type.name)
+    }
+
+    @Test
+    void 'as coercion with parenthesised single type is accepted as plain 
type'() {
+        // The new grammar allows `as (T)` which previously was a syntax error;
+        // it should be equivalent to `as T` and not produce an 
IntersectionTypeClassNode.
+        ClassNode type = singleCastTargetType('def x = "hello" as (Integer)')
+        assert !(type instanceof IntersectionTypeClassNode)
+        assertEquals('Integer', type.name)
+    }
+
+    @Test
+    void 'duplicate types in intersection cast are rejected'() {
+        assertParseFails('def r = (Runnable & Runnable) { -> }', 'Duplicate 
type in intersection')
+    }
+
+    @Test
+    void 'duplicate types in intersection as coercion are rejected'() {
+        assertParseFails('def r = { -> } as (Runnable & Runnable)', 'Duplicate 
type in intersection')
+    }
+
+    @Test
+    void 'intersection right-hand side rejected for !instanceof'() {
+        assertParseFails('def b = x !instanceof (Runnable & 
java.io.Serializable)',
+                'not supported as the right-hand side of !instanceof')
+    }
+
+    @Test
+    void 'parenthesised single type accepted for !instanceof'() {
+        ModuleNode ast = buildAST('def b = x !instanceof (Runnable)')
+        assertNotNull(ast)
+        assertTrue(!ast.context.errorCollector.hasErrors())
+    }
+
+    
//--------------------------------------------------------------------------
+
+    private static ClassNode singleCastTargetType(String src) {
+        ModuleNode ast = buildAST(src)
+        assertNotNull(ast, "AST should build for: $src")
+        assertTrue(!ast.context.errorCollector.hasErrors(),
+                "Parse should not have errors for: $src; got: 
${ast.context.errorCollector.errors}")
+        BlockStatement block = ast.statementBlock
+        ExpressionStatement stmt = (ExpressionStatement) block.statements[0]
+        Expression expr = stmt.expression
+        CastExpression cast
+        if (expr instanceof DeclarationExpression) {
+            cast = (CastExpression) ((DeclarationExpression) 
expr).rightExpression
+        } else if (expr instanceof BinaryExpression) {
+            cast = (CastExpression) ((BinaryExpression) expr).rightExpression
+        } else {
+            cast = (CastExpression) expr
+        }
+        return cast.type
+    }
+
+    private static ModuleNode buildAST(String src) {
+        try {
+            CompilerConfiguration config = new 
CompilerConfiguration(CompilerConfiguration.DEFAULT)
+            config.pluginFactory = ParserPluginFactory.antlr4()
+            return ParserPlugin.buildAST(src, config, new GroovyClassLoader(), 
null)
+        } catch (Throwable t) {
+            return null
+        }
+    }
+
+    private static void assertParseFails(String src, String 
expectedMessageFragment) {
+        Throwable thrown = null
+        try {
+            CompilerConfiguration config = new 
CompilerConfiguration(CompilerConfiguration.DEFAULT)
+            config.pluginFactory = ParserPluginFactory.antlr4()
+            ModuleNode ast = ParserPlugin.buildAST(src, config, new 
GroovyClassLoader(), null)
+            if (ast == null || ast.context.errorCollector.hasErrors()) {
+                String allMessages = ast == null ? '' : 
ast.context.errorCollector.errors*.toString().join('\n')
+                assertTrue(ast == null || 
allMessages.contains(expectedMessageFragment),
+                        "Expected error containing '$expectedMessageFragment' 
for: $src; got: $allMessages")
+                return
+            }
+        } catch (Throwable t) {
+            thrown = t
+        }
+        if (thrown != null) {
+            assertTrue(thrown.message != null && 
thrown.message.contains(expectedMessageFragment),
+                    "Expected error containing '$expectedMessageFragment' for: 
$src; got: ${thrown.message}")
+        } else {
+            assertTrue(false, "Expected parse to fail for: $src")
+        }
+    }
+}

Reply via email to