From dec2c800b0f7b6f1025e7f8fde5cc82785a1e9aa Mon Sep 17 00:00:00 2001 From: Paul King Date: Thu, 2 Jul 2026 23:40:23 +1000 Subject: [PATCH 1/9] GEP-19: structural pattern matching in switch (phase 1: type patterns with binding and when guards) Adds arrow-form case labels of the form `case Type ident ->` and `case Type ident when guard ->` to switch statements and expressions. Patterns are lowered in the parser: the switch subject becomes a closure parameter, an unguarded pattern becomes a class literal label, a guarded pattern becomes a closure label testing the type then the guard with the pattern variable in scope, and each pattern case body is prefixed with a declaration of the narrowed pattern variable. Static type checking and static compilation work unchanged; legacy isCase labels mix freely with patterns. Pattern labels require the arrow form and primitive type patterns are rejected while JEP 507 remains in preview. Assisted-by: Claude Code (Claude Fable 5) --- src/antlr/GroovyParser.g4 | 15 +- .../groovy/parser/antlr4/AstBuilder.java | 120 +++++++++++- .../core/SwitchExpression_27x.groovy | 98 ++++++++++ .../fail/SwitchExpression_11x.groovy | 24 +++ .../fail/SwitchExpression_12x.groovy | 24 +++ .../groovy/SwitchPatternMatchingTest.groovy | 185 ++++++++++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 1 + .../parser/antlr4/SyntaxErrorTest.groovy | 2 + 8 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 src/test-resources/core/SwitchExpression_27x.groovy create mode 100644 src/test-resources/fail/SwitchExpression_11x.groovy create mode 100644 src/test-resources/fail/SwitchExpression_12x.groovy create mode 100644 src/test/groovy/groovy/SwitchPatternMatchingTest.groovy diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 3eb0dedeede..39dc63ba58b 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -789,11 +789,24 @@ switchBlockStatementExpressionGroup ; switchExpressionLabel - : ( CASE expressionList[true] + : ( CASE (casePattern | expressionList[true]) | DEFAULT ) ac=(ARROW | COLON) ; +// GEP-19 structural pattern matching: pattern forms usable in case labels +casePattern + : typePattern (nls caseGuard)? + ; + +typePattern + : standardType identifier + ; + +caseGuard + : { "when".equals(_input.LT(1).getText()) }? identifier nls expression + ; + expression // must come before postfixExpression to resolve the ambiguities between casting and call on parentheses expression, e.g. (int)(1 / 2) : castParExpression castOperandExpression #castExprAlt 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 aaeb90b9fd7..998700f0838 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -162,13 +162,22 @@ import static org.apache.groovy.parser.antlr4.GroovyParser.*; import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureAST; import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureEndPosition; +import static org.codehaus.groovy.ast.tools.GeneralUtils.args; import static org.codehaus.groovy.ast.tools.GeneralUtils.assignX; import static org.codehaus.groovy.ast.tools.GeneralUtils.block; import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.castX; 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.declS; import static org.codehaus.groovy.ast.tools.GeneralUtils.declX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.isInstanceOfX; import static org.codehaus.groovy.ast.tools.GeneralUtils.listX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.notX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.params; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; @@ -1012,10 +1021,34 @@ public Expression visitSwitchExprAlt(final SwitchExprAltContext ctx) { * } * }.call() * + * If any case label is a pattern (GEP-19), the switch subject is passed as a + * closure parameter so pattern bindings can refer to it, e.g. + *
+     * switch(x) {
+     *   case Integer i when i > 0 -> 'p'
+     *   case String s             -> s
+     *   default                   -> 'z'
+     * }
+     * 
+ * will be transformed to: + *
+     * { __$$pv0 ->
+     *     switch(__$$pv0) {
+     *       case { __$$pc1 -> if (!(__$$pc1 instanceof Integer)) return false
+     *                         Integer i = (Integer) __$$pc1
+     *                         return i > 0 }:
+     *                     { Integer i = (Integer) __$$pv0; return 'p' }
+     *       case String:  { String s = (String) __$$pv0; return s }
+     *       default:      return 'z'
+     *     }
+     * }.call(x)
+     * 
*/ @Override public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext ctx) { switchExpressionRuleContextStack.push(ctx); + boolean hasPattern = containsCasePattern(ctx); + switchPatternSubjectStack.push(hasPattern ? "__$$pv" + switchPatternVariableSeq++ : ""); try { validateSwitchExpressionLabels(ctx); List, Boolean, Boolean>> statementsAndArrowAndYieldOrThrow = @@ -1048,23 +1081,38 @@ public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext } } + Expression subject = this.visitExpressionInPar(ctx.expressionInPar()); + String subjectName = switchPatternSubjectStack.peek(); Statement statement = configureAST( new SwitchStatement( - this.visitExpressionInPar(ctx.expressionInPar()), + hasPattern ? varX(subjectName) : subject, caseStatements, defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE ), ctx); statement = createBlockStatement(List.of(statement)); - MethodCallExpression immediateExecution = callX(closureX(null, statement), CALL_STR); + MethodCallExpression immediateExecution; + if (hasPattern) { + Parameter subjectParameter = new Parameter(ClassHelper.dynamicType(), subjectName); + immediateExecution = callX(closureX(params(subjectParameter), statement), CALL_STR, args(subject)); + } else { + immediateExecution = callX(closureX(null, statement), CALL_STR); + } immediateExecution.setImplicitThis(false); return immediateExecution; } finally { + switchPatternSubjectStack.pop(); switchExpressionRuleContextStack.pop(); } } + private static boolean containsCasePattern(final SwitchExpressionContext ctx) { + return ctx.switchBlockStatementExpressionGroup().stream() + .flatMap(e -> e.switchExpressionLabel().stream()) + .anyMatch(e -> asBoolean(e.casePattern())); + } + @Override public Tuple3, Boolean, Boolean> visitSwitchBlockStatementExpressionGroup(SwitchBlockStatementExpressionGroupContext ctx) { int labelCnt = ctx.switchExpressionLabel().size(); @@ -1163,15 +1211,20 @@ public void visitThrowStatement(ThrowStatement statement) { } for (int i = 0, n = tuple.getV2().size(); i < n; i += 1) { Expression expr = tuple.getV2().get(i); + + // check whether processing the last label. if yes, block statement should be attached. + Statement code = (isLast && i == n - 1) ? codeBlock + : EmptyStatement.INSTANCE; + + Statement bindingDecl = expr.getNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); + if (bindingDecl != null && !(code instanceof EmptyStatement)) { + expr.removeNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); + code = createBlockStatement(List.of(bindingDecl, code)); + } + statementList.add( configureAST( - new CaseStatement( - expr, - - // check whether processing the last label. if yes, block statement should be attached. - (isLast && i == n - 1) ? codeBlock - : EmptyStatement.INSTANCE - ), + new CaseStatement(expr, code), firstLabelHolder.get(0))); } break; @@ -1202,6 +1255,12 @@ private void validateSwitchExpressionLabels(SwitchExpressionContext ctx) { public Tuple3, Integer> visitSwitchExpressionLabel(SwitchExpressionLabelContext ctx) { final Integer acType = ctx.ac.getType(); if (asBoolean(ctx.CASE())) { + if (asBoolean(ctx.casePattern())) { + if (ARROW != acType) { + throw createParsingFailedException("`case` with a pattern label supports only the arrow form (`->`)", ctx.casePattern()); + } + return tuple(ctx.CASE().getSymbol(), Collections.singletonList(this.visitCasePattern(ctx.casePattern())), acType); + } return tuple(ctx.CASE().getSymbol(), this.visitExpressionList(ctx.expressionList()), acType); } else if (asBoolean(ctx.DEFAULT())) { return tuple(ctx.DEFAULT().getSymbol(), Collections.singletonList(EmptyExpression.INSTANCE), acType); @@ -1210,6 +1269,44 @@ public Tuple3, Integer> visitSwitchExpressionLabel(Switc throw createParsingFailedException("Unsupported switch expression label: " + ctx.getText(), ctx); } + /** + * Builds the case label expression for a pattern label (GEP-19). A type pattern + * without a guard becomes a plain class literal label; with a {@code when} guard + * it becomes a closure label checking the type then evaluating the guard with the + * pattern variable in scope. In both forms, the statement declaring the pattern + * variable within the case body is attached as node metadata for + * {@link #visitSwitchBlockStatementExpressionGroup}. + */ + @Override + public Expression visitCasePattern(final CasePatternContext ctx) { + TypePatternContext typePatternCtx = ctx.typePattern(); + ClassNode type = this.visitType(typePatternCtx.type()); + if (ClassHelper.isPrimitiveType(type)) { + throw createParsingFailedException("primitive type patterns are not yet supported", typePatternCtx); + } + String name = this.visitIdentifier(typePatternCtx.identifier()); + String subjectName = switchPatternSubjectStack.peek(); + + Expression labelExpr; + if (asBoolean(ctx.caseGuard())) { + Expression guard = (Expression) this.visit(ctx.caseGuard().expression()); + String candidateName = "__$$pc" + switchPatternVariableSeq++; + Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); + Statement guardCode = block( + ifS(notX(isInstanceOfX(varX(candidateName), type)), returnS(constX(Boolean.FALSE, true))), + declS(localVarX(name, type), castX(type, varX(candidateName))), + returnS(guard) + ); + labelExpr = configureAST(closureX(params(candidate), guardCode), ctx); + } else { + labelExpr = configureAST(new ClassExpression(type), typePatternCtx); + } + + Statement bindingDecl = declS(configureAST(localVarX(name, type), typePatternCtx.identifier()), castX(type, varX(subjectName))); + labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecl); + return labelExpr; + } + // } statement ------------------------------------------------------------- @Override @@ -4982,6 +5079,10 @@ public List getDeclarationExpressions() { private final Deque> anonymousInnerClassesDefinedInMethodStack = new ArrayDeque<>(); private final Deque switchExpressionRuleContextStack = new ArrayDeque<>(); + /** Name of the synthetic variable holding the switch subject of the enclosing switch expression ("" if it has no pattern labels). */ + private final Deque switchPatternSubjectStack = new ArrayDeque<>(); + private int switchPatternVariableSeq; + private Tuple2 numberFormatError; private int visitingClosureCount; @@ -5025,6 +5126,7 @@ public List getDeclarationExpressions() { private static final String INSIDE_PARENTHESES_LEVEL = "_INSIDE_PARENTHESES_LEVEL"; private static final String IS_INSIDE_INSTANCEOF_EXPR = "_IS_INSIDE_INSTANCEOF_EXPR"; private static final String IS_SWITCH_DEFAULT = "_IS_SWITCH_DEFAULT"; + private static final String SWITCH_TYPE_PATTERN_BINDING = "_SWITCH_TYPE_PATTERN_BINDING"; private static final String IS_NUMERIC = "_IS_NUMERIC"; private static final String IS_STRING = "_IS_STRING"; private static final String IS_INTERFACE_WITH_DEFAULT_METHODS = "_IS_INTERFACE_WITH_DEFAULT_METHODS"; diff --git a/src/test-resources/core/SwitchExpression_27x.groovy b/src/test-resources/core/SwitchExpression_27x.groovy new file mode 100644 index 00000000000..1bbb3b8b48e --- /dev/null +++ b/src/test-resources/core/SwitchExpression_27x.groovy @@ -0,0 +1,98 @@ +/* + * 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. + */ + +// GEP-19: type patterns with binding and `when` guards in switch expressions + +def describe(obj) { + switch (obj) { + case Integer i when i > 0 -> "positive $i" + case Integer i -> "non-positive $i" + case String s -> s.toUpperCase() + case List list -> "list of ${list.size()}" + default -> 'other' + } +} + +assert describe(42) == 'positive 42' +assert describe(-7) == 'non-positive -7' +assert describe('abc') == 'ABC' +assert describe([1, 2, 3]) == 'list of 3' +assert describe(3.5) == 'other' +assert describe(null) == 'other' + +// pattern labels mixed with legacy labels +def kind(x) { + switch (x) { + case 0, 1 -> 'small constant' + case Number n when n < 0 -> 'negative' + case Number n -> "number $n" + case null -> 'nil' + default -> 'other' + } +} + +assert kind(0) == 'small constant' +assert kind(-3) == 'negative' +assert kind(10) == 'number 10' +assert kind(null) == 'nil' +assert kind('x') == 'other' + +// guards may reference outer locals as well as the pattern variable +def threshold = 5 +def sizeCheck = switch ([1, 2, 3, 4, 5, 6]) { + case List l when l.size() > threshold -> 'big' + case List l -> 'small' + default -> 'other' +} +assert sizeCheck == 'big' + +// arrow switch with patterns used as a statement +def out = [] +switch ('hi') { + case String s -> out << s.reverse() + default -> out << 'none' +} +assert out == ['ih'] + +// nested switch expressions with patterns +def nested = switch (1) { + case Integer i -> switch ('a') { + case String s -> s + i + default -> 'inner other' + } + default -> 'outer other' +} +assert nested == 'a1' + +// array type pattern +def arr = switch (new String[]{'a', 'b'}) { + case String[] sa -> sa.length + default -> -1 +} +assert arr == 2 + +// switch subject is evaluated exactly once +int count = 0 +def next = { count++; 'x' } +def got = switch (next()) { + case String s when s == 'x' -> 'match' + default -> 'no' +} +assert got == 'match' +assert count == 1 diff --git a/src/test-resources/fail/SwitchExpression_11x.groovy b/src/test-resources/fail/SwitchExpression_11x.groovy new file mode 100644 index 00000000000..8a8bfe09f57 --- /dev/null +++ b/src/test-resources/fail/SwitchExpression_11x.groovy @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// GEP-19: pattern case labels require the arrow form +def result = switch (42) { + case Integer i: yield i + 1 + default: yield 0 +} diff --git a/src/test-resources/fail/SwitchExpression_12x.groovy b/src/test-resources/fail/SwitchExpression_12x.groovy new file mode 100644 index 00000000000..efc10590f12 --- /dev/null +++ b/src/test-resources/fail/SwitchExpression_12x.groovy @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// GEP-19: primitive type patterns are not yet supported (JEP 507 still in preview) +def result = switch (42) { + case int i -> i + 1 + default -> 0 +} diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy new file mode 100644 index 00000000000..a66a17645d1 --- /dev/null +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -0,0 +1,185 @@ +/* + * 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 + +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript +import static groovy.test.GroovyAssert.shouldFail + +/** + * Tests for GEP-19 structural pattern matching in switch: type patterns + * with binding and {@code when} guards (arrow-form labels). + */ +final class SwitchPatternMatchingTest { + + @Test + void testTypePatternDispatch() { + def describe = { obj -> + switch (obj) { + case String s -> s.toUpperCase() + case Integer i -> i * 2 + case Number n -> n.doubleValue() + default -> 'other' + } + } + assert describe('abc') == 'ABC' + assert describe(21) == 42 + assert describe(1.5G) == 1.5d + assert describe(null) == 'other' + } + + @Test + void testWhenGuardFallsThroughToNextCase() { + def sign = { obj -> + switch (obj) { + case Integer i when i > 0 -> 'positive' + case Integer i when i < 0 -> 'negative' + case Integer i -> 'zero' + default -> 'not an int' + } + } + assert sign(7) == 'positive' + assert sign(-7) == 'negative' + assert sign(0) == 'zero' + assert sign('x') == 'not an int' + } + + @Test + void testGuardSeesPatternVariableAndOuterLocals() { + int min = 3 + def result = switch ('abcd') { + case String s when s.length() > min -> "long ${s.length()}" + case String s -> 'short' + default -> 'other' + } + assert result == 'long 4' + } + + @Test + void testPatternsCoexistWithLegacyLabels() { + def classify = { obj -> + switch (obj) { + case null -> 'nil' + case 'a'..'c' -> 'a to c' + case ~/[a-z]+/ -> 'letters' + case Integer i when i > 9 -> 'big int' + case Integer i -> "int $i" + case { it == [] } -> 'empty list closure' + default -> 'other' + } + } + assert classify('abc') == 'letters' + assert classify('b') == 'a to c' + assert classify(10) == 'big int' + assert classify(5) == 'int 5' + assert classify([]) == 'empty list closure' + assert classify(null) == 'nil' + assert classify(3.5) == 'other' + } + + @Test + void testArrowSwitchStatementWithPatterns() { + def out = [] + switch (42 as Object) { + case String s -> out << "string $s" + case Integer i -> out << "int ${i + 1}" + default -> out << 'other' + } + assert out == ['int 43'] + } + + @Test + void testPatternVariableNotVisibleAfterSwitch() { + shouldFail MissingPropertyException, ''' + switch (42 as Object) { + case Integer i -> i + default -> 0 + } + i + ''' + } + + @Test + void testSubjectEvaluatedOnce() { + assertScript ''' + int count = 0 + def next = { count++; 42 } + def result = switch (next()) { + case Integer i when i == 42 -> 'match' + default -> 'no' + } + assert result == 'match' + assert count == 1 + ''' + } + + @Test + void testTypePatternCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + + @CompileStatic + def describe(Object obj) { + switch (obj) { + case Integer i when i > 0 -> 'positive ' + (i * 2) + case String s -> s.toUpperCase() // proves narrowing: only String has toUpperCase() + case Number n -> 'number ' + n.doubleValue() + default -> 'other' + } + } + assert describe(21) == 'positive 42' + assert describe('abc') == 'ABC' + assert describe(-1.5G) == 'number -1.5' + assert describe(new Object()) == 'other' + assert describe(null) == 'other' + ''' + } + + @Test + void testGenericTypePattern() { + def result = switch (['a', 'bb'] as Object) { + case List strings -> strings*.size().sum() + default -> -1 + } + assert result == 3 + } + + @Test + void testPatternRequiresArrowForm() { + def err = shouldFail ''' + def result = switch (42) { + case Integer i: yield i + default: yield 0 + } + ''' + assert err.message.contains('arrow form') + } + + @Test + void testStatementFormSwitchDoesNotSupportPatterns() { + shouldFail ''' + switch (42) { + case Integer i: + println i + break + } + ''' + } +} diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index 3ca7c7b28cc..c19ed17993c 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -531,6 +531,7 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_24x.groovy') doRunAndTestAntlr4('core/SwitchExpression_25x.groovy') doRunAndTestAntlr4('core/SwitchExpression_26x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_27x.groovy') } @Test diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index bbe0175f72c..1bc10c4a0fe 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -565,6 +565,8 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/SwitchExpression_08x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_09x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_10x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_11x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_12x.groovy') } @NotYetImplemented @Test From 03e639798b8b7db2fed41c36ffba17e9b9077f64 Mon Sep 17 00:00:00 2001 From: Paul King Date: Thu, 2 Jul 2026 23:40:46 +1000 Subject: [PATCH 2/9] GEP-19: structural pattern matching in switch (phase 2: STC exhaustiveness, dominance and compatibility checks) The parser records the original subject expression and per-label pattern type/guard metadata on the lowered switch. The static type checker uses the recorded subject for the switch condition type, restoring unqualified enum-constant labels and closure-label parameter hints in switches that also contain patterns, and checks pattern labels: an error for a pattern type provably disjoint from the subject type, a warning for an arm dominated by a preceding type test, and a warning for a non-exhaustive pattern switch (no default, no unconditional pattern, sealed hierarchy not fully covered). Exhaustiveness is only assessed when every label is statically analysable. Dynamic Groovy is unaffected. Assisted-by: Claude Code (Claude Fable 5) --- .../groovy/parser/antlr4/AstBuilder.java | 25 ++- .../stc/StaticTypeCheckingVisitor.java | 81 ++++++++- .../groovy/SwitchPatternMatchingTest.groovy | 167 ++++++++++++++++++ 3 files changed, 265 insertions(+), 8 deletions(-) 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 998700f0838..2898ab4423c 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -1083,13 +1083,16 @@ public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext Expression subject = this.visitExpressionInPar(ctx.expressionInPar()); String subjectName = switchPatternSubjectStack.peek(); - Statement statement = configureAST( - new SwitchStatement( - hasPattern ? varX(subjectName) : subject, - caseStatements, - defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE - ), - ctx); + SwitchStatement switchStatement = new SwitchStatement( + hasPattern ? varX(subjectName) : subject, + caseStatements, + defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE + ); + if (hasPattern) { + // read by StaticTypeCheckingVisitor to determine the subject type of a pattern switch + switchStatement.putNodeMetaData(SWITCH_PATTERN_SUBJECT, subject); + } + Statement statement = configureAST(switchStatement, ctx); statement = createBlockStatement(List.of(statement)); MethodCallExpression immediateExecution; @@ -1298,10 +1301,14 @@ public Expression visitCasePattern(final CasePatternContext ctx) { returnS(guard) ); labelExpr = configureAST(closureX(params(candidate), guardCode), ctx); + labelExpr.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); } else { labelExpr = configureAST(new ClassExpression(type), typePatternCtx); } + // read by StaticTypeCheckingVisitor to check pattern switch case labels + labelExpr.putNodeMetaData(SWITCH_PATTERN_TYPE, type); + Statement bindingDecl = declS(configureAST(localVarX(name, type), typePatternCtx.identifier()), castX(type, varX(subjectName))); labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecl); return labelExpr; @@ -5127,6 +5134,10 @@ public List getDeclarationExpressions() { private static final String IS_INSIDE_INSTANCEOF_EXPR = "_IS_INSIDE_INSTANCEOF_EXPR"; private static final String IS_SWITCH_DEFAULT = "_IS_SWITCH_DEFAULT"; private static final String SWITCH_TYPE_PATTERN_BINDING = "_SWITCH_TYPE_PATTERN_BINDING"; + // the next three keys are also read (as literals) by StaticTypeCheckingVisitor + private static final String SWITCH_PATTERN_SUBJECT = "_SWITCH_PATTERN_SUBJECT"; + private static final String SWITCH_PATTERN_TYPE = "_SWITCH_PATTERN_TYPE"; + private static final String SWITCH_PATTERN_GUARDED = "_SWITCH_PATTERN_GUARDED"; private static final String IS_NUMERIC = "_IS_NUMERIC"; private static final String IS_STRING = "_IS_STRING"; private static final String IS_INTERFACE_WITH_DEFAULT_METHODS = "_IS_INTERFACE_WITH_DEFAULT_METHODS"; diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index 0e7b4d99ce5..ebacf196fe9 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -4817,6 +4817,7 @@ public void visitIfElse(final IfStatement ifElse) { /** {@inheritDoc} */ @Override public void visitSwitch(final SwitchStatement statement) { + checkPatternSwitchLabels(statement); // GEP-19 typeCheckingContext.pushEnclosingSwitchStatement(statement); try { Map> oldTracker = pushAssignmentTracking(); @@ -4836,7 +4837,85 @@ public void visitSwitch(final SwitchStatement statement) { protected void afterSwitchConditionExpressionVisited(final SwitchStatement statement) { typeCheckingContext.pushTemporaryTypeInfo(); Expression conditionExpression = statement.getExpression(); - conditionExpression.putNodeMetaData(TYPE, getType(conditionExpression)); + // GEP-19: a pattern switch is lowered so that its condition is a synthetic + // closure parameter; the parser records the original subject (see AstBuilder) + Expression subjectExpression = statement.getNodeMetaData("_SWITCH_PATTERN_SUBJECT"); + conditionExpression.putNodeMetaData(TYPE, getType(subjectExpression != null ? subjectExpression : conditionExpression)); + } + + /** + * Checks the pattern labels of a pattern switch (GEP-19): reports an error for a + * pattern that cannot match the switch subject's static type, a warning for a + * pattern dominated by a preceding case label, and a warning when the switch is + * not exhaustive. Exhaustiveness is only checked when every case label can be + * statically analysed (patterns, class literals and {@code null}). + */ + private void checkPatternSwitchLabels(final SwitchStatement statement) { + Expression subjectExpression = statement.getNodeMetaData("_SWITCH_PATTERN_SUBJECT"); + if (subjectExpression == null) return; + ClassNode subjectType = wrapTypeIfNecessary(getType(subjectExpression)); + + List priorTypeTests = new ArrayList<>(); + boolean opaqueLabels = false, unconditional = false; + for (CaseStatement caseStatement : statement.getCaseStatements()) { + Expression label = caseStatement.getExpression(); + ClassNode patternType = label.getNodeMetaData("_SWITCH_PATTERN_TYPE"); + ClassNode typeTest = patternType; + if (typeTest == null && label instanceof ClassExpression) { + typeTest = label.getType(); // legacy class literal label has type-test semantics + } + if (typeTest == null) { + if (!isNullConstant(label)) opaqueLabels = true; + continue; + } + ClassNode wrappedTest = wrapTypeIfNecessary(typeTest); + if (patternType != null && provablyDisjoint(subjectType, wrappedTest)) { + addStaticTypeError("The case pattern type " + prettyPrintTypeName(typeTest) + " is incompatible with the switch subject type " + prettyPrintTypeName(subjectType), label); + continue; + } + for (ClassNode prior : priorTypeTests) { + if (implementsInterfaceOrIsSubclassOf(wrappedTest, prior)) { + addPatternSwitchWarning("The case pattern is dominated by a preceding case label and will never match", label); + break; + } + } + if (!Boolean.TRUE.equals(label.getNodeMetaData("_SWITCH_PATTERN_GUARDED"))) { + priorTypeTests.add(wrappedTest); + if (implementsInterfaceOrIsSubclassOf(subjectType, wrappedTest)) unconditional = true; + } + } + if (statement.getDefaultStatement().isEmpty() && !opaqueLabels && !unconditional + && !coversAllPermittedSubclasses(subjectType, priorTypeTests)) { + addPatternSwitchWarning("The pattern switch is not exhaustive and may match nothing; consider adding a default branch", statement); + } + } + + private void addPatternSwitchWarning(final String text, final ASTNode node) { + Token token = new Token(0, "", node.getLineNumber(), node.getColumnNumber()); // ASTNode to CSTNode + typeCheckingContext.getErrorCollector().addWarning(new WarningMessage(WarningMessage.LIKELY_ERRORS, text, token, getSourceUnit())); + } + + private static boolean provablyDisjoint(final ClassNode subjectType, final ClassNode patternType) { + if (isObjectType(subjectType) + || subjectType.isArray() || patternType.isArray() + || implementsInterfaceOrIsSubclassOf(patternType, subjectType) + || implementsInterfaceOrIsSubclassOf(subjectType, patternType)) { + return false; + } + if (subjectType.isInterface()) return Modifier.isFinal(patternType.getModifiers()); + if (patternType.isInterface()) return Modifier.isFinal(subjectType.getModifiers()); + return true; // unrelated classes + } + + private static boolean coversAllPermittedSubclasses(final ClassNode type, final List typeTests) { + if (!type.isSealed()) return false; + List permittedSubclasses = type.getPermittedSubclasses(); + if (permittedSubclasses.isEmpty()) return false; + for (ClassNode permitted : permittedSubclasses) { + boolean covered = typeTests.stream().anyMatch(t -> implementsInterfaceOrIsSubclassOf(permitted, t)); + if (!covered && !coversAllPermittedSubclasses(permitted, typeTests)) return false; + } + return true; } /** {@inheritDoc} */ diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index a66a17645d1..e0e7a8f2556 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -18,6 +18,8 @@ */ package groovy +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.Phases import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.assertScript @@ -182,4 +184,169 @@ final class SwitchPatternMatchingTest { } ''' } + + // GEP-19 phase 2: static type checking of pattern switches + + @Test + void testImpossiblePatternRejectedWhenTypeChecked() { + def err = shouldFail ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Integer i) { + switch (i) { + case String s -> s + default -> 'other' + } + } + ''' + assert err.message.contains('incompatible with the switch subject type') + } + + @Test + void testImpossiblePatternIgnoredInDynamicCode() { + // dynamic Groovy stays permissive: the arm simply never matches + assertScript ''' + def m(Integer i) { + switch (i) { + case String s -> s + default -> 'other' + } + } + assert m(42) == 'other' + ''' + } + + @Test + void testDominatedPatternWarningWhenTypeChecked() { + def warnings = patternSwitchWarnings ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Object o) { + switch (o) { + case Number n -> 'number' + case Integer i -> 'integer' + default -> 'other' + } + } + ''' + assert warnings.any { it.contains('dominated by a preceding case label') } + } + + @Test + void testNonExhaustivePatternSwitchWarningWhenTypeChecked() { + def warnings = patternSwitchWarnings ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Object o) { + switch (o) { + case String s -> s + case Integer i -> i + } + } + ''' + assert warnings.any { it.contains('not exhaustive') } + } + + @Test + void testDefaultBranchMakesPatternSwitchExhaustive() { + assert patternSwitchWarnings(''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Object o) { + switch (o) { + case String s -> s + default -> 'other' + } + } + ''').isEmpty() + } + + @Test + void testUnconditionalPatternMakesPatternSwitchExhaustive() { + assert patternSwitchWarnings(''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Integer i) { + switch (i) { + case Number n -> n.intValue() + } + } + ''').isEmpty() + } + + @Test + void testSealedHierarchyCoverageMakesPatternSwitchExhaustive() { + assert patternSwitchWarnings(''' + import groovy.transform.TypeChecked + sealed interface Shape permits Circle, Square {} + final class Circle implements Shape { double radius = 1 } + final class Square implements Shape { double side = 1 } + @TypeChecked + def area(Shape shape) { + switch (shape) { + case Circle c -> 3.14 * c.radius * c.radius + case Square s -> s.side * s.side + } + } + ''').isEmpty() + } + + @Test + void testIncompleteSealedHierarchyCoverageWarns() { + def warnings = patternSwitchWarnings ''' + import groovy.transform.TypeChecked + sealed interface Shape permits Circle, Square {} + final class Circle implements Shape { double radius = 1 } + final class Square implements Shape { double side = 1 } + @TypeChecked + def area(Shape shape) { + switch (shape) { + case Circle c -> 3.14 * c.radius * c.radius + } + } + ''' + assert warnings.any { it.contains('not exhaustive') } + } + + @Test + void testGuardedPatternDoesNotCountTowardsExhaustiveness() { + def warnings = patternSwitchWarnings ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Number n) { + switch (n) { + case Number x when x.intValue() > 0 -> 'positive' + } + } + ''' + assert warnings.any { it.contains('not exhaustive') } + } + + @Test + void testEnumShorthandMixedWithPatternsWhenCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + enum Color { RED, GREEN, BLUE } + @CompileStatic + def describe(Color c) { + switch (c) { + case RED -> 'red' + case Color k when k.name().startsWith('G') -> 'greenish' + default -> 'other' + } + } + assert describe(Color.RED) == 'red' + assert describe(Color.GREEN) == 'greenish' + assert describe(Color.BLUE) == 'other' + ''' + } + + private static List patternSwitchWarnings(String source) { + def cu = new CompilationUnit() + cu.addSource('PatternSwitchTestScript.groovy', source) + cu.compile(Phases.CLASS_GENERATION) + (cu.errorCollector.warnings ?: [])*.message.findAll { + it.contains('pattern switch') || it.contains('case pattern') + } + } } From d9edbe66bde336e3cea74b3a71bc45dd52bd8174 Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 00:01:50 +1000 Subject: [PATCH 3/9] GEP-19: structural pattern matching in switch (phase 3: record patterns) Adds record patterns to arrow-form case labels, e.g. `case Point(int x, int y) when x == y ->`, with nested record patterns, `_` wildcards, and var/def component bindings. Record patterns are positional and component names are unknown at parse time, so the lowering emits calls to the new RecordPatternSupport runtime helper, which deconstructs native records through their record components and falls back to toList() for other deconstructable values such as emulated Groovy records. A closure case label performs the type, arity and component checks; component bindings are redeclared in the case body. Components are mandatory and must be pattern-shaped so legacy method call labels such as `case foo()` and `case foo(bar)` keep isCase semantics. The static type checker reports an arity mismatch against the record's components, treats record patterns as conditional for dominance purposes, and leaves exhaustiveness unassessed for switches containing them. Assisted-by: Claude Code (Claude Fable 5) --- src/antlr/GroovyParser.g4 | 19 +- .../groovy/parser/antlr4/AstBuilder.java | 171 ++++++++++++++++-- .../groovy/runtime/RecordPatternSupport.java | 76 ++++++++ .../stc/StaticTypeCheckingVisitor.java | 10 + .../core/SwitchExpression_28x.groovy | 74 ++++++++ .../fail/SwitchExpression_13x.groovy | 25 +++ .../groovy/SwitchPatternMatchingTest.groovy | 161 +++++++++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 1 + .../parser/antlr4/SyntaxErrorTest.groovy | 1 + 9 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java create mode 100644 src/test-resources/core/SwitchExpression_28x.groovy create mode 100644 src/test-resources/fail/SwitchExpression_13x.groovy diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 39dc63ba58b..4367e285e00 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -796,13 +796,30 @@ switchExpressionLabel // GEP-19 structural pattern matching: pattern forms usable in case labels casePattern - : typePattern (nls caseGuard)? + : (typePattern | recordPattern) (nls caseGuard)? ; typePattern : standardType identifier ; +// components are mandatory and must be pattern-shaped so that legacy method +// call labels such as `case foo()` and `case foo(bar)` keep isCase semantics +recordPattern + : standardType LPAREN nls recordPatternComponents nls RPAREN + ; + +recordPatternComponents + : recordPatternComponent (COMMA nls recordPatternComponent)* + ; + +recordPatternComponent + : recordPattern + | (DEF | VAR) identifier + | typePattern + | { "_".equals(_input.LT(1).getText()) }? identifier + ; + caseGuard : { "when".equals(_input.LT(1).getText()) }? identifier nls expression ; 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 2898ab4423c..471214cd034 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -42,6 +42,7 @@ import org.apache.groovy.parser.antlr4.internal.DescriptiveErrorStrategy; import org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager; import org.apache.groovy.parser.antlr4.util.StringUtils; +import org.apache.groovy.runtime.RecordPatternSupport; import org.apache.groovy.util.Maps; import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.GroovyBugError; @@ -168,6 +169,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; import static org.codehaus.groovy.ast.tools.GeneralUtils.castX; +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.declS; @@ -176,6 +178,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.isInstanceOfX; import static org.codehaus.groovy.ast.tools.GeneralUtils.listX; import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.neX; import static org.codehaus.groovy.ast.tools.GeneralUtils.notX; import static org.codehaus.groovy.ast.tools.GeneralUtils.params; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; @@ -1219,10 +1222,14 @@ public void visitThrowStatement(ThrowStatement statement) { Statement code = (isLast && i == n - 1) ? codeBlock : EmptyStatement.INSTANCE; - Statement bindingDecl = expr.getNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); - if (bindingDecl != null && !(code instanceof EmptyStatement)) { + List bindingDecls = expr.getNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); + if (bindingDecls != null && !(code instanceof EmptyStatement)) { expr.removeNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); - code = createBlockStatement(List.of(bindingDecl, code)); + if (!bindingDecls.isEmpty()) { + List codeWithBindings = new ArrayList<>(bindingDecls); + codeWithBindings.add(code); + code = createBlockStatement(codeWithBindings); + } } statementList.add( @@ -1274,14 +1281,20 @@ public Tuple3, Integer> visitSwitchExpressionLabel(Switc /** * Builds the case label expression for a pattern label (GEP-19). A type pattern - * without a guard becomes a plain class literal label; with a {@code when} guard - * it becomes a closure label checking the type then evaluating the guard with the - * pattern variable in scope. In both forms, the statement declaring the pattern - * variable within the case body is attached as node metadata for + * without a guard becomes a plain class literal label; a guarded type pattern or + * a record pattern becomes a closure label performing the type, arity and + * component checks before evaluating the guard (if any) with the pattern + * variables in scope. In all forms, the statements declaring the pattern + * variables within the case body are attached as node metadata for * {@link #visitSwitchBlockStatementExpressionGroup}. */ @Override public Expression visitCasePattern(final CasePatternContext ctx) { + Expression guard = asBoolean(ctx.caseGuard()) ? (Expression) this.visit(ctx.caseGuard().expression()) : null; + if (asBoolean(ctx.recordPattern())) { + return this.createRecordPatternLabel(ctx, guard); + } + TypePatternContext typePatternCtx = ctx.typePattern(); ClassNode type = this.visitType(typePatternCtx.type()); if (ClassHelper.isPrimitiveType(type)) { @@ -1291,8 +1304,7 @@ public Expression visitCasePattern(final CasePatternContext ctx) { String subjectName = switchPatternSubjectStack.peek(); Expression labelExpr; - if (asBoolean(ctx.caseGuard())) { - Expression guard = (Expression) this.visit(ctx.caseGuard().expression()); + if (guard != null) { String candidateName = "__$$pc" + switchPatternVariableSeq++; Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); Statement guardCode = block( @@ -1310,10 +1322,145 @@ public Expression visitCasePattern(final CasePatternContext ctx) { labelExpr.putNodeMetaData(SWITCH_PATTERN_TYPE, type); Statement bindingDecl = declS(configureAST(localVarX(name, type), typePatternCtx.identifier()), castX(type, varX(subjectName))); - labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecl); + labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, Collections.singletonList(bindingDecl)); + return labelExpr; + } + + /** + * Builds a closure case label for a record pattern (GEP-19), e.g. + * {@code case Point(int x, int y) when x == y ->} becomes: + *
+     * case { __$$pc1 -> if (!(__$$pc1 instanceof Point)) return false
+     *                   List __$$rc2 = RecordPatternSupport.components(__$$pc1)
+     *                   if (__$$rc2.size() != 2) return false
+     *                   def __$$rv3 = __$$rc2.get(0)
+     *                   if (!(__$$rv3 instanceof Integer)) return false
+     *                   int x = (int) __$$rv3
+     *                   ... likewise for y ...
+     *                   return x == y }:
+     * 
+ * Component names are not known at parse time (record patterns are positional), + * so deconstruction goes through {@link org.apache.groovy.runtime.RecordPatternSupport}. + */ + private Expression createRecordPatternLabel(final CasePatternContext ctx, final Expression guard) { + PatternNode pattern = this.buildRecordPattern(ctx.recordPattern()); + String candidateName = "__$$pc" + switchPatternVariableSeq++; + Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); + List checkStatements = new ArrayList<>(); + appendRecordPatternChecks(pattern, varX(candidateName), checkStatements); + checkStatements.add(returnS(guard != null ? guard : constX(Boolean.TRUE, true))); + Expression labelExpr = configureAST(closureX(params(candidate), createBlockStatement(checkStatements)), ctx); + + // read by StaticTypeCheckingVisitor to check pattern switch case labels; + // a record pattern is always conditional (arity and component checks) + labelExpr.putNodeMetaData(SWITCH_PATTERN_TYPE, pattern.type); + labelExpr.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); + labelExpr.putNodeMetaData(SWITCH_PATTERN_RECORD, pattern.components.size()); + + List bindingDecls = new ArrayList<>(); + if (pattern.hasBindings()) { + appendRecordPatternExtraction(pattern, varX(switchPatternSubjectStack.peek()), bindingDecls, false); + } + labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecls); return labelExpr; } + private PatternNode buildRecordPattern(final RecordPatternContext ctx) { + PatternNode pattern = new PatternNode(); + pattern.type = this.visitType(ctx.type()); + if (ClassHelper.isPrimitiveType(pattern.type)) { + throw createParsingFailedException("primitive type patterns are not yet supported", ctx); + } + pattern.components = new ArrayList<>(); + for (RecordPatternComponentContext componentCtx : ctx.recordPatternComponents().recordPatternComponent()) { + pattern.components.add(this.buildRecordPatternComponent(componentCtx)); + } + return pattern; + } + + private PatternNode buildRecordPatternComponent(final RecordPatternComponentContext ctx) { + if (asBoolean(ctx.recordPattern())) { + return this.buildRecordPattern(ctx.recordPattern()); + } + PatternNode component = new PatternNode(); + String name; + if (asBoolean(ctx.typePattern())) { + component.type = this.visitType(ctx.typePattern().type()); + name = this.visitIdentifier(ctx.typePattern().identifier()); + } else { + name = this.visitIdentifier(ctx.identifier()); + if (null == ctx.DEF() && null == ctx.VAR() && !"_".equals(name)) { + throw createParsingFailedException("record pattern component must be a type pattern, a var/def binding, a nested record pattern or `_`", ctx); + } + } + component.name = "_".equals(name) ? null : name; // `_` is bind-and-discard + return component; + } + + /** Emits the type, arity and component checks (plus bindings) for a record pattern; used inside closure case labels. */ + private void appendRecordPatternChecks(final PatternNode pattern, final Expression source, final List statements) { + statements.add(ifS(notX(isInstanceOfX(source, pattern.type)), returnS(constX(Boolean.FALSE, true)))); + appendRecordPatternExtraction(pattern, source, statements, true); + } + + /** + * Emits the component extraction of a record pattern rooted at {@code source}. + * With {@code withChecks}, arity and component type checks guard each step + * (for use in a case label closure); without, only the statements needed to + * declare the pattern variables are emitted (for use in the case body, where + * the match is already established). + */ + private void appendRecordPatternExtraction(final PatternNode pattern, final Expression source, final List statements, final boolean withChecks) { + String componentsName = "__$$rc" + switchPatternVariableSeq++; + statements.add(declS(localVarX(componentsName, ClassHelper.LIST_TYPE.getPlainNodeReference()), + callX(classX(RECORD_PATTERN_SUPPORT_TYPE), "components", args(source)))); + if (withChecks) { + statements.add(ifS(neX(callX(varX(componentsName), "size"), constX(pattern.components.size(), true)), returnS(constX(Boolean.FALSE, true)))); + } + for (int i = 0, n = pattern.components.size(); i < n; i += 1) { + PatternNode component = pattern.components.get(i); + if (withChecks ? component.isUncheckedWildcard() : !component.hasBindings()) continue; + String componentName = "__$$rv" + switchPatternVariableSeq++; + statements.add(declS(localVarX(componentName), callX(varX(componentsName), "get", args(constX(i, true))))); + Expression componentVar = varX(componentName); + if (component.components != null) { // nested record pattern + if (withChecks) { + appendRecordPatternChecks(component, componentVar, statements); + } else { + appendRecordPatternExtraction(component, componentVar, statements, false); + } + } else { + if (withChecks && component.type != null) { + statements.add(ifS(notX(isInstanceOfX(componentVar, ClassHelper.getWrapper(component.type))), returnS(constX(Boolean.FALSE, true)))); + } + if (component.name != null) { + statements.add(declS(localVarX(component.name, component.type != null ? component.type : ClassHelper.dynamicType()), + component.type != null ? castX(component.type, componentVar) : componentVar)); + } + } + } + } + + /** A parsed pattern (GEP-19): a record pattern when {@code components} is non-null, otherwise a binding or wildcard. */ + private static class PatternNode { + ClassNode type; // null for an untyped (var/def) binding or a bare wildcard + String name; // null for a wildcard or a nested record pattern + List components; // non-null for a record pattern + + boolean isUncheckedWildcard() { + return type == null && name == null && components == null; + } + + boolean hasBindings() { + if (name != null) return true; + if (components == null) return false; + for (PatternNode component : components) { + if (component.hasBindings()) return true; + } + return false; + } + } + // } statement ------------------------------------------------------------- @Override @@ -5134,10 +5281,12 @@ public List getDeclarationExpressions() { private static final String IS_INSIDE_INSTANCEOF_EXPR = "_IS_INSIDE_INSTANCEOF_EXPR"; private static final String IS_SWITCH_DEFAULT = "_IS_SWITCH_DEFAULT"; private static final String SWITCH_TYPE_PATTERN_BINDING = "_SWITCH_TYPE_PATTERN_BINDING"; - // the next three keys are also read (as literals) by StaticTypeCheckingVisitor + // the next four keys are also read (as literals) by StaticTypeCheckingVisitor private static final String SWITCH_PATTERN_SUBJECT = "_SWITCH_PATTERN_SUBJECT"; private static final String SWITCH_PATTERN_TYPE = "_SWITCH_PATTERN_TYPE"; private static final String SWITCH_PATTERN_GUARDED = "_SWITCH_PATTERN_GUARDED"; + private static final String SWITCH_PATTERN_RECORD = "_SWITCH_PATTERN_RECORD"; + private static final ClassNode RECORD_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(RecordPatternSupport.class); private static final String IS_NUMERIC = "_IS_NUMERIC"; private static final String IS_STRING = "_IS_STRING"; private static final String IS_INTERFACE_WITH_DEFAULT_METHODS = "_IS_INTERFACE_WITH_DEFAULT_METHODS"; diff --git a/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java new file mode 100644 index 00000000000..26e97cd66c4 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java @@ -0,0 +1,76 @@ +/* + * 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.GroovyRuntimeException; +import groovy.lang.MissingMethodException; +import org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.runtime.InvokerHelper; + +import java.lang.reflect.RecordComponent; +import java.util.ArrayList; +import java.util.List; + +/** + * Runtime deconstruction support for record patterns (GEP-19) — the emit + * target of the pattern switch lowering in the parser. + *

+ * Record patterns are positional, but component names are not known at parse + * time, so the lowering cannot emit direct accessor calls. Instead it emits + * {@code RecordPatternSupport.components(value)} calls; this class resolves the + * components at runtime: native records are deconstructed through their record + * components (invoked via the meta-object protocol, so Groovy and Java records + * behave identically), and other values are deconstructed through a + * {@code toList()} method if one exists (which covers emulated Groovy records). + * + * @since 6.0.0 + */ +@Incubating +public final class RecordPatternSupport { + + private RecordPatternSupport() { + } + + /** + * Returns the components of the given value, in declaration order. + * + * @throws GroovyRuntimeException if the value is neither a record nor deconstructable via {@code toList()} + */ + public static List components(final Object value) { + if (value == null) { + throw new GroovyRuntimeException("Cannot deconstruct null"); + } + RecordComponent[] recordComponents = value.getClass().getRecordComponents(); + if (recordComponents != null) { + List result = new ArrayList<>(recordComponents.length); + for (RecordComponent recordComponent : recordComponents) { + result.add(InvokerHelper.invokeMethod(value, recordComponent.getName(), InvokerHelper.EMPTY_ARGS)); + } + return result; + } + try { + Object components = InvokerHelper.invokeMethod(value, "toList", InvokerHelper.EMPTY_ARGS); + if (components instanceof List) { + return (List) components; + } + } catch (MissingMethodException ignore) { + } + throw new GroovyRuntimeException("Cannot deconstruct an instance of " + value.getClass().getName() + ": it is neither a record nor does it provide a toList() method"); + } +} diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index ebacf196fe9..3072e1b12a9 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -4873,6 +4873,16 @@ private void checkPatternSwitchLabels(final SwitchStatement statement) { addStaticTypeError("The case pattern type " + prettyPrintTypeName(typeTest) + " is incompatible with the switch subject type " + prettyPrintTypeName(subjectType), label); continue; } + Integer recordPatternArity = label.getNodeMetaData("_SWITCH_PATTERN_RECORD"); + if (recordPatternArity != null) { + // a record pattern is conditional (arity and component checks), so it + // cannot be proven exhaustive here; leave exhaustiveness unassessed + opaqueLabels = true; + int componentCount = patternType.getRecordComponents().size(); + if (componentCount > 0 && componentCount != recordPatternArity) { + addStaticTypeError("The record pattern specifies " + recordPatternArity + " component(s) but " + prettyPrintTypeName(patternType) + " has " + componentCount, label); + } + } for (ClassNode prior : priorTypeTests) { if (implementsInterfaceOrIsSubclassOf(wrappedTest, prior)) { addPatternSwitchWarning("The case pattern is dominated by a preceding case label and will never match", label); diff --git a/src/test-resources/core/SwitchExpression_28x.groovy b/src/test-resources/core/SwitchExpression_28x.groovy new file mode 100644 index 00000000000..8f2c6739de0 --- /dev/null +++ b/src/test-resources/core/SwitchExpression_28x.groovy @@ -0,0 +1,74 @@ +/* + * 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. + */ + +// GEP-19: record patterns in switch expressions + +record Point(int x, int y) {} +record Line(Point start, Point end) {} + +def describe(obj) { + switch (obj) { + case Point(int x, int y) when x == y -> "diagonal $x" + case Point(int x, _) -> "x=$x" + case Line(Point(_, _), Point p2) -> "ends at (${p2.x()}, ${p2.y()})" + default -> 'other' + } +} + +assert describe(new Point(3, 3)) == 'diagonal 3' +assert describe(new Point(1, 2)) == 'x=1' +assert describe(new Line(new Point(0, 0), new Point(4, 5))) == 'ends at (4, 5)' +assert describe('hello') == 'other' + +// var and def component bindings +def sum = switch (new Point(3, 4)) { + case Point(var a, def b) -> a + b + default -> -1 +} +assert sum == 7 + +// component type narrowing: mismatching component falls through +record Box(Object value) {} +def content = switch (new Box('text')) { + case Box(Integer i) -> "int $i" + case Box(String s) -> "string ${s.toUpperCase()}" + default -> 'other' +} +assert content == 'string TEXT' + +// arity mismatch never matches +def one = switch (new Point(1, 2)) { + case Point(var a) -> "one $a" + default -> 'no match' +} +assert one == 'no match' + +// legacy method call labels keep isCase semantics +def lower(s) { s.toLowerCase() } +def noArg() { 42 } +def legacy = switch ('abc') { + case lower('ABC') -> 'call label' + default -> 'no' +} +assert legacy == 'call label' +def legacy2 = switch (42) { + case noArg() -> 'no-arg call label' + default -> 'no' +} +assert legacy2 == 'no-arg call label' diff --git a/src/test-resources/fail/SwitchExpression_13x.groovy b/src/test-resources/fail/SwitchExpression_13x.groovy new file mode 100644 index 00000000000..54de2ae0158 --- /dev/null +++ b/src/test-resources/fail/SwitchExpression_13x.groovy @@ -0,0 +1,25 @@ +/* + * 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. + */ + +// GEP-19: record pattern case labels require the arrow form +record Point(int x, int y) {} +def result = switch (new Point(1, 2)) { + case Point(int x, int y): yield x + y + default: yield 0 +} diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index e0e7a8f2556..c8d2f932400 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -341,6 +341,167 @@ final class SwitchPatternMatchingTest { ''' } + // GEP-19 phase 3: record patterns + + @Test + void testRecordPatternBasics() { + assertScript ''' + record Point(int x, int y) {} + def r = switch (new Point(1, 2)) { + case Point(int x, int y) -> x + y + default -> -1 + } + assert r == 3 + ''' + } + + @Test + void testRecordPatternGuardAndWildcard() { + assertScript ''' + record Point(int x, int y) {} + def m = { obj -> + switch (obj) { + case Point(int x, int y) when x == y -> "diagonal $x" + case Point(int x, _) -> "x=$x" + default -> 'other' + } + } + assert m(new Point(3, 3)) == 'diagonal 3' + assert m(new Point(1, 2)) == 'x=1' + assert m('s') == 'other' + ''' + } + + @Test + void testNestedRecordPattern() { + assertScript ''' + record Point(int x, int y) {} + record Line(Point start, Point end) {} + def r = switch (new Line(new Point(0, 0), new Point(4, 5))) { + case Line(Point(var x1, _), Point p2) -> "$x1 to ${p2.x()},${p2.y()}" + default -> 'other' + } + assert r == '0 to 4,5' + ''' + } + + @Test + void testRecordPatternComponentTypeNarrowing() { + assertScript ''' + record Box(Object value) {} + def m = { obj -> + switch (obj) { + case Box(Integer i) -> "int $i" + case Box(String s) -> "string $s" + default -> 'other' + } + } + assert m(new Box(42)) == 'int 42' + assert m(new Box('t')) == 'string t' + assert m(new Box(1.5)) == 'other' + ''' + } + + @Test + void testRecordPatternArityMismatchNeverMatches() { + assertScript ''' + record Point(int x, int y) {} + def r = switch (new Point(1, 2)) { + case Point(var a) -> "one $a" + default -> 'no match' + } + assert r == 'no match' + ''' + } + + @Test + void testRecordPatternCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + record Point(int x, int y) {} + @CompileStatic + def m(Object o) { + switch (o) { + case Point(int x, int y) when x == y -> 'diagonal ' + (x * 2) + case Point(int x, _) -> 'x ' + x + default -> 'other' + } + } + assert m(new Point(2, 2)) == 'diagonal 4' + assert m(new Point(1, 5)) == 'x 1' + assert m('s') == 'other' + ''' + } + + @Test + void testRecordPatternArityCheckedWhenTypeChecked() { + def err = shouldFail ''' + import groovy.transform.TypeChecked + record Point(int x, int y) {} + @TypeChecked + def m(Object o) { + switch (o) { + case Point(int x, _) when x > 0 -> x + case Point(var a) -> a + default -> 'other' + } + } + ''' + assert err.message.contains('specifies 1 component(s) but') + } + + @Test + void testRecordPatternDominatedByEarlierTypePattern() { + def warnings = patternSwitchWarnings ''' + import groovy.transform.TypeChecked + record Point(int x, int y) {} + @TypeChecked + def m(Object o) { + switch (o) { + case Point p -> 'point' + case Point(int x, int y) -> 'components' + default -> 'other' + } + } + ''' + assert warnings.any { it.contains('dominated by a preceding case label') } + } + + @Test + void testLegacyMethodCallLabelsKeepIsCaseSemantics() { + assertScript ''' + def lower(s) { s.toLowerCase() } + def noArg() { 42 } + def r = switch ('abc') { + case lower('ABC') -> 'call label' + default -> 'no' + } + assert r == 'call label' + def r2 = switch (42) { + case noArg() -> 'no-arg call label' + default -> 'no' + } + assert r2 == 'no-arg call label' + ''' + } + + @Test + void testDeconstructableViaToList() { + // not a record, but provides toList(): deconstructs like emulated Groovy records + assertScript ''' + class Pair { + def a, b + Pair(a, b) { this.a = a; this.b = b } + List toList() { [a, b] } + } + def r = switch (new Pair(1, 'x')) { + case Pair(var a, var b) -> "$a-$b" + default -> 'no' + } + assert r == '1-x' + ''' + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index c19ed17993c..0a80288cbf1 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -532,6 +532,7 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_25x.groovy') doRunAndTestAntlr4('core/SwitchExpression_26x.groovy') doRunAndTestAntlr4('core/SwitchExpression_27x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_28x.groovy') } @Test diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index 1bc10c4a0fe..437cdd549c4 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -567,6 +567,7 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/SwitchExpression_10x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_11x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_12x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_13x.groovy') } @NotYetImplemented @Test From 5d13f53851d74841c7f3344f36a693ff800be8c2 Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 02:18:14 +1000 Subject: [PATCH 4/9] GEP-19: structural pattern matching in switch (phase 4: record patterns in instanceof) Extends instanceof to accept record patterns, e.g. `if (p instanceof Point(int x, var y))`, including nested record patterns, `_` wildcards and var/def component bindings, usable in any expression context (if, while, ternary, boolean composition) under both dynamic and static compilation. The lowering emits a conjunction of ordinary expressions and instanceof bindings (JEP 394), combined with the non-short-circuiting `&` operator: an instanceof binding declares and default-initialises its variable where it occurs, so evaluating every conjunct unconditionally keeps all pattern variables definitely assigned for the bytecode verifier (short-circuit joins merge frames in which the variables do not yet exist). New RecordPatternSupport helpers keep each conjunct safe to evaluate after an earlier conjunct has failed while never invoking accessors on values that did not pass their own type check. A var/def component is combined with `| true`, giving it unconditional semantics: a null component keeps the match alive and leaves the binding null. Assisted-by: Claude Code (Claude Fable 5) --- src/antlr/GroovyParser.g4 | 3 +- .../groovy/parser/antlr4/AstBuilder.java | 72 ++++++++++++++ .../groovy/runtime/RecordPatternSupport.java | 21 ++++ src/test/groovy/groovy/InstanceofTest.groovy | 99 +++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 4367e285e00..660e3659f7e 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -351,7 +351,8 @@ referenceType ; matchingType // see: instanceof - : standardType identifier? + : recordPattern + | standardType identifier? ; standardType // see: returnType 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 471214cd034..0525715c65a 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -165,6 +165,7 @@ import static org.apache.groovy.parser.antlr4.util.PositionConfigureUtils.configureEndPosition; import static org.codehaus.groovy.ast.tools.GeneralUtils.args; import static org.codehaus.groovy.ast.tools.GeneralUtils.assignX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.binX; import static org.codehaus.groovy.ast.tools.GeneralUtils.block; import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; @@ -174,6 +175,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; import static org.codehaus.groovy.ast.tools.GeneralUtils.declS; import static org.codehaus.groovy.ast.tools.GeneralUtils.declX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.eqX; import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS; import static org.codehaus.groovy.ast.tools.GeneralUtils.isInstanceOfX; import static org.codehaus.groovy.ast.tools.GeneralUtils.listX; @@ -1441,6 +1443,68 @@ private void appendRecordPatternExtraction(final PatternNode pattern, final Expr } } + /** + * Builds the lowered expression for a record pattern in {@code instanceof} (GEP-19), + * a conjunction of ordinary expressions and {@code instanceof} bindings (JEP 394), e.g. + * {@code p instanceof Point(int x, var y)} becomes: + *
+     * p instanceof Point __$$rp1
+     *   & RecordPatternSupport.componentsOrEmpty(__$$rp1) instanceof List __$$rc2
+     *   & __$$rc2.size() == 2
+     *   & RecordPatternSupport.component(__$$rc2, 0) instanceof Integer x
+     *   & (RecordPatternSupport.component(__$$rc2, 1) instanceof Object y | true)
+     * 
+ * The conjunction deliberately uses the non-short-circuiting {@code &} operator: + * each {@code instanceof} binding declares and default-initialises its variable + * where it occurs, so evaluating every conjunct unconditionally keeps all pattern + * variables definitely assigned for the bytecode verifier (short-circuit joins + * would merge frames in which the variables do not exist). The helper methods + * make each conjunct safe to evaluate after an earlier conjunct has failed, and + * accessors are only ever invoked on values that passed their own type check. + * The {@code | true} form gives {@code var}/{@code def} components their + * unconditional semantics: a {@code null} component keeps the match alive and + * leaves the binding {@code null}. + */ + private Expression createRecordPatternInstanceof(final Expression left, final org.codehaus.groovy.syntax.Token instanceOf, final RecordPatternContext ctx) { + PatternNode pattern = this.buildRecordPattern(ctx); + List conjuncts = new ArrayList<>(); + appendRecordPatternInstanceofConjuncts(pattern, left, instanceOf, conjuncts); + Expression result = conjuncts.get(0); + for (int i = 1, n = conjuncts.size(); i < n; i += 1) { + result = binX(result, org.codehaus.groovy.syntax.Token.newSymbol(Types.BITWISE_AND, instanceOf.getStartLine(), instanceOf.getStartColumn()), conjuncts.get(i)); + } + return result; + } + + private void appendRecordPatternInstanceofConjuncts(final PatternNode pattern, final Expression source, final org.codehaus.groovy.syntax.Token instanceOf, final List conjuncts) { + String recordName = "__$$rp" + switchPatternVariableSeq++; + conjuncts.add(binX(source, instanceOf, declX(varX(recordName, pattern.type), EmptyExpression.INSTANCE))); + // componentsOrEmpty(...) always yields a List; this conjunct just binds it + String componentsName = "__$$rc" + switchPatternVariableSeq++; + conjuncts.add(binX(callX(classX(RECORD_PATTERN_SUPPORT_TYPE), "componentsOrEmpty", args(varX(recordName))), instanceOf, + declX(varX(componentsName, ClassHelper.LIST_TYPE.getPlainNodeReference()), EmptyExpression.INSTANCE))); + conjuncts.add(eqX(callX(varX(componentsName), "size"), constX(pattern.components.size(), true))); + for (int i = 0, n = pattern.components.size(); i < n; i += 1) { + PatternNode component = pattern.components.get(i); + if (component.isUncheckedWildcard()) continue; + Expression componentExpr = callX(classX(RECORD_PATTERN_SUPPORT_TYPE), "component", args(varX(componentsName), constX(i, true))); + if (component.components != null) { // nested record pattern + appendRecordPatternInstanceofConjuncts(component, componentExpr, instanceOf, conjuncts); + } else if (component.type != null) { + ClassNode bindType = ClassHelper.getWrapper(component.type); + Expression rhs = component.name != null + ? declX(varX(component.name, bindType), EmptyExpression.INSTANCE) + : new ClassExpression(bindType); + conjuncts.add(binX(componentExpr, instanceOf, rhs)); + } else { // var/def binding: unconditional, also matches a null component + conjuncts.add(binX( + binX(componentExpr, instanceOf, declX(varX(component.name, ClassHelper.OBJECT_TYPE.getPlainNodeReference()), EmptyExpression.INSTANCE)), + org.codehaus.groovy.syntax.Token.newSymbol(Types.BITWISE_OR, instanceOf.getStartLine(), instanceOf.getStartColumn()), + constX(Boolean.TRUE, true))); + } + } + } + /** A parsed pattern (GEP-19): a record pattern when {@code components} is non-null, otherwise a binding or wildcard. */ private static class PatternNode { ClassNode type; // null for an untyped (var/def) binding or a bare wildcard @@ -3488,6 +3552,14 @@ public Expression visitRelationalExprAlt(final RelationalExprAltContext ctx) { case INSTANCEOF: ctx.matchingType().putNodeMetaData(IS_INSIDE_INSTANCEOF_EXPR, Boolean.TRUE); + if (asBoolean(ctx.matchingType().recordPattern())) { // GEP-19 + return configureAST( + this.createRecordPatternInstanceof( + (Expression) this.visit(ctx.left), + this.createGroovyToken(ctx.op), + ctx.matchingType().recordPattern()), + ctx); + } return configureAST( new BinaryExpression( (Expression) this.visit(ctx.left), diff --git a/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java index 26e97cd66c4..81ea6fc4e7c 100644 --- a/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java +++ b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java @@ -25,6 +25,7 @@ import java.lang.reflect.RecordComponent; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -73,4 +74,24 @@ public static List components(final Object value) { } throw new GroovyRuntimeException("Cannot deconstruct an instance of " + value.getClass().getName() + ": it is neither a record nor does it provide a toList() method"); } + + /** + * Like {@link #components(Object)} but yields an empty list for {@code null}. + * Used by the {@code instanceof} record pattern lowering, whose conjuncts are + * combined without short-circuiting and so may deconstruct the (null) default + * value of a pattern variable whose type check failed. + */ + public static List componentsOrEmpty(final Object value) { + return value == null ? Collections.emptyList() : components(value); + } + + /** + * Bounds-safe component access, yielding {@code null} when the index is out of + * range. Used by the {@code instanceof} record pattern lowering (see + * {@link #componentsOrEmpty}) where a failed arity check does not stop the + * remaining component conjuncts from being evaluated. + */ + public static Object component(final List components, final int index) { + return index < components.size() ? components.get(index) : null; + } } diff --git a/src/test/groovy/groovy/InstanceofTest.groovy b/src/test/groovy/groovy/InstanceofTest.groovy index 7896c65ac9b..25e8cd1e0de 100644 --- a/src/test/groovy/groovy/InstanceofTest.groovy +++ b/src/test/groovy/groovy/InstanceofTest.groovy @@ -20,6 +20,7 @@ package groovy import org.junit.jupiter.api.Test +import static groovy.test.GroovyAssert.assertScript import static groovy.test.GroovyAssert.shouldFail final class InstanceofTest { @@ -223,4 +224,102 @@ final class InstanceofTest { } assert y == 'foobar' } + + // GEP-19: record patterns in instanceof + + @Test + void testRecordPattern() { + assertScript ''' + record Point(int x, int y) {} + def p = new Point(3, 4) + if (p instanceof Point(int x, int y)) { + assert x == 3 && y == 4 + } else { + assert false : 'expected match' + } + assert !('s' instanceof Point(int x, int y)) + ''' + } + + @Test + void testRecordPatternInCondition() { + assertScript ''' + record Point(int x, int y) {} + def p = new Point(3, 4) + assert p instanceof Point(int x, _) && x == 3 + def r = p instanceof Point(var a, var b) ? a + b : -1 + assert r == 7 + ''' + } + + @Test + void testNestedRecordPattern() { + assertScript ''' + record Point(int x, int y) {} + record Line(Point start, Point end) {} + def l = new Line(new Point(0, 1), new Point(4, 5)) + if (l instanceof Line(Point(_, var y1), Point p2)) { + assert y1 == 1 + assert p2.x() == 4 + } else { + assert false : 'expected match' + } + ''' + } + + @Test + void testRecordPatternComponentTypeAndArity() { + assertScript ''' + record Box(Object value) {} + assert new Box('t') instanceof Box(String s) && s == 't' + assert !(new Box(42) instanceof Box(String s2)) + record Point(int x, int y) {} + assert !(new Point(1, 2) instanceof Point(var a)) // arity mismatch + ''' + } + + @Test + void testRecordPatternVarBindsNullComponent() { + assertScript ''' + record Box(Object value) {} + def b = new Box(null) + if (b instanceof Box(var v)) { + assert v == null + } else { + assert false : 'var component should match null' + } + assert !(b instanceof Box(String s)) // typed component does not match null + ''' + } + + @Test + void testRecordPatternInWhileLoop() { + assertScript ''' + record Cons(Object head, Object tail) {} + def list = new Cons(1, new Cons(2, new Cons(3, null))) + def sum = 0 + while (list instanceof Cons(Integer h, var t)) { + sum += h + list = t + } + assert sum == 6 + ''' + } + + @Test + void testRecordPatternCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + record Point(int x, int y) {} + @CompileStatic + def m(Object o) { + if (o instanceof Point(int x, int y)) { + return x + y + } + return -1 + } + assert m(new Point(3, 4)) == 7 + assert m('s') == -1 + ''' + } } From bcc6a045dbfbdb435d78a23636d50311405dadd1 Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 05:36:59 +1000 Subject: [PATCH 5/9] GEP-19: structural pattern matching in switch (phase 5: list patterns) List patterns destructure List, array and Iterable values in switch case labels: element positions accept literals (matched by equality), type patterns, var/def bindings, nested record/list patterns and a single rest binding in any position (var... t, Type... t, ... t, or bare ...). A [...] literal is a pattern iff it is empty or some element is a binding form or nested pattern; otherwise it keeps its legacy isCase (containment) semantics, including the colon form. Destructuring goes through the new ListPatternSupport runtime helper. The static type checker rejects a list pattern whose subject provably cannot be a List, array or Iterable, and treats list patterns as conditional for exhaustiveness. Not valid in instanceof (no type at the head), per the GEP. Assisted-by: Claude Code (Claude Fable 5) --- src/antlr/GroovyParser.g4 | 29 +- .../groovy/parser/antlr4/AstBuilder.java | 240 +++++++++++++- .../groovy/runtime/ListPatternSupport.java | 89 ++++++ .../stc/StaticTypeCheckingVisitor.java | 6 + .../core/SwitchExpression_29x.groovy | 67 ++++ .../fail/SwitchExpression_14x.groovy | 24 ++ .../groovy/SwitchPatternMatchingTest.groovy | 302 ++++++++++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 1 + .../parser/antlr4/SyntaxErrorTest.groovy | 1 + 9 files changed, 752 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/apache/groovy/runtime/ListPatternSupport.java create mode 100644 src/test-resources/core/SwitchExpression_29x.groovy create mode 100644 src/test-resources/fail/SwitchExpression_14x.groovy diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 660e3659f7e..46851d97d16 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -797,7 +797,7 @@ switchExpressionLabel // GEP-19 structural pattern matching: pattern forms usable in case labels casePattern - : (typePattern | recordPattern) (nls caseGuard)? + : (typePattern | recordPattern | listPattern) (nls caseGuard)? ; typePattern @@ -821,6 +821,33 @@ recordPatternComponent | { "_".equals(_input.LT(1).getText()) }? identifier ; +// a `[...]` label parses as a list pattern whenever its elements fit; whether it +// is treated as a pattern or keeps legacy isCase semantics is decided in the +// AST builder (a non-empty literal with no binding form, rest or nested pattern +// among its elements is rebuilt as a legacy list expression label) +listPattern + : LBRACK nls (listPatternElements nls)? RBRACK + ; + +listPatternElements + : listPatternElement (COMMA nls listPatternElement)* + ; + +listPatternElement + : listPatternRest + | recordPattern + | listPattern + | (DEF | VAR) identifier + | typePattern + | expression + ; + +// at most one rest binding per list pattern (validated in the AST builder); +// `...` and `... t` are shortcuts for `var... _` and `var... t` +listPatternRest + : (DEF | VAR | standardType)? ELLIPSIS identifier? + ; + caseGuard : { "when".equals(_input.LT(1).getText()) }? identifier nls expression ; 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 0525715c65a..d95fc921560 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -42,6 +42,7 @@ import org.apache.groovy.parser.antlr4.internal.DescriptiveErrorStrategy; import org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager; import org.apache.groovy.parser.antlr4.util.StringUtils; +import org.apache.groovy.runtime.ListPatternSupport; import org.apache.groovy.runtime.RecordPatternSupport; import org.apache.groovy.util.Maps; import org.apache.groovy.util.SystemUtil; @@ -180,8 +181,11 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.isInstanceOfX; import static org.codehaus.groovy.ast.tools.GeneralUtils.listX; import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ltX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.minusX; import static org.codehaus.groovy.ast.tools.GeneralUtils.neX; import static org.codehaus.groovy.ast.tools.GeneralUtils.notX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX; import static org.codehaus.groovy.ast.tools.GeneralUtils.params; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; @@ -1118,7 +1122,34 @@ public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext private static boolean containsCasePattern(final SwitchExpressionContext ctx) { return ctx.switchBlockStatementExpressionGroup().stream() .flatMap(e -> e.switchExpressionLabel().stream()) - .anyMatch(e -> asBoolean(e.casePattern())); + .anyMatch(e -> isPatternLabel(e.casePattern())); + } + + private static boolean isPatternLabel(final CasePatternContext ctx) { + if (!asBoolean(ctx)) return false; + // a `[...]` label parses via the broader listPattern rule; only a + // pattern-shaped literal is treated as a pattern (see isPatternShapedList) + return !asBoolean(ctx.listPattern()) || isPatternShapedList(ctx.listPattern()); + } + + /** + * Whether a {@code [...]} literal is a list pattern (GEP-19) rather than a legacy + * {@code isCase} label: it is a pattern if and only if it is empty, or some element + * is a binding form (a rest binding, a {@code var}/{@code def} binding or a type + * pattern) or a nested pattern (a record pattern or a pattern-shaped list literal). + */ + private static boolean isPatternShapedList(final ListPatternContext ctx) { + if (!asBoolean(ctx.listPatternElements())) return true; // `[]` is the empty list pattern + for (ListPatternElementContext elementCtx : ctx.listPatternElements().listPatternElement()) { + if (asBoolean(elementCtx.listPatternRest()) + || asBoolean(elementCtx.recordPattern()) + || asBoolean(elementCtx.typePattern()) + || null != elementCtx.DEF() || null != elementCtx.VAR() + || (asBoolean(elementCtx.listPattern()) && isPatternShapedList(elementCtx.listPattern()))) { + return true; + } + } + return false; } @Override @@ -1268,6 +1299,14 @@ public Tuple3, Integer> visitSwitchExpressionLabel(Switc final Integer acType = ctx.ac.getType(); if (asBoolean(ctx.CASE())) { if (asBoolean(ctx.casePattern())) { + if (!isPatternLabel(ctx.casePattern())) { + // a plain list literal that parsed via the broader listPattern rule, + // e.g. `case [1, 2, 3]:` -- it keeps its legacy isCase semantics + if (asBoolean(ctx.casePattern().caseGuard())) { + throw createParsingFailedException("`when` guards are only supported on pattern labels; a list pattern needs a binding form among its elements", ctx.casePattern().caseGuard()); + } + return tuple(ctx.CASE().getSymbol(), Collections.singletonList(this.rebuildListExpression(ctx.casePattern().listPattern())), acType); + } if (ARROW != acType) { throw createParsingFailedException("`case` with a pattern label supports only the arrow form (`->`)", ctx.casePattern()); } @@ -1296,6 +1335,9 @@ public Expression visitCasePattern(final CasePatternContext ctx) { if (asBoolean(ctx.recordPattern())) { return this.createRecordPatternLabel(ctx, guard); } + if (asBoolean(ctx.listPattern())) { + return this.createListPatternLabel(ctx, guard); + } TypePatternContext typePatternCtx = ctx.typePattern(); ClassNode type = this.visitType(typePatternCtx.type()); @@ -1443,6 +1485,187 @@ private void appendRecordPatternExtraction(final PatternNode pattern, final Expr } } + /** + * Builds a closure case label for a list pattern (GEP-19), e.g. + * {@code case [Integer h, var... t] when h > 0 ->} becomes: + *
+     * case { __$$pc1 -> List __$$lv2 = ListPatternSupport.elementsOrNull(__$$pc1)
+     *                   if (__$$lv2 == null) return false
+     *                   if (__$$lv2.size() < 1) return false
+     *                   def __$$le3 = __$$lv2.get(0)
+     *                   if (!(__$$le3 instanceof Integer)) return false
+     *                   Integer h = (Integer) __$$le3
+     *                   List t = ListPatternSupport.rest(__$$lv2, 1, 0)
+     *                   return h > 0 }:
+     * 
+ * List patterns destructure {@code List}, array and {@code Iterable} values, + * so element access goes through {@link org.apache.groovy.runtime.ListPatternSupport}. + */ + private Expression createListPatternLabel(final CasePatternContext ctx, final Expression guard) { + PatternNode pattern = this.buildListPattern(ctx.listPattern()); + String candidateName = "__$$pc" + switchPatternVariableSeq++; + Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); + List checkStatements = new ArrayList<>(); + appendListPatternExtraction(pattern, varX(candidateName), checkStatements, true); + checkStatements.add(returnS(guard != null ? guard : constX(Boolean.TRUE, true))); + Expression labelExpr = configureAST(closureX(params(candidate), createBlockStatement(checkStatements)), ctx); + + // read by StaticTypeCheckingVisitor to check pattern switch case labels; + // a list pattern has no type at the head and is always conditional + labelExpr.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); + labelExpr.putNodeMetaData(SWITCH_PATTERN_LIST, Boolean.TRUE); + + List bindingDecls = new ArrayList<>(); + if (pattern.hasBindings()) { + appendListPatternExtraction(pattern, varX(switchPatternSubjectStack.peek()), bindingDecls, false); + } + labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecls); + return labelExpr; + } + + private PatternNode buildListPattern(final ListPatternContext ctx) { + PatternNode pattern = new PatternNode(); + pattern.list = true; + pattern.components = new ArrayList<>(); + if (asBoolean(ctx.listPatternElements())) { + boolean restSeen = false; + for (ListPatternElementContext elementCtx : ctx.listPatternElements().listPatternElement()) { + PatternNode element = this.buildListPatternElement(elementCtx); + if (element.rest) { + if (restSeen) { + throw createParsingFailedException("a list pattern supports at most one rest binding", elementCtx); + } + restSeen = true; + } + pattern.components.add(element); + } + } + return pattern; + } + + private PatternNode buildListPatternElement(final ListPatternElementContext ctx) { + if (asBoolean(ctx.listPatternRest())) { + ListPatternRestContext restCtx = ctx.listPatternRest(); + PatternNode element = new PatternNode(); + element.rest = true; + if (asBoolean(restCtx.type())) { + element.type = this.visitType(restCtx.type()); + } + if (asBoolean(restCtx.identifier())) { + String name = this.visitIdentifier(restCtx.identifier()); + element.name = "_".equals(name) ? null : name; // `_` is bind-and-discard + } + return element; + } + if (asBoolean(ctx.recordPattern())) { + return this.buildRecordPattern(ctx.recordPattern()); + } + if (asBoolean(ctx.listPattern())) { + if (isPatternShapedList(ctx.listPattern())) { + return this.buildListPattern(ctx.listPattern()); + } + PatternNode element = new PatternNode(); // a plain list literal element is matched by equality + element.constant = this.rebuildListExpression(ctx.listPattern()); + return element; + } + PatternNode element = new PatternNode(); + if (asBoolean(ctx.typePattern())) { + element.type = this.visitType(ctx.typePattern().type()); + String name = this.visitIdentifier(ctx.typePattern().identifier()); + element.name = "_".equals(name) ? null : name; + } else if (null != ctx.DEF() || null != ctx.VAR()) { + String name = this.visitIdentifier(ctx.identifier()); + element.name = "_".equals(name) ? null : name; + } else { + element.constant = (Expression) this.visit(ctx.expression()); // matched by equality + } + return element; + } + + /** Rebuilds a legacy list expression from a {@code [...]} literal that parsed via the listPattern rule but is not pattern-shaped. */ + private Expression rebuildListExpression(final ListPatternContext ctx) { + List expressions = new ArrayList<>(); + if (asBoolean(ctx.listPatternElements())) { + for (ListPatternElementContext elementCtx : ctx.listPatternElements().listPatternElement()) { + expressions.add(asBoolean(elementCtx.listPattern()) + ? this.rebuildListExpression(elementCtx.listPattern()) + : (Expression) this.visit(elementCtx.expression())); + } + } + return configureAST(new ListExpression(expressions), ctx); + } + + /** + * Emits the destructuring of a list pattern rooted at {@code source}. With + * {@code withChecks}, the shape and element checks guard each step (for use in + * a case label closure); without, only the statements needed to declare the + * pattern variables are emitted (for use in the case body, where the match is + * already established). Without a rest binding the pattern requires exactly as + * many elements as specified; with one, at least the fixed elements, those + * following the rest binding being addressed from the end. + */ + private void appendListPatternExtraction(final PatternNode pattern, final Expression source, final List statements, final boolean withChecks) { + String elementsName = "__$$lv" + switchPatternVariableSeq++; + statements.add(declS(localVarX(elementsName, ClassHelper.LIST_TYPE.getPlainNodeReference()), + callX(classX(LIST_PATTERN_SUPPORT_TYPE), "elementsOrNull", args(source)))); + int n = pattern.components.size(); + int restIndex = -1; + for (int i = 0; i < n; i += 1) { + if (pattern.components.get(i).rest) restIndex = i; + } + int fixed = n - (restIndex < 0 ? 0 : 1); + if (withChecks) { + statements.add(ifS(eqX(varX(elementsName), nullX()), returnS(constX(Boolean.FALSE, true)))); + if (restIndex < 0) { + statements.add(ifS(neX(callX(varX(elementsName), "size"), constX(fixed, true)), returnS(constX(Boolean.FALSE, true)))); + } else if (fixed > 0) { + statements.add(ifS(ltX(callX(varX(elementsName), "size"), constX(fixed, true)), returnS(constX(Boolean.FALSE, true)))); + } + } + for (int i = 0; i < n; i += 1) { + PatternNode element = pattern.components.get(i); + if (withChecks ? element.isUncheckedWildcard() : !element.hasBindings()) continue; + if (element.rest) { + String restName = element.name != null ? element.name : "__$$lr" + switchPatternVariableSeq++; + statements.add(declS(localVarX(restName, ClassHelper.LIST_TYPE.getPlainNodeReference()), + callX(classX(LIST_PATTERN_SUPPORT_TYPE), "rest", args(varX(elementsName), constX(i, true), constX(n - i - 1, true))))); + if (withChecks && element.type != null) { + statements.add(ifS(notX(callX(classX(LIST_PATTERN_SUPPORT_TYPE), "allInstanceOf", + args(varX(restName), classX(ClassHelper.getWrapper(element.type))))), returnS(constX(Boolean.FALSE, true)))); + } + continue; + } + Expression indexExpr = (restIndex >= 0 && i > restIndex) + ? minusX(callX(varX(elementsName), "size"), constX(n - i, true)) + : constX(i, true); + Expression elementAccess = callX(varX(elementsName), "get", args(indexExpr)); + if (element.constant != null) { + statements.add(ifS(notX(eqX(elementAccess, element.constant)), returnS(constX(Boolean.FALSE, true)))); + continue; + } + String elementName = "__$$le" + switchPatternVariableSeq++; + statements.add(declS(localVarX(elementName), elementAccess)); + Expression elementVar = varX(elementName); + if (element.components != null) { // nested pattern + if (element.list) { + appendListPatternExtraction(element, elementVar, statements, withChecks); + } else if (withChecks) { + appendRecordPatternChecks(element, elementVar, statements); + } else { + appendRecordPatternExtraction(element, elementVar, statements, false); + } + } else { + if (withChecks && element.type != null) { + statements.add(ifS(notX(isInstanceOfX(elementVar, ClassHelper.getWrapper(element.type))), returnS(constX(Boolean.FALSE, true)))); + } + if (element.name != null) { + statements.add(declS(localVarX(element.name, element.type != null ? element.type : ClassHelper.dynamicType()), + element.type != null ? castX(element.type, elementVar) : elementVar)); + } + } + } + } + /** * Builds the lowered expression for a record pattern in {@code instanceof} (GEP-19), * a conjunction of ordinary expressions and {@code instanceof} bindings (JEP 394), e.g. @@ -1505,14 +1728,17 @@ private void appendRecordPatternInstanceofConjuncts(final PatternNode pattern, f } } - /** A parsed pattern (GEP-19): a record pattern when {@code components} is non-null, otherwise a binding or wildcard. */ + /** A parsed pattern (GEP-19): a record or list pattern when {@code components} is non-null, otherwise a binding, literal element or wildcard. */ private static class PatternNode { - ClassNode type; // null for an untyped (var/def) binding or a bare wildcard - String name; // null for a wildcard or a nested record pattern - List components; // non-null for a record pattern + ClassNode type; // null for an untyped (var/def) binding or a bare wildcard; the element type for a typed rest binding + String name; // null for a wildcard or a nested pattern + List components; // non-null for a record or list pattern + boolean list; // components are list pattern elements rather than record components + boolean rest; // a rest binding element within a list pattern + Expression constant; // a literal element within a list pattern, matched by equality boolean isUncheckedWildcard() { - return type == null && name == null && components == null; + return type == null && name == null && components == null && constant == null; } boolean hasBindings() { @@ -5358,7 +5584,9 @@ public List getDeclarationExpressions() { private static final String SWITCH_PATTERN_TYPE = "_SWITCH_PATTERN_TYPE"; private static final String SWITCH_PATTERN_GUARDED = "_SWITCH_PATTERN_GUARDED"; private static final String SWITCH_PATTERN_RECORD = "_SWITCH_PATTERN_RECORD"; + private static final String SWITCH_PATTERN_LIST = "_SWITCH_PATTERN_LIST"; private static final ClassNode RECORD_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(RecordPatternSupport.class); + private static final ClassNode LIST_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(ListPatternSupport.class); private static final String IS_NUMERIC = "_IS_NUMERIC"; private static final String IS_STRING = "_IS_STRING"; private static final String IS_INTERFACE_WITH_DEFAULT_METHODS = "_IS_INTERFACE_WITH_DEFAULT_METHODS"; diff --git a/src/main/java/org/apache/groovy/runtime/ListPatternSupport.java b/src/main/java/org/apache/groovy/runtime/ListPatternSupport.java new file mode 100644 index 00000000000..e8971ca3979 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/ListPatternSupport.java @@ -0,0 +1,89 @@ +/* + * 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 org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Runtime destructuring support for list patterns (GEP-19) — the emit + * target of the pattern switch lowering in the parser. + *

+ * List patterns destructure {@code List}, array and {@code Iterable} values + * structurally; other values (and {@code null}) do not match. An + * {@code Iterable} is materialised as a list for matching, so it must be + * traversable non-destructively. + * + * @since 6.0.0 + */ +@Incubating +public final class ListPatternSupport { + + private ListPatternSupport() { + } + + /** + * Returns the given value as a list of its elements, or {@code null} when the + * value is not destructurable by a list pattern: lists are returned as is, + * arrays (including primitive arrays) and other iterables as a copy. + */ + @SuppressWarnings("unchecked") + public static List elementsOrNull(final Object value) { + if (value instanceof List) { + return (List) value; + } + if (value instanceof Object[]) { + return Arrays.asList((Object[]) value); + } + if (value != null && value.getClass().isArray()) { + return DefaultTypeTransformation.primitiveArrayToList(value); + } + if (value instanceof Iterable) { + List result = new ArrayList<>(); + for (Object element : (Iterable) value) { + result.add(element); + } + return result; + } + return null; + } + + /** + * Returns a copy of the elements bound by a rest binding: those from + * {@code from} up to (but not including) the last {@code dropRight} elements. + */ + public static List rest(final List elements, final int from, final int dropRight) { + return new ArrayList<>(elements.subList(from, elements.size() - dropRight)); + } + + /** + * Whether every element is an instance of the given type; used for the type + * check of a typed rest binding such as {@code Integer... t}. + */ + public static boolean allInstanceOf(final List elements, final Class type) { + for (Object element : elements) { + if (!type.isInstance(element)) return false; + } + return true; + } +} diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index 3072e1b12a9..f8a17482777 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -164,6 +164,7 @@ import static org.codehaus.groovy.ast.ClassHelper.Double_TYPE; import static org.codehaus.groovy.ast.ClassHelper.Enum_Type; import static org.codehaus.groovy.ast.ClassHelper.Float_TYPE; +import static org.codehaus.groovy.ast.ClassHelper.ITERABLE_TYPE; import static org.codehaus.groovy.ast.ClassHelper.Integer_TYPE; import static org.codehaus.groovy.ast.ClassHelper.Iterator_TYPE; import static org.codehaus.groovy.ast.ClassHelper.LIST_TYPE; @@ -4865,6 +4866,11 @@ private void checkPatternSwitchLabels(final SwitchStatement statement) { typeTest = label.getType(); // legacy class literal label has type-test semantics } if (typeTest == null) { + if (Boolean.TRUE.equals(label.getNodeMetaData("_SWITCH_PATTERN_LIST")) + && !subjectType.isArray() && provablyDisjoint(subjectType, ITERABLE_TYPE)) { + // a list pattern destructures List, array and Iterable values only + addStaticTypeError("The list pattern is incompatible with the switch subject type " + prettyPrintTypeName(subjectType), label); + } if (!isNullConstant(label)) opaqueLabels = true; continue; } diff --git a/src/test-resources/core/SwitchExpression_29x.groovy b/src/test-resources/core/SwitchExpression_29x.groovy new file mode 100644 index 00000000000..fb99f599757 --- /dev/null +++ b/src/test-resources/core/SwitchExpression_29x.groovy @@ -0,0 +1,67 @@ +/* + * 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. + */ + +// GEP-19: list patterns in switch expressions + +def describe(x) { + switch (x) { + case [] -> 'empty' + case [var only] -> "single: $only" + case [Integer h, var... t] when h > 0 -> "positive head $h, ${t.size()} more" + case [var first, var... middle, var last] -> "$first .. $last" + case [...] -> 'other non-empty list' + default -> 'not a list' + } +} + +assert describe([]) == 'empty' +assert describe(['x']) == 'single: x' +assert describe([1, 2, 3]) == 'positive head 1, 2 more' +assert describe([-1, 2, 3]) == '-1 .. 3' +assert describe(new int[] {5, 6}) == 'positive head 5, 1 more' +assert describe('hello') == 'not a list' + +// literal elements are matched by equality +def starts = switch ([1, 9, 8]) { + case [1, var x, ...] -> "starts with 1, then $x" + default -> 'no' +} +assert starts == 'starts with 1, then 9' + +// typed rest binding checks every element +def total = switch ([1, 2, 3]) { + case [Integer... nums] -> nums.sum() + default -> 'not all ints' +} +assert total == 6 + +// nested record and list patterns +record Point(int x, int y) {} +def nested = switch ([new Point(3, 4), [5]]) { + case [Point(var x, _), [var tail]] -> "$x-$tail" + default -> 'no' +} +assert nested == '3-5' + +// a list literal without a binding form keeps its legacy containment semantics +def legacy = switch (2) { + case [1, 2, 3] -> 'contained' + default -> 'no' +} +assert legacy == 'contained' diff --git a/src/test-resources/fail/SwitchExpression_14x.groovy b/src/test-resources/fail/SwitchExpression_14x.groovy new file mode 100644 index 00000000000..e1cc65b40b2 --- /dev/null +++ b/src/test-resources/fail/SwitchExpression_14x.groovy @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// GEP-19: a list pattern supports at most one rest binding +def result = switch ([1, 2, 3]) { + case [var... front, var... back] -> 'ambiguous' + default -> 'no' +} diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index c8d2f932400..a9fae8d1027 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -502,6 +502,308 @@ final class SwitchPatternMatchingTest { ''' } + @Test + void testListPatternDispatch() { + assertScript ''' + def describe(x) { + switch (x) { + case [] -> 'empty' + case [var only] -> "one: $only" + case [var a, var b] -> "two: $a, $b" + default -> 'other' + } + } + assert describe([]) == 'empty' + assert describe([42]) == 'one: 42' + assert describe([1, 2]) == 'two: 1, 2' + assert describe([1, 2, 3]) == 'other' + assert describe('s') == 'other' // not a List, array or Iterable + assert describe([a: 1]) == 'other' + assert describe(null) == 'other' // patterns do not match null + ''' + } + + @Test + void testListPatternRestForms() { + assertScript ''' + def r = switch ([1, 2, 3]) { + case [var h, var... t] -> "h=$h, t=$t" + default -> 'no' + } + assert r == 'h=1, t=[2, 3]' + def r2 = switch ([1]) { + case [var h, var... t] -> "h=$h, t=$t" + default -> 'no' + } + assert r2 == 'h=1, t=[]' + def r3 = switch (['a', 'b']) { + case [... t] -> t // `... t` is a shortcut for `var... t` + default -> 'no' + } + assert r3 == ['a', 'b'] + def r4 = switch (1..4) { + case [var first, var... middle, var last] -> "$first, $middle, $last" + default -> 'no' + } + assert r4 == '1, [2, 3], 4' + ''' + } + + @Test + void testEmptyAndBareRestListPatterns() { + assertScript ''' + def shape(x) { + switch (x) { + case [] -> 'empty' + case [...] -> 'non-empty' + default -> 'not a list' + } + } + assert shape([]) == 'empty' + assert shape([1, 2]) == 'non-empty' + assert shape(42) == 'not a list' + // without a preceding empty case, `[...]` matches any list, including empty + def r = switch ([]) { + case [...] -> 'any list' + default -> 'no' + } + assert r == 'any list' + ''' + } + + @Test + void testListPatternTypedElements() { + assertScript ''' + def r = switch ([1, 'x']) { + case [Integer i, String s] -> "$i-$s" + default -> 'no' + } + assert r == '1-x' + def r2 = switch (['x', 1]) { + case [Integer i, String s] -> "$i-$s" + default -> 'no' + } + assert r2 == 'no' + def r3 = switch ([1, null]) { + case [Integer i, String s] -> "$i-$s" // typed element does not match null + case [Integer i, var v] -> "$i:$v" // var element does + default -> 'no' + } + assert r3 == '1:null' + ''' + } + + @Test + void testListPatternTypedRest() { + assertScript ''' + def sum(x) { + switch (x) { + case [Integer... nums] -> nums.sum() ?: 0 + default -> 'not all ints' + } + } + assert sum([1, 2, 3]) == 6 + assert sum([]) == 0 + assert sum([1, 'x']) == 'not all ints' + ''' + } + + @Test + void testListPatternLiteralElements() { + assertScript ''' + def r = switch ([1, 9, 8]) { + case [1, var x, ...] -> "starts with 1, then $x" + default -> 'no' + } + assert r == 'starts with 1, then 9' + def r2 = switch ([2, 9]) { + case [1, var x, ...] -> 'yes' + default -> 'no' + } + assert r2 == 'no' + ''' + } + + @Test + void testListPatternGuard() { + assertScript ''' + def r = switch ([1, 2]) { + case [var a, var b] when a < b -> 'ascending' + case [var a, var b] -> 'other pair' + default -> 'no' + } + assert r == 'ascending' + def r2 = switch ([2, 1]) { + case [var a, var b] when a < b -> 'ascending' + case [var a, var b] -> 'other pair' + default -> 'no' + } + assert r2 == 'other pair' + ''' + } + + @Test + void testNestedPatternsInListPattern() { + assertScript ''' + record Point(int x, int y) {} + def r = switch ([new Point(3, 4), 'z']) { + case [Point(var x, _), var tag] -> "$x-$tag" + default -> 'no' + } + assert r == '3-z' + def r2 = switch ([[1], 2]) { + case [[var a], var b] -> "$a, $b" + default -> 'no' + } + assert r2 == '1, 2' + def r3 = switch ([1, 2]) { + case [[var a], var b] -> "$a, $b" // 1 is not destructurable + default -> 'no' + } + assert r3 == 'no' + ''' + } + + @Test + void testListPatternDestructuresArraysAndIterables() { + assertScript ''' + def describe(x) { + switch (x) { + case [var a, var... rest] -> "$a+${rest.size()}" + default -> 'no' + } + } + assert describe(new int[] {7, 8, 9}) == '7+2' + assert describe(new String[] {'a'}) == 'a+0' + assert describe([10, 20] as LinkedHashSet) == '10+1' + ''' + } + + @Test + void testListPatternWildcardElements() { + assertScript ''' + def r = switch ([1, 2, 3]) { + case [var _, var x, var _] -> x // `var _` is bind-and-discard + default -> 'no' + } + assert r == 2 + def r2 = switch ([1, 2, 3, 4]) { + case [var h, ... _] -> h + default -> 'no' + } + assert r2 == 1 + ''' + } + + @Test + void testListPatternCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + @CompileStatic + def m(Object o) { + switch (o) { + case [Integer a, var... t] -> a + t.size() + case [] -> 0 + default -> -1 + } + } + assert m([5, 'x', 'y']) == 7 + assert m([]) == 0 + assert m('s') == -1 + ''' + } + + @Test + void testLegacyListLiteralLabelsKeepIsCaseSemantics() { + // a list literal without a binding form keeps its legacy containment semantics + assertScript ''' + def r = switch (2) { + case [1, 2, 3] -> 'contained' + default -> 'no' + } + assert r == 'contained' + def r2 = switch (5) { + case [1, 2, 3] -> 'contained' + default -> 'no' + } + assert r2 == 'no' + def r3 = switch (2) { + case [1, 2, 3]: yield 'contained' // colon form stays legacy as well + default: yield 'no' + } + assert r3 == 'contained' + def r4 = switch ([1, 2]) { + case [[1, 2], [3, 4]] -> 'contained' // nested literals stay legacy too + default -> 'no' + } + assert r4 == 'contained' + def r5 = switch (3) { + case [1, 2, 3] -> 'contained' // legacy containment amid pattern labels + case Integer i -> "int $i" + default -> 'no' + } + assert r5 == 'contained' + def r6 = switch (7) { + case [1, 2, 3] -> 'contained' + case Integer i -> "int $i" + default -> 'no' + } + assert r6 == 'int 7' + ''' + } + + @Test + void testGuardOnLegacyListLabelRejected() { + def err = shouldFail ''' + def r = switch (2) { + case [1, 2, 3] when true -> 'x' + default -> 'y' + } + ''' + assert err.message.contains('`when` guards are only supported on pattern labels') + } + + @Test + void testListPatternSupportsAtMostOneRestBinding() { + def err = shouldFail ''' + def r = switch ([1, 2]) { + case [var... a, var... b] -> 'x' + default -> 'y' + } + ''' + assert err.message.contains('at most one rest binding') + } + + @Test + void testListPatternIncompatibleSubjectRejectedWhenTypeChecked() { + def err = shouldFail ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Integer i) { + switch (i) { + case [var a] -> a + default -> 'other' + } + } + ''' + assert err.message.contains('list pattern is incompatible with the switch subject type') + } + + @Test + void testListPatternExhaustivenessUnassessed() { + // a list pattern is always conditional, so no exhaustiveness warning is issued + assert patternSwitchWarnings(''' + import groovy.transform.TypeChecked + @TypeChecked + def m(List l) { + switch (l) { + case [var a] -> a + case [] -> 0 + } + } + ''').isEmpty() + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index 0a80288cbf1..6d22c9c220c 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -533,6 +533,7 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_26x.groovy') doRunAndTestAntlr4('core/SwitchExpression_27x.groovy') doRunAndTestAntlr4('core/SwitchExpression_28x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_29x.groovy') } @Test diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index 437cdd549c4..8ee4fc7e774 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -568,6 +568,7 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/SwitchExpression_11x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_12x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_13x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_14x.groovy') } @NotYetImplemented @Test From 7683e055c0b9b2cb6d9fc87b25043e5a714cfb6c Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 06:09:51 +1000 Subject: [PATCH 6/9] GEP-19: structural pattern matching in switch (phase 6: map patterns) Map patterns destructure Map values in switch case labels with open semantics: a pattern matches if all named keys are present and their value patterns match; extra entries are ignored unless captured by a rest binding (... rest binds the remaining entries as a map copy, bare ... discards them). Entry values accept literals (matched by equality), type patterns, var/def bindings and nested record/list/map patterns; map patterns also nest within list patterns. Keys must be constants. [:] is the empty map pattern. A [k: v, ...] literal without a binding form, rest or nested pattern keeps its legacy isCase (boolean lookup) semantics, including the colon form. Destructuring goes through the new MapPatternSupport runtime helper. The static type checker rejects a map pattern whose subject provably cannot be a Map and treats map patterns as conditional for exhaustiveness. Not valid in instanceof (no type at the head), per the GEP. Assisted-by: Claude Code (Claude Fable 5) --- src/antlr/GroovyParser.g4 | 19 +- .../groovy/parser/antlr4/AstBuilder.java | 229 ++++++++++++++++-- .../groovy/runtime/MapPatternSupport.java | 64 +++++ .../stc/StaticTypeCheckingVisitor.java | 5 + .../core/SwitchExpression_30x.groovy | 57 +++++ .../fail/SwitchExpression_15x.groovy | 25 ++ .../groovy/SwitchPatternMatchingTest.groovy | 190 +++++++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 1 + .../parser/antlr4/SyntaxErrorTest.groovy | 1 + 9 files changed, 572 insertions(+), 19 deletions(-) create mode 100644 src/main/java/org/apache/groovy/runtime/MapPatternSupport.java create mode 100644 src/test-resources/core/SwitchExpression_30x.groovy create mode 100644 src/test-resources/fail/SwitchExpression_15x.groovy diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 46851d97d16..7a8d0ca8762 100644 --- a/src/antlr/GroovyParser.g4 +++ b/src/antlr/GroovyParser.g4 @@ -797,7 +797,7 @@ switchExpressionLabel // GEP-19 structural pattern matching: pattern forms usable in case labels casePattern - : (typePattern | recordPattern | listPattern) (nls caseGuard)? + : (typePattern | recordPattern | listPattern | mapPattern) (nls caseGuard)? ; typePattern @@ -837,6 +837,7 @@ listPatternElement : listPatternRest | recordPattern | listPattern + | mapPattern | (DEF | VAR) identifier | typePattern | expression @@ -848,6 +849,22 @@ listPatternRest : (DEF | VAR | standardType)? ELLIPSIS identifier? ; +// like listPattern, this rule over-matches: whether a `[k: v, ...]` literal is a +// pattern or keeps legacy isCase semantics is decided in the AST builder +mapPattern + : LBRACK + ( mapPatternEntry (COMMA mapPatternEntry)* + | COLON + ) + RBRACK + ; + +// `... rest` binds the entries not named by the pattern; `...` discards them +mapPatternEntry + : mapEntryLabel COLON nls listPatternElement + | ELLIPSIS identifier? + ; + caseGuard : { "when".equals(_input.LT(1).getText()) }? identifier nls expression ; 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 d95fc921560..109faa51e22 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -43,6 +43,7 @@ import org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager; import org.apache.groovy.parser.antlr4.util.StringUtils; import org.apache.groovy.runtime.ListPatternSupport; +import org.apache.groovy.runtime.MapPatternSupport; import org.apache.groovy.runtime.RecordPatternSupport; import org.apache.groovy.util.Maps; import org.apache.groovy.util.SystemUtil; @@ -1127,25 +1128,47 @@ private static boolean containsCasePattern(final SwitchExpressionContext ctx) { private static boolean isPatternLabel(final CasePatternContext ctx) { if (!asBoolean(ctx)) return false; - // a `[...]` label parses via the broader listPattern rule; only a + // a `[...]` label parses via the broader listPattern/mapPattern rules; only a // pattern-shaped literal is treated as a pattern (see isPatternShapedList) - return !asBoolean(ctx.listPattern()) || isPatternShapedList(ctx.listPattern()); + if (asBoolean(ctx.listPattern())) return isPatternShapedList(ctx.listPattern()); + if (asBoolean(ctx.mapPattern())) return isPatternShapedMap(ctx.mapPattern()); + return true; } /** * Whether a {@code [...]} literal is a list pattern (GEP-19) rather than a legacy * {@code isCase} label: it is a pattern if and only if it is empty, or some element * is a binding form (a rest binding, a {@code var}/{@code def} binding or a type - * pattern) or a nested pattern (a record pattern or a pattern-shaped list literal). + * pattern) or a nested pattern (a record pattern or a pattern-shaped list or map + * literal). */ private static boolean isPatternShapedList(final ListPatternContext ctx) { if (!asBoolean(ctx.listPatternElements())) return true; // `[]` is the empty list pattern for (ListPatternElementContext elementCtx : ctx.listPatternElements().listPatternElement()) { - if (asBoolean(elementCtx.listPatternRest()) - || asBoolean(elementCtx.recordPattern()) - || asBoolean(elementCtx.typePattern()) - || null != elementCtx.DEF() || null != elementCtx.VAR() - || (asBoolean(elementCtx.listPattern()) && isPatternShapedList(elementCtx.listPattern()))) { + if (isPatternShapedElement(elementCtx)) return true; + } + return false; + } + + private static boolean isPatternShapedElement(final ListPatternElementContext ctx) { + return asBoolean(ctx.listPatternRest()) + || asBoolean(ctx.recordPattern()) + || asBoolean(ctx.typePattern()) + || null != ctx.DEF() || null != ctx.VAR() + || (asBoolean(ctx.listPattern()) && isPatternShapedList(ctx.listPattern())) + || (asBoolean(ctx.mapPattern()) && isPatternShapedMap(ctx.mapPattern())); + } + + /** + * Whether a {@code [k: v, ...]} literal is a map pattern (GEP-19) rather than a + * legacy {@code isCase} label: it is a pattern if and only if it is empty + * ({@code [:]}), or it has a rest binding, or some entry value is a binding form + * or a nested pattern. + */ + private static boolean isPatternShapedMap(final MapPatternContext ctx) { + if (ctx.mapPatternEntry().isEmpty()) return true; // `[:]` is the empty map pattern + for (MapPatternEntryContext entryCtx : ctx.mapPatternEntry()) { + if (asBoolean(entryCtx.ELLIPSIS()) || isPatternShapedElement(entryCtx.listPatternElement())) { return true; } } @@ -1300,12 +1323,15 @@ public Tuple3, Integer> visitSwitchExpressionLabel(Switc if (asBoolean(ctx.CASE())) { if (asBoolean(ctx.casePattern())) { if (!isPatternLabel(ctx.casePattern())) { - // a plain list literal that parsed via the broader listPattern rule, - // e.g. `case [1, 2, 3]:` -- it keeps its legacy isCase semantics + // a plain list or map literal that parsed via the broader listPattern or + // mapPattern rule, e.g. `case [1, 2, 3]:` -- it keeps its legacy isCase semantics if (asBoolean(ctx.casePattern().caseGuard())) { - throw createParsingFailedException("`when` guards are only supported on pattern labels; a list pattern needs a binding form among its elements", ctx.casePattern().caseGuard()); + throw createParsingFailedException("`when` guards are only supported on pattern labels; a list or map pattern needs a binding form", ctx.casePattern().caseGuard()); } - return tuple(ctx.CASE().getSymbol(), Collections.singletonList(this.rebuildListExpression(ctx.casePattern().listPattern())), acType); + Expression legacyLabel = asBoolean(ctx.casePattern().listPattern()) + ? this.rebuildListExpression(ctx.casePattern().listPattern()) + : this.rebuildMapExpression(ctx.casePattern().mapPattern()); + return tuple(ctx.CASE().getSymbol(), Collections.singletonList(legacyLabel), acType); } if (ARROW != acType) { throw createParsingFailedException("`case` with a pattern label supports only the arrow form (`->`)", ctx.casePattern()); @@ -1338,6 +1364,9 @@ public Expression visitCasePattern(final CasePatternContext ctx) { if (asBoolean(ctx.listPattern())) { return this.createListPatternLabel(ctx, guard); } + if (asBoolean(ctx.mapPattern())) { + return this.createMapPatternLabel(ctx, guard); + } TypePatternContext typePatternCtx = ctx.typePattern(); ClassNode type = this.visitType(typePatternCtx.type()); @@ -1568,6 +1597,14 @@ private PatternNode buildListPatternElement(final ListPatternElementContext ctx) element.constant = this.rebuildListExpression(ctx.listPattern()); return element; } + if (asBoolean(ctx.mapPattern())) { + if (isPatternShapedMap(ctx.mapPattern())) { + return this.buildMapPattern(ctx.mapPattern()); + } + PatternNode element = new PatternNode(); // a plain map literal element is matched by equality + element.constant = this.rebuildMapExpression(ctx.mapPattern()); + return element; + } PatternNode element = new PatternNode(); if (asBoolean(ctx.typePattern())) { element.type = this.visitType(ctx.typePattern().type()); @@ -1587,14 +1624,29 @@ private Expression rebuildListExpression(final ListPatternContext ctx) { List expressions = new ArrayList<>(); if (asBoolean(ctx.listPatternElements())) { for (ListPatternElementContext elementCtx : ctx.listPatternElements().listPatternElement()) { - expressions.add(asBoolean(elementCtx.listPattern()) - ? this.rebuildListExpression(elementCtx.listPattern()) - : (Expression) this.visit(elementCtx.expression())); + expressions.add(this.rebuildElementExpression(elementCtx)); } } return configureAST(new ListExpression(expressions), ctx); } + /** Rebuilds a legacy map expression from a {@code [k: v, ...]} literal that parsed via the mapPattern rule but is not pattern-shaped. */ + private Expression rebuildMapExpression(final MapPatternContext ctx) { + List entries = new ArrayList<>(); + for (MapPatternEntryContext entryCtx : ctx.mapPatternEntry()) { + Expression keyExpr = this.visitMapEntryLabel(entryCtx.mapEntryLabel()); + Expression valueExpr = this.rebuildElementExpression(entryCtx.listPatternElement()); + entries.add(configureAST(new MapEntryExpression(keyExpr, valueExpr), entryCtx)); + } + return configureAST(new MapExpression(entries), ctx); + } + + private Expression rebuildElementExpression(final ListPatternElementContext ctx) { + if (asBoolean(ctx.listPattern())) return this.rebuildListExpression(ctx.listPattern()); + if (asBoolean(ctx.mapPattern())) return this.rebuildMapExpression(ctx.mapPattern()); + return (Expression) this.visit(ctx.expression()); + } + /** * Emits the destructuring of a list pattern rooted at {@code source}. With * {@code withChecks}, the shape and element checks guard each step (for use in @@ -1649,6 +1701,8 @@ private void appendListPatternExtraction(final PatternNode pattern, final Expres if (element.components != null) { // nested pattern if (element.list) { appendListPatternExtraction(element, elementVar, statements, withChecks); + } else if (element.map) { + appendMapPatternExtraction(element, elementVar, statements, withChecks); } else if (withChecks) { appendRecordPatternChecks(element, elementVar, statements); } else { @@ -1666,6 +1720,141 @@ private void appendListPatternExtraction(final PatternNode pattern, final Expres } } + /** + * Builds a closure case label for a map pattern (GEP-19), e.g. + * {@code case [name: String n, ... rest] when n ->} becomes: + *
+     * case { __$$pc1 -> Map __$$mv2 = MapPatternSupport.entriesOrNull(__$$pc1)
+     *                   if (__$$mv2 == null) return false
+     *                   if (!__$$mv2.containsKey('name')) return false
+     *                   def __$$me3 = __$$mv2.get('name')
+     *                   if (!(__$$me3 instanceof String)) return false
+     *                   String n = (String) __$$me3
+     *                   Map rest = MapPatternSupport.rest(__$$mv2, ['name'])
+     *                   return n }:
+     * 
+ * Matching is open: all named keys must be present and their value patterns + * match; extra entries are ignored unless captured by a rest binding. The + * empty map pattern {@code [:]} matches only an empty map. + */ + private Expression createMapPatternLabel(final CasePatternContext ctx, final Expression guard) { + PatternNode pattern = this.buildMapPattern(ctx.mapPattern()); + String candidateName = "__$$pc" + switchPatternVariableSeq++; + Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); + List checkStatements = new ArrayList<>(); + appendMapPatternExtraction(pattern, varX(candidateName), checkStatements, true); + checkStatements.add(returnS(guard != null ? guard : constX(Boolean.TRUE, true))); + Expression labelExpr = configureAST(closureX(params(candidate), createBlockStatement(checkStatements)), ctx); + + // read by StaticTypeCheckingVisitor to check pattern switch case labels; + // a map pattern has no type at the head and is always conditional + labelExpr.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); + labelExpr.putNodeMetaData(SWITCH_PATTERN_MAP, Boolean.TRUE); + + List bindingDecls = new ArrayList<>(); + if (pattern.hasBindings()) { + appendMapPatternExtraction(pattern, varX(switchPatternSubjectStack.peek()), bindingDecls, false); + } + labelExpr.putNodeMetaData(SWITCH_TYPE_PATTERN_BINDING, bindingDecls); + return labelExpr; + } + + private PatternNode buildMapPattern(final MapPatternContext ctx) { + PatternNode pattern = new PatternNode(); + pattern.map = true; + pattern.components = new ArrayList<>(); + boolean restSeen = false; + for (MapPatternEntryContext entryCtx : ctx.mapPatternEntry()) { + PatternNode entry; + if (asBoolean(entryCtx.ELLIPSIS())) { + if (restSeen) { + throw createParsingFailedException("a map pattern supports at most one rest binding", entryCtx); + } + restSeen = true; + entry = new PatternNode(); + entry.rest = true; + if (asBoolean(entryCtx.identifier())) { + String name = this.visitIdentifier(entryCtx.identifier()); + entry.name = "_".equals(name) ? null : name; // `_` is bind-and-discard + } + } else { + Expression keyExpr = this.visitMapEntryLabel(entryCtx.mapEntryLabel()); + if (!(keyExpr instanceof ConstantExpression)) { + throw createParsingFailedException("map pattern keys must be constants", entryCtx.mapEntryLabel()); + } + entry = this.buildListPatternElement(entryCtx.listPatternElement()); + if (entry.rest) { + throw createParsingFailedException("a rest binding is not supported as a map pattern value", entryCtx.listPatternElement()); + } + entry.key = ((ConstantExpression) keyExpr).getValue(); + } + pattern.components.add(entry); + } + return pattern; + } + + /** + * Emits the destructuring of a map pattern rooted at {@code source}; the + * {@code withChecks} contract matches {@link #appendListPatternExtraction}. + * Every named key requires a presence check even when its value pattern is a + * wildcard, since open matching is defined over the keys. + */ + private void appendMapPatternExtraction(final PatternNode pattern, final Expression source, final List statements, final boolean withChecks) { + String entriesName = "__$$mv" + switchPatternVariableSeq++; + statements.add(declS(localVarX(entriesName, ClassHelper.MAP_TYPE.getPlainNodeReference()), + callX(classX(MAP_PATTERN_SUPPORT_TYPE), "entriesOrNull", args(source)))); + if (withChecks) { + statements.add(ifS(eqX(varX(entriesName), nullX()), returnS(constX(Boolean.FALSE, true)))); + if (pattern.components.isEmpty()) { // `[:]` matches only an empty map + statements.add(ifS(notX(callX(varX(entriesName), "isEmpty")), returnS(constX(Boolean.FALSE, true)))); + } + } + for (PatternNode entry : pattern.components) { + if (!withChecks && !entry.hasBindings()) continue; + if (entry.rest) { + if (entry.name == null) continue; // `...` just relaxes the match, which is open anyway + List namedKeys = new ArrayList<>(); + for (PatternNode named : pattern.components) { + if (!named.rest) namedKeys.add(constX(named.key)); + } + statements.add(declS(localVarX(entry.name, ClassHelper.MAP_TYPE.getPlainNodeReference()), + callX(classX(MAP_PATTERN_SUPPORT_TYPE), "rest", args(varX(entriesName), listX(namedKeys))))); + continue; + } + if (withChecks) { + statements.add(ifS(notX(callX(varX(entriesName), "containsKey", args(constX(entry.key)))), returnS(constX(Boolean.FALSE, true)))); + } + Expression valueAccess = callX(varX(entriesName), "get", args(constX(entry.key))); + if (entry.constant != null) { + statements.add(ifS(notX(eqX(valueAccess, entry.constant)), returnS(constX(Boolean.FALSE, true)))); + continue; + } + if (entry.isUncheckedWildcard()) continue; // presence check only + String valueName = "__$$me" + switchPatternVariableSeq++; + statements.add(declS(localVarX(valueName), valueAccess)); + Expression valueVar = varX(valueName); + if (entry.components != null) { // nested pattern + if (entry.list) { + appendListPatternExtraction(entry, valueVar, statements, withChecks); + } else if (entry.map) { + appendMapPatternExtraction(entry, valueVar, statements, withChecks); + } else if (withChecks) { + appendRecordPatternChecks(entry, valueVar, statements); + } else { + appendRecordPatternExtraction(entry, valueVar, statements, false); + } + } else { + if (withChecks && entry.type != null) { + statements.add(ifS(notX(isInstanceOfX(valueVar, ClassHelper.getWrapper(entry.type))), returnS(constX(Boolean.FALSE, true)))); + } + if (entry.name != null) { + statements.add(declS(localVarX(entry.name, entry.type != null ? entry.type : ClassHelper.dynamicType()), + entry.type != null ? castX(entry.type, valueVar) : valueVar)); + } + } + } + } + /** * Builds the lowered expression for a record pattern in {@code instanceof} (GEP-19), * a conjunction of ordinary expressions and {@code instanceof} bindings (JEP 394), e.g. @@ -1732,10 +1921,12 @@ private void appendRecordPatternInstanceofConjuncts(final PatternNode pattern, f private static class PatternNode { ClassNode type; // null for an untyped (var/def) binding or a bare wildcard; the element type for a typed rest binding String name; // null for a wildcard or a nested pattern - List components; // non-null for a record or list pattern + List components; // non-null for a record, list or map pattern boolean list; // components are list pattern elements rather than record components - boolean rest; // a rest binding element within a list pattern - Expression constant; // a literal element within a list pattern, matched by equality + boolean map; // components are map pattern entries rather than record components + boolean rest; // a rest binding element within a list or map pattern + Expression constant; // a literal element within a list or map pattern, matched by equality + Object key; // the constant key of a map pattern entry boolean isUncheckedWildcard() { return type == null && name == null && components == null && constant == null; @@ -5585,8 +5776,10 @@ public List getDeclarationExpressions() { private static final String SWITCH_PATTERN_GUARDED = "_SWITCH_PATTERN_GUARDED"; private static final String SWITCH_PATTERN_RECORD = "_SWITCH_PATTERN_RECORD"; private static final String SWITCH_PATTERN_LIST = "_SWITCH_PATTERN_LIST"; + private static final String SWITCH_PATTERN_MAP = "_SWITCH_PATTERN_MAP"; private static final ClassNode RECORD_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(RecordPatternSupport.class); private static final ClassNode LIST_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(ListPatternSupport.class); + private static final ClassNode MAP_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(MapPatternSupport.class); private static final String IS_NUMERIC = "_IS_NUMERIC"; private static final String IS_STRING = "_IS_STRING"; private static final String IS_INTERFACE_WITH_DEFAULT_METHODS = "_IS_INTERFACE_WITH_DEFAULT_METHODS"; diff --git a/src/main/java/org/apache/groovy/runtime/MapPatternSupport.java b/src/main/java/org/apache/groovy/runtime/MapPatternSupport.java new file mode 100644 index 00000000000..037c7386714 --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/MapPatternSupport.java @@ -0,0 +1,64 @@ +/* + * 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 org.apache.groovy.lang.annotation.Incubating; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Runtime destructuring support for map patterns (GEP-19) — the emit + * target of the pattern switch lowering in the parser. + *

+ * Map patterns destructure {@code Map} values with open semantics: a pattern + * matches if all named keys are present and their value patterns match; extra + * entries are ignored unless captured by a rest binding. Other values (and + * {@code null}) do not match. + * + * @since 6.0.0 + */ +@Incubating +public final class MapPatternSupport { + + private MapPatternSupport() { + } + + /** + * Returns the given value as a map of its entries, or {@code null} when the + * value is not destructurable by a map pattern. + */ + @SuppressWarnings("unchecked") + public static Map entriesOrNull(final Object value) { + return value instanceof Map ? (Map) value : null; + } + + /** + * Returns a copy of the entries bound by a rest binding: those whose keys are + * not named by the map pattern. + */ + public static Map rest(final Map entries, final List namedKeys) { + Map result = new LinkedHashMap<>(entries); + for (Object key : namedKeys) { + result.remove(key); + } + return result; + } +} diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index f8a17482777..24a65ec0992 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -4871,6 +4871,11 @@ private void checkPatternSwitchLabels(final SwitchStatement statement) { // a list pattern destructures List, array and Iterable values only addStaticTypeError("The list pattern is incompatible with the switch subject type " + prettyPrintTypeName(subjectType), label); } + if (Boolean.TRUE.equals(label.getNodeMetaData("_SWITCH_PATTERN_MAP")) + && provablyDisjoint(subjectType, MAP_TYPE)) { + // a map pattern destructures Map values only + addStaticTypeError("The map pattern is incompatible with the switch subject type " + prettyPrintTypeName(subjectType), label); + } if (!isNullConstant(label)) opaqueLabels = true; continue; } diff --git a/src/test-resources/core/SwitchExpression_30x.groovy b/src/test-resources/core/SwitchExpression_30x.groovy new file mode 100644 index 00000000000..dba1acab5e8 --- /dev/null +++ b/src/test-resources/core/SwitchExpression_30x.groovy @@ -0,0 +1,57 @@ +/* + * 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. + */ + +// GEP-19: map patterns in switch expressions + +def describe(x) { + switch (x) { + case [:] -> 'empty map' + case [type: 'circle', radius: var r] -> "circle r=$r" + case [name: String n, ... rest] -> "named $n; others=${rest.size()}" + case [id: var i, ...] when i > 0 -> "id $i" + default -> 'other' + } +} + +assert describe([:]) == 'empty map' +assert describe([type: 'circle', radius: 5]) == 'circle r=5' +assert describe([name: 'sam', age: 42]) == 'named sam; others=1' +assert describe([id: 7, x: 0]) == 'id 7' +assert describe([id: -1]) == 'other' +assert describe('hello') == 'other' + +// nested patterns as entry values and map patterns within list patterns +record Point(int x, int y) {} +def nested = switch ([origin: new Point(3, 4), tags: ['a', 'b']]) { + case [origin: Point(var x, _), tags: [var first, ...]] -> "$x-$first" + default -> 'other' +} +assert nested == '3-a' +def inList = switch ([[a: 1], 'x']) { + case [[a: var v], var tag] -> "$v-$tag" + default -> 'other' +} +assert inList == '1-x' + +// a map literal without a binding form keeps its legacy lookup semantics +def legacy = switch ('a') { + case [a: true, b: false] -> 'truthy value' + default -> 'no' +} +assert legacy == 'truthy value' diff --git a/src/test-resources/fail/SwitchExpression_15x.groovy b/src/test-resources/fail/SwitchExpression_15x.groovy new file mode 100644 index 00000000000..36a3416e567 --- /dev/null +++ b/src/test-resources/fail/SwitchExpression_15x.groovy @@ -0,0 +1,25 @@ +/* + * 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. + */ + +// GEP-19: map pattern keys must be constants +def k = 'name' +def result = switch ([name: 'sam']) { + case [("$k"): var n] -> n + default -> 'other' +} diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index a9fae8d1027..6aa858512c8 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -804,6 +804,196 @@ final class SwitchPatternMatchingTest { ''').isEmpty() } + @Test + void testMapPatternDispatch() { + assertScript ''' + def describe(x) { + switch (x) { + case [:] -> 'empty map' + case [name: var n, age: var a] -> "person $n, $a" + case [name: var n] -> "named $n" + default -> 'other' + } + } + assert describe([:]) == 'empty map' + assert describe([name: 'sam', age: 42]) == 'person sam, 42' + assert describe([name: 'sam', age: 42, id: 1]) == 'person sam, 42' // open: extra keys ignored + assert describe([name: 'dee']) == 'named dee' + assert describe([age: 42]) == 'other' // required key missing + assert describe([1, 2]) == 'other' // not a map + assert describe(null) == 'other' // patterns do not match null + ''' + } + + @Test + void testMapPatternLiteralAndTypedValues() { + assertScript ''' + def r = switch ([type: 'circle', radius: 5]) { + case [type: 'circle', radius: var r] -> "circle r=$r" + case [type: 'square', side: var s] -> "square s=$s" + default -> 'other' + } + assert r == 'circle r=5' + def r2 = switch ([name: 42]) { + case [name: String n] -> "string $n" // typed value does not match + case [name: var v] -> "any $v" + default -> 'other' + } + assert r2 == 'any 42' + def r3 = switch ([name: null]) { + case [name: String n] -> "string $n" // typed value does not match null + case [name: var v] -> "present $v" // var does, if the key is present + default -> 'other' + } + assert r3 == 'present null' + ''' + } + + @Test + void testMapPatternRest() { + assertScript ''' + def r = switch ([name: 'sam', age: 42, id: 1]) { + case [name: String n, ... rest] -> "named $n; others=$rest" + default -> 'other' + } + assert r == "named sam; others=[age:42, id:1]" + def r2 = switch ([name: 'sam']) { + case [name: String n, ... rest] -> rest + default -> 'other' + } + assert r2 == [:] + def r3 = switch ([name: 'dee', x: 1]) { + case [name: def n, ...] -> "any named map ($n)" // others discarded + default -> 'other' + } + assert r3 == 'any named map (dee)' + ''' + } + + @Test + void testMapPatternGuard() { + assertScript ''' + def r = switch ([age: 42]) { + case [age: Integer a] when a >= 18 -> 'adult' + case [age: Integer a] -> 'minor' + default -> 'other' + } + assert r == 'adult' + def r2 = switch ([age: 7]) { + case [age: Integer a] when a >= 18 -> 'adult' + case [age: Integer a] -> 'minor' + default -> 'other' + } + assert r2 == 'minor' + ''' + } + + @Test + void testNestedPatternsInMapPattern() { + assertScript ''' + record Point(int x, int y) {} + def r = switch ([origin: new Point(3, 4), tags: ['a', 'b']]) { + case [origin: Point(var x, _), tags: [var first, ...]] -> "$x-$first" + default -> 'other' + } + assert r == '3-a' + def r2 = switch ([outer: [inner: 7]]) { + case [outer: [inner: var v]] -> v + default -> 'other' + } + assert r2 == 7 + def r3 = switch ([[a: 1], 'x']) { + case [[a: var v], var tag] -> "$v-$tag" // map pattern nested in a list pattern + default -> 'other' + } + assert r3 == '1-x' + ''' + } + + @Test + void testMapPatternCompileStatic() { + assertScript ''' + import groovy.transform.CompileStatic + @CompileStatic + def m(Object o) { + switch (o) { + case [name: String n, ... rest] -> n + rest.size() + case [:] -> 'empty' + default -> 'other' + } + } + assert m([name: 'sam', x: 1, y: 2]) == 'sam2' + assert m([:]) == 'empty' + assert m('s') == 'other' + ''' + } + + @Test + void testLegacyMapLiteralLabelsKeepIsCaseSemantics() { + // a map literal without a binding form keeps its legacy lookup semantics + assertScript ''' + def r = switch ('a') { + case [a: true, b: false] -> 'truthy value' + default -> 'no' + } + assert r == 'truthy value' + def r2 = switch ('b') { + case [a: true, b: false] -> 'truthy value' + default -> 'no' + } + assert r2 == 'no' + def r3 = switch ('a') { + case [a: [1, 2]]: yield 'truthy value' // colon form stays legacy as well + default: yield 'no' + } + assert r3 == 'truthy value' + def r4 = switch ('a') { + case [a: true] -> 'legacy amid pattern labels' + case String s -> "string $s" + default -> 'no' + } + assert r4 == 'legacy amid pattern labels' + ''' + } + + @Test + void testMapPatternKeysMustBeConstants() { + def err = shouldFail ''' + def k = 'name' + def r = switch ([name: 'sam']) { + case [("$k".toString()): var n] -> n + default -> 'other' + } + ''' + assert err.message.contains('map pattern keys must be constants') + } + + @Test + void testMapPatternSupportsAtMostOneRestBinding() { + def err = shouldFail ''' + def r = switch ([a: 1]) { + case [a: var v, ... r1, ... r2] -> 'x' + default -> 'y' + } + ''' + assert err.message.contains('at most one rest binding') + } + + @Test + void testMapPatternIncompatibleSubjectRejectedWhenTypeChecked() { + def err = shouldFail ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(Integer i) { + switch (i) { + case [name: var n] -> n + default -> 'other' + } + } + ''' + assert err.message.contains('map pattern is incompatible with the switch subject type') + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index 6d22c9c220c..2fc8dac724c 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -534,6 +534,7 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_27x.groovy') doRunAndTestAntlr4('core/SwitchExpression_28x.groovy') doRunAndTestAntlr4('core/SwitchExpression_29x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_30x.groovy') } @Test diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy index 8ee4fc7e774..044165b27a1 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/SyntaxErrorTest.groovy @@ -569,6 +569,7 @@ final class SyntaxErrorTest { TestUtils.doRunAndShouldFail('fail/SwitchExpression_12x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_13x.groovy') TestUtils.doRunAndShouldFail('fail/SwitchExpression_14x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_15x.groovy') } @NotYetImplemented @Test From 8a29eb47945a90c94ec66fb417911638e0455ac6 Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 07:13:09 +1000 Subject: [PATCH 7/9] GEP-19: structural pattern matching in switch (phase 8: closure-free lowering) A switch expression whose case labels are all patterns now lowers each arm to nested if statements inside its own block instead of closure case labels: check steps become guarding ifs and binding steps (including the JEP 394 instanceof bindings) execute between them, with the when guard as the innermost check. Statement nesting short-circuits and every binding declaration dominates its reads, per-arm scoping lets pattern variable names repeat across arms, no per-arm closure is allocated or dispatched through Closure.isCase, and record/list/map patterns are destructured once per match rather than twice (pinned by a new test). Measured on a mixed five-way dispatch (1M iterations): ~1.4x faster dynamic, ~6x faster under @CompileStatic, where arms compile to plain INSTANCEOF branches. Switches mixing patterns with legacy labels keep the closure-label lowering. The static type checker runs the same pattern label checks over the arm metadata now attached to the generated closure (which, unlike the call expression, survives ResolveVisitor's rebuild). Emitting SwitchBootstraps.typeSwitch indy dispatch remains future work for when a Java 21+ bytecode target is available; JEP 507 primitive patterns remain deferred. Assisted-by: Claude Code (Claude Fable 5) --- .../groovy/parser/antlr4/AstBuilder.java | 244 +++++++++++++++++- .../stc/StaticTypeCheckingVisitor.java | 28 +- .../groovy/SwitchPatternMatchingTest.groovy | 40 +++ 3 files changed, 297 insertions(+), 15 deletions(-) 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 109faa51e22..acc41353a17 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -178,6 +178,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.declS; import static org.codehaus.groovy.ast.tools.GeneralUtils.declX; import static org.codehaus.groovy.ast.tools.GeneralUtils.eqX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.geX; import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS; import static org.codehaus.groovy.ast.tools.GeneralUtils.isInstanceOfX; import static org.codehaus.groovy.ast.tools.GeneralUtils.listX; @@ -1058,7 +1059,10 @@ public Expression visitSwitchExprAlt(final SwitchExprAltContext ctx) { public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext ctx) { switchExpressionRuleContextStack.push(ctx); boolean hasPattern = containsCasePattern(ctx); + // when every case label is a pattern, a faster closure-free lowering applies + boolean allPatterns = hasPattern && allCasePatterns(ctx); switchPatternSubjectStack.push(hasPattern ? "__$$pv" + switchPatternVariableSeq++ : ""); + switchPatternFastStack.push(allPatterns); try { validateSwitchExpressionLabels(ctx); List, Boolean, Boolean>> statementsAndArrowAndYieldOrThrow = @@ -1093,33 +1097,71 @@ public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext Expression subject = this.visitExpressionInPar(ctx.expressionInPar()); String subjectName = switchPatternSubjectStack.peek(); - SwitchStatement switchStatement = new SwitchStatement( - hasPattern ? varX(subjectName) : subject, - caseStatements, - defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE - ); - if (hasPattern) { - // read by StaticTypeCheckingVisitor to determine the subject type of a pattern switch - switchStatement.putNodeMetaData(SWITCH_PATTERN_SUBJECT, subject); + Statement statement; + List armConditions = null; + if (allPatterns) { + // closure-free lowering: each arm is an instanceof-conjunction `if` in its + // own block, so pattern variable names may repeat across arms and a failed + // match (or guard) falls through to the next arm; a matched arm returns + armConditions = new ArrayList<>(); + List arms = new ArrayList<>(); + for (CaseStatement caseStatement : caseStatements) { + Expression label = caseStatement.getExpression(); + armConditions.add(label); + List armItems = label.getNodeMetaData(SWITCH_PATTERN_ARM_ITEMS); + label.removeNodeMetaData(SWITCH_PATTERN_ARM_ITEMS); + Statement arm = createBlockStatement(nestArmItems(armItems, caseStatement.getCode())); + arm.setSourcePosition(caseStatement); + arms.add(arm); + } + if (defaultStatement != null) { + arms.add(defaultStatement); + } + statement = configureAST(createBlockStatement(arms), ctx); + } else { + SwitchStatement switchStatement = new SwitchStatement( + hasPattern ? varX(subjectName) : subject, + caseStatements, + defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE + ); + if (hasPattern) { + // read by StaticTypeCheckingVisitor to determine the subject type of a pattern switch + switchStatement.putNodeMetaData(SWITCH_PATTERN_SUBJECT, subject); + } + statement = createBlockStatement(List.of(configureAST(switchStatement, ctx))); } - Statement statement = configureAST(switchStatement, ctx); - statement = createBlockStatement(List.of(statement)); MethodCallExpression immediateExecution; if (hasPattern) { Parameter subjectParameter = new Parameter(ClassHelper.dynamicType(), subjectName); - immediateExecution = callX(closureX(params(subjectParameter), statement), CALL_STR, args(subject)); + Expression closure = closureX(params(subjectParameter), statement); + if (allPatterns) { + // read by StaticTypeCheckingVisitor to check pattern switch case labels; + // attached to the closure, which (unlike the call) survives ResolveVisitor + closure.putNodeMetaData(SWITCH_PATTERN_SUBJECT, subject); + closure.putNodeMetaData(SWITCH_PATTERN_ARMS, armConditions); + closure.putNodeMetaData(SWITCH_PATTERN_DEFAULT, defaultStatement != null); + } + immediateExecution = callX(closure, CALL_STR, args(subject)); } else { immediateExecution = callX(closureX(null, statement), CALL_STR); } immediateExecution.setImplicitThis(false); return immediateExecution; } finally { + switchPatternFastStack.pop(); switchPatternSubjectStack.pop(); switchExpressionRuleContextStack.pop(); } } + private static boolean allCasePatterns(final SwitchExpressionContext ctx) { + return ctx.switchBlockStatementExpressionGroup().stream() + .flatMap(e -> e.switchExpressionLabel().stream()) + .filter(e -> asBoolean(e.CASE())) + .allMatch(e -> isPatternLabel(e.casePattern())); + } + private static boolean containsCasePattern(final SwitchExpressionContext ctx) { return ctx.switchBlockStatementExpressionGroup().stream() .flatMap(e -> e.switchExpressionLabel().stream()) @@ -1358,6 +1400,9 @@ public Tuple3, Integer> visitSwitchExpressionLabel(Switc @Override public Expression visitCasePattern(final CasePatternContext ctx) { Expression guard = asBoolean(ctx.caseGuard()) ? (Expression) this.visit(ctx.caseGuard().expression()) : null; + if (Boolean.TRUE.equals(switchPatternFastStack.peek())) { + return this.createPatternArmLabel(ctx, guard); + } if (asBoolean(ctx.recordPattern())) { return this.createRecordPatternLabel(ctx, guard); } @@ -1399,6 +1444,175 @@ public Expression visitCasePattern(final CasePatternContext ctx) { return labelExpr; } + /** + * Builds the case label for a pattern arm of a closure-free pattern switch + * (GEP-19). The label is a placeholder carrying the arm's matching steps as + * node metadata: an ordered mix of check expressions and binding statements + * that {@link #visitSwitchExpression} nests into + * {@code if (check) { binding; if (check) { ... armCode } } }. + * Statement nesting short-circuits, so unlike the {@code instanceof} + * conjunction lowering no null-tolerant helpers are needed, and every + * binding declaration dominates its reads for the bytecode verifier. + * Pattern variables are bound while matching, so unlike the closure-label + * lowering no per-arm closure is allocated and record, list and map + * patterns are destructured once rather than twice. The {@code when} + * guard, if any, is the innermost check, evaluated only once the pattern + * has matched and its variables are bound. + */ + private Expression createPatternArmLabel(final CasePatternContext ctx, final Expression guard) { + org.codehaus.groovy.syntax.Token instanceOf = org.codehaus.groovy.syntax.Token.newSymbol( + Types.KEYWORD_INSTANCEOF, ctx.getStart().getLine(), ctx.getStart().getCharPositionInLine() + 1); + Expression subjectVar = varX(switchPatternSubjectStack.peek()); + Expression label = constX(Boolean.TRUE, true); + List items = new ArrayList<>(); + if (asBoolean(ctx.typePattern())) { + TypePatternContext typePatternCtx = ctx.typePattern(); + ClassNode type = this.visitType(typePatternCtx.type()); + if (ClassHelper.isPrimitiveType(type)) { + throw createParsingFailedException("primitive type patterns are not yet supported", typePatternCtx); + } + String name = this.visitIdentifier(typePatternCtx.identifier()); + items.add(binX(subjectVar, instanceOf, declX(varX(name, type), EmptyExpression.INSTANCE))); + label.putNodeMetaData(SWITCH_PATTERN_TYPE, type); + } else { + if (asBoolean(ctx.recordPattern())) { + PatternNode pattern = this.buildRecordPattern(ctx.recordPattern()); + appendRecordPatternArmItems(pattern, subjectVar, instanceOf, items); + label.putNodeMetaData(SWITCH_PATTERN_TYPE, pattern.type); + label.putNodeMetaData(SWITCH_PATTERN_RECORD, pattern.components.size()); + } else if (asBoolean(ctx.listPattern())) { + appendListPatternArmItems(this.buildListPattern(ctx.listPattern()), subjectVar, instanceOf, items); + label.putNodeMetaData(SWITCH_PATTERN_LIST, Boolean.TRUE); + } else { + appendMapPatternArmItems(this.buildMapPattern(ctx.mapPattern()), subjectVar, instanceOf, items); + label.putNodeMetaData(SWITCH_PATTERN_MAP, Boolean.TRUE); + } + // record, list and map patterns are always conditional + label.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); + } + if (guard != null) { + items.add(guard); + label.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); + } + label.putNodeMetaData(SWITCH_PATTERN_ARM_ITEMS, items); + return configureAST(label, ctx); + } + + /** Nests an arm's matching steps around its code: check expressions become guarding {@code if}s, binding statements execute between them. */ + private Statement nestArmItems(final List items, final Statement armCode) { + Statement result = armCode; + for (int i = items.size() - 1; i >= 0; i -= 1) { + Object item = items.get(i); + if (item instanceof Expression) { + result = ifS((Expression) item, result); + } else { + result = createBlockStatement(List.of((Statement) item, result)); + } + } + return result; + } + + /** Emits the matching steps of a record pattern rooted at {@code source} for a pattern arm (see {@link #createPatternArmLabel}). */ + private void appendRecordPatternArmItems(final PatternNode pattern, final Expression source, final org.codehaus.groovy.syntax.Token instanceOf, final List items) { + String recordName = "__$$rp" + switchPatternVariableSeq++; + items.add(binX(source, instanceOf, declX(varX(recordName, pattern.type), EmptyExpression.INSTANCE))); + String componentsName = "__$$rc" + switchPatternVariableSeq++; + items.add(declS(localVarX(componentsName, ClassHelper.LIST_TYPE.getPlainNodeReference()), + callX(classX(RECORD_PATTERN_SUPPORT_TYPE), "components", args(varX(recordName))))); + items.add(eqX(callX(varX(componentsName), "size"), constX(pattern.components.size(), true))); + for (int i = 0, n = pattern.components.size(); i < n; i += 1) { + PatternNode component = pattern.components.get(i); + if (component.isUncheckedWildcard()) continue; + appendElementArmItems(component, callX(varX(componentsName), "get", args(constX(i, true))), instanceOf, items); + } + } + + /** Emits the matching steps of a list pattern rooted at {@code source} for a pattern arm (see {@link #createPatternArmLabel}). */ + private void appendListPatternArmItems(final PatternNode pattern, final Expression source, final org.codehaus.groovy.syntax.Token instanceOf, final List items) { + String elementsName = "__$$lv" + switchPatternVariableSeq++; + items.add(binX(callX(classX(LIST_PATTERN_SUPPORT_TYPE), "elementsOrNull", args(source)), instanceOf, + declX(varX(elementsName, ClassHelper.LIST_TYPE.getPlainNodeReference()), EmptyExpression.INSTANCE))); + int n = pattern.components.size(); + int restIndex = -1; + for (int i = 0; i < n; i += 1) { + if (pattern.components.get(i).rest) restIndex = i; + } + int fixed = n - (restIndex < 0 ? 0 : 1); + if (restIndex < 0) { + items.add(eqX(callX(varX(elementsName), "size"), constX(fixed, true))); + } else if (fixed > 0) { + items.add(geX(callX(varX(elementsName), "size"), constX(fixed, true))); + } + for (int i = 0; i < n; i += 1) { + PatternNode element = pattern.components.get(i); + if (element.rest) { + if (element.type == null && element.name == null) continue; // bare rest just relaxes the size check + String restName = element.name != null ? element.name : "__$$lr" + switchPatternVariableSeq++; + items.add(declS(localVarX(restName, ClassHelper.LIST_TYPE.getPlainNodeReference()), + callX(classX(LIST_PATTERN_SUPPORT_TYPE), "rest", args(varX(elementsName), constX(i, true), constX(n - i - 1, true))))); + if (element.type != null) { + items.add(callX(classX(LIST_PATTERN_SUPPORT_TYPE), "allInstanceOf", + args(varX(restName), classX(ClassHelper.getWrapper(element.type))))); + } + continue; + } + if (element.isUncheckedWildcard()) continue; + Expression indexExpr = (restIndex >= 0 && i > restIndex) + ? minusX(callX(varX(elementsName), "size"), constX(n - i, true)) + : constX(i, true); + appendElementArmItems(element, callX(varX(elementsName), "get", args(indexExpr)), instanceOf, items); + } + } + + /** Emits the matching steps of a map pattern rooted at {@code source} for a pattern arm (see {@link #createPatternArmLabel}). */ + private void appendMapPatternArmItems(final PatternNode pattern, final Expression source, final org.codehaus.groovy.syntax.Token instanceOf, final List items) { + String entriesName = "__$$mv" + switchPatternVariableSeq++; + items.add(binX(callX(classX(MAP_PATTERN_SUPPORT_TYPE), "entriesOrNull", args(source)), instanceOf, + declX(varX(entriesName, ClassHelper.MAP_TYPE.getPlainNodeReference()), EmptyExpression.INSTANCE))); + if (pattern.components.isEmpty()) { // `[:]` matches only an empty map + items.add(callX(varX(entriesName), "isEmpty")); + return; + } + for (PatternNode entry : pattern.components) { + if (entry.rest) { + if (entry.name == null) continue; // `...` just relaxes the match, which is open anyway + List namedKeys = new ArrayList<>(); + for (PatternNode named : pattern.components) { + if (!named.rest) namedKeys.add(constX(named.key)); + } + items.add(declS(localVarX(entry.name, ClassHelper.MAP_TYPE.getPlainNodeReference()), + callX(classX(MAP_PATTERN_SUPPORT_TYPE), "rest", args(varX(entriesName), listX(namedKeys))))); + continue; + } + items.add(callX(varX(entriesName), "containsKey", args(constX(entry.key)))); + if (entry.isUncheckedWildcard()) continue; // presence check only + appendElementArmItems(entry, callX(varX(entriesName), "get", args(constX(entry.key))), instanceOf, items); + } + } + + /** Emits the matching steps for one record component, list element or map entry value. */ + private void appendElementArmItems(final PatternNode element, final Expression access, final org.codehaus.groovy.syntax.Token instanceOf, final List items) { + if (element.constant != null) { + items.add(eqX(access, element.constant)); + } else if (element.components != null) { // nested pattern + if (element.list) { + appendListPatternArmItems(element, access, instanceOf, items); + } else if (element.map) { + appendMapPatternArmItems(element, access, instanceOf, items); + } else { + appendRecordPatternArmItems(element, access, instanceOf, items); + } + } else if (element.type != null) { + ClassNode bindType = ClassHelper.getWrapper(element.type); + Expression rhs = element.name != null + ? declX(varX(element.name, bindType), EmptyExpression.INSTANCE) + : new ClassExpression(bindType); + items.add(binX(access, instanceOf, rhs)); + } else { // var/def binding: unconditional, also matches a null value + items.add(declS(localVarX(element.name, ClassHelper.dynamicType()), access)); + } + } + /** * Builds a closure case label for a record pattern (GEP-19), e.g. * {@code case Point(int x, int y) when x == y ->} becomes: @@ -1881,6 +2095,10 @@ private Expression createRecordPatternInstanceof(final Expression left, final or PatternNode pattern = this.buildRecordPattern(ctx); List conjuncts = new ArrayList<>(); appendRecordPatternInstanceofConjuncts(pattern, left, instanceOf, conjuncts); + return foldConjuncts(conjuncts, instanceOf); + } + + private static Expression foldConjuncts(final List conjuncts, final org.codehaus.groovy.syntax.Token instanceOf) { Expression result = conjuncts.get(0); for (int i = 1, n = conjuncts.size(); i < n; i += 1) { result = binX(result, org.codehaus.groovy.syntax.Token.newSymbol(Types.BITWISE_AND, instanceOf.getStartLine(), instanceOf.getStartColumn()), conjuncts.get(i)); @@ -5724,6 +5942,7 @@ public List getDeclarationExpressions() { /** Name of the synthetic variable holding the switch subject of the enclosing switch expression ("" if it has no pattern labels). */ private final Deque switchPatternSubjectStack = new ArrayDeque<>(); + private final Deque switchPatternFastStack = new ArrayDeque<>(); private int switchPatternVariableSeq; private Tuple2 numberFormatError; @@ -5777,6 +5996,9 @@ public List getDeclarationExpressions() { private static final String SWITCH_PATTERN_RECORD = "_SWITCH_PATTERN_RECORD"; private static final String SWITCH_PATTERN_LIST = "_SWITCH_PATTERN_LIST"; private static final String SWITCH_PATTERN_MAP = "_SWITCH_PATTERN_MAP"; + private static final String SWITCH_PATTERN_ARM_ITEMS = "_SWITCH_PATTERN_ARM_ITEMS"; + private static final String SWITCH_PATTERN_ARMS = "_SWITCH_PATTERN_ARMS"; + private static final String SWITCH_PATTERN_DEFAULT = "_SWITCH_PATTERN_DEFAULT"; private static final ClassNode RECORD_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(RecordPatternSupport.class); private static final ClassNode LIST_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(ListPatternSupport.class); private static final ClassNode MAP_PATTERN_SUPPORT_TYPE = ClassHelper.makeCached(MapPatternSupport.class); diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index 24a65ec0992..5faa55112e7 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -4241,6 +4241,12 @@ public void visitMethodCallExpression(final MethodCallExpression call) { addStaticTypeError("Cannot resolve dynamic method name at compile time", call.getMethod()); return; } + Expression closureOfPatternSwitch = call.getObjectExpression(); // GEP-19: the closure-free lowering of an all-pattern switch + List patternSwitchArms = closureOfPatternSwitch.getNodeMetaData("_SWITCH_PATTERN_ARMS"); + if (patternSwitchArms != null) { + checkPatternSwitchLabels(closureOfPatternSwitch.getNodeMetaData("_SWITCH_PATTERN_SUBJECT"), patternSwitchArms, + Boolean.TRUE.equals(closureOfPatternSwitch.getNodeMetaData("_SWITCH_PATTERN_DEFAULT")), call); + } if (extension.beforeMethodCall(call)) { extension.afterMethodCall(call); return; @@ -4854,12 +4860,26 @@ protected void afterSwitchConditionExpressionVisited(final SwitchStatement state private void checkPatternSwitchLabels(final SwitchStatement statement) { Expression subjectExpression = statement.getNodeMetaData("_SWITCH_PATTERN_SUBJECT"); if (subjectExpression == null) return; + List labels = new ArrayList<>(); + for (CaseStatement caseStatement : statement.getCaseStatements()) { + labels.add(caseStatement.getExpression()); + } + checkPatternSwitchLabels(subjectExpression, labels, !statement.getDefaultStatement().isEmpty(), statement); + } + + /** + * The pattern switch label checks, shared between the two lowerings of a pattern + * switch (GEP-19): the closure-label lowering retains a {@code SwitchStatement} + * whose case expressions carry the pattern metadata, while the closure-free + * lowering of an all-pattern switch attaches the arm condition expressions to + * the generated call (see the parser's AstBuilder). + */ + private void checkPatternSwitchLabels(final Expression subjectExpression, final List labels, final boolean hasDefault, final ASTNode switchNode) { ClassNode subjectType = wrapTypeIfNecessary(getType(subjectExpression)); List priorTypeTests = new ArrayList<>(); boolean opaqueLabels = false, unconditional = false; - for (CaseStatement caseStatement : statement.getCaseStatements()) { - Expression label = caseStatement.getExpression(); + for (Expression label : labels) { ClassNode patternType = label.getNodeMetaData("_SWITCH_PATTERN_TYPE"); ClassNode typeTest = patternType; if (typeTest == null && label instanceof ClassExpression) { @@ -4905,9 +4925,9 @@ && provablyDisjoint(subjectType, MAP_TYPE)) { if (implementsInterfaceOrIsSubclassOf(subjectType, wrappedTest)) unconditional = true; } } - if (statement.getDefaultStatement().isEmpty() && !opaqueLabels && !unconditional + if (!hasDefault && !opaqueLabels && !unconditional && !coversAllPermittedSubclasses(subjectType, priorTypeTests)) { - addPatternSwitchWarning("The pattern switch is not exhaustive and may match nothing; consider adding a default branch", statement); + addPatternSwitchWarning("The pattern switch is not exhaustive and may match nothing; consider adding a default branch", switchNode); } } diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index 6aa858512c8..969a0ff3ca3 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -994,6 +994,46 @@ final class SwitchPatternMatchingTest { assert err.message.contains('map pattern is incompatible with the switch subject type') } + @Test + void testPatternVariableNamesMayRepeatAcrossArms() { + // each arm of a pattern switch has its own scope + assertScript ''' + record Circle(int r) {} + record Square(int side) {} + def area(shape) { + switch (shape) { + case Circle(var d) -> 3 * d * d + case Square(var d) -> d * d + case Integer d -> d + default -> 0 + } + } + assert area(new Circle(2)) == 12 + assert area(new Square(3)) == 9 + assert area(5) == 5 + ''' + } + + @Test + void testRecordDeconstructedOncePerMatch() { + // the closure-free lowering binds pattern variables while matching, + // so a matched record is deconstructed exactly once + assertScript ''' + class Counter { + static int reads = 0 + final int value + Counter(int value) { this.value = value } + List toList() { reads++; [value] } + } + def r = switch (new Counter(7)) { + case Counter(var v) -> v + default -> -1 + } + assert r == 7 + assert Counter.reads == 1 + ''' + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) From e8630786d1790455ac49e37450e2505bf7d80b29 Mon Sep 17 00:00:00 2001 From: Paul King Date: Fri, 3 Jul 2026 16:51:20 +1000 Subject: [PATCH 8/9] GEP-19: address review feedback (empty-record arity check, primitive component binding types) The STC record pattern arity check now also fires for zero-component records: any record pattern against one is a statically-known mismatch (a pattern has at least one component). isRecord() covers native records with no components; a positive component count keeps covering emulated records; other toList() deconstructables remain unchecked. The closure-free switch lowering now binds a primitive-typed component with its declared primitive type (the instanceof check still uses the wrapper, via a synthetic local), matching the closure-label lowering and Java record patterns (JEP 440), so a pattern variable's static type no longer depends on which lowering a switch takes. Both behaviours are pinned by new tests. Assisted-by: Claude Code (Claude Fable 5) --- .../groovy/parser/antlr4/AstBuilder.java | 16 +++++-- .../stc/StaticTypeCheckingVisitor.java | 4 +- .../groovy/SwitchPatternMatchingTest.groovy | 48 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) 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 acc41353a17..6bd3188aceb 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -1604,10 +1604,18 @@ private void appendElementArmItems(final PatternNode element, final Expression a } } else if (element.type != null) { ClassNode bindType = ClassHelper.getWrapper(element.type); - Expression rhs = element.name != null - ? declX(varX(element.name, bindType), EmptyExpression.INSTANCE) - : new ClassExpression(bindType); - items.add(binX(access, instanceOf, rhs)); + if (element.name == null) { + items.add(binX(access, instanceOf, new ClassExpression(bindType))); + } else if (ClassHelper.isPrimitiveType(element.type)) { + // the instanceof check needs the wrapper, but the pattern variable is + // bound with its declared primitive type, matching the closure-label + // lowering and Java record patterns (JEP 440) + String wrapperName = "__$$pw" + switchPatternVariableSeq++; + items.add(binX(access, instanceOf, declX(varX(wrapperName, bindType), EmptyExpression.INSTANCE))); + items.add(declS(localVarX(element.name, element.type), castX(element.type, varX(wrapperName)))); + } else { + items.add(binX(access, instanceOf, declX(varX(element.name, bindType), EmptyExpression.INSTANCE))); + } } else { // var/def binding: unconditional, also matches a null value items.add(declS(localVarX(element.name, ClassHelper.dynamicType()), access)); } diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index 5faa55112e7..7877d242c69 100644 --- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java +++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java @@ -4910,7 +4910,9 @@ && provablyDisjoint(subjectType, MAP_TYPE)) { // cannot be proven exhaustive here; leave exhaustiveness unassessed opaqueLabels = true; int componentCount = patternType.getRecordComponents().size(); - if (componentCount > 0 && componentCount != recordPatternArity) { + // isRecord() covers zero-component (native) records; a positive component + // count covers emulated records; other deconstructables (toList) are unchecked + if ((patternType.isRecord() || componentCount > 0) && componentCount != recordPatternArity) { addStaticTypeError("The record pattern specifies " + recordPatternArity + " component(s) but " + prettyPrintTypeName(patternType) + " has " + componentCount, label); } } diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index 969a0ff3ca3..4ff15a9888a 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -1034,6 +1034,54 @@ final class SwitchPatternMatchingTest { ''' } + @Test + void testEmptyRecordPatternArityCheckedWhenTypeChecked() { + // a record pattern always has at least one component, so any pattern + // against a zero-component record is a statically-known mismatch + def err = shouldFail ''' + import groovy.transform.TypeChecked + record Empty() {} + @TypeChecked + def m(Object o) { + switch (o) { + case Empty(var a) -> a + default -> 'other' + } + } + ''' + assert err.message.contains('specifies 1 component(s) but') + } + + @Test + void testPrimitiveComponentBindingTypeConsistentAcrossLowerings() { + // a primitive-typed component binds its declared primitive type whether the + // switch takes the closure-free lowering (all-pattern) or the closure-label + // lowering (mixed with a legacy label) + assertScript ''' + import groovy.transform.CompileStatic + record Point(int x, int y) {} + String pick(int i) { 'int' } + String pick(Integer i) { 'Integer' } + @CompileStatic + String pure(Object o) { + switch (o) { + case Point(int x, _) -> pick(x) + default -> 'other' + } + } + @CompileStatic + String mixed(Object o) { + switch (o) { + case Point(int x, _) -> pick(x) + case 'legacy' -> 'legacy' + default -> 'other' + } + } + assert pure(new Point(1, 2)) == 'int' + assert mixed(new Point(1, 2)) == 'int' + ''' + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) From 5fcba6e23de6504508f12710db42b97393043919 Mon Sep 17 00:00:00 2001 From: Paul King Date: Sat, 4 Jul 2026 16:28:18 +1000 Subject: [PATCH 9/9] GEP-19: support primitive type patterns (JEP 507 alignment) Groovy 7 timeframes are expected to align with JEP 507 becoming stable, so `case int i` is now accepted rather than rejected. Following JEP 507 semantics for a reference-typed subject, a primitive type pattern tests the wrapper type (an Integer matches `int` but a Byte or Long does not; no widening or narrowing) and binds the pattern variable with its declared primitive type, in both the closure-label and closure-free lowerings. A record pattern with a primitive type (e.g. `case int(var x)`) remains an error, now with a message saying why. The feature is incubating, so semantics can be tweaked if JEP 507 shifts before it finalises. Assisted-by: Claude Code (Claude Fable 5) --- .../groovy/parser/antlr4/AstBuilder.java | 28 ++--- .../core/SwitchExpression_31x.groovy | 55 +++++++++ .../fail/SwitchExpression_12x.groovy | 4 +- .../groovy/SwitchPatternMatchingTest.groovy | 105 ++++++++++++++++++ .../parser/antlr4/GroovyParserTest.groovy | 1 + 5 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 src/test-resources/core/SwitchExpression_31x.groovy 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 6bd3188aceb..ce3bed01a72 100644 --- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java +++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java @@ -1415,9 +1415,10 @@ public Expression visitCasePattern(final CasePatternContext ctx) { TypePatternContext typePatternCtx = ctx.typePattern(); ClassNode type = this.visitType(typePatternCtx.type()); - if (ClassHelper.isPrimitiveType(type)) { - throw createParsingFailedException("primitive type patterns are not yet supported", typePatternCtx); - } + // a primitive type pattern tests the wrapper and binds the primitive, matching + // JEP 507's semantics for a reference-typed subject (e.g. `case int i` matches + // exactly the Integer instances); the box test never widens or narrows + ClassNode checkType = ClassHelper.getWrapper(type); String name = this.visitIdentifier(typePatternCtx.identifier()); String subjectName = switchPatternSubjectStack.peek(); @@ -1426,14 +1427,14 @@ public Expression visitCasePattern(final CasePatternContext ctx) { String candidateName = "__$$pc" + switchPatternVariableSeq++; Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); Statement guardCode = block( - ifS(notX(isInstanceOfX(varX(candidateName), type)), returnS(constX(Boolean.FALSE, true))), + ifS(notX(isInstanceOfX(varX(candidateName), checkType)), returnS(constX(Boolean.FALSE, true))), declS(localVarX(name, type), castX(type, varX(candidateName))), returnS(guard) ); labelExpr = configureAST(closureX(params(candidate), guardCode), ctx); labelExpr.putNodeMetaData(SWITCH_PATTERN_GUARDED, Boolean.TRUE); } else { - labelExpr = configureAST(new ClassExpression(type), typePatternCtx); + labelExpr = configureAST(new ClassExpression(checkType), typePatternCtx); } // read by StaticTypeCheckingVisitor to check pattern switch case labels @@ -1467,13 +1468,14 @@ private Expression createPatternArmLabel(final CasePatternContext ctx, final Exp List items = new ArrayList<>(); if (asBoolean(ctx.typePattern())) { TypePatternContext typePatternCtx = ctx.typePattern(); - ClassNode type = this.visitType(typePatternCtx.type()); - if (ClassHelper.isPrimitiveType(type)) { - throw createParsingFailedException("primitive type patterns are not yet supported", typePatternCtx); - } - String name = this.visitIdentifier(typePatternCtx.identifier()); - items.add(binX(subjectVar, instanceOf, declX(varX(name, type), EmptyExpression.INSTANCE))); - label.putNodeMetaData(SWITCH_PATTERN_TYPE, type); + // handles primitive type patterns too: the instanceof check tests the + // wrapper and the pattern variable binds the primitive (JEP 507 semantics + // for a reference-typed subject) + PatternNode pattern = new PatternNode(); + pattern.type = this.visitType(typePatternCtx.type()); + pattern.name = this.visitIdentifier(typePatternCtx.identifier()); + appendElementArmItems(pattern, subjectVar, instanceOf, items); + label.putNodeMetaData(SWITCH_PATTERN_TYPE, pattern.type); } else { if (asBoolean(ctx.recordPattern())) { PatternNode pattern = this.buildRecordPattern(ctx.recordPattern()); @@ -1664,7 +1666,7 @@ private PatternNode buildRecordPattern(final RecordPatternContext ctx) { PatternNode pattern = new PatternNode(); pattern.type = this.visitType(ctx.type()); if (ClassHelper.isPrimitiveType(pattern.type)) { - throw createParsingFailedException("primitive type patterns are not yet supported", ctx); + throw createParsingFailedException("a record pattern cannot deconstruct a primitive type", ctx); } pattern.components = new ArrayList<>(); for (RecordPatternComponentContext componentCtx : ctx.recordPatternComponents().recordPatternComponent()) { diff --git a/src/test-resources/core/SwitchExpression_31x.groovy b/src/test-resources/core/SwitchExpression_31x.groovy new file mode 100644 index 00000000000..6fb2cbc49f5 --- /dev/null +++ b/src/test-resources/core/SwitchExpression_31x.groovy @@ -0,0 +1,55 @@ +/* + * 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. + */ + +// GEP-19: primitive type patterns (JEP 507 alignment): the pattern tests the +// wrapper type and binds the primitive; there is no widening or narrowing +def describe(subject) { + switch (subject) { + case int i when i < 0 -> "negative int $i" + case int i -> "int $i" + case long l -> "long $l" + case double d -> "double $d" + case boolean b -> "boolean $b" + case char c -> "char $c" + default -> 'other' + } +} + +assert describe(42) == 'int 42' +assert describe(-7) == 'negative int -7' +assert describe(42L) == 'long 42' +assert describe(3.5d) == 'double 3.5' +assert describe(true) == 'boolean true' +assert describe('x' as char) == 'char x' +assert describe(42 as byte) == 'other' +assert describe(3.5) == 'other' // BigDecimal is not a double +assert describe(null) == 'other' + +// mixed with a legacy label (closure-label lowering) +def area(shape) { + switch (shape) { + case int side when side > 0 -> side * side + case ~/circle:\d+/ -> 'circle' + default -> 'unknown' + } +} + +assert area(3) == 9 +assert area('circle:4') == 'circle' +assert area(-3) == 'unknown' diff --git a/src/test-resources/fail/SwitchExpression_12x.groovy b/src/test-resources/fail/SwitchExpression_12x.groovy index efc10590f12..c9473264306 100644 --- a/src/test-resources/fail/SwitchExpression_12x.groovy +++ b/src/test-resources/fail/SwitchExpression_12x.groovy @@ -17,8 +17,8 @@ * under the License. */ -// GEP-19: primitive type patterns are not yet supported (JEP 507 still in preview) +// GEP-19: a record pattern cannot deconstruct a primitive type def result = switch (42) { - case int i -> i + 1 + case int(var i) -> i + 1 default -> 0 } diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy index 4ff15a9888a..5a3e6aeb0c4 100644 --- a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -1082,6 +1082,111 @@ final class SwitchPatternMatchingTest { ''' } + @Test + void testPrimitiveTypePatternDispatch() { + // JEP 507 alignment for a reference-typed subject: a primitive type pattern + // tests the wrapper type and binds the primitive; no widening or narrowing + assertScript ''' + def describe(subject) { + switch (subject) { + case int i -> "int $i" + case long l -> "long $l" + case double d -> "double $d" + case boolean b -> "boolean $b" + case char c -> "char $c" + default -> 'other' + } + } + assert describe(42) == 'int 42' + assert describe(42L) == 'long 42' + assert describe(3.5d) == 'double 3.5' + assert describe(false) == 'boolean false' + assert describe('x' as char) == 'char x' + assert describe(42 as byte) == 'other' // Byte is not an int + assert describe(3.5) == 'other' // BigDecimal is not a double + assert describe(null) == 'other' + ''' + } + + @Test + void testPrimitiveTypePatternWithGuard() { + assertScript ''' + def fizzbuzz(n) { + switch (n) { + case int i when i % 15 == 0 -> 'FizzBuzz' + case int i when i % 3 == 0 -> 'Fizz' + case int i when i % 5 == 0 -> 'Buzz' + case int i -> i.toString() + default -> 'not an int' + } + } + assert (1..15).collect { fizzbuzz(it) }.join(',') == + '1,2,Fizz,4,Buzz,Fizz,7,8,Fizz,Buzz,11,Fizz,13,14,FizzBuzz' + assert fizzbuzz(15L) == 'not an int' + ''' + } + + @Test + void testPrimitiveTypePatternMixedWithLegacyLabel() { + // both the guarded (closure label) and unguarded (class literal label) + // forms of the closure-label lowering + assertScript ''' + def describe(subject) { + switch (subject) { + case int i when i < 0 -> "negative $i" + case int i -> "int $i" + case 'legacy' -> 'legacy' + default -> 'other' + } + } + assert describe(-3) == 'negative -3' + assert describe(42) == 'int 42' + assert describe('legacy') == 'legacy' + assert describe(42L) == 'other' + ''' + } + + @Test + void testPrimitiveTypePatternBindingTypeConsistentAcrossLowerings() { + assertScript ''' + import groovy.transform.CompileStatic + String pick(int i) { 'int' } + String pick(Integer i) { 'Integer' } + @CompileStatic + String pure(Object o) { + switch (o) { + case int i -> pick(i) + default -> 'other' + } + } + @CompileStatic + String mixed(Object o) { + switch (o) { + case int i when i > 0 -> pick(i) + case 'legacy' -> 'legacy' + default -> 'other' + } + } + assert pure(42) == 'int' + assert mixed(42) == 'int' + ''' + } + + @Test + void testPrimitiveTypePatternIncompatibleSubjectTypeChecked() { + def err = shouldFail ''' + import groovy.transform.TypeChecked + @TypeChecked + def m(String s) { + switch (s) { + case int i -> i + default -> 0 + } + } + ''' + assert err.message.contains('The case pattern type int is incompatible with the switch subject type java.lang.String') + } + private static List patternSwitchWarnings(String source) { def cu = new CompilationUnit() cu.addSource('PatternSwitchTestScript.groovy', source) diff --git a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy index 2fc8dac724c..334d96d040e 100644 --- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy +++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy @@ -535,6 +535,7 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_28x.groovy') doRunAndTestAntlr4('core/SwitchExpression_29x.groovy') doRunAndTestAntlr4('core/SwitchExpression_30x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_31x.groovy') } @Test