Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check exhaustivity of pattern matches with "if" guards and custom extractors (by default) and of unsealed types (by opt-in). #9140

Merged
merged 13 commits into from Oct 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion project/ScalaOptionParser.scala
Expand Up @@ -83,7 +83,7 @@ object ScalaOptionParser {

// TODO retrieve these data programmatically, ala https://github.com/scala/scala-tool-support/blob/master/bash-completion/src/main/scala/BashCompletion.scala
private def booleanSettingNames = List("-X", "-Xasync", "-Xcheckinit", "-Xdev", "-Xdisable-assertions", "-Xexperimental", "-Xfatal-warnings", "-Xlog-free-terms", "-Xlog-free-types", "-Xlog-implicit-conversions", "-Xlog-implicits", "-Xlog-reflective-calls",
"-Xno-forwarders", "-Xno-patmat-analysis", "-Xprint-pos", "-Xprint-types", "-Xprompt", "-Xresident", "-Xshow-phases", "-Xverify", "-Y",
"-Xno-forwarders", "-Xno-patmat-analysis", "-Xno-unsealed-patmat-analysis", "-Xnon-strict-patmat-analysis", "-Xprint-pos", "-Xprint-types", "-Xprompt", "-Xresident", "-Xshow-phases", "-Xverify", "-Y",
"-Ybreak-cycles", "-Ydebug", "-Ycompact-trees", "-YdisableFlatCpCaching", "-Ydoc-debug",
"-Yide-debug",
"-Yissue-debug", "-Ylog-classpath", "-Ymacro-debug-lite", "-Ymacro-debug-verbose", "-Ymacro-no-expand",
Expand Down
3 changes: 3 additions & 0 deletions src/compiler/scala/tools/nsc/settings/ScalaSettings.scala
Expand Up @@ -168,6 +168,9 @@ trait ScalaSettings extends StandardScalaSettings with Warnings { _: MutableSett
def isAtLeastJunit = isTruthy || XmixinForceForwarders.value == "junit"
}

val nonStrictPatmatAnalysis = BooleanSetting("-Xnon-strict-patmat-analysis", "Disable strict exhaustivity analysis, which assumes guards are false and refutable extractors don't match")
val noUnsealedPatmatAnalysis = BooleanSetting("-Xno-unsealed-patmat-analysis", "Pattern match on an unsealed class without a catch-all.")

// XML parsing options
object XxmlSettings extends MultiChoiceEnumeration {
val coalescing = Choice("coalescing", "Convert PCData to Text and coalesce sibling nodes")
Expand Down
66 changes: 46 additions & 20 deletions src/compiler/scala/tools/nsc/transform/patmat/MatchAnalysis.scala
Expand Up @@ -68,13 +68,14 @@ trait TreeAndTypeAnalysis extends Debugging {
}

def equivalentTree(a: Tree, b: Tree): Boolean = (a, b) match {
case (Select(qual1, _), Select(qual2, _)) => equivalentTree(qual1, qual2) && a.symbol == b.symbol
case (Ident(_), Ident(_)) => a.symbol == b.symbol
case (Literal(c1), Literal(c2)) => c1 == c2
case (This(_), This(_)) => a.symbol == b.symbol
case (Apply(fun1, args1), Apply(fun2, args2)) => equivalentTree(fun1, fun2) && args1.corresponds(args2)(equivalentTree)
// Those are the only cases we need to handle in the pattern matcher
case _ => false
case (Select(qual1, _), Select(qual2, _)) => equivalentTree(qual1, qual2) && a.symbol == b.symbol
case (Ident(_), Ident(_)) => a.symbol == b.symbol
case (Literal(c1), Literal(c2)) => c1 == c2
case (This(_), This(_)) => a.symbol == b.symbol
case (Apply(fun1, args1), Apply(fun2, args2)) => equivalentTree(fun1, fun2) && args1.corresponds(args2)(equivalentTree)
case (TypeApply(fun1, args1), TypeApply(fun2, args2)) => equivalentTree(fun1, fun2) && args1.corresponds(args2)(equivalentTree)
case (a @ TypeTree(), b @ TypeTree()) => a.tpe =:= b.tpe
case _ => false // Those are the only cases we need to handle in the pattern matcher
}

trait CheckableTreeAndTypeAnalysis {
Expand Down Expand Up @@ -238,8 +239,9 @@ trait MatchApproximation extends TreeAndTypeAnalysis with ScalaLogic with MatchT
uniqueTypeProps.getOrElseUpdate((testedPath, pt), Eq(Var(testedPath), TypeConst(checkableType(pt))))

// a variable in this set should never be replaced by a tree that "does not consist of a selection on a variable in this set" (intuitively)
private val pointsToBound = mutable.HashSet(root)
private val trees = mutable.HashSet.empty[Tree]
private val pointsToBound = mutable.HashSet(root)
private val trees = mutable.HashSet.empty[Tree]
private val extractBinders = mutable.HashMap.empty[Tree, Symbol]

// the substitution that renames variables to variables in pointsToBound
private var normalize: Substitution = EmptySubstitution
Expand Down Expand Up @@ -282,7 +284,21 @@ trait MatchApproximation extends TreeAndTypeAnalysis with ScalaLogic with MatchT
// binderToUniqueTree uses the type of the first symbol that was encountered as the type for all future binders
abstract class TreeMakerToProp extends (TreeMaker => Prop) {
// requires(if (!substitutionComputed))
def updateSubstitution(subst: Substitution): Unit = {
def updateSubstitution(tm: TreeMaker): Unit = {
val subst = tm.subPatternsAsSubstitution

tm match {
case x @ ExtractorTreeMaker(_, None, binder) =>
val extractor = accumSubst(normalize(x.extractor))
extractBinders.collectFirst {
case (t, reuseBinder) if equivalentTree(t, extractor) => reuseBinder
} match {
case Some(reuseBinder) => normalize >>= Substitution(binder, binderToUniqueTree(reuseBinder))
case None => extractBinders(extractor) = binder
}
case _ =>
}

// find part of substitution that replaces bound symbols by new symbols, and reverse that part
// so that we don't introduce new aliases for existing symbols, thus keeping the set of bound symbols minimal

Expand All @@ -307,7 +323,7 @@ trait MatchApproximation extends TreeAndTypeAnalysis with ScalaLogic with MatchT

val okSubst = Substitution(unboundFrom.toList, unboundTo.toList) // it's important substitution does not duplicate trees here -- it helps to keep hash consing simple, anyway
foreach2(okSubst.from, okSubst.to){(f, t) =>
if (pointsToBound exists (sym => t.exists(_.symbol == sym)))
if (pointsToBound.exists(sym => t.exists(_.symbol == sym)) || tm.isInstanceOf[ExtractorTreeMaker])
pointsToBound += f
}
// debug.patmat("pointsToBound: "+ pointsToBound)
Expand All @@ -328,7 +344,7 @@ trait MatchApproximation extends TreeAndTypeAnalysis with ScalaLogic with MatchT
* TODO: don't ignore outer-checks
*/
def apply(tm: TreeMaker): Prop = {
if (!substitutionComputed) updateSubstitution(tm.subPatternsAsSubstitution)
if (!substitutionComputed) updateSubstitution(tm)

tm match {
case ttm@TypeTestTreeMaker(prevBinder, testedBinder, pt, _) =>
Expand Down Expand Up @@ -372,10 +388,11 @@ trait MatchApproximation extends TreeAndTypeAnalysis with ScalaLogic with MatchT
}

// special-case: interpret pattern `List()` as `Nil`
// as of 2.13, List.unapply returns an UnapplySeqWrapper (rather than a List)
// TODO: make it more general List(1, 2) => 1 :: 2 :: Nil -- not sure this is a good idea...
private val rewriteListPattern: PartialFunction[TreeMaker, Prop] = {
case p @ ExtractorTreeMaker(_, _, testedBinder)
if testedBinder.tpe.typeSymbol == ListClass && p.checkedLength == Some(0) =>
if testedBinder.tpe.typeSymbol == UnapplySeqWrapperClass && p.checkedLength == Some(0) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this catch other uses of SeqFactory.unapplySeq, like case Seq()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Which is regrettable, but I couldn't convince myself that that was bad so I went with it... Do you think it'll affect other things and/or have a better idea to fixing the regression?

uniqueEqualityProp(binderToUniqueTree(p.prevBinder), unique(Ident(NilModule) setType NilModule.tpe))
}
val fullRewrite = (irrefutableExtractor orElse rewriteListPattern)
Expand Down Expand Up @@ -497,21 +514,25 @@ trait MatchAnalysis extends MatchApproximation {

// exhaustivity

def exhaustive(prevBinder: Symbol, cases: List[List[TreeMaker]], pt: Type): List[String] = if (uncheckableType(prevBinder.info)) Nil else {
def exhaustive(prevBinder: Symbol, cases: List[List[TreeMaker]], pt: Type): List[String] = if (settings.noUnsealedPatmatAnalysis && uncheckableType(prevBinder.info)) Nil else {
// customize TreeMakersToProps (which turns a tree of tree makers into a more abstract DAG of tests)
// - approximate the pattern `List()` (unapplySeq on List with empty length) as `Nil`,
// otherwise the common (xs: List[Any]) match { case List() => case x :: xs => } is deemed unexhaustive
// - back off (to avoid crying exhaustive too often) when:
// - there are guards -->
// - there are extractor calls (that we can't secretly/soundly) rewrite
// - back off (to avoid crying exhaustive too often) in unhandled cases
val start = if (StatisticsStatics.areSomeColdStatsEnabled) statistics.startTimer(statistics.patmatAnaExhaust) else null
var backoff = false
val strict = !settings.nonStrictPatmatAnalysis.value

val approx = new TreeMakersToProps(prevBinder)
val symbolicCases = approx.approximateMatch(cases, approx.onUnknown { tm =>
approx.fullRewrite.applyOrElse[TreeMaker, Prop](tm, {
case BodyTreeMaker(_, _) => True // irrelevant -- will be discarded by symbolCase later
case _ => // debug.patmat("backing off due to "+ tm)
case ExtractorTreeMaker(_, _, _)
| ProductExtractorTreeMaker(_, _)
| GuardTreeMaker(_) if strict =>
False
case _ =>
debug.patmat("backing off due to "+ tm)
backoff = true
False
})
Expand Down Expand Up @@ -556,7 +577,8 @@ trait MatchAnalysis extends MatchApproximation {
// sorting before pruning is important here in order to
// keep neg/t7020.scala stable
// since e.g. List(_, _) would cover List(1, _)
val pruned = CounterExample.prune(counterExamples.sortBy(_.toString)).map(_.toString)
// and make sure the strings are distinct, see Shmeez & TestSequence06 in run/patmatnew.scala
val pruned = CounterExample.prune(counterExamples.sortBy(_.toString)).map(_.toString).distinct

if (StatisticsStatics.areSomeColdStatsEnabled) statistics.stopTimer(statistics.patmatAnaExhaust, start)
pruned
Expand Down Expand Up @@ -746,10 +768,13 @@ trait MatchAnalysis extends MatchApproximation {
// so, naively, you might try to construct a counter example like _ :: Nil(_ :: _, _ :: _),
// since we didn't realize the tail of the outer cons was a Nil)
def modelToCounterExample(scrutVar: Var)(varAssignment: Map[Var, (Seq[Const], Seq[Const])]): Option[CounterExample] = {
val strict = !settings.nonStrictPatmatAnalysis.value

// chop a path into a list of symbols
def chop(path: Tree): List[Symbol] = path match {
case Ident(_) => List(path.symbol)
case Select(pre, name) => chop(pre) :+ path.symbol
case Apply(fun, args) => chop(fun) :+ path.symbol
case _ =>
// debug.patmat("don't know how to chop "+ path)
Nil
Expand Down Expand Up @@ -873,7 +898,8 @@ trait MatchAnalysis extends MatchApproximation {
// if uniqueEqualTo contains more than one symbol of the same domain
// then we can safely ignore these counter examples since we will eventually encounter
// both counter examples separately
case _ if inSameDomain => None
// ... in strict mode, consider variable assignment as a wild counter-example
case _ if inSameDomain => if (strict) Some(WildcardExample) else None

// not a valid counter-example, possibly since we have a definite type but there was a field mismatch
// TODO: improve reasoning -- in the mean time, a false negative is better than an annoying false positive
Expand Down
Expand Up @@ -269,13 +269,16 @@ trait MatchTranslation {
val casesNoSubstOnly = caseDefs map { caseDef => (propagateSubstitution(translateCase(scrutSym, pt)(caseDef), EmptySubstitution))}

val exSym = freshSym(pos, pureType(ThrowableTpe), "ex")
val suppression =
if (settings.XnoPatmatAnalysis) Suppression.FullSuppression
else Suppression.NoSuppression.copy(suppressExhaustive = true) // try/catches needn't be exhaustive

List(
atPos(pos) {
CaseDef(
Bind(exSym, Ident(nme.WILDCARD)), // TODO: does this need fixing upping?
EmptyTree,
combineCasesNoSubstOnly(REF(exSym), scrutSym, casesNoSubstOnly, pt, selectorPos, matchOwner, Some(scrut => Throw(REF(exSym))))
combineCasesNoSubstOnly(REF(exSym), scrutSym, casesNoSubstOnly, pt, selectorPos, matchOwner, Some(scrut => Throw(REF(exSym))), suppression)
)
})
}
Expand Down
43 changes: 25 additions & 18 deletions src/compiler/scala/tools/nsc/transform/patmat/MatchTreeMaking.scala
Expand Up @@ -583,53 +583,60 @@ trait MatchTreeMaking extends MatchCodeGen with Debugging {
removeSubstOnly(treeMakers)
}

def getSuppression(scrut: Tree): Suppression = scrut match {
case _ if settings.XnoPatmatAnalysis => Suppression.FullSuppression
case Typed(tree, tpt) =>
val suppressExhaustive = tpt.tpe.hasAnnotation(UncheckedClass)
val suppressUnreachable = tree match {
case Ident(name) => name.startsWith(nme.CHECK_IF_REFUTABLE_STRING) // scala/bug#7183 don't warn for withFilter's that turn out to be irrefutable.
case _ => false
}
Suppression(suppressExhaustive, suppressUnreachable)
case _ => Suppression.NoSuppression
}

// calls propagateSubstitution on the treemakers
def combineCases(scrut: Tree, scrutSym: Symbol, casesRaw: List[List[TreeMaker]], pt: Type, selectorPos: Position, owner: Symbol, matchFailGenOverride: Option[Tree => Tree]): Tree = {
// drops SubstOnlyTreeMakers, since their effect is now contained in the TreeMakers that follow them
val casesNoSubstOnly = casesRaw map (propagateSubstitution(_, EmptySubstitution))
combineCasesNoSubstOnly(scrut, scrutSym, casesNoSubstOnly, pt, selectorPos, owner, matchFailGenOverride)
combineCasesNoSubstOnly(scrut, scrutSym, casesNoSubstOnly, pt, selectorPos, owner, matchFailGenOverride, getSuppression(scrut))
}

// pt is the fully defined type of the cases (either pt or the lub of the types of the cases)
def combineCasesNoSubstOnly(scrut: Tree, scrutSym: Symbol, casesNoSubstOnly: List[List[TreeMaker]], pt: Type,
selectorPos: Position, owner: Symbol, matchFailGenOverride: Option[Tree => Tree]): Tree =
selectorPos: Position, owner: Symbol, matchFailGenOverride: Option[Tree => Tree],
suppression: Suppression,
): Tree =
fixerUpper(owner, scrut.pos) {
def matchFailGen = matchFailGenOverride orElse Some(Throw(MatchErrorClass.tpe, _: Tree))

debug.patmat("combining cases: "+ (casesNoSubstOnly.map(_.mkString(" >> ")).mkString("{", "\n", "}")))

val (suppression, requireSwitch): (Suppression, Boolean) =
if (settings.XnoPatmatAnalysis) (Suppression.FullSuppression, false)
val requireSwitch: Boolean =
if (settings.XnoPatmatAnalysis) false
else scrut match {
case Typed(tree, tpt) =>
val suppressExhaustive = tpt.tpe hasAnnotation UncheckedClass
val suppressUnreachable = tree match {
case Ident(name) if name startsWith nme.CHECK_IF_REFUTABLE_STRING => true // scala/bug#7183 don't warn for withFilter's that turn out to be irrefutable.
case _ => false
}
val suppression = Suppression(suppressExhaustive, suppressUnreachable)
val hasSwitchAnnotation = treeInfo.isSwitchAnnotation(tpt.tpe)
// matches with two or fewer cases need not apply for switchiness (if-then-else will do)
// `case 1 | 2` is considered as two cases.
def exceedsTwoCasesOrAlts = {
// avoids traversing the entire list if there are more than 3 elements
def lengthMax3[T](l: List[T]): Int = l match {
def lengthMax3(l: List[List[TreeMaker]]): Int = l match {
case a :: b :: c :: _ => 3
case cases =>
cases.map({
case cases =>
cases.map {
case AlternativesTreeMaker(_, alts, _) :: _ => lengthMax3(alts)
case c => 1
}).sum
}.sum
}
lengthMax3(casesNoSubstOnly) > 2
}
val requireSwitch = hasSwitchAnnotation && exceedsTwoCasesOrAlts
(suppression, requireSwitch)
hasSwitchAnnotation && exceedsTwoCasesOrAlts
case _ =>
(Suppression.NoSuppression, false)
false
}

emitSwitch(scrut, scrutSym, casesNoSubstOnly, pt, matchFailGenOverride, unchecked = suppression.suppressExhaustive).getOrElse{
emitSwitch(scrut, scrutSym, casesNoSubstOnly, pt, matchFailGenOverride, unchecked = suppression.suppressExhaustive).getOrElse {
if (requireSwitch) typer.context.warning(scrut.pos, "could not emit switch for @switch annotated match", WarningCategory.OtherMatchAnalysis)

if (!casesNoSubstOnly.isEmpty) {
Expand Down
Expand Up @@ -191,11 +191,14 @@ trait Interface extends ast.TreeDSL {

def reportUnreachable(pos: Position) = typer.context.warning(pos, "unreachable code", WarningCategory.OtherMatchAnalysis)
def reportMissingCases(pos: Position, counterExamples: List[String]) = {
val ceString =
if (counterExamples.tail.isEmpty) "input: " + counterExamples.head
else "inputs: " + counterExamples.mkString(", ")
val ceString = counterExamples match {
case Nil => "" // never occurs, but not carried in the type
case "_" :: Nil => ""
case ex :: Nil => s"\nIt would fail on the following input: $ex"
case exs => s"\nIt would fail on the following inputs: ${exs.mkString(", ")}"
}

typer.context.warning(pos, "match may not be exhaustive.\nIt would fail on the following "+ ceString, WarningCategory.OtherMatchAnalysis)
typer.context.warning(pos, s"match may not be exhaustive.$ceString", WarningCategory.OtherMatchAnalysis)
}
}

Expand Down
1 change: 1 addition & 0 deletions src/interactive/scala/tools/nsc/interactive/Global.scala
Expand Up @@ -56,6 +56,7 @@ trait InteractiveAnalyzer extends Analyzer {
override def missingSelectErrorTree(tree: Tree, qual: Tree, name: Name): Tree = tree match {
case Select(_, _) => treeCopy.Select(tree, qual, name)
case SelectFromTypeTree(_, _) => treeCopy.SelectFromTypeTree(tree, qual, name)
case _ => tree
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/interactive/scala/tools/nsc/interactive/Pickler.scala
Expand Up @@ -99,7 +99,7 @@ object Pickler {
* where a value of the given type `T` could not be unpickled from input.
* @tparam T the type of unpickled values in case of success.
*/
abstract class Unpickled[+T] {
sealed abstract class Unpickled[+T] {
/** Transforms success values to success values using given function,
* leaves failures alone
* @param f the function to apply.
Expand Down Expand Up @@ -145,7 +145,7 @@ object Pickler {
* @param rd the lexer unpickled values were read from (can be used to get
* error position, for instance).
*/
class UnpickleFailure(msg: => String, val rd: Lexer) extends Unpickled[Nothing] {
final class UnpickleFailure(msg: => String, val rd: Lexer) extends Unpickled[Nothing] {
def errMsg = msg
override def toString = "Failure at "+rd.tokenPos+":\n"+msg
}
Expand Down
2 changes: 1 addition & 1 deletion src/library/scala/unchecked.scala
Expand Up @@ -15,7 +15,7 @@ package scala
/** An annotation to designate that the annotated entity
* should not be considered for additional compiler checks.
* Specific applications include annotating the subject of
* a match expression to suppress exhaustiveness warnings, and
* a match expression to suppress exhaustiveness and reachability warnings, and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old PR, but this doesn't seem to be the case in Scala 2? You also mention it in #9140 (comment).

* annotating a type argument in a match case to suppress
* unchecked warnings.
*
Expand Down