Skip to content

Reference implementation: structural pattern matching for switch/instanceof (GEP-19 Groovy 7 v2) - #2933

Open
paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:gep19-port2
Open

paulk-asert wants to merge 3 commits into
apache:masterfrom
paulk-asert:gep19-port2

Conversation

@paulk-asert

@paulk-asert paulk-asert commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

NOT for merging yet. This is the reference implementation for GEP-19, re-ported onto current master so we have something concrete to discuss for the feature boundary and for performance testing. It replaces #2655, which was based on a master from before the Groovy 6 switch work and no longer applied.

What changed since #2655

  • Re-ported onto current master, which now carries GROOVY-12399, GROOVY-12405, GROOVY-12406 and GROOVY-12408. Position, not arm shape, now decides whether a switch produces a value, and switch statements have the same jump-table dispatch and duplicate-label diagnostic as switch expressions.
  • The prerequisite fix landed separately as GROOVY-12397, so this branch no longer carries it.
  • Exhaustiveness is now checked for pattern switch statements. Before position became decisive, every arrow switch was an expression and so was checked on that path; a pattern switch written as a statement, or as a whole method body, is now a statement and nothing was checking it. The check is triggered by a pattern label and nothing else — see below.
  • var... rest now parses in a map pattern. Only the bare ... rest shortcut worked, though the GEP uses var... rest as its canonical spelling.
  • Bracket-form assignment def [...] = expr is deferred and is not implemented here. The GEP has been updated to match.
  • The GEP itself has been brought up to date with what this work established: position and exhaustiveness semantics, null behaviour, the carve-out list, and a Remaining work section.

What's included

Type patterns with when guards, aligning with JEP 394/441:

def desc = switch (obj) {
    case Integer i when i > 0 -> "positive $i"
    case String s             -> s.toUpperCase()
    default                   -> 'other'
}

Record patterns in case labels and in instanceof (JEP 440), positional, nestable, with var/def bindings and the _ wildcard. Works for native and emulated Groovy records, Java records, and any class providing toList():

case Line(Point(_, var y), Point p2) -> ...
if (p instanceof Point(int x, int y)) { ... }

List patterns — literals, typed elements, nested patterns, and a single rest binding in any position, over List, arrays and re-iterable Iterables:

case []                                   -> 'empty'
case [1, var x, ...]                      -> "starts with 1, then $x"
case [var first, var... middle, var last] -> ...

Map patterns — open matching, literal values by equality, nested patterns as values, and a rest binding for the remaining entries:

case [type: 'circle', radius: var r] -> "circle r=$r"
case [name: String n, var... rest]   -> "named $n; others=$rest"

Exhaustiveness, and why the trigger matters

A switch expression must always be exhaustive; that is GROOVY-12255 and is unchanged here. A switch statement is now checked too, but only when it carries a pattern label, which is where JEP 441 draws the line.

The gating is the important part. The obvious implementation is to reuse the check the expression path already calls, but that check is unconditional by design, so reusing it would demand a default from every switch statement in existence. Keying on pattern labels means no existing source is affected at all, since pattern syntax is new and no released Groovy parses it. A class-literal label can still help satisfy the check once a pattern has triggered it, which keeps what triggers the requirement separate from what satisfies it.

Whether case null should also trigger it, as in Java, is left open. Java has that rule because case null is itself new in Java 21; in Groovy it is long-standing and matches today, so adopting it would reject working code.

Lowering

Everything is parse-time desugaring: no new AST node kinds and no bytecode-format changes. Each pattern arm is an ordinary CaseStatement whose label is a boolean test on a synthetic subject variable, with the matching steps as if (!check) break <arm>. Pattern variables come from instanceof bindings and JEP 394 flow scoping, so there are no closures, casts or per-arm allocations, and a value is destructured once per match. Under @CompileStatic the arms are plain INSTANCEOF branches.

Compatibility

Legacy isCase matching is untouched and mixes freely with patterns in one switch: case foo(bar), case [1, 2, 3] (containment) and case [a: true] (lookup) keep today's semantics. Pattern mode is opt-in through binding markers.

Two carve-outs, both itemised in the GEP:

  • case List<String> l -> was a chained comparison that could never usefully evaluate; it is now a type pattern.
  • case Foo(_) ->, where Foo names a method rather than a type and _ is in scope as a variable, was a call label and is now a record pattern. Only the capitalised spelling is affected; case foo(_) -> still calls foo.

Colon-form labels are deliberately unaffected: a pattern requires the arrow form, so a colon label is never read as one and keeps whatever it means today.

Deliberately not included

  • Bracket-form assignment def [...] = expr — deferred pending review. It overlaps the shipped GEP-20 parens form, forks rest semantics between switch and assignment, and has the opposite failure philosophy (strict vs null-padding).
  • List/map patterns in instanceof — no type at the head, per the GEP.
  • SwitchBootstraps.typeSwitch indy dispatch — needs a Java 21+ bytecode target.
  • Primitive type patterns (JEP 507) are included, incubating.

Open for discussion

  • Severity. A non-exhaustive switch is currently an error. The GEP text has said warn-only. This interacts with the next item.
  • Deconstruction coverage. Record, list and map patterns never count toward exhaustiveness, because a deconstruction can fail on arity or a component, so a switch Java accepts can be rejected here. Implementing recursive component coverage would fix it and would let severity be tightened.
  • case null as an exhaustiveness trigger.
  • return / break / continue in arrow arms. Rejected since the 4.0 parser and legal in Java. Pattern labels are arrow-only, so under this proposal there is no workaround; worth fixing alongside.
  • Minimum JDK for Groovy 7. Below 21 there is no MatchException and no SwitchBootstraps.typeSwitch.

…ce implementation)

Ports PR apache#2655 onto the first-class switch expression AST (GROOVY-12255).
Everything is parse-time lowering to ordinary AST plus node metadata
(org.apache.groovy.ast.tools.SwitchPatternUtils):

- a pattern switch carries a synthetic subject Parameter; VariableScopeVisitor
  declares it, the type checker types it like a loop variable and the
  bytecode writers bind it to the evaluated selector instead of a temp slot
- a pattern arm is a CaseStatement marked PATTERN_ARM whose label is a boolean
  test on the subject (the writers evaluate it instead of calling isCase) and
  whose code starts with the matching steps, each `if (!(check)) break <arm>`
  with the arm's statement label registered as the next case test; pattern
  variables come from instanceof bindings and JEP 394 flow scoping, so no
  casts, closures or per-arm allocation remain (the closure-label and
  "closure-free" lowerings of the PR are gone)
- type patterns with `when` guards, record patterns (native, emulated, Java
  records and toList() deconstructables via RecordPatternSupport), list
  patterns (ListPatternSupport) and map patterns (MapPatternSupport) in case
  labels; record patterns in instanceof as a short-circuit && chain of
  bindings, a var component bound through RecordPatternSupport.bindable/bound
  so that a null component still matches
- static type checking: an error for a pattern that cannot match the subject
  type or a record pattern of the wrong arity, a warning for a dominated
  pattern; an unconditional pattern or sealed-hierarchy coverage makes the
  switch exhaustive, otherwise the existing "does not cover all possible
  input values" error applies (the PR issued a warning)
- primitive type patterns (JEP 507 alignment) as in the PR's last commit

Test resources fail/SwitchExpression_11x..15x of the PR are 15x..19x here.
… syntax tests

Follow-up to the port, applying what the switch-position work established.

A switch statement carrying a pattern label is now checked for
exhaustiveness. Until GROOVY-12408 that happened by accident: GROOVY-12255
made every arrow switch an expression, so a pattern switch written as a
statement, or as a whole method body, went down the expression path and was
checked there. Now that position decides, such a switch really is a
statement and nothing was checking it.

The check is triggered by a pattern label and nothing else. That matters
for compatibility, because the obvious wiring is to reuse the expression
check, which is unconditional by design: every switch expression must be
exhaustive whatever its labels. Reusing it would demand a default from
every switch statement in existence. JEP 441 draws the line in the same
place, requiring exhaustiveness of a statement only when it uses a pattern
label; constant and class-literal labels never demand a default. Pattern
syntax is new here, so no existing source can contain one and none is
affected. A class literal can still help satisfy the check once a pattern
has triggered it, which keeps what triggers the requirement separate from
what satisfies it.

Whether to follow Java in treating `case null` as a trigger is left open.
Java has that rule because `case null` is itself new in Java 21; in Groovy
it is long-standing and works today, so adopting the trigger would break
working code and is a decision for the GEP rather than a detail here.

checkSwitchExpressionExhaustiveness and coversAllEnumConstants took a
SwitchExpression but needed only the selector, the case list and the
default, so both are generalised the way GROOVY-12406 generalised the
duplicate-label check. The statement diagnostic reads "the switch statement
does not cover all possible input values", matching javac.

The ten parse-and-run resource tests hung off GroovyParserTest and
SyntaxErrorTest, which GROOVY-12400 replaced with the generated covering-set
tests. They now have their own driver: the covering set exercises the
grammar, these exercise the feature.
@paulk-asert paulk-asert changed the title Gep19 port2 Reference implementation: structural pattern matching for switch/instanceof (GEP-19 Groovy 7 v2) Sep 14, 2026
The GEP uses `var... rest` as the canonical spelling for a map pattern's
rest binding, in the abstract, in the design principles and in the map
pattern section. Only the bare `... rest` shortcut parsed; `var... rest`
failed with "Missing ':'".

The list form already allowed the prefix, through listPatternRest's
`(DEF | VAR | standardType)?`, but mapPatternEntry's rest alternative was
a bare `ELLIPSIS identifier?`. It now takes the same `var` / `def`
prefix.

No type prefix, unlike the list form: a map pattern's rest binding is
always a Map, so `Integer... rest` has nothing to test and stays an
error. The AST builder needs no change, since it reads ELLIPSIS and
identifier from the entry either way.

Legacy map labels are unaffected, verified against master: `case [a: 1]`
still uses Map.isCase and `case [1, 2, 3]` still tests containment.
@testlens-app

testlens-app Bot commented Sep 14, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: f246e46
▶️ Tests: 0 executed
⚪️ Checks: 25/25 completed


Learn more about TestLens at testlens.app/docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant