From e8f9812990ce2454ed4ddee435b6cea79f3a27ae Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Tue, 2 Jul 2024 15:19:06 +0200 Subject: [PATCH] add method, annotation and test cases --- .../dotty/tools/dotc/core/Definitions.scala | 1 + .../dotc/semanticdb/ExtractSemanticDB.scala | 7 +- .../tools/dotc/transform/patmat/Space.scala | 3 +- .../src/dotty/tools/dotc/typer/Checking.scala | 1 + .../src/dotty/tools/dotc/typer/Typer.scala | 2 +- .../dotty/tools/repl/TabcompleteTests.scala | 5 +- .../reference/experimental/runtimeChecked.md | 125 ++++++++++++++++++ docs/sidebar.yml | 1 + .../tools/languageserver/CompletionTest.scala | 1 + .../annotation/internal/RuntimeChecked.scala | 11 ++ .../scala/runtime/stdLibPatches/Predef.scala | 13 ++ .../pc/tests/completion/CompletionSuite.scala | 1 + tests/neg/runtimeChecked-2.check | 5 + tests/neg/runtimeChecked-2.scala | 13 ++ tests/neg/runtimeChecked.check | 7 + tests/neg/runtimeChecked.scala | 14 ++ .../stdlibExperimentalDefinitions.scala | 3 + tests/run/runtimeChecked-2.scala | 8 ++ tests/run/runtimeChecked.scala | 12 ++ 19 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 docs/_docs/reference/experimental/runtimeChecked.md create mode 100644 library/src/scala/annotation/internal/RuntimeChecked.scala create mode 100644 tests/neg/runtimeChecked-2.check create mode 100644 tests/neg/runtimeChecked-2.scala create mode 100644 tests/neg/runtimeChecked.check create mode 100644 tests/neg/runtimeChecked.scala create mode 100644 tests/run/runtimeChecked-2.scala create mode 100644 tests/run/runtimeChecked.scala diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index 1f0a673f90b1..56b5ef02c7c3 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -1034,6 +1034,7 @@ class Definitions { @tu lazy val TransparentTraitAnnot: ClassSymbol = requiredClass("scala.annotation.transparentTrait") @tu lazy val NativeAnnot: ClassSymbol = requiredClass("scala.native") @tu lazy val RepeatedAnnot: ClassSymbol = requiredClass("scala.annotation.internal.Repeated") + @tu lazy val RuntimeCheckedAnnot: ClassSymbol = requiredClass("scala.annotation.internal.RuntimeChecked") @tu lazy val SourceFileAnnot: ClassSymbol = requiredClass("scala.annotation.internal.SourceFile") @tu lazy val ScalaSignatureAnnot: ClassSymbol = requiredClass("scala.reflect.ScalaSignature") @tu lazy val ScalaLongSignatureAnnot: ClassSymbol = requiredClass("scala.reflect.ScalaLongSignature") diff --git a/compiler/src/dotty/tools/dotc/semanticdb/ExtractSemanticDB.scala b/compiler/src/dotty/tools/dotc/semanticdb/ExtractSemanticDB.scala index 357202229e50..8c1f22005af3 100644 --- a/compiler/src/dotty/tools/dotc/semanticdb/ExtractSemanticDB.scala +++ b/compiler/src/dotty/tools/dotc/semanticdb/ExtractSemanticDB.scala @@ -458,14 +458,15 @@ object ExtractSemanticDB: def unapply(tree: ValDef)(using Context): Option[(Tree, Tree)] = tree.rhs match case Match(Typed(selected: Tree, tpt: TypeTree), CaseDef(pat: Tree, _, _) :: Nil) - if tpt.span.exists && !tpt.span.hasLength && tpt.tpe.isAnnotatedByUnchecked => + if tpt.span.exists && !tpt.span.hasLength && tpt.tpe.isAnnotatedByUncheckedOrRuntimeChecked => Some((pat, selected)) case _ => None extension (tpe: Types.Type) - private inline def isAnnotatedByUnchecked(using Context) = tpe match - case Types.AnnotatedType(_, annot) => annot.symbol == defn.UncheckedAnnot + private inline def isAnnotatedByUncheckedOrRuntimeChecked(using Context) = tpe match + case Types.AnnotatedType(_, annot) => + annot.symbol == defn.UncheckedAnnot || annot.symbol == defn.RuntimeCheckedAnnot case _ => false def collectPats(pat: Tree): List[Tree] = diff --git a/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala b/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala index 3ad13ec011b5..5e708b992873 100644 --- a/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala +++ b/compiler/src/dotty/tools/dotc/transform/patmat/Space.scala @@ -794,6 +794,7 @@ object SpaceEngine { } !sel.tpe.hasAnnotation(defn.UncheckedAnnot) + && !sel.tpe.hasAnnotation(defn.RuntimeCheckedAnnot) && { ctx.settings.YcheckAllPatmat.value || isCheckable(sel.tpe) @@ -903,7 +904,7 @@ object SpaceEngine { def checkMatch(m: Match)(using Context): Unit = checkMatchExhaustivityOnly(m) if reachabilityCheckable(m.selector) then checkReachability(m) - + def checkMatchExhaustivityOnly(m: Match)(using Context): Unit = if exhaustivityCheckable(m.selector) then checkExhaustivity(m) } diff --git a/compiler/src/dotty/tools/dotc/typer/Checking.scala b/compiler/src/dotty/tools/dotc/typer/Checking.scala index 1f82b9ddc084..421f00e61584 100644 --- a/compiler/src/dotty/tools/dotc/typer/Checking.scala +++ b/compiler/src/dotty/tools/dotc/typer/Checking.scala @@ -981,6 +981,7 @@ trait Checking { def recur(pat: Tree, pt: Type): Boolean = !sourceVersion.isAtLeast(`3.2`) || pt.hasAnnotation(defn.UncheckedAnnot) + || pt.hasAnnotation(defn.RuntimeCheckedAnnot) || { patmatch.println(i"check irrefutable $pat: ${pat.tpe} against $pt") pat match diff --git a/compiler/src/dotty/tools/dotc/typer/Typer.scala b/compiler/src/dotty/tools/dotc/typer/Typer.scala index c90de0ae19a1..dab778706a5f 100644 --- a/compiler/src/dotty/tools/dotc/typer/Typer.scala +++ b/compiler/src/dotty/tools/dotc/typer/Typer.scala @@ -2067,7 +2067,7 @@ class Typer(@constructorOnly nestingLevel: Int = 0) extends Namer result match { case result @ Match(sel, CaseDef(pat, _, _) :: _) => tree.selector.removeAttachment(desugar.CheckIrrefutable) match { - case Some(checkMode) if !sel.tpe.hasAnnotation(defn.UncheckedAnnot) => + case Some(checkMode) if !(sel.tpe.hasAnnotation(defn.UncheckedAnnot) || sel.tpe.hasAnnotation(defn.RuntimeCheckedAnnot)) => val isPatDef = checkMode == desugar.MatchCheck.IrrefutablePatDef if !checkIrrefutable(sel, pat, isPatDef) && sourceVersion.isAtLeast(`3.2`) diff --git a/compiler/test/dotty/tools/repl/TabcompleteTests.scala b/compiler/test/dotty/tools/repl/TabcompleteTests.scala index f719752be353..95419824d9d1 100644 --- a/compiler/test/dotty/tools/repl/TabcompleteTests.scala +++ b/compiler/test/dotty/tools/repl/TabcompleteTests.scala @@ -9,7 +9,7 @@ import org.junit.Test class TabcompleteTests extends ReplTest { @Test def tabCompleteList = initially { - val comp = tabComplete("List.r") + val comp = tabComplete("List.ra") assertEquals(List("range"), comp.distinct) } @@ -112,7 +112,7 @@ class TabcompleteTests extends ReplTest { val comp = tabComplete("(null: AnyRef).") assertEquals( List("!=", "##", "->", "==", "asInstanceOf", "ensuring", "eq", "equals", "formatted", - "getClass", "hashCode", "isInstanceOf", "ne", "nn", "notify", "notifyAll", "synchronized", "toString", "wait", "→"), + "getClass", "hashCode", "isInstanceOf", "ne", "nn", "notify", "notifyAll", "runtimeChecked", "synchronized", "toString", "wait", "→"), comp.distinct.sorted) } @@ -163,6 +163,7 @@ class TabcompleteTests extends ReplTest { "nn", "notify", "notifyAll", + "runtimeChecked", "synchronized", "toString", "valueOf", diff --git a/docs/_docs/reference/experimental/runtimeChecked.md b/docs/_docs/reference/experimental/runtimeChecked.md new file mode 100644 index 000000000000..6b8b9da0e256 --- /dev/null +++ b/docs/_docs/reference/experimental/runtimeChecked.md @@ -0,0 +1,125 @@ +--- +layout: doc-page +title: "The runtimeChecked method" +nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/runtimeChecked.html +--- + +The `runtimeChecked` method is an extension method, defined in `scala.Predef`. It can be called on any expression. An expression marked as `runtimeChecked` is exempt from certain static checks in the compiler, for example pattern match exhaustivity. It is intended to replace `: @unchecked` type ascription in these cases. + +## Example + +A common use case for `runtimeChecked` is to assert that a pattern will always match, either for convenience, or because there is a known invariant that the types can not express. + +e.g. looking up an expected entry in a dynamically loaded dictionary-like structure +```scala +// example 1 +trait AppConfig: + def get(key: String): Option[String] + +val config: AppConfig = ??? + +val Some(appVersion) = config.get("appVersion").runtimeChecked +``` + +or to assert that a value can only match some specific patterns: +```scala +// example 2 +enum Day: + case Mon, Tue, Wed, Thu, Fri, Sat, Sun + +val weekDay: Option[Day] = ??? + +weekDay.runtimeChecked match + case Some(Mon | Tue | Wed | Thu | Fri) => println("got weekday") +// case Some(Sat | Sun) => // weekend should not appear + case None => +``` + +In both of these cases, without `runtimeChecked` then there would either be an error (example 1), or a warning (example 2), because statically, the compiler knows that there could be other cases at runtime - so is right to caution the programmer. + +```scala +// warning in example 2 when we don't add `.runtimeChecked`. +-- [E029] Pattern Match Exhaustivity Warning: ---------------------------------- +6 |weekDay match + |^^^^^^^ + |match may not be exhaustive. + | + |It would fail on pattern case: Some(Sat), Some(Sun) +``` + +## Safety + +The `runtimeChecked` method only turns off static checks that can be soundly performed at runtime. This means that patterns with unchecked type-tests will still generate warnings. For example: +```scala +scala> val xs = List(1: Any) + | xs.runtimeChecked match { + | case is: ::[Int] => is.head + | } +1 warning found +-- Unchecked Warning: ---------------------------------------------------------- +3 | case is: ::[Int] => is.head + | ^ + |the type test for ::[Int] cannot be checked at runtime because its type arguments can't be determined from List[Any] +val res0: Int = 1 +``` +As the warning hints, `::[Int]` can not be tested at runtime on a value of type `List[Any]`, so using `runtimeChecked` still protects the user against assertions that can not be validated. + +To fully avoid warnings, as with previous Scala versions, `@unchecked` should be put on the type argument: +```scala +scala> xs.runtimeChecked match { + | case is: ::[Int @unchecked] => is.head + | } +val res1: Int = 1 +``` + + +## Specification + +We add a new annotation `scala.internal.RuntimeChecked`, this is part of the standard Scala 3 library. A programmer is not expected to use this annotation directly. + +```scala +package scala.annotation.internal + +@experimental +final class RuntimeChecked extends Annotation +``` + +Any term that is the scrutinee of a pattern match, that has a type annotated with `RuntimeChecked`, is exempt from pattern match exhaustivity checking. + + +The user facing API is provided by a new extension method `scala.Predef.runtimeChecked`, qualified for any value: +```scala +extension [T](x: T) + @experimental + inline def runtimeChecked: x.type @RuntimeChecked = x: @RuntimeChecked +``` + +The `runtimeChecked` method returns its argument, refining its type with the `RuntimeChecked` annotation. + +## Motivation + +As described in [Pattern Bindings](../changed-features/pattern-bindings.md), under `-source:future` it is an error for a pattern definition to be refutable. For instance, consider: +```scala +def xs: List[Any] = ??? +val y :: ys = xs +``` + +This compiled without warning in 3.0, became a warning in 3.2, and we would like to make it an error by default in a future 3.x version. +As an escape hatch in 3.2 we recommended to use a type ascription of `: @unchecked`: +``` +-- Warning: ../../new/test.scala:6:16 ------------------------------------------ +6 | val y :: ys = xs + | ^^ + |pattern's type ::[Any] is more specialized than the right hand side expression's type List[Any] + | + |If the narrowing is intentional, this can be communicated by adding `: @unchecked` after the expression, + |which may result in a MatchError at runtime. +``` + +We suggest that `: @unchecked` is syntactically awkward, and also a misnomer - in fact in this case the the pattern is fully checked, but the necessary checks occur at runtime. The `runtimeChecked` method is then a successor to `@unchecked` for this purpose. + +We propose that `@unchecked` will still be necessary for silencing warnings on unsound type tests. + +### Restoring Scala 2.13 semantics with runtimeChecked + +In Scala 3, the `: @unchecked` type ascription has the effect of turning off all pattern-match warnings on the match scrutinee - this differs from 2.13 in which it strictly turns off only pattern exhaustivity checking. `runtimeChecked` restores the semantics of Scala 2.13. diff --git a/docs/sidebar.yml b/docs/sidebar.yml index efdab80595a6..5048669ef664 100644 --- a/docs/sidebar.yml +++ b/docs/sidebar.yml @@ -157,6 +157,7 @@ subsection: - page: reference/experimental/named-tuples.md - page: reference/experimental/modularity.md - page: reference/experimental/typeclasses.md + - page: reference/experimental/runtimeChecked.md - page: reference/syntax.md - title: Language Versions index: reference/language-versions/language-versions.md diff --git a/language-server/test/dotty/tools/languageserver/CompletionTest.scala b/language-server/test/dotty/tools/languageserver/CompletionTest.scala index d64bb44c1a5d..ae2a63fb697f 100644 --- a/language-server/test/dotty/tools/languageserver/CompletionTest.scala +++ b/language-server/test/dotty/tools/languageserver/CompletionTest.scala @@ -1028,6 +1028,7 @@ class CompletionTest { ("ensuring", Method, "(cond: Boolean): Foo.Bar.type"), ("##", Method, "=> Int"), ("nn", Method, "=> Foo.Bar.type"), + ("runtimeChecked", Method, "=> Foo.Bar.type"), ("==", Method, "(x$0: Any): Boolean"), ("ensuring", Method, "(cond: Boolean, msg: => Any): Foo.Bar.type"), ("ne", Method, "(x$0: Object): Boolean"), diff --git a/library/src/scala/annotation/internal/RuntimeChecked.scala b/library/src/scala/annotation/internal/RuntimeChecked.scala new file mode 100644 index 000000000000..d2106d720156 --- /dev/null +++ b/library/src/scala/annotation/internal/RuntimeChecked.scala @@ -0,0 +1,11 @@ +package scala.annotation.internal + +import scala.annotation.Annotation +import scala.annotation.experimental + +/**An annotation marking an intention that all checks on a value can be reliably performed at runtime. + * + * The compiler will remove certain static checks except those that can't be performed at runtime. + */ +@experimental +final class RuntimeChecked() extends Annotation diff --git a/library/src/scala/runtime/stdLibPatches/Predef.scala b/library/src/scala/runtime/stdLibPatches/Predef.scala index 77b014b80466..c946ccc53ea1 100644 --- a/library/src/scala/runtime/stdLibPatches/Predef.scala +++ b/library/src/scala/runtime/stdLibPatches/Predef.scala @@ -1,6 +1,7 @@ package scala.runtime.stdLibPatches import scala.annotation.experimental +import scala.annotation.internal.RuntimeChecked object Predef: import compiletime.summonFrom @@ -80,4 +81,16 @@ object Predef: @experimental infix type is[A <: AnyKind, B <: Any{type Self <: AnyKind}] = B { type Self = A } + extension [T](x: T) + /**Asserts that a term should be exempt from static checks that can be reliably checked at runtime. + * @example {{{ + * val xs: Option[Int] = Some(1) + * xs.runtimeChecked match { + * case is: Some[Int] => is.get + * } // no warning about exhaustiveness, as all patterns can be checked at runtime. + * }}} + */ + @experimental + inline def runtimeChecked: x.type @RuntimeChecked = x: @RuntimeChecked + end Predef diff --git a/presentation-compiler/test/dotty/tools/pc/tests/completion/CompletionSuite.scala b/presentation-compiler/test/dotty/tools/pc/tests/completion/CompletionSuite.scala index f660baa6af6d..a78c9858ccd0 100644 --- a/presentation-compiler/test/dotty/tools/pc/tests/completion/CompletionSuite.scala +++ b/presentation-compiler/test/dotty/tools/pc/tests/completion/CompletionSuite.scala @@ -117,6 +117,7 @@ class CompletionSuite extends BaseCompletionSuite: |fromSpecific(from: Any)(it: IterableOnce[Nothing]): List[Nothing] |fromSpecific(it: IterableOnce[Nothing]): List[Nothing] |nn: List.type & List.type + |runtimeChecked scala.collection.immutable |toFactory(from: Any): Factory[Nothing, List[Nothing]] |formatted(fmtstr: String): String |→[B](y: B): (List.type, B) diff --git a/tests/neg/runtimeChecked-2.check b/tests/neg/runtimeChecked-2.check new file mode 100644 index 000000000000..1b30d637a6b9 --- /dev/null +++ b/tests/neg/runtimeChecked-2.check @@ -0,0 +1,5 @@ +-- [E030] Match case Unreachable Warning: tests/neg/runtimeChecked-2.scala:10:11 --------------------------------------- +10 | case is: Some[t] => ??? // unreachable + | ^^^^^^^^^^^ + | Unreachable case +No warnings can be incurred under -Werror (or -Xfatal-warnings) diff --git a/tests/neg/runtimeChecked-2.scala b/tests/neg/runtimeChecked-2.scala new file mode 100644 index 000000000000..bfb5aff2b1ba --- /dev/null +++ b/tests/neg/runtimeChecked-2.scala @@ -0,0 +1,13 @@ +//> using options -Werror -source:future -experimental + +object Foo { + + val xs: Option[Int] = Some(1) + + def test: Int = + xs.runtimeChecked match { // this test asserts that reachability is not avoided by runtimeChecked + case is: Some[t] => is.get + case is: Some[t] => ??? // unreachable + } +} +// nopos-error: No warnings can be incurred under -Werror (or -Xfatal-warnings) diff --git a/tests/neg/runtimeChecked.check b/tests/neg/runtimeChecked.check new file mode 100644 index 000000000000..3d984e08517d --- /dev/null +++ b/tests/neg/runtimeChecked.check @@ -0,0 +1,7 @@ +-- [E092] Pattern Match Unchecked Warning: tests/neg/runtimeChecked.scala:11:11 ---------------------------------------- +11 | case is: ::[Int/* can not be checked so still err */] => is.head + | ^ + |the type test for ::[Int] cannot be checked at runtime because its type arguments can't be determined from List[Any] + | + | longer explanation available when compiling with `-explain` +No warnings can be incurred under -Werror (or -Xfatal-warnings) diff --git a/tests/neg/runtimeChecked.scala b/tests/neg/runtimeChecked.scala new file mode 100644 index 000000000000..d3c1a91844cc --- /dev/null +++ b/tests/neg/runtimeChecked.scala @@ -0,0 +1,14 @@ +//> using options -Werror -source:future -experimental + +object Foo { + + val xs: List[Any] = List(1: Any) + + def test: Int = + xs.runtimeChecked match { // this test asserts that unsound type tests still require @unchecked + // tests/run/runtimeChecked.scala adds @unchecked to the + // unsound type test to avoid the warning. + case is: ::[Int/* can not be checked so still err */] => is.head + } +} +// nopos-error: No warnings can be incurred under -Werror (or -Xfatal-warnings) diff --git a/tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala b/tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala index 7079c7320ba0..86f8356cc2cc 100644 --- a/tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala +++ b/tests/run-tasty-inspector/stdlibExperimentalDefinitions.scala @@ -85,6 +85,9 @@ val experimentalDefinitionInLibrary = Set( "scala.annotation.internal.WitnessNames", "scala.compiletime.package$package$.deferred", "scala.runtime.stdLibPatches.Predef$.is", + + // New feature: SIP 57 - runtimeChecked replacement of @unchecked + "scala.Predef$.runtimeChecked", "scala.annotation.internal.RuntimeChecked" ) diff --git a/tests/run/runtimeChecked-2.scala b/tests/run/runtimeChecked-2.scala new file mode 100644 index 000000000000..d34ead3d6695 --- /dev/null +++ b/tests/run/runtimeChecked-2.scala @@ -0,0 +1,8 @@ +//> using options -Werror -source:future -experimental + +val xs: List[Any] = List(1: Any) + +@main +def Test: Unit = + val head :: _ = xs.runtimeChecked + assert(head == 1) diff --git a/tests/run/runtimeChecked.scala b/tests/run/runtimeChecked.scala new file mode 100644 index 000000000000..e0a5ee042381 --- /dev/null +++ b/tests/run/runtimeChecked.scala @@ -0,0 +1,12 @@ +//> using options -Werror -source:future -experimental + +val xs: List[Any] = List(1: Any) + +@main +def Test: Unit = + val head = xs.runtimeChecked match { + // tests/neg/runtimeChecked.scala asserts that @unchecked is + // still needed for unsound type tests. + case is: ::[Int @unchecked] => is.head + } + assert(head == 1)