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 & Serializable) () -> ... + * value as (A & 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") + } + } +}
