From 0a391cddcdc7f965a081176c4728e9c23c1989e5 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 17:12:02 +0200 Subject: [PATCH 01/16] Add SubMatch Tree --- compiler/src/dotty/tools/dotc/ast/Trees.scala | 8 ++++++++ compiler/src/dotty/tools/dotc/ast/tpd.scala | 3 +++ compiler/src/dotty/tools/dotc/ast/untpd.scala | 1 + .../src/dotty/tools/dotc/printing/RefinedPrinter.scala | 5 ++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/compiler/src/dotty/tools/dotc/ast/Trees.scala b/compiler/src/dotty/tools/dotc/ast/Trees.scala index 8749f7ddc10c..0d893dfa81a5 100644 --- a/compiler/src/dotty/tools/dotc/ast/Trees.scala +++ b/compiler/src/dotty/tools/dotc/ast/Trees.scala @@ -608,12 +608,18 @@ object Trees { extends TermTree[T] { type ThisTree[+T <: Untyped] = Match[T] def isInline = false + def isSubMatch = false } class InlineMatch[+T <: Untyped] private[ast] (selector: Tree[T], cases: List[CaseDef[T]])(implicit @constructorOnly src: SourceFile) extends Match(selector, cases) { override def isInline = true override def toString = s"InlineMatch($selector, $cases)" } + /** with selector match { cases } */ + final class SubMatch[+T <: Untyped] private[ast] (selector: Tree[T], cases: List[CaseDef[T]])(implicit @constructorOnly src: SourceFile) + extends Match(selector, cases) { + override def isSubMatch = true + } /** case pat if guard => body */ case class CaseDef[+T <: Untyped] private[ast] (pat: Tree[T], guard: Tree[T], body: Tree[T])(implicit @constructorOnly src: SourceFile) @@ -1180,6 +1186,7 @@ object Trees { type Closure = Trees.Closure[T] type Match = Trees.Match[T] type InlineMatch = Trees.InlineMatch[T] + type SubMatch = Trees.SubMatch[T] type CaseDef = Trees.CaseDef[T] type Labeled = Trees.Labeled[T] type Return = Trees.Return[T] @@ -1329,6 +1336,7 @@ object Trees { def Match(tree: Tree)(selector: Tree, cases: List[CaseDef])(using Context): Match = tree match { case tree: Match if (selector eq tree.selector) && (cases eq tree.cases) => tree case tree: InlineMatch => finalize(tree, untpd.InlineMatch(selector, cases)(sourceFile(tree))) + case tree: SubMatch => finalize(tree, untpd.SubMatch(selector, cases)(sourceFile(tree))) case _ => finalize(tree, untpd.Match(selector, cases)(sourceFile(tree))) } def CaseDef(tree: Tree)(pat: Tree, guard: Tree, body: Tree)(using Context): CaseDef = tree match { diff --git a/compiler/src/dotty/tools/dotc/ast/tpd.scala b/compiler/src/dotty/tools/dotc/ast/tpd.scala index 92c20afe7a73..b64d9065227f 100644 --- a/compiler/src/dotty/tools/dotc/ast/tpd.scala +++ b/compiler/src/dotty/tools/dotc/ast/tpd.scala @@ -143,6 +143,9 @@ object tpd extends Trees.Instance[Type] with TypedTreeInfo { def InlineMatch(selector: Tree, cases: List[CaseDef])(using Context): Match = ta.assignType(untpd.InlineMatch(selector, cases), selector, cases) + def SubMatch(selector: Tree, cases: List[CaseDef])(using Context): Match = + ta.assignType(untpd.SubMatch(selector, cases), selector, cases) + def Labeled(bind: Bind, expr: Tree)(using Context): Labeled = ta.assignType(untpd.Labeled(bind, expr)) diff --git a/compiler/src/dotty/tools/dotc/ast/untpd.scala b/compiler/src/dotty/tools/dotc/ast/untpd.scala index 96c8c4c4f845..3705ec545c97 100644 --- a/compiler/src/dotty/tools/dotc/ast/untpd.scala +++ b/compiler/src/dotty/tools/dotc/ast/untpd.scala @@ -411,6 +411,7 @@ object untpd extends Trees.Instance[Untyped] with UntypedTreeInfo { def Closure(env: List[Tree], meth: Tree, tpt: Tree)(implicit src: SourceFile): Closure = new Closure(env, meth, tpt) def Match(selector: Tree, cases: List[CaseDef])(implicit src: SourceFile): Match = new Match(selector, cases) def InlineMatch(selector: Tree, cases: List[CaseDef])(implicit src: SourceFile): Match = new InlineMatch(selector, cases) + def SubMatch(selector: Tree, cases: List[CaseDef])(implicit src: SourceFile): SubMatch = new SubMatch(selector, cases) def CaseDef(pat: Tree, guard: Tree, body: Tree)(implicit src: SourceFile): CaseDef = new CaseDef(pat, guard, body) def Labeled(bind: Bind, expr: Tree)(implicit src: SourceFile): Labeled = new Labeled(bind, expr) def Return(expr: Tree, from: Tree)(implicit src: SourceFile): Return = new Return(expr, from) diff --git a/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala b/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala index 1e58268c2e38..ea767d030fec 100644 --- a/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala +++ b/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala @@ -565,7 +565,10 @@ class RefinedPrinter(_ctx: Context) extends PlainPrinter(_ctx) { selTxt ~ keywordStr(" match ") ~ blockText(cases) } case CaseDef(pat, guard, body) => - keywordStr("case ") ~ inPattern(toText(pat)) ~ optText(guard)(keywordStr(" if ") ~ _) ~ " => " ~ caseBlockText(body) + val bodyText = body match + case t: SubMatch => keywordStr(" with ") ~ toText(t) + case t => " => " ~ caseBlockText(t) + keywordStr("case ") ~ inPattern(toText(pat)) ~ optText(guard)(keywordStr(" if ") ~ _) ~ bodyText case Labeled(bind, expr) => changePrec(GlobalPrec) { toText(bind.name) ~ keywordStr("[") ~ toText(bind.symbol.info) ~ keywordStr("]: ") ~ toText(expr) } case Return(expr, from) => From 17ca7fc170af69649ddfc31c4726ca536ea99cec Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 17:18:23 +0200 Subject: [PATCH 02/16] Add scala.language.experimental.matchWithSubCases --- compiler/src/dotty/tools/dotc/config/Feature.scala | 1 + library/src/scala/language.scala | 7 ++++++- library/src/scala/runtime/stdLibPatches/language.scala | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/config/Feature.scala b/compiler/src/dotty/tools/dotc/config/Feature.scala index 23305a6b0333..8ff77ecf6461 100644 --- a/compiler/src/dotty/tools/dotc/config/Feature.scala +++ b/compiler/src/dotty/tools/dotc/config/Feature.scala @@ -37,6 +37,7 @@ object Feature: val modularity = experimental("modularity") val quotedPatternsWithPolymorphicFunctions = experimental("quotedPatternsWithPolymorphicFunctions") val packageObjectValues = experimental("packageObjectValues") + val matchWithSubCases = experimental("matchWithSubCases") def experimentalAutoEnableFeatures(using Context): List[TermName] = defn.languageExperimentalFeatures diff --git a/library/src/scala/language.scala b/library/src/scala/language.scala index 050e77dd7e7b..d5c45e1bf406 100644 --- a/library/src/scala/language.scala +++ b/library/src/scala/language.scala @@ -342,7 +342,7 @@ object language { * @see [[https://github.com/scala/improvement-proposals/pull/79]] */ @compileTimeOnly("`betterFors` can only be used at compile time in import statements") - @deprecated("The `experimental.betterFors` language import no longer has any effect, the feature is being stablised and can be enabled using `-preview` flag", since = "3.7") + @deprecated("The `experimental.betterFors` language import no longer has any effect, the feature is being stabilised and can be enabled using `-preview` flag", since = "3.7") object betterFors /** Experimental support for package object values @@ -350,6 +350,11 @@ object language { @compileTimeOnly("`packageObjectValues` can only be used at compile time in import statements") object packageObjectValues + /** Experimental support for match expressions with sub-cases. + */ + @compileTimeOnly("`matchWithSubCases` can only be used at compile time in import statements") + object matchWithSubCases + } /** The deprecated object contains features that are no longer officially suypported in Scala. diff --git a/library/src/scala/runtime/stdLibPatches/language.scala b/library/src/scala/runtime/stdLibPatches/language.scala index c16f9eec7fe7..970c2242fb50 100644 --- a/library/src/scala/runtime/stdLibPatches/language.scala +++ b/library/src/scala/runtime/stdLibPatches/language.scala @@ -147,13 +147,18 @@ object language: * @see [[https://github.com/scala/improvement-proposals/pull/79]] */ @compileTimeOnly("`betterFors` can only be used at compile time in import statements") - @deprecated("The `experimental.betterFors` language import no longer has any effect, the feature is being stablised and can be enabled using `-preview` flag", since = "3.7") + @deprecated("The `experimental.betterFors` language import no longer has any effect, the feature is being stabilised and can be enabled using `-preview` flag", since = "3.7") object betterFors /** Experimental support for package object values */ @compileTimeOnly("`packageObjectValues` can only be used at compile time in import statements") object packageObjectValues + + /** Experimental support for match expressions with sub-cases. + */ + @compileTimeOnly("`matchWithSubCases` can only be used at compile time in import statements") + object matchWithSubCases end experimental /** The deprecated object contains features that are no longer officially suypported in Scala. From 39227894f9d4d18520c39a1528deb6ba74374497 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 18:31:41 +0200 Subject: [PATCH 03/16] Add parsing of match sub cases --- .../dotty/tools/dotc/parsing/Parsers.scala | 34 +++++++++----- tests/run/sub-cases.scala | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 tests/run/sub-cases.scala diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index f6dd3c2396d4..ecd5c8275142 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -2592,6 +2592,13 @@ object Parsers { Match(t, inBracesOrIndented(caseClauses(() => caseClause()))) } + /** SubMatchClause ::= `match' `{' CaseClauses `}' + */ + def subMatchClause(t: Tree): SubMatch = atSpan(startOffset(t), accept(MATCH)): + val cases = inBracesOrIndented(caseClauses(() => caseClause())) + if !in.isNestedEnd then acceptStatSep() // else is sub sub match + SubMatch(t, cases) + /** `match' <<< TypeCaseClauses >>> */ def matchType(t: Tree): MatchTypeTree = @@ -3092,24 +3099,27 @@ object Parsers { buf.toList } - /** CaseClause ::= ‘case’ Pattern [Guard] `=>' Block - * ExprCaseClause ::= ‘case’ Pattern [Guard] ‘=>’ Expr + /** CaseClause ::= ‘case’ Pattern [Guard] (‘with’ SimpleExpr SubMatchClause | `=>' Block) + * ExprCaseClause ::= ‘case’ Pattern [Guard] (‘with’ SimpleExpr SubMatchClause | `=>' Expr) */ def caseClause(exprOnly: Boolean = false): CaseDef = atSpan(in.offset) { val (pat, grd) = inSepRegion(InCase) { accept(CASE) (withinMatchPattern(pattern()), guard()) } - CaseDef(pat, grd, atSpan(accept(ARROW)) { - if exprOnly then - if in.indentSyntax && in.isAfterLineEnd && in.token != INDENT then - warning(em"""Misleading indentation: this expression forms part of the preceding catch case. - |If this is intended, it should be indented for clarity. - |Otherwise, if the handler is intended to be empty, use a multi-line catch with - |an indented case.""") - expr() - else block() - }) + val body = + if in.token == WITH && in.featureEnabled(Feature.matchWithSubCases) then atSpan(in.skipToken()): + subMatchClause(simpleExpr(Location.ElseWhere)) + else atSpan(accept(ARROW)): + if exprOnly then + if in.indentSyntax && in.isAfterLineEnd && in.token != INDENT then + warning(em"""Misleading indentation: this expression forms part of the preceding catch case. + |If this is intended, it should be indented for clarity. + |Otherwise, if the handler is intended to be empty, use a multi-line catch with + |an indented case.""") + expr() + else block() + CaseDef(pat, grd, body) } /** TypeCaseClause ::= ‘case’ (InfixType | ‘_’) ‘=>’ Type [semi] diff --git a/tests/run/sub-cases.scala b/tests/run/sub-cases.scala new file mode 100644 index 000000000000..ca72145e6b28 --- /dev/null +++ b/tests/run/sub-cases.scala @@ -0,0 +1,46 @@ +import scala.language.experimental.matchWithSubCases + +enum E: + case A(e: E) + case B(e: E) + case C + + def f: Option[E] = this match + case A(e) => Some(e) + case B(e) => Some(e) + case C => None + +end E +import E.* + + +@main def Test = + + def test(e: E): Int = e match + case A(B(e1)) if true with e1.f match + case Some(x) with x match + case A(_) => 11 + case C => 12 + case B(A(e1)) with e1.f match + case Some(C) => 21 + case None => 22 + case _ => 3 + end test + + def check(e: E, r: Int): Unit = assert(test(e) == r) + + check(A(A(C)), 3) + + val x1 = B(A(C)) + check(A(B(x1)), 11) + + val x2 = B(C) + check(A(B(x2)), 12) + + check(A(B(C)), 3) + check(A(B(B(B(C)))), 3) + + check(B(A(x2)), 21) + check(B(A(C)), 22) + + check(A(A(C)), 3) From 4c289542d84d7672f45b84594ef217993543923c Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 18:33:41 +0200 Subject: [PATCH 04/16] Add pickling of match sub cases --- compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala | 1 + compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala | 3 +++ tasty/src/dotty/tools/tasty/TastyFormat.scala | 1 + 3 files changed, 5 insertions(+) diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala index 4e63c7e973fe..8f3da159c972 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala @@ -590,6 +590,7 @@ class TreePickler(pickler: TastyPickler, attributes: Attributes) { if (tree.isInline) if (selector.isEmpty) writeByte(IMPLICIT) else { writeByte(INLINE); pickleTree(selector) } + else if tree.isSubMatch then { writeByte(WITH); pickleTree(selector) } else pickleTree(selector) tree.cases.foreach(pickleTree) } diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala index ad9d485b5ee5..5745129ce7e7 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala @@ -1539,6 +1539,9 @@ class TreeUnpickler(reader: TastyReader, readByte() InlineMatch(readTree(), readCases(end)) } + else if nextByte == WITH then + readByte() + SubMatch(readTree(), readCases(end)) else Match(readTree(), readCases(end))) case RETURN => val from = readSymRef() diff --git a/tasty/src/dotty/tools/tasty/TastyFormat.scala b/tasty/src/dotty/tools/tasty/TastyFormat.scala index 37e3a3acfdab..b0b0b3b0682f 100644 --- a/tasty/src/dotty/tools/tasty/TastyFormat.scala +++ b/tasty/src/dotty/tools/tasty/TastyFormat.scala @@ -511,6 +511,7 @@ object TastyFormat { final val EMPTYCLAUSE = 45 final val SPLITCLAUSE = 46 final val TRACKED = 47 + final val WITH = 48 // Tree Cat. 2: tag Nat final val firstNatTreeTag = SHAREDterm From 5e46b0161088d3f7646d9739c06d55f7abea53b7 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 18:40:34 +0200 Subject: [PATCH 05/16] Handle sub cases in PatternMatcher --- .../tools/dotc/transform/PatternMatcher.scala | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/transform/PatternMatcher.scala b/compiler/src/dotty/tools/dotc/transform/PatternMatcher.scala index 8bf88a0027c4..03b49b2371f1 100644 --- a/compiler/src/dotty/tools/dotc/transform/PatternMatcher.scala +++ b/compiler/src/dotty/tools/dotc/transform/PatternMatcher.scala @@ -37,6 +37,10 @@ class PatternMatcher extends MiniPhase { override def transformMatch(tree: Match)(using Context): Tree = if (tree.isInstanceOf[InlineMatch]) tree + else if tree.isSubMatch then + // A sub match in a case def body will be transformed when the outer match is processed. + // This assummes that no earlier miniphase needs sub matches to have been transformed before the outer match. + tree else { // Widen termrefs with underlying `=> T` types. Otherwise ElimByName will produce // inconsistent types. See i7743.scala. @@ -482,12 +486,23 @@ object PatternMatcher { } } - private def caseDefPlan(scrutinee: Symbol, cdef: CaseDef): Plan = { - var onSuccess: Plan = ResultPlan(cdef.body) - if (!cdef.guard.isEmpty) - onSuccess = TestPlan(GuardTest, cdef.guard, cdef.guard.span, onSuccess) - patternPlan(scrutinee, cdef.pat, onSuccess) - } + private def caseDefPlan(scrutinee: Symbol, cdef: CaseDef): Plan = + val CaseDef(pat, guard, body) = cdef + val caseDefBodyPlan: Plan = body match + case t: SubMatch => subMatchPlan(t) + case _ => ResultPlan(body) + val onSuccess: Plan = + if guard.isEmpty then caseDefBodyPlan + else TestPlan(GuardTest, guard, guard.span, caseDefBodyPlan) + patternPlan(scrutinee, pat, onSuccess) + end caseDefPlan + + // like matchPlan but without a final matchError ResultPlan at the end of SeqPlans + // s.t. we fall back to the outer SeqPlan + private def subMatchPlan(tree: SubMatch): Plan = + letAbstract(tree.selector) { scrutinee => + tree.cases.map(caseDefPlan(scrutinee, _)).reduceRight(SeqPlan(_, _)) + } private def matchPlan(tree: Match): Plan = letAbstract(tree.selector) { scrutinee => From e26be672ce99d171ae9be7f82ab478de1526c844 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 18:45:12 +0200 Subject: [PATCH 06/16] Handle exhaustivity and reachability checking of matches with sub cases --- compiler/src/dotty/tools/dotc/ast/Trees.scala | 2 ++ .../tools/dotc/transform/patmat/Space.scala | 8 ++--- tests/warn/sub-cases-exhaustivity.check | 20 +++++++++++ tests/warn/sub-cases-exhaustivity.scala | 33 +++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 tests/warn/sub-cases-exhaustivity.check create mode 100644 tests/warn/sub-cases-exhaustivity.scala diff --git a/compiler/src/dotty/tools/dotc/ast/Trees.scala b/compiler/src/dotty/tools/dotc/ast/Trees.scala index 0d893dfa81a5..d144703efeb7 100644 --- a/compiler/src/dotty/tools/dotc/ast/Trees.scala +++ b/compiler/src/dotty/tools/dotc/ast/Trees.scala @@ -625,6 +625,8 @@ object Trees { case class CaseDef[+T <: Untyped] private[ast] (pat: Tree[T], guard: Tree[T], body: Tree[T])(implicit @constructorOnly src: SourceFile) extends Tree[T] { type ThisTree[+T <: Untyped] = CaseDef[T] + /** Should this case be considered partial for exhaustivity and unreachability checking */ + def maybePartial(using Context): Boolean = !guard.isEmpty || body.isInstanceOf[SubMatch[T]] } /** label[tpt]: { expr } */ diff --git a/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala b/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala index b7e1f349a377..82e69ccd072e 100644 --- a/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala +++ b/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala @@ -883,7 +883,7 @@ object SpaceEngine { val targetSpace = trace(i"targetSpace($selTyp)")(project(selTyp)) val patternSpace = Or(m.cases.foldLeft(List.empty[Space]) { (acc, x) => - val space = if x.guard.isEmpty then trace(i"project(${x.pat})")(project(x.pat)) else Empty + val space = if x.maybePartial then Empty else trace(i"project(${x.pat})")(project(x.pat)) space :: acc }) @@ -925,7 +925,7 @@ object SpaceEngine { @tailrec def recur(cases: List[CaseDef], prevs: List[Space], deferred: List[Tree]): Unit = cases match case Nil => - case CaseDef(pat, guard, _) :: rest => + case (c @ CaseDef(pat, _, _)) :: rest => val curr = trace(i"project($pat)")(projectPat(pat)) val covered = trace("covered")(simplify(intersect(curr, targetSpace))) val prev = trace("prev")(simplify(Or(prevs))) @@ -951,8 +951,8 @@ object SpaceEngine { hadNullOnly = true report.warning(MatchCaseOnlyNullWarning(), pat.srcPos) - // in redundancy check, take guard as false in order to soundly approximate - val newPrev = if guard.isEmpty then covered :: prevs else prevs + // in redundancy check, take guard as false (or potential sub cases as partial) for a sound approximation + val newPrev = if c.maybePartial then prevs else covered :: prevs recur(rest, newPrev, Nil) recur(m.cases, Nil, Nil) diff --git a/tests/warn/sub-cases-exhaustivity.check b/tests/warn/sub-cases-exhaustivity.check new file mode 100644 index 000000000000..ef59bd28d8fb --- /dev/null +++ b/tests/warn/sub-cases-exhaustivity.check @@ -0,0 +1,20 @@ +-- [E029] Pattern Match Exhaustivity Warning: tests/warn/sub-cases-exhaustivity.scala:15:2 ----------------------------- +15 | e match // warn: match may not be exhaustive: It would fail on pattern case: E.A(_) | E.B(_) + | ^ + | match may not be exhaustive. + | + | It would fail on pattern case: E.A(_), E.B(_) + | + | longer explanation available when compiling with `-explain` +-- [E029] Pattern Match Exhaustivity Warning: tests/warn/sub-cases-exhaustivity.scala:24:2 ----------------------------- +24 | e match // warn: match may not be exhaustive: It would fail on pattern case: E.B(_) + | ^ + | match may not be exhaustive. + | + | It would fail on pattern case: E.B(_) + | + | longer explanation available when compiling with `-explain` +-- [E030] Match case Unreachable Warning: tests/warn/sub-cases-exhaustivity.scala:32:9 --------------------------------- +32 | case A(_) => 3 // warn: unreacheable + | ^^^^ + | Unreachable case diff --git a/tests/warn/sub-cases-exhaustivity.scala b/tests/warn/sub-cases-exhaustivity.scala new file mode 100644 index 000000000000..e47b189c8748 --- /dev/null +++ b/tests/warn/sub-cases-exhaustivity.scala @@ -0,0 +1,33 @@ +import scala.language.experimental.matchWithSubCases + +enum E: + case A(e: E) + case B(e: E) + case C + + def f: E = ??? +end E +import E.* + +object Test: + val e: E = ??? + + e match // warn: match may not be exhaustive: It would fail on pattern case: E.A(_) | E.B(_) + case A(e1) with e1.f match + case B(_) => 11 + case C => 12 + case B(e1) with e1.f match + case C => 21 + case A(_) => 22 + case C => 3 + + e match // warn: match may not be exhaustive: It would fail on pattern case: E.B(_) + case A(e1) with e1.f match + case B(_) => 11 + case C => 12 + case B(e1) with e1.f match + case C => 21 + case A(_) => 22 + case A(_) => 3 // nowarn: should not be reported as unreachable + case A(_) => 3 // warn: unreacheable + case C => 4 From 352317ce5e668d18e6954b83a0ac7e59d70f7611 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 13 Aug 2025 18:46:18 +0200 Subject: [PATCH 07/16] Handle inlining of matches with sub cases A sub match is inline iff the outer match is --- .../tools/dotc/inlines/InlineReducer.scala | 10 +++++-- tests/pos/inline-sub-match.scala | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/pos/inline-sub-match.scala diff --git a/compiler/src/dotty/tools/dotc/inlines/InlineReducer.scala b/compiler/src/dotty/tools/dotc/inlines/InlineReducer.scala index 3692df7560b2..7043169e2ae3 100644 --- a/compiler/src/dotty/tools/dotc/inlines/InlineReducer.scala +++ b/compiler/src/dotty/tools/dotc/inlines/InlineReducer.scala @@ -394,9 +394,13 @@ class InlineReducer(inliner: Inliner)(using Context): case ConstantValue(v: Boolean) => (v, true) case _ => (false, false) } - if guardOK then Some((caseBindings.map(_.subst(from, to)), cdef.body.subst(from, to), canReduceGuard)) - else if canReduceGuard then None - else Some((caseBindings.map(_.subst(from, to)), cdef.body.subst(from, to), canReduceGuard)) + if !canReduceGuard then Some((List.empty, EmptyTree, false)) + else if !guardOK then None + else cdef.body.subst(from, to) match + case t: SubMatch => // a sub match of an inline match is also inlined + reduceInlineMatch(t.selector, t.selector.tpe, t.cases, typer).map: + (subCaseBindings, rhs) => (caseBindings.map(_.subst(from, to)) ++ subCaseBindings, rhs, true) + case b => Some((caseBindings.map(_.subst(from, to)), b, true)) } else None } diff --git a/tests/pos/inline-sub-match.scala b/tests/pos/inline-sub-match.scala new file mode 100644 index 000000000000..2a254e1ee9d3 --- /dev/null +++ b/tests/pos/inline-sub-match.scala @@ -0,0 +1,30 @@ +import scala.language.experimental.matchWithSubCases + +object Test: + + // using transparent to test whether test whether reduced as expected + transparent inline def foo(i: Int, j: Int): String = + inline i match + case 0 with j match + case 1 => "01" + case 2 => "02" + case 1 with j match + case 1 => "11" + case 2 => "12" + case _ => "3" + + val r01: "01" = foo(0, 1) + val r02: "02" = foo(0, 2) + val r11: "11" = foo(1, 1) + val r31: "3" = foo(3, 1) + + transparent inline def bar(x: Option[Any]): String = + inline x match + case Some(y: Int) with y match + case 1 => "a" + case 2 => "b" + case Some(z: String) => "c" + case _ => "d" + + val x = bar(Some(2)) // FIX this reduces to "c", but this appears to be a more general issue than sub-matches + val y = bar(Some("hello")) From 3e894551abbedc0d739ed748e84ad3dda931cd61 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Thu, 14 Aug 2025 12:02:55 +0200 Subject: [PATCH 08/16] Test match with sub sub cases and catch with sub cases --- tests/pos/sub-sub-match.scala | 20 ++++++++++++++++++++ tests/run/sub-catch.scala | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/pos/sub-sub-match.scala create mode 100644 tests/run/sub-catch.scala diff --git a/tests/pos/sub-sub-match.scala b/tests/pos/sub-sub-match.scala new file mode 100644 index 000000000000..bef450c13817 --- /dev/null +++ b/tests/pos/sub-sub-match.scala @@ -0,0 +1,20 @@ +import scala.language.experimental.matchWithSubCases + +object Test: + val x: Option[Option[Int]] = ??? + x match + case Some(x2) with x2 match + case Some(x3) with x3 match + case 1 => "a" + case 2 => "b" + case None => "d" + + x match { + case Some(x2) with x2 match { + case Some (x3) with x3 match { + case 1 => "a" + case 2 => "b" + } + } + case None => "d" + } diff --git a/tests/run/sub-catch.scala b/tests/run/sub-catch.scala new file mode 100644 index 000000000000..07fc61dec777 --- /dev/null +++ b/tests/run/sub-catch.scala @@ -0,0 +1,30 @@ +import scala.language.experimental.matchWithSubCases + +enum E extends Exception: + case A(x: Any) + case B(x: Any) + case C + +end E +import E.* + +def test(op: => Nothing): String = + try op catch + case A(x: Int) if true with x match + case 1 => "A(1)" + case 2 => "A(2)" + case B(x: String) with x match + case "a" => "B(a)" + case "b" => "B(b)" + case _ => "other" +end test + +@main def Test = + + def check(e: E, r: String): Unit = assert(test(throw e) == r) + + check(A(1), "A(1)") + check(A(2), "A(2)") + check(A(3), "other") + check(B("b"), "B(b)") + check(B(1), "other") From 3b86dc9a4754ba3ca20bb5ff664f68e59ef762d5 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Fri, 15 Aug 2025 13:04:10 +0200 Subject: [PATCH 09/16] Handle partial functions with sub cases --- .../dotty/tools/dotc/transform/ExpandSAMs.scala | 6 +++++- tests/run/pf-sub-cases.scala | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/run/pf-sub-cases.scala diff --git a/compiler/src/dotty/tools/dotc/transform/ExpandSAMs.scala b/compiler/src/dotty/tools/dotc/transform/ExpandSAMs.scala index 4288a82e0191..f76419f5198e 100644 --- a/compiler/src/dotty/tools/dotc/transform/ExpandSAMs.scala +++ b/compiler/src/dotty/tools/dotc/transform/ExpandSAMs.scala @@ -180,7 +180,11 @@ class ExpandSAMs extends MiniPhase: def isDefinedAtRhs(paramRefss: List[List[Tree]])(using Context) = val tru = Literal(Constant(true)) - def translateCase(cdef: CaseDef) = cpy.CaseDef(cdef)(body = tru) + def translateCase(cdef: CaseDef): CaseDef = + val body1 = cdef.body match + case b: SubMatch => cpy.Match(b)(b.selector, b.cases.map(translateCase)) + case _ => tru + cpy.CaseDef(cdef)(body = body1) val paramRef = paramRefss.head.head val defaultValue = Literal(Constant(false)) translateMatch(isDefinedAtFn)(paramRef.symbol, pfRHS.cases.map(translateCase), defaultValue) diff --git a/tests/run/pf-sub-cases.scala b/tests/run/pf-sub-cases.scala new file mode 100644 index 000000000000..1640e33a5f8d --- /dev/null +++ b/tests/run/pf-sub-cases.scala @@ -0,0 +1,14 @@ +import scala.language.experimental.matchWithSubCases + +val pf: PartialFunction[Option[Option[Int]], String] = + case Some(x2) with x2 match + case Some(x3) with x3 match + case 1 => "a" + case 2 => "b" + case Some(None) => "c" + +@main def Test = + assert(pf(Some(Some(2))) == "b") + assert(pf(Some(None)) == "c") + assert(!pf.isDefinedAt(None)) + assert(!pf.isDefinedAt(Some(Some(3)))) From 4da6103fe94961bf3792d169aa4e0a613454e92e Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Fri, 15 Aug 2025 13:21:21 +0200 Subject: [PATCH 10/16] Rename experimental feature to `subCases` Avoids the issue with match completion with suggests importing the feature. --- compiler/src/dotty/tools/dotc/config/Feature.scala | 2 +- compiler/src/dotty/tools/dotc/parsing/Parsers.scala | 2 +- library/src/scala/language.scala | 6 +++--- library/src/scala/runtime/stdLibPatches/language.scala | 6 +++--- ...{inline-sub-match.scala => inline-match-sub-cases.scala} | 2 +- .../pos/{sub-sub-match.scala => match-sub-sub-cases.scala} | 2 +- tests/run/{sub-catch.scala => catch-sub-cases.scala} | 2 +- tests/run/{sub-cases.scala => match-sub-cases.scala} | 2 +- tests/run/pf-sub-cases.scala | 2 +- tests/warn/sub-cases-exhaustivity.scala | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) rename tests/pos/{inline-sub-match.scala => inline-match-sub-cases.scala} (93%) rename tests/pos/{sub-sub-match.scala => match-sub-sub-cases.scala} (87%) rename tests/run/{sub-catch.scala => catch-sub-cases.scala} (91%) rename tests/run/{sub-cases.scala => match-sub-cases.scala} (93%) diff --git a/compiler/src/dotty/tools/dotc/config/Feature.scala b/compiler/src/dotty/tools/dotc/config/Feature.scala index 8ff77ecf6461..70a77c9560b2 100644 --- a/compiler/src/dotty/tools/dotc/config/Feature.scala +++ b/compiler/src/dotty/tools/dotc/config/Feature.scala @@ -37,7 +37,7 @@ object Feature: val modularity = experimental("modularity") val quotedPatternsWithPolymorphicFunctions = experimental("quotedPatternsWithPolymorphicFunctions") val packageObjectValues = experimental("packageObjectValues") - val matchWithSubCases = experimental("matchWithSubCases") + val subCases = experimental("subCases") def experimentalAutoEnableFeatures(using Context): List[TermName] = defn.languageExperimentalFeatures diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index ecd5c8275142..baec062d6403 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -3108,7 +3108,7 @@ object Parsers { (withinMatchPattern(pattern()), guard()) } val body = - if in.token == WITH && in.featureEnabled(Feature.matchWithSubCases) then atSpan(in.skipToken()): + if in.token == WITH && in.featureEnabled(Feature.subCases) then atSpan(in.skipToken()): subMatchClause(simpleExpr(Location.ElseWhere)) else atSpan(accept(ARROW)): if exprOnly then diff --git a/library/src/scala/language.scala b/library/src/scala/language.scala index d5c45e1bf406..bacbb09ad615 100644 --- a/library/src/scala/language.scala +++ b/library/src/scala/language.scala @@ -350,10 +350,10 @@ object language { @compileTimeOnly("`packageObjectValues` can only be used at compile time in import statements") object packageObjectValues - /** Experimental support for match expressions with sub-cases. + /** Experimental support for match expressions with sub cases. */ - @compileTimeOnly("`matchWithSubCases` can only be used at compile time in import statements") - object matchWithSubCases + @compileTimeOnly("`subCases` can only be used at compile time in import statements") + object subCases } diff --git a/library/src/scala/runtime/stdLibPatches/language.scala b/library/src/scala/runtime/stdLibPatches/language.scala index 970c2242fb50..0e83efb850f4 100644 --- a/library/src/scala/runtime/stdLibPatches/language.scala +++ b/library/src/scala/runtime/stdLibPatches/language.scala @@ -155,10 +155,10 @@ object language: @compileTimeOnly("`packageObjectValues` can only be used at compile time in import statements") object packageObjectValues - /** Experimental support for match expressions with sub-cases. + /** Experimental support for match expressions with sub cases. */ - @compileTimeOnly("`matchWithSubCases` can only be used at compile time in import statements") - object matchWithSubCases + @compileTimeOnly("`subCases` can only be used at compile time in import statements") + object subCases end experimental /** The deprecated object contains features that are no longer officially suypported in Scala. diff --git a/tests/pos/inline-sub-match.scala b/tests/pos/inline-match-sub-cases.scala similarity index 93% rename from tests/pos/inline-sub-match.scala rename to tests/pos/inline-match-sub-cases.scala index 2a254e1ee9d3..19e02dad1cf8 100644 --- a/tests/pos/inline-sub-match.scala +++ b/tests/pos/inline-match-sub-cases.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases object Test: diff --git a/tests/pos/sub-sub-match.scala b/tests/pos/match-sub-sub-cases.scala similarity index 87% rename from tests/pos/sub-sub-match.scala rename to tests/pos/match-sub-sub-cases.scala index bef450c13817..3a357dfd0216 100644 --- a/tests/pos/sub-sub-match.scala +++ b/tests/pos/match-sub-sub-cases.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases object Test: val x: Option[Option[Int]] = ??? diff --git a/tests/run/sub-catch.scala b/tests/run/catch-sub-cases.scala similarity index 91% rename from tests/run/sub-catch.scala rename to tests/run/catch-sub-cases.scala index 07fc61dec777..1ba52a66cfce 100644 --- a/tests/run/sub-catch.scala +++ b/tests/run/catch-sub-cases.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases enum E extends Exception: case A(x: Any) diff --git a/tests/run/sub-cases.scala b/tests/run/match-sub-cases.scala similarity index 93% rename from tests/run/sub-cases.scala rename to tests/run/match-sub-cases.scala index ca72145e6b28..1df01bef0f9e 100644 --- a/tests/run/sub-cases.scala +++ b/tests/run/match-sub-cases.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases enum E: case A(e: E) diff --git a/tests/run/pf-sub-cases.scala b/tests/run/pf-sub-cases.scala index 1640e33a5f8d..6f5dbf2049b3 100644 --- a/tests/run/pf-sub-cases.scala +++ b/tests/run/pf-sub-cases.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases val pf: PartialFunction[Option[Option[Int]], String] = case Some(x2) with x2 match diff --git a/tests/warn/sub-cases-exhaustivity.scala b/tests/warn/sub-cases-exhaustivity.scala index e47b189c8748..41e6d1175b48 100644 --- a/tests/warn/sub-cases-exhaustivity.scala +++ b/tests/warn/sub-cases-exhaustivity.scala @@ -1,4 +1,4 @@ -import scala.language.experimental.matchWithSubCases +import scala.language.experimental.subCases enum E: case A(e: E) From f7e051271ad978ba28182fb3084b4edbfbe440c7 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Fri, 15 Aug 2025 18:49:59 +0200 Subject: [PATCH 11/16] Allow single sub case without new line --- .../src/dotty/tools/dotc/parsing/Parsers.scala | 10 +++++++--- tests/pos/match-single-sub-case.scala | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/pos/match-single-sub-case.scala diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index baec062d6403..f28e5c7aa9bc 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -2595,8 +2595,10 @@ object Parsers { /** SubMatchClause ::= `match' `{' CaseClauses `}' */ def subMatchClause(t: Tree): SubMatch = atSpan(startOffset(t), accept(MATCH)): - val cases = inBracesOrIndented(caseClauses(() => caseClause())) - if !in.isNestedEnd then acceptStatSep() // else is sub sub match + val cases = + if in.token == CASE + then caseClause(exprOnly = true) :: Nil // single sub case without new line + else inBracesOrIndented(caseClauses(() => caseClause())) SubMatch(t, cases) /** `match' <<< TypeCaseClauses >>> @@ -3109,7 +3111,9 @@ object Parsers { } val body = if in.token == WITH && in.featureEnabled(Feature.subCases) then atSpan(in.skipToken()): - subMatchClause(simpleExpr(Location.ElseWhere)) + val t = subMatchClause(simpleExpr(Location.ElseWhere)) + if in.isStatSep then in.nextToken() // else may have been consumed by sub sub match + t else atSpan(accept(ARROW)): if exprOnly then if in.indentSyntax && in.isAfterLineEnd && in.token != INDENT then diff --git a/tests/pos/match-single-sub-case.scala b/tests/pos/match-single-sub-case.scala new file mode 100644 index 000000000000..b6299412bd66 --- /dev/null +++ b/tests/pos/match-single-sub-case.scala @@ -0,0 +1,17 @@ +import scala.language.experimental.subCases + +// single sub case can be one the same line as outer case +object Test: + val x: Option[Option[Int]] = ??? + x match + case Some(x2) with x2 match case Some(x3) => "aa" + case Some(x2) if false with x2 match case Some(x3) if true => "aa" + case Some(x2) with x2 match case Some(x3) with x2 match case Some(x3) => "bb" + case Some(y2) with y2 match + case Some(y3) with y3 match + case 1 => "a" + case 2 => "b" + case Some(x2) with x2 match case Some(x3) with x3 match + case 1 => "a" + case 2 => "b" + case None => "d" From 367acdd503a7276b1f258d1c1aaff15cb4372947 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 20 Aug 2025 10:43:44 +0200 Subject: [PATCH 12/16] Replace sub match introducer keyword from `with` to `if` --- .../dotty/tools/dotc/parsing/Parsers.scala | 80 +++++++++++++++---- tests/neg/parser-stability-20.scala | 3 +- tests/pos/inline-match-sub-cases.scala | 6 +- tests/pos/match-single-sub-case.scala | 14 ++-- tests/pos/match-sub-sub-cases.scala | 12 +-- tests/run/catch-sub-cases.scala | 4 +- tests/run/match-sub-cases.scala | 8 +- tests/run/pf-sub-cases.scala | 4 +- tests/warn/sub-cases-exhaustivity.scala | 8 +- 9 files changed, 94 insertions(+), 45 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index f28e5c7aa9bc..be2ca49361a9 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -2585,22 +2585,54 @@ object Parsers { mkIf(cond, thenp, elsep) } + /* When parsing (what will become) a sub sub match, that is, + * when in a guard of case of a match, in a guard of case of a match; + * we will eventually reach Scanners.handleNewLine at the end of the sub sub match + * with an in.currretRegion of the shape `InCase +: Indented :+ InCase :+ Indented :+ ...` + * if we did not do dropInnerCaseRegion. + * In effect, a single outdent would be inserted by handleNewLine after the sub sub match. + * This causes the remaining cases of the outer match to be included in the intermediate sub match. + * For example: + * match + * case x1 if x1 match + * case y if y match + * case z => "a" + * case x2 => "b" + * would become + * match + * case x1 if x1 match { + * case y if y match { + * case z => "a" + * } + * case x2 => "b" + * } + * This issue is avoided by dropping the `InCase` region when parsing match clause, + * since `Indetented :+ Indented :+ ...` now allows handleNewLine to insert two outdents. + * Note that this _could_ break previous code which relied on matches within guards + * being considered as a separate region without explicit indentation. + */ + private def dropInnerCaseRegion(): Unit = + in.currentRegion match + case Indented(width, prefix, Scanners.InCase(r)) => in.currentRegion = Indented(width, prefix, r) + case Scanners.InCase(r) => in.currentRegion = r + case _ => + /** MatchClause ::= `match' `{' CaseClauses `}' + * | `match' ExprCaseClause */ def matchClause(t: Tree): Match = atSpan(startOffset(t), in.skipToken()) { - Match(t, inBracesOrIndented(caseClauses(() => caseClause()))) + val cases = + if in.featureEnabled(Feature.subCases) then + dropInnerCaseRegion() + if in.token == CASE + then caseClause(exprOnly = true) :: Nil // single case without new line + else inBracesOrIndented(caseClauses(() => caseClause())) + else + inBracesOrIndented(caseClauses(() => caseClause())) + Match(t, cases) } - /** SubMatchClause ::= `match' `{' CaseClauses `}' - */ - def subMatchClause(t: Tree): SubMatch = atSpan(startOffset(t), accept(MATCH)): - val cases = - if in.token == CASE - then caseClause(exprOnly = true) :: Nil // single sub case without new line - else inBracesOrIndented(caseClauses(() => caseClause())) - SubMatch(t, cases) - /** `match' <<< TypeCaseClauses >>> */ def matchType(t: Tree): MatchTypeTree = @@ -3109,12 +3141,19 @@ object Parsers { accept(CASE) (withinMatchPattern(pattern()), guard()) } - val body = - if in.token == WITH && in.featureEnabled(Feature.subCases) then atSpan(in.skipToken()): - val t = subMatchClause(simpleExpr(Location.ElseWhere)) + var grd1 = grd // may be reset to EmptyTree (and used as sub match body instead) if there is no leading ARROW + val tok = in.token + + extension (self: Tree) def asSubMatch: Tree = self match + case Match(sel, cases) if in.featureEnabled(Feature.subCases) => if in.isStatSep then in.nextToken() // else may have been consumed by sub sub match - t - else atSpan(accept(ARROW)): + SubMatch(sel, cases) + case _ => + syntaxErrorOrIncomplete(ExpectedTokenButFound(ARROW, tok)) + EmptyTree + + val body = tok match + case ARROW => atSpan(in.skipToken()): if exprOnly then if in.indentSyntax && in.isAfterLineEnd && in.token != INDENT then warning(em"""Misleading indentation: this expression forms part of the preceding catch case. @@ -3123,7 +3162,16 @@ object Parsers { |an indented case.""") expr() else block() - CaseDef(pat, grd, body) + case IF => atSpan(in.skipToken()): + // a sub match after a guard is parsed the same as one without + val t = inSepRegion(InCase)(postfixExpr(Location.InGuard)) + t.asSubMatch + case other => + val t = grd1.asSubMatch + grd1 = EmptyTree + t + + CaseDef(pat, grd1, body) } /** TypeCaseClause ::= ‘case’ (InfixType | ‘_’) ‘=>’ Type [semi] diff --git a/tests/neg/parser-stability-20.scala b/tests/neg/parser-stability-20.scala index 7163044ccca0..12b57953886a 100644 --- a/tests/neg/parser-stability-20.scala +++ b/tests/neg/parser-stability-20.scala @@ -2,5 +2,4 @@ object x0 { def unapply= Array x0 match x0 // error -case x0( // error -// error \ No newline at end of file +case x0( diff --git a/tests/pos/inline-match-sub-cases.scala b/tests/pos/inline-match-sub-cases.scala index 19e02dad1cf8..fdd6cced6584 100644 --- a/tests/pos/inline-match-sub-cases.scala +++ b/tests/pos/inline-match-sub-cases.scala @@ -5,10 +5,10 @@ object Test: // using transparent to test whether test whether reduced as expected transparent inline def foo(i: Int, j: Int): String = inline i match - case 0 with j match + case 0 if j match case 1 => "01" case 2 => "02" - case 1 with j match + case 1 if j match case 1 => "11" case 2 => "12" case _ => "3" @@ -20,7 +20,7 @@ object Test: transparent inline def bar(x: Option[Any]): String = inline x match - case Some(y: Int) with y match + case Some(y: Int) if y match case 1 => "a" case 2 => "b" case Some(z: String) => "c" diff --git a/tests/pos/match-single-sub-case.scala b/tests/pos/match-single-sub-case.scala index b6299412bd66..71d9e1a9c2ea 100644 --- a/tests/pos/match-single-sub-case.scala +++ b/tests/pos/match-single-sub-case.scala @@ -1,17 +1,17 @@ import scala.language.experimental.subCases -// single sub case can be one the same line as outer case +// single sub case can be on the same line as outer case object Test: val x: Option[Option[Int]] = ??? x match - case Some(x2) with x2 match case Some(x3) => "aa" - case Some(x2) if false with x2 match case Some(x3) if true => "aa" - case Some(x2) with x2 match case Some(x3) with x2 match case Some(x3) => "bb" - case Some(y2) with y2 match - case Some(y3) with y3 match + case Some(x2) if x2 match case Some(x3) => "aa" + case Some(x2) if false if x2 match case Some(x3) if true => "aa" + case Some(x2) if x2 match case Some(x3) if x2 match case Some(x3) => "bb" + case Some(y2) if y2 match + case Some(y3) if y3 match case 1 => "a" case 2 => "b" - case Some(x2) with x2 match case Some(x3) with x3 match + case Some(x2) if x2 match case Some(x3) if x3 match case 1 => "a" case 2 => "b" case None => "d" diff --git a/tests/pos/match-sub-sub-cases.scala b/tests/pos/match-sub-sub-cases.scala index 3a357dfd0216..e1715543913b 100644 --- a/tests/pos/match-sub-sub-cases.scala +++ b/tests/pos/match-sub-sub-cases.scala @@ -3,15 +3,17 @@ import scala.language.experimental.subCases object Test: val x: Option[Option[Int]] = ??? x match - case Some(x2) with x2 match - case Some(x3) with x3 match + case Some(x2) if true if x2 match + case Some(x3) if false if x3 match case 1 => "a" - case 2 => "b" + case x if x % 2 == 0 if x match + case 4 => "b" + case 6 => "b" case None => "d" x match { - case Some(x2) with x2 match { - case Some (x3) with x3 match { + case Some(x2) if x2 match { + case Some (x3) if x3 match { case 1 => "a" case 2 => "b" } diff --git a/tests/run/catch-sub-cases.scala b/tests/run/catch-sub-cases.scala index 1ba52a66cfce..48b18ac8c52b 100644 --- a/tests/run/catch-sub-cases.scala +++ b/tests/run/catch-sub-cases.scala @@ -10,10 +10,10 @@ import E.* def test(op: => Nothing): String = try op catch - case A(x: Int) if true with x match + case A(x: Int) if true if x match case 1 => "A(1)" case 2 => "A(2)" - case B(x: String) with x match + case B(x: String) if x match case "a" => "B(a)" case "b" => "B(b)" case _ => "other" diff --git a/tests/run/match-sub-cases.scala b/tests/run/match-sub-cases.scala index 1df01bef0f9e..a03bd7841f57 100644 --- a/tests/run/match-sub-cases.scala +++ b/tests/run/match-sub-cases.scala @@ -17,12 +17,12 @@ import E.* @main def Test = def test(e: E): Int = e match - case A(B(e1)) if true with e1.f match - case Some(x) with x match + case A(B(e1)) if true if e1.f match + case Some(x) if x match case A(_) => 11 case C => 12 - case B(A(e1)) with e1.f match - case Some(C) => 21 + case B(A(e1)) if e1.f match + case Some(C) if false || true => 21 case None => 22 case _ => 3 end test diff --git a/tests/run/pf-sub-cases.scala b/tests/run/pf-sub-cases.scala index 6f5dbf2049b3..c79ed84f41e1 100644 --- a/tests/run/pf-sub-cases.scala +++ b/tests/run/pf-sub-cases.scala @@ -1,8 +1,8 @@ import scala.language.experimental.subCases val pf: PartialFunction[Option[Option[Int]], String] = - case Some(x2) with x2 match - case Some(x3) with x3 match + case Some(x2) if x2 match + case Some(x3) if x3 match case 1 => "a" case 2 => "b" case Some(None) => "c" diff --git a/tests/warn/sub-cases-exhaustivity.scala b/tests/warn/sub-cases-exhaustivity.scala index 41e6d1175b48..73cea9c14fa9 100644 --- a/tests/warn/sub-cases-exhaustivity.scala +++ b/tests/warn/sub-cases-exhaustivity.scala @@ -13,19 +13,19 @@ object Test: val e: E = ??? e match // warn: match may not be exhaustive: It would fail on pattern case: E.A(_) | E.B(_) - case A(e1) with e1.f match + case A(e1) if e1.f match case B(_) => 11 case C => 12 - case B(e1) with e1.f match + case B(e1) if e1.f match case C => 21 case A(_) => 22 case C => 3 e match // warn: match may not be exhaustive: It would fail on pattern case: E.B(_) - case A(e1) with e1.f match + case A(e1) if e1.f match case B(_) => 11 case C => 12 - case B(e1) with e1.f match + case B(e1) if e1.f match case C => 21 case A(_) => 22 case A(_) => 3 // nowarn: should not be reported as unreachable From 24bdb6e436fb56676e4d6dac74daef13e9021fa9 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Wed, 20 Aug 2025 13:42:11 +0200 Subject: [PATCH 13/16] Fix error case EmptyTree span --- compiler/src/dotty/tools/dotc/parsing/Parsers.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index be2ca49361a9..409e6f09b78c 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -3150,10 +3150,10 @@ object Parsers { SubMatch(sel, cases) case _ => syntaxErrorOrIncomplete(ExpectedTokenButFound(ARROW, tok)) - EmptyTree + atSpan(self.span)(Block(Nil, EmptyTree)) val body = tok match - case ARROW => atSpan(in.skipToken()): + case ARROW => atSpan(in.skipToken()): if exprOnly then if in.indentSyntax && in.isAfterLineEnd && in.token != INDENT then warning(em"""Misleading indentation: this expression forms part of the preceding catch case. From e5a73b00dc00c5c9e6dc9ac338ca6c397a783010 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Thu, 21 Aug 2025 10:17:07 +0200 Subject: [PATCH 14/16] Use preexisting tag for pickling of match with sub cases --- compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala | 2 +- compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala | 2 +- tasty/src/dotty/tools/tasty/TastyFormat.scala | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala index 8f3da159c972..20a405271078 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreePickler.scala @@ -590,7 +590,7 @@ class TreePickler(pickler: TastyPickler, attributes: Attributes) { if (tree.isInline) if (selector.isEmpty) writeByte(IMPLICIT) else { writeByte(INLINE); pickleTree(selector) } - else if tree.isSubMatch then { writeByte(WITH); pickleTree(selector) } + else if tree.isSubMatch then { writeByte(LAZY); pickleTree(selector) } else pickleTree(selector) tree.cases.foreach(pickleTree) } diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala index 5745129ce7e7..08d4008a26e4 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala @@ -1539,7 +1539,7 @@ class TreeUnpickler(reader: TastyReader, readByte() InlineMatch(readTree(), readCases(end)) } - else if nextByte == WITH then + else if nextByte == LAZY then // similarly to InlineMatch we use an arbitrary Cat.1 tag readByte() SubMatch(readTree(), readCases(end)) else Match(readTree(), readCases(end))) diff --git a/tasty/src/dotty/tools/tasty/TastyFormat.scala b/tasty/src/dotty/tools/tasty/TastyFormat.scala index b0b0b3b0682f..37e3a3acfdab 100644 --- a/tasty/src/dotty/tools/tasty/TastyFormat.scala +++ b/tasty/src/dotty/tools/tasty/TastyFormat.scala @@ -511,7 +511,6 @@ object TastyFormat { final val EMPTYCLAUSE = 45 final val SPLITCLAUSE = 46 final val TRACKED = 47 - final val WITH = 48 // Tree Cat. 2: tag Nat final val firstNatTreeTag = SHAREDterm From 466da836971df4b136b0e24e7897bba6155fcb38 Mon Sep 17 00:00:00 2001 From: Eugene Flesselle Date: Thu, 21 Aug 2025 10:39:34 +0200 Subject: [PATCH 15/16] Add reference doc --- .../_docs/reference/experimental/sub-cases.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/_docs/reference/experimental/sub-cases.md diff --git a/docs/_docs/reference/experimental/sub-cases.md b/docs/_docs/reference/experimental/sub-cases.md new file mode 100644 index 000000000000..de80a8cfb752 --- /dev/null +++ b/docs/_docs/reference/experimental/sub-cases.md @@ -0,0 +1,72 @@ +--- +layout: doc-page +title: "Match Expressions with Sub Cases" +--- + +A case in a match expression can be followed by another sub-match expression, introduced by the `if` keyword. +For example: + +```scala +enum Version: + case Legacy + case Stable(major: Int, minor: Int) + +case class Document(title: String, version: Version) + +def version(d: Option[Document]) = d match + case Some(x) if x.version match + case Version.Stable(m, n) if m > 2 => s"$m.$n" + case Version.Legacy => "legacy" + case _ => "unsupported" + +assert(version(Some(Document("...", Version.Stable(3, 1)))) = "3.1") +assert(version(Some(Document("...", Version.Stable(2, 1)))) = "unsupported") +assert(version(Some(Document("...", Version.Legacy))) = "legacy") +assert(version(Some(Document("...", None))) = "unsupported") +``` + +The cases of a sub-match expression are tested iff the outer case matches. +The sub match scrutinee can refer to variables bound from the outer pattern. +Evaluation of sub-matches then proceeds as usual. +For example, if `version` is applied on `Some(Document("...", Version.Stable(3, 1)))`, the first outer pattern matches (i.e., `Some(x)`), causing the sub match expression to be evaluated with scrutinee `Version.Stable(3, 1)`, yielding `"3.1"`. + +The cases of a sub-match expression need not be exhaustive. +If they were, we would not need sub-match at all: a usual match in the body of the first case would suffice, +e.g., `case Some(x) => x.version match ...`. +If none of the sub-cases succeed, then control flow returns to the outer match expression and proceeds as though the current case had not matched. +For example, `Some(Document("...", Version.Stable(2, 1)))` matches the first pattern, but none of its sub-cases, and we therefore obtain the result `"unsupported"`. + +More generally, sub-matches also allow: +- Arbitrary nesting, e.g. sub-sub-matches are supported. +- Interleaved boolean guards, e.g. `case Some(x: Int) if x != 0 if x match ...`. +- Interleaving pattern extractors and computations for the scrutinees of sub-matches. + + +## Motivation + +Without sub matches, one would typically duplicate either the default case or the outer pattern. +That is, use: +```scala +def version(d: Option[Document]) = d match + case Some(x) => x.version match + case Version.Stable(m, n) if m > 2 => s"$m.$n" + case Version.Legacy => "legacy" + case _ => "unsupported" + case _ => "unsupported" +``` +or +```scala +def version(d: Option[Document]) = d match + case Some(Document(_, Version.Stable(m, n))) if m > 2 => s"$m.$n" + case Some(Document(_, Version.Legacy)) => "legacy" + case _ => "unsupported" +``` + + + + + + + + + From c432c0fd281224369f94f69fac41634fc92ab51d Mon Sep 17 00:00:00 2001 From: Hamza Remmal Date: Thu, 21 Aug 2025 13:22:02 +0200 Subject: [PATCH 16/16] Apply suggestions from code review --- compiler/src/dotty/tools/dotc/parsing/Parsers.scala | 4 ++-- compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index 409e6f09b78c..5ce07f587420 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -3133,8 +3133,8 @@ object Parsers { buf.toList } - /** CaseClause ::= ‘case’ Pattern [Guard] (‘with’ SimpleExpr SubMatchClause | `=>' Block) - * ExprCaseClause ::= ‘case’ Pattern [Guard] (‘with’ SimpleExpr SubMatchClause | `=>' Expr) + /** CaseClause ::= ‘case’ Pattern [Guard] (‘if’ InfixExpr MatchClause | `=>' Block) + * ExprCaseClause ::= ‘case’ Pattern [Guard] (‘if’ InfixExpr MatchClause | `=>' Expr) */ def caseClause(exprOnly: Boolean = false): CaseDef = atSpan(in.offset) { val (pat, grd) = inSepRegion(InCase) { diff --git a/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala b/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala index ea767d030fec..9cd67c4d31f4 100644 --- a/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala +++ b/compiler/src/dotty/tools/dotc/printing/RefinedPrinter.scala @@ -566,7 +566,7 @@ class RefinedPrinter(_ctx: Context) extends PlainPrinter(_ctx) { } case CaseDef(pat, guard, body) => val bodyText = body match - case t: SubMatch => keywordStr(" with ") ~ toText(t) + case t: SubMatch => keywordStr(" if ") ~ toText(t) case t => " => " ~ caseBlockText(t) keywordStr("case ") ~ inPattern(toText(pat)) ~ optText(guard)(keywordStr(" if ") ~ _) ~ bodyText case Labeled(bind, expr) =>