diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4 index 3eb0dedeede..7a8d0ca8762 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 @@ -789,11 +790,85 @@ 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 | recordPattern | listPattern | mapPattern) (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 + ; + +// 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 + | mapPattern + | (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? + ; + +// 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 + ; + 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..ce3bed01a72 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,9 @@ 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.MapPatternSupport; +import org.apache.groovy.runtime.RecordPatternSupport; import org.apache.groovy.util.Maps; import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.GroovyBugError; @@ -162,13 +165,30 @@ 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.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; +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; 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; +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; import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; @@ -1012,10 +1032,37 @@ 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); + // 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 = @@ -1048,23 +1095,128 @@ public MethodCallExpression visitSwitchExpression(final SwitchExpressionContext } } - Statement statement = configureAST( - new SwitchStatement( - this.visitExpressionInPar(ctx.expressionInPar()), - caseStatements, - defaultStatement != null ? defaultStatement : EmptyStatement.INSTANCE - ), - ctx); - statement = createBlockStatement(List.of(statement)); + Expression subject = this.visitExpressionInPar(ctx.expressionInPar()); + String subjectName = switchPatternSubjectStack.peek(); + 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))); + } - MethodCallExpression immediateExecution = callX(closureX(null, statement), CALL_STR); + MethodCallExpression immediateExecution; + if (hasPattern) { + Parameter subjectParameter = new Parameter(ClassHelper.dynamicType(), subjectName); + 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()) + .anyMatch(e -> isPatternLabel(e.casePattern())); + } + + private static boolean isPatternLabel(final CasePatternContext ctx) { + if (!asBoolean(ctx)) return false; + // a `[...]` label parses via the broader listPattern/mapPattern rules; only a + // pattern-shaped literal is treated as a pattern (see isPatternShapedList) + 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 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 (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; + } + } + return false; + } + @Override public Tuple3, Boolean, Boolean> visitSwitchBlockStatementExpressionGroup(SwitchBlockStatementExpressionGroupContext ctx) { int labelCnt = ctx.switchExpressionLabel().size(); @@ -1163,15 +1315,24 @@ 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; + + List bindingDecls = expr.getNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); + if (bindingDecls != null && !(code instanceof EmptyStatement)) { + expr.removeNodeMetaData(SWITCH_TYPE_PATTERN_BINDING); + if (!bindingDecls.isEmpty()) { + List codeWithBindings = new ArrayList<>(bindingDecls); + codeWithBindings.add(code); + code = createBlockStatement(codeWithBindings); + } + } + 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 +1363,23 @@ 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 (!isPatternLabel(ctx.casePattern())) { + // 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 or map pattern needs a binding form", ctx.casePattern().caseGuard()); + } + 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()); + } + 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 +1388,788 @@ 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; 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 (Boolean.TRUE.equals(switchPatternFastStack.peek())) { + return this.createPatternArmLabel(ctx, guard); + } + if (asBoolean(ctx.recordPattern())) { + return this.createRecordPatternLabel(ctx, guard); + } + 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()); + // 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(); + + Expression labelExpr; + if (guard != null) { + String candidateName = "__$$pc" + switchPatternVariableSeq++; + Parameter candidate = new Parameter(ClassHelper.dynamicType(), candidateName); + Statement guardCode = block( + 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(checkType), 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, Collections.singletonList(bindingDecl)); + 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(); + // 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()); + 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); + 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)); + } + } + + /** + * 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("a record pattern cannot deconstruct a primitive type", 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)); + } + } + } + } + + /** + * 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; + } + 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()); + 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(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 + * 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 (element.map) { + appendMapPatternExtraction(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 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. + * {@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); + 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)); + } + 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 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; 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, list or map pattern + boolean list; // components are list pattern elements rather than record components + 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; + } + + 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 @@ -3237,6 +4197,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), @@ -4982,6 +5950,11 @@ 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 final Deque switchPatternFastStack = new ArrayDeque<>(); + private int switchPatternVariableSeq; + private Tuple2 numberFormatError; private int visitingClosureCount; @@ -5025,6 +5998,20 @@ 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"; + // 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 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); 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/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/apache/groovy/runtime/RecordPatternSupport.java b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java new file mode 100644 index 00000000000..81ea6fc4e7c --- /dev/null +++ b/src/main/java/org/apache/groovy/runtime/RecordPatternSupport.java @@ -0,0 +1,97 @@ +/* + * 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.Collections; +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"); + } + + /** + * 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/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java index 0e7b4d99ce5..7877d242c69 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; @@ -4240,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; @@ -4817,6 +4824,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 +4844,121 @@ 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; + 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 (Expression label : labels) { + 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 (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 (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; + } + 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; + } + 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(); + // 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); + } + } + 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 (!hasDefault && !opaqueLabels && !unconditional + && !coversAllPermittedSubclasses(subjectType, priorTypeTests)) { + addPatternSwitchWarning("The pattern switch is not exhaustive and may match nothing; consider adding a default branch", switchNode); + } + } + + 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-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/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/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/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/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_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..c9473264306 --- /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: a record pattern cannot deconstruct a primitive type +def result = switch (42) { + case int(var i) -> i + 1 + default -> 0 +} 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-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-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/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 + ''' + } } diff --git a/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy new file mode 100644 index 00000000000..5a3e6aeb0c4 --- /dev/null +++ b/src/test/groovy/groovy/SwitchPatternMatchingTest.groovy @@ -0,0 +1,1198 @@ +/* + * 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.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.Phases +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 + } + ''' + } + + // 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' + ''' + } + + // 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' + ''' + } + + @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() + } + + @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') + } + + @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 + ''' + } + + @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' + ''' + } + + @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) + cu.compile(Phases.CLASS_GENERATION) + (cu.errorCollector.warnings ?: [])*.message.findAll { + it.contains('pattern switch') || it.contains('case pattern') + } + } +} 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..334d96d040e 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,11 @@ final class GroovyParserTest { doRunAndTestAntlr4('core/SwitchExpression_24x.groovy') doRunAndTestAntlr4('core/SwitchExpression_25x.groovy') doRunAndTestAntlr4('core/SwitchExpression_26x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_27x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_28x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_29x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_30x.groovy') + doRunAndTestAntlr4('core/SwitchExpression_31x.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..044165b27a1 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,11 @@ 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') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_13x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_14x.groovy') + TestUtils.doRunAndShouldFail('fail/SwitchExpression_15x.groovy') } @NotYetImplemented @Test